diff --git a/.changeset/swift-pens-glow.md b/.changeset/swift-pens-glow.md new file mode 100644 index 000000000..a01f11432 --- /dev/null +++ b/.changeset/swift-pens-glow.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix asymmetric behavior in `deepEquals` when comparing different special types (Date, RegExp, Map, Set, TypedArray, Temporal, Array). Previously, comparing values like `deepEquals(Date, Temporal.Duration)` could return a different result than `deepEquals(Temporal.Duration, Date)`. Now both directions correctly return `false` for mismatched types, ensuring `deepEquals` is a proper equivalence relation. diff --git a/package.json b/package.json index ea2f51369..290faef9a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "generate-docs": "node scripts/generate-docs.ts" }, "devDependencies": { + "@fast-check/vitest": "^0.2.0", + "fast-check": "^3.23.0", "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.1", "@svitejs/changesets-changelog-github-compact": "^1.2.0", diff --git a/packages/db-ivm/tests/hash.property.test.ts b/packages/db-ivm/tests/hash.property.test.ts new file mode 100644 index 000000000..ac941c76f --- /dev/null +++ b/packages/db-ivm/tests/hash.property.test.ts @@ -0,0 +1,330 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { hash } from '../src/hashing/hash' + +/** + * Property-based tests for hash function + * + * Key properties: + * 1. Determinism: hash(x) always returns the same value + * 2. Structural equality: equal structures should have the same hash + * 3. Property order independence: objects with same properties in different order have same hash + * 4. Number normalization: -0 and 0 have same hash, NaN has consistent hash + * 5. Type markers: different types should generally produce different hashes + */ + +// Arbitraries for generating test values +const arbitraryPrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +) + +const arbitraryDate = fc.date({ noInvalidDate: true }) + +const arbitraryUint8Array = fc.uint8Array({ minLength: 0, maxLength: 128 }) + +const arbitrarySimpleObject = fc.dictionary(fc.string(), fc.integer(), { + maxKeys: 5, +}) + +const arbitrarySimpleArray = fc.array(fc.integer(), { maxLength: 10 }) + +describe(`hash property-based tests`, () => { + describe(`determinism`, () => { + fcTest.prop([arbitraryPrimitive])( + `hash is deterministic for primitives`, + (value) => { + const first = hash(value) + const second = hash(value) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitrarySimpleObject])( + `hash is deterministic for objects`, + (obj) => { + const first = hash(obj) + const second = hash(obj) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitrarySimpleArray])( + `hash is deterministic for arrays`, + (arr) => { + const first = hash(arr) + const second = hash(arr) + expect(first).toBe(second) + }, + ) + + fcTest.prop([arbitraryDate])(`hash is deterministic for dates`, (date) => { + const first = hash(date) + const second = hash(date) + expect(first).toBe(second) + }) + + fcTest.prop([arbitraryUint8Array])( + `hash is deterministic for Uint8Arrays`, + (arr) => { + const first = hash(arr) + const second = hash(arr) + expect(first).toBe(second) + }, + ) + }) + + describe(`structural equality`, () => { + fcTest.prop([arbitrarySimpleObject])( + `cloned objects have same hash`, + (obj) => { + const clone = { ...obj } + expect(hash(clone)).toBe(hash(obj)) + }, + ) + + fcTest.prop([arbitrarySimpleArray])( + `cloned arrays have same hash`, + (arr) => { + const clone = [...arr] + expect(hash(clone)).toBe(hash(arr)) + }, + ) + + fcTest.prop([arbitraryDate])( + `dates with same time have same hash`, + (date) => { + const clone = new Date(date.getTime()) + expect(hash(clone)).toBe(hash(date)) + }, + ) + + fcTest.prop([arbitraryUint8Array])( + `Uint8Arrays with same content have same hash`, + (arr) => { + const clone = new Uint8Array(arr) + expect(hash(clone)).toBe(hash(arr)) + }, + ) + + fcTest.prop([ + fc.array(fc.tuple(fc.string(), fc.integer()), { maxLength: 5 }), + ])(`Maps with same entries have same hash`, (entries) => { + const map1 = new Map(entries) + const map2 = new Map(entries) + expect(hash(map1)).toBe(hash(map2)) + }) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `Sets with same values have same hash`, + (arr) => { + const set1 = new Set(arr) + const set2 = new Set(arr) + expect(hash(set1)).toBe(hash(set2)) + }, + ) + }) + + describe(`property order independence`, () => { + fcTest.prop([ + fc.tuple( + fc.string().filter((s) => s !== ``), + fc.integer(), + fc.string().filter((s) => s !== ``), + fc.integer(), + ), + ])( + `objects with same properties in different order have same hash`, + ([key1, val1, key2, val2]) => { + // Skip if keys are the same + if (key1 === key2) return + + const obj1 = { [key1]: val1, [key2]: val2 } + const obj2 = { [key2]: val2, [key1]: val1 } + expect(hash(obj1)).toBe(hash(obj2)) + }, + ) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 2, maxKeys: 5 }), + ])(`object hash is independent of property insertion order`, (obj) => { + const keys = Object.keys(obj) + const reversedKeys = [...keys].reverse() + + // Create new object with reversed key order + const reversed: Record = {} + for (const key of reversedKeys) { + reversed[key] = obj[key]! + } + + expect(hash(reversed)).toBe(hash(obj)) + }) + }) + + describe(`number normalization`, () => { + fcTest.prop([fc.constant(0)])(`0 and -0 have the same hash`, () => { + expect(hash(0)).toBe(hash(-0)) + }) + + fcTest.prop([fc.constant(NaN)])(`NaN has consistent hash`, () => { + const first = hash(NaN) + const second = hash(NaN) + expect(first).toBe(second) + }) + + fcTest.prop([fc.integer()])(`integers hash consistently`, (n) => { + expect(hash(n)).toBe(hash(n)) + }) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `doubles hash consistently`, + (n) => { + expect(hash(n)).toBe(hash(n)) + }, + ) + }) + + describe(`type distinction`, () => { + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 5 })])( + `array and object with same indices have different hashes`, + (arr) => { + // Create object with same numeric keys + const obj: Record = {} + arr.forEach((val, idx) => { + obj[String(idx)] = val + }) + + // Arrays and objects should generally have different hashes due to markers + // but for empty ones, both might hash the same + if (arr.length > 0) { + expect(hash(arr)).not.toBe(hash(obj)) + } + }, + ) + + fcTest.prop([fc.integer()])( + `number and string representation have different hashes`, + (n) => { + expect(hash(n)).not.toBe(hash(String(n))) + }, + ) + + fcTest.prop([fc.boolean()])( + `boolean and its string representation have different hashes`, + (b) => { + expect(hash(b)).not.toBe(hash(String(b))) + }, + ) + + fcTest.prop([fc.date({ noInvalidDate: true })])( + `date and its timestamp have different hashes`, + (date) => { + expect(hash(date)).not.toBe(hash(date.getTime())) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 5 })])( + `array and Set with same values have different hashes`, + (arr) => { + const set = new Set(arr) + expect(hash(arr)).not.toBe(hash(set)) + }, + ) + }) + + describe(`nested structures`, () => { + fcTest.prop([ + fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 }), + ])(`nested arrays hash consistently`, (nested) => { + const clone = nested.map((inner) => [...inner]) + expect(hash(clone)).toBe(hash(nested)) + }) + + fcTest.prop([ + fc.dictionary( + fc.string(), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), + { maxKeys: 3 }, + ), + ])(`nested objects hash consistently`, (nested) => { + const clone = Object.fromEntries( + Object.entries(nested).map(([k, v]) => [k, { ...v }]), + ) + expect(hash(clone)).toBe(hash(nested)) + }) + }) + + describe(`hash produces numbers`, () => { + fcTest.prop([arbitraryPrimitive])( + `hash returns a number for primitives`, + (value) => { + expect(typeof hash(value)).toBe(`number`) + expect(Number.isFinite(hash(value))).toBe(true) + }, + ) + + fcTest.prop([arbitrarySimpleObject])( + `hash returns a number for objects`, + (obj) => { + expect(typeof hash(obj)).toBe(`number`) + expect(Number.isFinite(hash(obj))).toBe(true) + }, + ) + + fcTest.prop([arbitrarySimpleArray])( + `hash returns a number for arrays`, + (arr) => { + expect(typeof hash(arr)).toBe(`number`) + expect(Number.isFinite(hash(arr))).toBe(true) + }, + ) + }) + + describe(`inequality detection`, () => { + fcTest.prop([fc.integer(), fc.integer()])( + `different integers have different hashes (most of the time)`, + (a, b) => { + // Hash collisions are possible, so we only check that equal values have equal hashes + if (a === b) { + expect(hash(a)).toBe(hash(b)) + } + // We don't assert different values have different hashes due to collisions + }, + ) + + fcTest.prop([fc.string(), fc.string()])( + `equal strings have equal hashes`, + (a, b) => { + if (a === b) { + expect(hash(a)).toBe(hash(b)) + } + }, + ) + + fcTest.prop([ + fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), + fc.integer(), + ])(`arrays with extra element have different hashes`, (arr, extra) => { + const extended = [...arr, extra] + expect(hash(arr)).not.toBe(hash(extended)) + }) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), + fc.string(), + fc.integer(), + ])( + `objects with extra property have different hashes`, + (obj, newKey, newValue) => { + if (!(newKey in obj)) { + const extended = { ...obj, [newKey]: newValue } + expect(hash(obj)).not.toBe(hash(extended)) + } + }, + ) + }) +}) diff --git a/packages/db/src/utils.ts b/packages/db/src/utils.ts index 248b93d36..00292e37a 100644 --- a/packages/db/src/utils.ts +++ b/packages/db/src/utils.ts @@ -52,12 +52,16 @@ function deepEqualsInternal( if (!(b instanceof Date)) return false return a.getTime() === b.getTime() } + // Symmetric check: if b is Date but a is not, they're not equal + if (b instanceof Date) return false // Handle RegExp objects if (a instanceof RegExp) { if (!(b instanceof RegExp)) return false return a.source === b.source && a.flags === b.flags } + // Symmetric check: if b is RegExp but a is not, they're not equal + if (b instanceof RegExp) return false // Handle Map objects - only if both are Maps if (a instanceof Map) { @@ -78,6 +82,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is Map but a is not, they're not equal + if (b instanceof Map) return false // Handle Set objects - only if both are Sets if (a instanceof Set) { @@ -106,6 +112,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is Set but a is not, they're not equal + if (b instanceof Set) return false // Handle TypedArrays if ( @@ -124,6 +132,14 @@ function deepEqualsInternal( return true } + // Symmetric check: if b is TypedArray but a is not, they're not equal + if ( + ArrayBuffer.isView(b) && + !(b instanceof DataView) && + !ArrayBuffer.isView(a) + ) { + return false + } // Handle Temporal objects // Check if both are Temporal objects of the same type @@ -142,6 +158,8 @@ function deepEqualsInternal( // Fallback to toString comparison for other types return a.toString() === b.toString() } + // Symmetric check: if b is Temporal but a is not, they're not equal + if (isTemporal(b)) return false // Handle arrays if (Array.isArray(a)) { @@ -159,6 +177,8 @@ function deepEqualsInternal( visited.delete(a) return result } + // Symmetric check: if b is array but a is not, they're not equal + if (Array.isArray(b)) return false // Handle objects if (typeof a === `object`) { diff --git a/packages/db/tests/comparison.property.test.ts b/packages/db/tests/comparison.property.test.ts new file mode 100644 index 000000000..dd6279001 --- /dev/null +++ b/packages/db/tests/comparison.property.test.ts @@ -0,0 +1,448 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { + areValuesEqual, + ascComparator, + descComparator, + makeComparator, + normalizeValue, +} from '../src/utils/comparison' +import type { CompareOptions } from '../src/query/builder/types' + +/** + * Property-based tests for comparison functions + * + * A valid comparator must satisfy: + * 1. Consistency: compare(a, b) always returns the same value + * 2. Antisymmetry: sign(compare(a, b)) === -sign(compare(b, a)) + * 3. Transitivity: if compare(a, b) <= 0 and compare(b, c) <= 0 then compare(a, c) <= 0 + * 4. Reflexivity: compare(a, a) === 0 + * + * Note: Object comparison uses stable IDs based on creation order, which means + * comparing two different object instances has order-dependent behavior. + * These tests focus on primitives, dates, and arrays where comparison is deterministic. + */ + +const defaultOpts: CompareOptions = { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, +} + +const lexicalOpts: CompareOptions = { + direction: `asc`, + nulls: `first`, + stringSort: `lexical`, +} + +const nullsLastOpts: CompareOptions = { + direction: `asc`, + nulls: `last`, + stringSort: `locale`, +} + +// Comparator-compatible values (primitives that can be compared deterministically) +// Note: We exclude plain objects because they use stable IDs which are creation-order dependent +const arbitraryComparablePrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.boolean(), +) + +const arbitraryDate = fc.date({ noInvalidDate: true }) + +const arbitraryComparableArray = fc.array(arbitraryComparablePrimitive, { + maxLength: 5, +}) + +// Helper to get sign of a number +const sign = (n: number): -1 | 0 | 1 => { + if (n < 0) return -1 + if (n > 0) return 1 + return 0 +} + +/** + * Check antisymmetry property: sign(compare(a, b)) === -sign(compare(b, a)) + * This handles the +0/-0 JavaScript edge case where -0 !== 0 in Object.is + */ +const checkAntisymmetry = (ab: number, ba: number): boolean => { + const signAB = sign(ab) + const signBA = sign(ba) + // Both zero, or opposite signs + return (signAB === 0 && signBA === 0) || signAB === -signBA +} + +// Same-type arbitraries for comparator tests (cross-type comparison is not guaranteed to be total ordering) +const arbitrarySameTypeString = fc.tuple(fc.string(), fc.string()) +const arbitrarySameTypeInt = fc.tuple(fc.integer(), fc.integer()) +const arbitrarySameTypeDouble = fc.tuple( + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), +) +const arbitrarySameTypeBool = fc.tuple(fc.boolean(), fc.boolean()) +const arbitrarySameTypeDate = fc.tuple( + fc.date({ noInvalidDate: true }), + fc.date({ noInvalidDate: true }), +) +const arbitrarySameTypeArray = fc.tuple( + fc.array(fc.integer(), { maxLength: 5 }), + fc.array(fc.integer(), { maxLength: 5 }), +) + +// Pair of same-type comparable values +const arbitrarySameTypePair = fc.oneof( + arbitrarySameTypeString, + arbitrarySameTypeInt, + arbitrarySameTypeDouble, + arbitrarySameTypeBool, + arbitrarySameTypeDate, + arbitrarySameTypeArray, +) + +// Triple of same-type values for transitivity +const arbitrarySameTypeTriple = fc.oneof( + fc.tuple(fc.integer(), fc.integer(), fc.integer()), + fc.tuple(fc.string(), fc.string(), fc.string()), + fc.tuple( + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + fc.double({ noNaN: true, noDefaultInfinity: true }), + ), +) + +describe(`ascComparator property-based tests`, () => { + describe(`comparator laws`, () => { + fcTest.prop([arbitraryComparablePrimitive])( + `reflexivity: compare(a, a) === 0`, + (a) => { + expect(ascComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `antisymmetry: sign(compare(a, b)) === -sign(compare(b, a)) for same types`, + ([a, b]) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([arbitrarySameTypeTriple])( + `transitivity: if a <= b and b <= c then a <= c (same types)`, + ([a, b, c]) => { + const ab = ascComparator(a, b, defaultOpts) + const bc = ascComparator(b, c, defaultOpts) + const ac = ascComparator(a, c, defaultOpts) + + if (ab <= 0 && bc <= 0) { + expect(ac).toBeLessThanOrEqual(0) + } + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `consistency: compare(a, b) always returns the same value`, + ([a, b]) => { + const result1 = ascComparator(a, b, defaultOpts) + const result2 = ascComparator(a, b, defaultOpts) + expect(result1).toBe(result2) + }, + ) + }) + + describe(`null handling`, () => { + fcTest.prop([arbitraryComparablePrimitive])( + `nulls first: null comes before any non-null value`, + (a) => { + expect(ascComparator(null, a, defaultOpts)).toBeLessThan(0) + expect(ascComparator(a, null, defaultOpts)).toBeGreaterThan(0) + }, + ) + + fcTest.prop([arbitraryComparablePrimitive])( + `nulls last: null comes after any non-null value`, + (a) => { + expect(ascComparator(null, a, nullsLastOpts)).toBeGreaterThan(0) + expect(ascComparator(a, null, nullsLastOpts)).toBeLessThan(0) + }, + ) + + fcTest.prop([fc.constant(null), fc.constant(null)])( + `null equals null`, + (a, b) => { + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.constant(undefined), fc.constant(undefined)])( + `undefined equals undefined`, + (a, b) => { + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([ + fc.constantFrom(null, undefined), + fc.constantFrom(null, undefined), + ])(`null and undefined are treated the same for comparison`, (a, b) => { + // Both null and undefined are treated as "null-ish" values + expect(ascComparator(a, b, defaultOpts)).toBe(0) + }) + }) + + describe(`string comparison`, () => { + fcTest.prop([fc.string()])( + `locale sort: reflexivity holds for strings`, + (a) => { + expect(ascComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.string(), fc.string()])( + `lexical sort: antisymmetry holds for strings`, + (a, b) => { + const ab = ascComparator(a, b, lexicalOpts) + const ba = ascComparator(b, a, lexicalOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + }) + + describe(`array comparison`, () => { + fcTest.prop([arbitraryComparableArray])( + `reflexivity: compare(arr, arr) === 0`, + (arr) => { + expect(ascComparator(arr, arr, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitrarySameTypeArray])( + `antisymmetry for arrays of same element type`, + ([a, b]) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 5 })])( + `arrays with same elements compare equal`, + (arr) => { + const copy = [...arr] + expect(ascComparator(arr, copy, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 1, maxLength: 5 })])( + `shorter prefix array comes before longer array`, + (arr) => { + const prefix = arr.slice(0, -1) + if (prefix.length < arr.length) { + expect(ascComparator(prefix, arr, defaultOpts)).toBeLessThan(0) + expect(ascComparator(arr, prefix, defaultOpts)).toBeGreaterThan(0) + } + }, + ) + }) + + describe(`date comparison`, () => { + fcTest.prop([arbitraryDate])( + `reflexivity: compare(date, date) === 0`, + (date) => { + expect(ascComparator(date, date, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([arbitraryDate, arbitraryDate])( + `antisymmetry for dates`, + (a, b) => { + const ab = ascComparator(a, b, defaultOpts) + const ba = ascComparator(b, a, defaultOpts) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) + + fcTest.prop([arbitraryDate])( + `dates with same time compare equal`, + (date) => { + const copy = new Date(date.getTime()) + expect(ascComparator(date, copy, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([ + // Use bounded dates to avoid overflow when adding offset + fc.date({ min: new Date(0), max: new Date(`2100-01-01`) }), + fc.integer({ min: 1, max: 1000000 }), + ])(`earlier date comes before later date`, (date, offset) => { + const later = new Date(date.getTime() + offset) + // Only test if the later date is valid + if (!isNaN(later.getTime())) { + expect(ascComparator(date, later, defaultOpts)).toBeLessThan(0) + expect(ascComparator(later, date, defaultOpts)).toBeGreaterThan(0) + } + }) + }) + + describe(`number comparison`, () => { + fcTest.prop([fc.integer()])(`reflexivity for integers`, (n) => { + expect(ascComparator(n, n, defaultOpts)).toBe(0) + }) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `reflexivity for doubles`, + (n) => { + expect(ascComparator(n, n, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.integer(), fc.integer()])( + `integer ordering is correct`, + (a, b) => { + const result = ascComparator(a, b, defaultOpts) + if (a < b) { + expect(result).toBeLessThan(0) + } else if (a > b) { + expect(result).toBeGreaterThan(0) + } else { + expect(result).toBe(0) + } + }, + ) + }) +}) + +describe(`descComparator property-based tests`, () => { + fcTest.prop([arbitrarySameTypePair])( + `descComparator reverses ascComparator ordering (same types)`, + ([a, b]) => { + const asc = ascComparator(a, b, defaultOpts) + const desc = descComparator(a, b, defaultOpts) + expect(checkAntisymmetry(asc, desc)).toBe(true) + }, + ) + + fcTest.prop([arbitraryComparablePrimitive])( + `reflexivity: descComparator(a, a) === 0`, + (a) => { + expect(descComparator(a, a, defaultOpts)).toBe(0) + }, + ) + + fcTest.prop([fc.integer({ min: 1 })])( + `nulls first in desc: null sorts before non-null values`, + (a) => { + // With nulls: 'first', null should come before non-null values in sorted output + // If compare(a, b) < 0, then a comes before b in the result + const result = descComparator(null, a, defaultOpts) + expect(result).toBeLessThan(0) + }, + ) +}) + +describe(`makeComparator property-based tests`, () => { + fcTest.prop([ + arbitrarySameTypePair, + fc.constantFrom(`asc`, `desc`), + fc.constantFrom(`first`, `last`), + ])( + `makeComparator produces valid comparator for any options (same types)`, + ([a, b], direction, nulls) => { + const opts: CompareOptions = { + direction: direction as `asc` | `desc`, + nulls: nulls as `first` | `last`, + stringSort: `locale`, + } + const comparator = makeComparator(opts) + + // Reflexivity + expect(comparator(a, a)).toBe(0) + + // Antisymmetry + const ab = comparator(a, b) + const ba = comparator(b, a) + expect(checkAntisymmetry(ab, ba)).toBe(true) + }, + ) +}) + +describe(`normalizeValue property-based tests`, () => { + fcTest.prop([arbitraryDate])(`dates normalize to their timestamp`, (date) => { + expect(normalizeValue(date)).toBe(date.getTime()) + }) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( + `small Uint8Arrays normalize to string representation`, + (arr) => { + const normalized = normalizeValue(arr) + expect(typeof normalized).toBe(`string`) + expect(normalized).toMatch(/^__u8__/) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 129, maxLength: 200 })])( + `large Uint8Arrays are not normalized`, + (arr) => { + const normalized = normalizeValue(arr) + expect(normalized).toBe(arr) + }, + ) + + fcTest.prop([fc.string()])(`strings pass through unchanged`, (str) => { + expect(normalizeValue(str)).toBe(str) + }) + + fcTest.prop([fc.integer()])(`integers pass through unchanged`, (n) => { + expect(normalizeValue(n)).toBe(n) + }) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 128 })])( + `normalization is idempotent for Uint8Arrays`, + (arr) => { + const normalized1 = normalizeValue(arr) + // For strings (which small arrays become), normalizing again should be identity + expect(normalizeValue(normalized1)).toBe(normalized1) + }, + ) +}) + +describe(`areValuesEqual property-based tests`, () => { + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 50 })])( + `Uint8Arrays with same content are equal`, + (arr) => { + const copy = new Uint8Array(arr) + expect(areValuesEqual(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([ + fc.uint8Array({ minLength: 1, maxLength: 50 }), + fc.integer({ min: 0, max: 49 }), + fc.integer({ min: 0, max: 255 }), + ])( + `Uint8Arrays with different content are not equal`, + (arr, index, newValue) => { + if (index < arr.length && arr[index] !== newValue) { + const modified = new Uint8Array(arr) + modified[index] = newValue + expect(areValuesEqual(arr, modified)).toBe(false) + } + }, + ) + + fcTest.prop([fc.integer()])(`reference equality for primitives`, (n) => { + expect(areValuesEqual(n, n)).toBe(true) + }) + + fcTest.prop([fc.integer(), fc.integer()])( + `different integers are not equal`, + (a, b) => { + if (a !== b) { + expect(areValuesEqual(a, b)).toBe(false) + } + }, + ) +}) diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts new file mode 100644 index 000000000..04c2fa536 --- /dev/null +++ b/packages/db/tests/cursor.property.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { buildCursor } from '../src/utils/cursor' +import { Func, PropRef, Value } from '../src/query/ir' +import type { OrderBy, OrderByClause } from '../src/query/ir' +import type { CompareOptions } from '../src/query/builder/types' + +/** + * Property-based tests for cursor building + * + * Key properties: + * 1. Empty inputs return undefined + * 2. Single column produces simple gt/lt based on direction + * 3. Direction affects operator choice (asc = gt, desc = lt) + * 4. Determinism - same inputs always produce same output + * 5. Result structure is always valid + */ + +// Arbitraries for generating test data +const arbitraryDirection = fc.constantFrom(`asc`, `desc`) + +const arbitraryNulls = fc.constantFrom(`first`, `last`) + +const arbitraryStringSort = fc.constantFrom(`locale`, `lexical`) + +const arbitraryCompareOptions = fc.record({ + direction: arbitraryDirection, + nulls: arbitraryNulls, + stringSort: arbitraryStringSort, +}) as fc.Arbitrary + +const arbitraryPropRef = fc + .array(fc.string({ minLength: 1, maxLength: 10 }), { + minLength: 1, + maxLength: 3, + }) + .map((path) => new PropRef(path)) + +const arbitraryOrderByClause = fc + .tuple(arbitraryPropRef, arbitraryCompareOptions) + .map( + ([expr, compareOptions]): OrderByClause => ({ + expression: expr, + compareOptions, + }), + ) + +const arbitraryOrderBy = ( + minLength: number, + maxLength: number, +): fc.Arbitrary => + fc.array(arbitraryOrderByClause, { minLength, maxLength }) + +const arbitraryValue = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), + fc.boolean(), + fc.constant(null), +) + +const arbitraryValues = ( + minLength: number, + maxLength: number, +): fc.Arbitrary> => + fc.array(arbitraryValue, { minLength, maxLength }) + +// Helper to check if result is a Func +function isFunc(expr: unknown): expr is Func { + return expr instanceof Func +} + +// Helper to get operator name from Func +function getFuncName(expr: Func): string { + return expr.name +} + +// Helper to recursively count operators in an expression +function countOperators(expr: unknown, name: string): number { + if (!isFunc(expr)) return 0 + const selfCount = expr.name === name ? 1 : 0 + return ( + selfCount + + expr.args.reduce((sum, arg) => sum + countOperators(arg, name), 0) + ) +} + +describe(`buildCursor property-based tests`, () => { + describe(`empty input handling`, () => { + fcTest.prop([arbitraryOrderBy(0, 5)])( + `returns undefined for empty values array`, + (orderBy) => { + const result = buildCursor(orderBy, []) + expect(result).toBeUndefined() + }, + ) + + fcTest.prop([arbitraryValues(0, 5)])( + `returns undefined for empty orderBy array`, + (values) => { + const result = buildCursor([], values) + expect(result).toBeUndefined() + }, + ) + + it(`returns undefined for both empty`, () => { + expect(buildCursor([], [])).toBeUndefined() + }) + }) + + describe(`single column cursor`, () => { + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `produces a simple comparison for single column`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + + // Should be either 'gt' or 'lt' based on direction + const func = result as Func + expect([`gt`, `lt`]).toContain(getFuncName(func)) + }, + ) + + fcTest.prop([ + arbitraryPropRef, + arbitraryNulls, + arbitraryStringSort, + arbitraryValue, + ])( + `ascending direction produces gt operator`, + (expr, nulls, stringSort, value) => { + const clause: OrderByClause = { + expression: expr, + compareOptions: { + direction: `asc`, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, + } + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(getFuncName(result as Func)).toBe(`gt`) + }, + ) + + fcTest.prop([ + arbitraryPropRef, + arbitraryNulls, + arbitraryStringSort, + arbitraryValue, + ])( + `descending direction produces lt operator`, + (expr, nulls, stringSort, value) => { + const clause: OrderByClause = { + expression: expr, + compareOptions: { + direction: `desc`, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, + } + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + expect(getFuncName(result as Func)).toBe(`lt`) + }, + ) + }) + + describe(`multi-column cursor structure`, () => { + fcTest.prop([arbitraryOrderBy(2, 4), arbitraryValues(2, 4)])( + `multi-column produces or at top level when matching lengths`, + (orderBy, values) => { + // Ensure we have matching lengths for a valid multi-column cursor + const minLen = Math.min(orderBy.length, values.length) + if (minLen < 2) return // Skip if not enough for multi-column + + const trimmedOrderBy = orderBy.slice(0, minLen) + const trimmedValues = values.slice(0, minLen) + + const result = buildCursor(trimmedOrderBy, trimmedValues) + + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + + // For 2+ columns, top level should be 'or' + const func = result as Func + expect(getFuncName(func)).toBe(`or`) + }, + ) + + fcTest.prop([ + fc.tuple(arbitraryOrderByClause, arbitraryOrderByClause), + fc.tuple(arbitraryValue, arbitraryValue), + ])( + `two columns produces correct structure`, + ([clause1, clause2], [val1, val2]) => { + const result = buildCursor([clause1, clause2], [val1, val2]) + + expect(result).toBeDefined() + const func = result as Func + + // Top level should be 'or' + expect(getFuncName(func)).toBe(`or`) + + // Should have structure: or(comparison1, and(eq, comparison2)) + expect(func.args).toHaveLength(2) + + // First arg should be direct gt/lt + expect(isFunc(func.args[0])).toBe(true) + expect([`gt`, `lt`]).toContain(getFuncName(func.args[0] as Func)) + + // Second arg should be 'and' combining eq and comparison + expect(isFunc(func.args[1])).toBe(true) + expect(getFuncName(func.args[1] as Func)).toBe(`and`) + }, + ) + }) + + describe(`determinism`, () => { + fcTest.prop([arbitraryOrderBy(1, 3), arbitraryValues(1, 3)])( + `buildCursor is deterministic`, + (orderBy, values) => { + const result1 = buildCursor(orderBy, values) + const result2 = buildCursor(orderBy, values) + + // Both should be defined or both undefined + expect(result1 === undefined).toBe(result2 === undefined) + + if (result1 !== undefined && result2 !== undefined) { + // Compare structure by JSON representation + expect(JSON.stringify(result1)).toBe(JSON.stringify(result2)) + } + }, + ) + }) + + describe(`value preservation`, () => { + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `cursor contains the provided value`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + const func = result as Func + + // Second argument should be a Value containing our value + expect(func.args[1]).toBeInstanceOf(Value) + expect((func.args[1] as Value).value).toBe(value) + }, + ) + + fcTest.prop([arbitraryOrderByClause, arbitraryValue])( + `cursor references the correct property`, + (clause, value) => { + const result = buildCursor([clause], [value]) + + expect(result).toBeDefined() + const func = result as Func + + // First argument should be the same PropRef + expect(func.args[0]).toBeInstanceOf(PropRef) + expect((func.args[0] as PropRef).path).toEqual( + (clause.expression as PropRef).path, + ) + }, + ) + }) + + describe(`length mismatch handling`, () => { + fcTest.prop([arbitraryOrderBy(3, 5), arbitraryValues(1, 2)])( + `handles more orderBy columns than values gracefully`, + (orderBy, values) => { + // Should use the minimum of the two lengths + const result = buildCursor(orderBy, values) + + if (values.length === 0) { + expect(result).toBeUndefined() + } else { + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + } + }, + ) + + fcTest.prop([arbitraryOrderBy(1, 2), arbitraryValues(3, 5)])( + `handles more values than orderBy columns gracefully`, + (orderBy, values) => { + // Should use the minimum of the two lengths + const result = buildCursor(orderBy, values) + + if (orderBy.length === 0) { + expect(result).toBeUndefined() + } else { + expect(result).toBeDefined() + expect(isFunc(result!)).toBe(true) + } + }, + ) + }) + + describe(`operator consistency`, () => { + fcTest.prop([ + fc.array( + fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), + { minLength: 2, maxLength: 4 }, + ), + arbitraryValues(2, 4), + ])(`all ascending columns use gt operators`, (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { + direction: `asc` as const, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count gt operators - should equal number of columns + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(minLen) + // Should have no lt operators + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(0) + } + }) + + fcTest.prop([ + fc.array( + fc.tuple(arbitraryPropRef, arbitraryNulls, arbitraryStringSort), + { minLength: 2, maxLength: 4 }, + ), + arbitraryValues(2, 4), + ])(`all descending columns use lt operators`, (clauseParts, values) => { + const orderBy: OrderBy = clauseParts.map(([expr, nulls, stringSort]) => ({ + expression: expr, + compareOptions: { + direction: `desc` as const, + nulls: nulls as `first` | `last`, + stringSort: stringSort as `locale` | `lexical`, + }, + })) + + const minLen = Math.min(orderBy.length, values.length) + const result = buildCursor( + orderBy.slice(0, minLen), + values.slice(0, minLen), + ) + + if (result) { + // Count lt operators - should equal number of columns + const ltCount = countOperators(result, `lt`) + expect(ltCount).toBe(minLen) + // Should have no gt operators + const gtCount = countOperators(result, `gt`) + expect(gtCount).toBe(0) + } + }) + }) +}) diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts new file mode 100644 index 000000000..eb03cef64 --- /dev/null +++ b/packages/db/tests/utils.property.test.ts @@ -0,0 +1,348 @@ +import { describe, expect, it } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { Temporal } from 'temporal-polyfill' +import { deepEquals } from '../src/utils' + +/** + * Custom arbitraries for generating values that deepEquals handles. + * + * Note: We use same-type arbitraries for symmetry/transitivity tests because + * deepEquals has known asymmetric behavior when comparing certain cross-type + * values (e.g., Date vs Temporal.Duration). This is documented in the + * "known edge cases" section below. + */ +const arbitraryPrimitive = fc.oneof( + fc.string(), + fc.integer(), + fc.double({ noNaN: true }), // NaN !== NaN, which would break reflexivity + fc.boolean(), + fc.constant(null), + fc.constant(undefined), +) + +const arbitraryDate = fc.date().map((d) => new Date(d.getTime())) + +const arbitraryRegExp = fc + .tuple(fc.string(), fc.constantFrom(``, `g`, `i`, `gi`, `m`, `gim`)) + .map(([source, flags]) => { + try { + // Escape special regex characters to avoid invalid patterns + const escapedSource = source.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`) + return new RegExp(escapedSource, flags) + } catch { + return /test/ + } + }) + +const arbitraryUint8Array = fc.uint8Array({ minLength: 0, maxLength: 20 }) + +const arbitraryFloat32Array = fc + .array(fc.float({ noNaN: true }), { minLength: 0, maxLength: 10 }) + .map((arr) => new Float32Array(arr)) + +const arbitraryTemporalPlainDate = fc + .tuple( + fc.integer({ min: 1, max: 9999 }), + fc.integer({ min: 1, max: 12 }), + fc.integer({ min: 1, max: 28 }), // Safe day range + ) + .map(([year, month, day]) => new Temporal.PlainDate(year, month, day)) + +const arbitraryTemporalDuration = fc + .tuple( + fc.integer({ min: 0, max: 100 }), + fc.integer({ min: 0, max: 59 }), + fc.integer({ min: 0, max: 59 }), + ) + .map(([hours, minutes, seconds]) => + Temporal.Duration.from({ hours, minutes, seconds }), + ) + +// Same-type value arbitraries for testing equivalence properties +// These avoid cross-type comparisons that have known asymmetric behavior +const arbitrarySameTypePrimitive = fc.oneof( + fc.tuple(fc.string(), fc.string()), + fc.tuple(fc.integer(), fc.integer()), + fc.tuple(fc.double({ noNaN: true }), fc.double({ noNaN: true })), + fc.tuple(fc.boolean(), fc.boolean()), +) + +const arbitrarySameTypeDate = fc.tuple(arbitraryDate, arbitraryDate) +const arbitrarySameTypeRegExp = fc.tuple(arbitraryRegExp, arbitraryRegExp) +const arbitrarySameTypeUint8Array = fc.tuple( + arbitraryUint8Array, + arbitraryUint8Array, +) +const arbitrarySameTypeTemporalDate = fc.tuple( + arbitraryTemporalPlainDate, + arbitraryTemporalPlainDate, +) +const arbitrarySameTypeTemporalDuration = fc.tuple( + arbitraryTemporalDuration, + arbitraryTemporalDuration, +) + +// Pair of values of the same type +const arbitrarySameTypePair = fc.oneof( + arbitrarySameTypePrimitive, + arbitrarySameTypeDate, + arbitrarySameTypeRegExp, + arbitrarySameTypeUint8Array, + arbitrarySameTypeTemporalDate, + arbitrarySameTypeTemporalDuration, + fc.tuple( + fc.array(fc.integer(), { maxLength: 5 }), + fc.array(fc.integer(), { maxLength: 5 }), + ), + fc.tuple( + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), + ), +) + +// Single values for reflexivity tests +const arbitrarySingleValue = fc.oneof( + arbitraryPrimitive, + arbitraryDate, + arbitraryRegExp, + arbitraryUint8Array, + arbitraryFloat32Array, + arbitraryTemporalPlainDate, + arbitraryTemporalDuration, + fc.array(fc.integer(), { maxLength: 5 }), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 5 }), +) + +describe(`deepEquals property-based tests`, () => { + describe(`equivalence relation properties`, () => { + fcTest.prop([arbitrarySingleValue])( + `reflexivity: deepEquals(a, a) is always true`, + (a) => { + expect(deepEquals(a, a)).toBe(true) + }, + ) + + fcTest.prop([arbitrarySameTypePair])( + `symmetry: deepEquals(a, b) === deepEquals(b, a) for same-type values`, + ([a, b]) => { + expect(deepEquals(a, b)).toBe(deepEquals(b, a)) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 5 })])( + `transitivity: if deepEquals(a, b) && deepEquals(b, c) then deepEquals(a, c)`, + (arr) => { + // Create three copies to test transitivity + const a = [...arr] + const b = [...arr] + const c = [...arr] + if (deepEquals(a, b) && deepEquals(b, c)) { + expect(deepEquals(a, c)).toBe(true) + } + }, + ) + }) + + describe(`cross-type comparisons`, () => { + it(`Date and Temporal.Duration are not equal in either direction`, () => { + const date = new Date(`1970-01-01T00:00:00.000Z`) + const duration = Temporal.Duration.from({ + hours: 0, + minutes: 0, + seconds: 0, + }) + + // Both directions should return false for different types (symmetric) + expect(deepEquals(date, duration)).toBe(false) + expect(deepEquals(duration, date)).toBe(false) + }) + + it(`Date and Temporal.PlainDate are not equal in either direction`, () => { + const date = new Date(`2023-01-01T00:00:00.000Z`) + const plainDate = new Temporal.PlainDate(2023, 1, 1) + + expect(deepEquals(date, plainDate)).toBe(false) + expect(deepEquals(plainDate, date)).toBe(false) + }) + + it(`RegExp and object are not equal in either direction`, () => { + const regex = /test/g + const obj = { source: `test`, flags: `g` } + + expect(deepEquals(regex, obj)).toBe(false) + expect(deepEquals(obj, regex)).toBe(false) + }) + + it(`Map and object are not equal in either direction`, () => { + const map = new Map([[`a`, 1]]) + const obj = { a: 1 } + + expect(deepEquals(map, obj)).toBe(false) + expect(deepEquals(obj, map)).toBe(false) + }) + + it(`Set and array are not equal in either direction`, () => { + const set = new Set([1, 2, 3]) + const arr = [1, 2, 3] + + expect(deepEquals(set, arr)).toBe(false) + expect(deepEquals(arr, set)).toBe(false) + }) + + it(`Uint8Array and array are not equal in either direction`, () => { + const typedArr = new Uint8Array([1, 2, 3]) + const arr = [1, 2, 3] + + expect(deepEquals(typedArr, arr)).toBe(false) + expect(deepEquals(arr, typedArr)).toBe(false) + }) + }) + + describe(`structural equality`, () => { + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 10 })])( + `arrays with same elements are equal`, + (arr) => { + const copy = [...arr] + expect(deepEquals(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 0, maxKeys: 10 }), + ])(`objects with same properties are equal`, (obj) => { + const copy = { ...obj } + expect(deepEquals(obj, copy)).toBe(true) + }) + + fcTest.prop([ + fc.array(fc.tuple(fc.string(), fc.integer()), { + minLength: 0, + maxLength: 5, + }), + ])(`Maps with same entries are equal`, (entries) => { + const map1 = new Map(entries) + const map2 = new Map(entries) + expect(deepEquals(map1, map2)).toBe(true) + }) + + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 10 })])( + `Sets with same primitive values are equal`, + (arr) => { + const set1 = new Set(arr) + const set2 = new Set(arr) + expect(deepEquals(set1, set2)).toBe(true) + }, + ) + + fcTest.prop([fc.uint8Array({ minLength: 0, maxLength: 50 })])( + `Uint8Arrays with same content are equal`, + (arr) => { + const copy = new Uint8Array(arr) + expect(deepEquals(arr, copy)).toBe(true) + }, + ) + + fcTest.prop([arbitraryDate])(`Dates with same time are equal`, (date) => { + const copy = new Date(date.getTime()) + expect(deepEquals(date, copy)).toBe(true) + }) + + fcTest.prop([arbitraryTemporalPlainDate])( + `Temporal.PlainDate with same values are equal`, + (date) => { + const copy = new Temporal.PlainDate(date.year, date.month, date.day) + expect(deepEquals(date, copy)).toBe(true) + }, + ) + }) + + describe(`inequality properties`, () => { + fcTest.prop([ + fc.array(fc.integer(), { minLength: 1, maxLength: 10 }), + fc.integer(), + ])(`arrays with different elements are not equal`, (arr, extraElement) => { + const modified = [...arr, extraElement] + expect(deepEquals(arr, modified)).toBe(false) + }) + + fcTest.prop([ + fc.dictionary(fc.string(), fc.integer(), { minKeys: 1, maxKeys: 5 }), + fc.string(), + fc.integer(), + ])(`objects with extra property are not equal`, (obj, newKey, newValue) => { + // Only test if the key doesn't already exist + if (!(newKey in obj)) { + const modified = { ...obj, [newKey]: newValue } + expect(deepEquals(obj, modified)).toBe(false) + } + }) + + fcTest.prop([fc.integer(), fc.string()])( + `different types are not equal`, + (num, str) => { + expect(deepEquals(num, str)).toBe(false) + }, + ) + + fcTest.prop([fc.date(), fc.date()])( + `dates with different times are not equal`, + (date1, date2) => { + if (date1.getTime() !== date2.getTime()) { + expect(deepEquals(date1, date2)).toBe(false) + } + }, + ) + }) + + describe(`edge cases`, () => { + fcTest.prop([arbitrarySingleValue])( + `null is never equal to a non-null value`, + (a) => { + if (a !== null) { + expect(deepEquals(null, a)).toBe(false) + expect(deepEquals(a, null)).toBe(false) + } + }, + ) + + fcTest.prop([arbitrarySingleValue])( + `undefined is never equal to a non-undefined value`, + (a) => { + if (a !== undefined) { + expect(deepEquals(undefined, a)).toBe(false) + expect(deepEquals(a, undefined)).toBe(false) + } + }, + ) + + fcTest.prop([fc.array(fc.integer(), { minLength: 0, maxLength: 5 })])( + `array is never equal to object with same values`, + (arr) => { + const obj = { ...arr } + expect(deepEquals(arr, obj)).toBe(false) + }, + ) + }) + + describe(`nested structure consistency`, () => { + fcTest.prop([ + fc.array(fc.array(fc.integer(), { maxLength: 3 }), { maxLength: 3 }), + ])(`nested arrays maintain equality through cloning`, (nestedArr) => { + const clone = nestedArr.map((inner) => [...inner]) + expect(deepEquals(nestedArr, clone)).toBe(true) + }) + + fcTest.prop([ + fc.dictionary( + fc.string(), + fc.dictionary(fc.string(), fc.integer(), { maxKeys: 3 }), + { maxKeys: 3 }, + ), + ])(`nested objects maintain equality through cloning`, (nestedObj) => { + const clone = Object.fromEntries( + Object.entries(nestedObj).map(([k, v]) => [k, { ...v }]), + ) + expect(deepEquals(nestedObj, clone)).toBe(true) + }) + }) +}) diff --git a/packages/electric-db-collection/tests/pg-serializer.property.test.ts b/packages/electric-db-collection/tests/pg-serializer.property.test.ts new file mode 100644 index 000000000..2eabf4576 --- /dev/null +++ b/packages/electric-db-collection/tests/pg-serializer.property.test.ts @@ -0,0 +1,227 @@ +import { describe, expect } from 'vitest' +import { fc, test as fcTest } from '@fast-check/vitest' +import { serialize } from '../src/pg-serializer' + +/** + * Property-based tests for pg-serializer + * + * Key properties: + * 1. Strings pass through unchanged + * 2. Numbers produce parseable numeric strings + * 3. Booleans serialize to 'true'/'false' + * 4. null and undefined both serialize to empty string + * 5. Dates produce valid ISO strings + * 6. Array escaping is consistent + */ + +describe(`pg-serializer property-based tests`, () => { + describe(`string serialization`, () => { + fcTest.prop([fc.string()])(`strings pass through unchanged`, (str) => { + expect(serialize(str)).toBe(str) + }) + + fcTest.prop([fc.string()])(`strings are idempotent`, (str) => { + // serialize(serialize(str)) should equal serialize(str) for strings + const once = serialize(str) + const twice = serialize(once) + expect(twice).toBe(once) + }) + }) + + describe(`number serialization`, () => { + fcTest.prop([fc.integer()])( + `integers round-trip through parseFloat`, + (n) => { + const serialized = serialize(n) + expect(parseFloat(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.double({ noNaN: true, noDefaultInfinity: true })])( + `finite doubles round-trip through parseFloat`, + (n) => { + const serialized = serialize(n) + expect(parseFloat(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.integer()])(`integers produce numeric strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) + }) + + describe(`bigint serialization`, () => { + fcTest.prop([fc.bigInt()])( + `bigints round-trip through BigInt parsing`, + (n) => { + const serialized = serialize(n) + expect(BigInt(serialized)).toBe(n) + }, + ) + + fcTest.prop([fc.bigInt()])(`bigints produce integer strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) + }) + + describe(`boolean serialization`, () => { + fcTest.prop([fc.boolean()])( + `booleans serialize to 'true' or 'false'`, + (b) => { + const serialized = serialize(b) + expect(serialized).toBe(b ? `true` : `false`) + }, + ) + + fcTest.prop([fc.boolean()])( + `booleans round-trip through string comparison`, + (b) => { + const serialized = serialize(b) + expect(serialized === `true`).toBe(b) + }, + ) + }) + + describe(`null/undefined serialization`, () => { + fcTest.prop([fc.constantFrom(null, undefined)])( + `null and undefined both serialize to empty string`, + (val) => { + expect(serialize(val)).toBe(``) + }, + ) + }) + + describe(`date serialization`, () => { + // Use bounded dates to avoid negative years which have different ISO format + const arbitraryBoundedDate = fc.date({ + noInvalidDate: true, + min: new Date(`0000-01-01T00:00:00.000Z`), + max: new Date(`9999-12-31T23:59:59.999Z`), + }) + + fcTest.prop([arbitraryBoundedDate])( + `dates produce valid ISO strings`, + (date) => { + const serialized = serialize(date) + expect(serialized).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ) + }, + ) + + fcTest.prop([arbitraryBoundedDate])( + `dates round-trip through Date parsing`, + (date) => { + const serialized = serialize(date) + const parsed = new Date(serialized) + expect(parsed.getTime()).toBe(date.getTime()) + }, + ) + }) + + describe(`array serialization`, () => { + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `integer arrays produce Postgres array format`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + }, + ) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `integer arrays can be parsed back`, + (arr) => { + const serialized = serialize(arr) + // Remove braces and parse + const inner = serialized.slice(1, -1) + if (inner === ``) { + expect(arr).toEqual([]) + } else { + const parsed = inner.split(`,`).map(Number) + expect(parsed).toEqual(arr) + } + }, + ) + + fcTest.prop([fc.array(fc.boolean(), { maxLength: 10 })])( + `boolean arrays serialize correctly`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + // Each element should be 'true' or 'false' + const inner = serialized.slice(1, -1) + if (inner !== ``) { + const elements = inner.split(`,`) + elements.forEach((el) => { + expect([`true`, `false`]).toContain(el) + }) + } + }, + ) + + fcTest.prop([fc.array(fc.constantFrom(null, undefined), { maxLength: 5 })])( + `arrays with null/undefined serialize to NULL`, + (arr) => { + const serialized = serialize(arr) + const inner = serialized.slice(1, -1) + if (inner !== ``) { + const elements = inner.split(`,`) + elements.forEach((el) => { + expect(el).toBe(`NULL`) + }) + } + }, + ) + }) + + describe(`string array escaping`, () => { + fcTest.prop([fc.array(fc.string(), { maxLength: 5 })])( + `string arrays are properly quoted`, + (arr) => { + const serialized = serialize(arr) + expect(serialized).toMatch(/^\{.*\}$/) + // Each string element should be quoted + }, + ) + + fcTest.prop([ + fc.string().filter((s) => s.includes(`"`) || s.includes(`\\`)), + ])(`strings with special chars are escaped`, (str) => { + const serialized = serialize([str]) + // Should contain escaped quotes or backslashes + if (str.includes(`"`)) { + expect(serialized).toContain(`\\"`) + } + if (str.includes(`\\`)) { + expect(serialized).toContain(`\\\\`) + } + }) + }) + + describe(`consistency properties`, () => { + fcTest.prop([ + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.date({ noInvalidDate: true }), + ), + ])(`serialize is deterministic`, (val) => { + const first = serialize(val) + const second = serialize(val) + expect(first).toBe(second) + }) + + fcTest.prop([fc.array(fc.integer(), { maxLength: 10 })])( + `array serialization is deterministic`, + (arr) => { + const first = serialize(arr) + const second = serialize(arr) + expect(first).toBe(second) + }, + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6a532eb9..b96c22038 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint/js': specifier: ^9.39.1 version: 9.39.1 + '@fast-check/vitest': + specifier: ^0.2.0 + version: 0.2.4(vitest@3.2.4) '@svitejs/changesets-changelog-github-compact': specifier: ^1.2.0 version: 1.2.0(encoding@0.1.13) @@ -59,6 +62,9 @@ importers: eslint-plugin-react: specifier: ^7.37.5 version: 7.37.5(eslint@9.39.1(jiti@2.6.1)) + fast-check: + specifier: ^3.23.0 + version: 3.23.2 husky: specifier: ^9.1.7 version: 9.1.7 @@ -2200,6 +2206,11 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@fast-check/vitest@0.2.4': + resolution: {integrity: sha512-Ilcr+JAIPhb1s6FRm4qoglQYSGXXrS+zAupZeNuWAA3qHVGDA1d1Gb84Hb/+otL3GzVZjFJESg5/1SfIvrgssA==} + peerDependencies: + vitest: ^1 || ^2 || ^3 || ^4 + '@firebase/ai@1.4.1': resolution: {integrity: sha512-bcusQfA/tHjUjBTnMx6jdoPMpDl3r8K15Z+snHz9wq0Foox0F/V+kNLXucEOHoTL2hTc9l+onZCyBJs2QoIC3g==} engines: {node: '>=18.0.0'} @@ -5676,6 +5687,10 @@ packages: extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + fast-check@3.23.2: + resolution: {integrity: sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==} + engines: {node: '>=8.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -7446,6 +7461,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + pure-rand@6.1.0: + resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -9971,6 +9989,11 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@fast-check/vitest@0.2.4(vitest@3.2.4)': + dependencies: + fast-check: 3.23.2 + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@24.7.0)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.2.0(postcss@8.5.6))(lightningcss@1.30.2)(sass@1.90.0)(terser@5.44.0)(tsx@4.21.0)(yaml@2.8.1) + '@firebase/ai@1.4.1(@firebase/app-types@0.9.3)(@firebase/app@0.13.2)': dependencies: '@firebase/app': 0.13.2 @@ -14086,6 +14109,10 @@ snapshots: extendable-error@0.1.7: {} + fast-check@3.23.2: + dependencies: + pure-rand: 6.1.0 + fast-deep-equal@3.1.3: {} fast-diff@1.3.0: {} @@ -15946,6 +15973,8 @@ snapshots: punycode@2.3.1: {} + pure-rand@6.1.0: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1