From b4a66374d5c24a6fbce8e3e16d1b79a1579b1360 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 25 Jun 2026 12:09:11 +0200 Subject: [PATCH 1/2] feat: adopt PostgreSQL float semantics for NaN in comparisons and ordering NaN (and invalid Dates, whose timestamp is NaN) had no consistent order: NaN === NaN is false in JS, so NaN compared unequal to everything and could not be sorted or stored in tree-based indexes deterministically. Following PostgreSQL, NaN is now treated as equal to itself and greater than every other non-null value: - ascComparator places NaN/invalid Dates last (greatest non-null), giving a valid total order so they can be sorted and indexed. - The WHERE evaluator's eq/gt/gte/lt/lte/in operators implement the same semantics: eq(x, NaN) matches NaN rows, range comparisons treat NaN as greatest, and IN matches a NaN member. null/undefined are unchanged (three-valued logic). Because the index ordering and the evaluator now agree on NaN, index-served and full-scan queries return identical results without any re-filtering. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/nan-postgres-semantics.md | 15 +++ docs/guides/live-queries.md | 7 ++ packages/db/src/query/compiler/evaluators.ts | 40 ++++++- packages/db/src/utils/comparison.ts | 25 ++++ packages/db/tests/comparison.test.ts | 41 +++++++ packages/db/tests/nan-semantics.test.ts | 113 ++++++++++++++++++ .../tests/query/compiler/evaluators.test.ts | 69 +++++++++++ 7 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 .changeset/nan-postgres-semantics.md create mode 100644 packages/db/tests/comparison.test.ts create mode 100644 packages/db/tests/nan-semantics.test.ts diff --git a/.changeset/nan-postgres-semantics.md b/.changeset/nan-postgres-semantics.md new file mode 100644 index 0000000000..33ace19449 --- /dev/null +++ b/.changeset/nan-postgres-semantics.md @@ -0,0 +1,15 @@ +--- +"@tanstack/db": minor +--- + +Adopt PostgreSQL float semantics for `NaN` in `where` clauses and ordering. + +`NaN` (and invalid `Date` values, whose timestamp is `NaN`) previously had no consistent order — `NaN === NaN` is `false` in JavaScript, so `NaN` compared unequal to everything and could not be sorted or indexed deterministically. Following PostgreSQL, `NaN` is now treated as **equal to itself** and **greater than every other non-null value**: + +- `eq(row.value, NaN)` matches rows whose value is `NaN`; `inArray(row.value, [NaN, ...])` matches them too. +- Range comparisons treat `NaN` as the greatest value: `gt`/`gte` include it, `lt`/`lte` exclude it. +- Ordering by a field containing `NaN` is now deterministic, with `NaN` sorting last (and `null` still ordered by `NULLS FIRST`/`NULLS LAST`). + +`null`/`undefined` are unaffected: they continue to use three-valued logic (a comparison with `null` yields `UNKNOWN`). + +This makes results independent of whether a query is served from an index or a full scan. diff --git a/docs/guides/live-queries.md b/docs/guides/live-queries.md index 988628261a..f7c3408952 100644 --- a/docs/guides/live-queries.md +++ b/docs/guides/live-queries.md @@ -728,6 +728,13 @@ not(condition) For a complete reference of all available functions, see the [Expression Functions Reference](#expression-functions-reference) section. +### Comparison semantics + +Comparisons follow SQL/PostgreSQL conventions rather than raw JavaScript: + +- **`null` / `undefined` use three-valued logic.** Any comparison involving `null` or `undefined` evaluates to `UNKNOWN`, so the row is not matched. For example `eq(user.score, null)` matches nothing — use a dedicated null check (e.g. `isUndefined`) to match missing values. +- **`NaN` follows PostgreSQL float semantics.** `NaN` is treated as equal to itself and greater than every other (non-null) value. So `eq(row.value, NaN)` matches `NaN` rows, `gt(row.value, x)` includes `NaN`, and ordering by such a field places `NaN` last. (Invalid `Date` values, whose timestamp is `NaN`, behave the same way.) This differs from JavaScript, where `NaN === NaN` is `false`, and matches how PostgreSQL orders and indexes floating-point values. + ## Select Use `select` to specify which fields to include in your results and transform your data. Without `select`, you get the full schema. diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 929ac56dfb..fa2e90725d 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -3,7 +3,11 @@ import { UnknownExpressionTypeError, UnknownFunctionError, } from '../../errors.js' -import { areValuesEqual, normalizeValue } from '../../utils/comparison.js' +import { + areValuesEqual, + isUnorderable, + normalizeValue, +} from '../../utils/comparison.js' import type { BasicExpression, Func, PropRef } from '../ir.js' import type { NamespacedRow } from '../../types.js' @@ -14,6 +18,19 @@ function isUnknown(value: any): boolean { return value === null || value === undefined } +/** + * Equality that follows PostgreSQL float semantics for `NaN`/invalid Dates: + * such values are equal to one another and unequal to anything else. For all + * other values it defers to {@link areValuesEqual}. Operands must not be + * null/undefined (callers handle UNKNOWN first). + */ +function valuesEqual(a: any, b: any): boolean { + if (isUnorderable(a) || isUnorderable(b)) { + return isUnorderable(a) && isUnorderable(b) + } + return areValuesEqual(a, b) +} + function toDateValue(value: any): Date | null { if (value instanceof Date) { return Number.isNaN(value.getTime()) ? null : value @@ -233,8 +250,9 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } - // Use areValuesEqual for proper Uint8Array/Buffer comparison - return areValuesEqual(a, b) + // NaN/invalid Dates are equal to one another (PostgreSQL semantics); + // otherwise use areValuesEqual for proper Uint8Array/Buffer comparison + return valuesEqual(a, b) } } case `gt`: { @@ -247,6 +265,11 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } + // NaN/invalid Dates sort greater than every other value, and are equal + // to one another (PostgreSQL semantics) + if (isUnorderable(a) || isUnorderable(b)) { + return isUnorderable(a) && !isUnorderable(b) + } return a > b } } @@ -260,6 +283,9 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } + if (isUnorderable(a) || isUnorderable(b)) { + return isUnorderable(a) + } return a >= b } } @@ -273,6 +299,9 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } + if (isUnorderable(a) || isUnorderable(b)) { + return isUnorderable(b) && !isUnorderable(a) + } return a < b } } @@ -286,6 +315,9 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (isUnknown(a) || isUnknown(b)) { return null } + if (isUnorderable(a) || isUnorderable(b)) { + return isUnorderable(b) + } return a <= b } } @@ -370,7 +402,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (!Array.isArray(array)) { return false } - return array.some((item) => normalizeValue(item) === value) + return array.some((item) => valuesEqual(normalizeValue(item), value)) } } diff --git a/packages/db/src/utils/comparison.ts b/packages/db/src/utils/comparison.ts index bf5ac1a913..992f0098c5 100644 --- a/packages/db/src/utils/comparison.ts +++ b/packages/db/src/utils/comparison.ts @@ -17,6 +17,21 @@ function getObjectId(obj: object): number { return id } +/** + * Whether a value has no IEEE-754 natural order: `NaN`, or an invalid Date + * (whose timestamp is `NaN`). The query engine follows PostgreSQL float + * semantics for these values — they are all equal to one another and greater + * than every other (non-null) value — so the comparator and the WHERE + * evaluator treat them explicitly instead of letting `NaN` compare unequal to + * everything (which has no consistent order and cannot be indexed or sorted). + */ +export function isUnorderable(value: any): boolean { + return ( + (typeof value === `number` && Number.isNaN(value)) || + (value instanceof Date && Number.isNaN(value.getTime())) + ) +} + /** * Universal comparison function for all data types * Handles null/undefined, strings, arrays, dates, objects, and primitives @@ -30,6 +45,16 @@ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => { if (a == null) return nulls === `first` ? -1 : 1 if (b == null) return nulls === `first` ? 1 : -1 + // Handle NaN / invalid Dates. Following PostgreSQL float semantics, they are + // all equal and sort greater than every other non-null value. This keeps the + // order total (NaN would otherwise compare equal to everything), so such + // values can be sorted and stored in tree-based indexes. + const aUnordered = isUnorderable(a) + const bUnordered = isUnorderable(b) + if (aUnordered && bUnordered) return 0 + if (aUnordered) return 1 + if (bUnordered) return -1 + // if a and b are both strings, compare them based on locale if (typeof a === `string` && typeof b === `string`) { if (opts.stringSort === `locale`) { diff --git a/packages/db/tests/comparison.test.ts b/packages/db/tests/comparison.test.ts new file mode 100644 index 0000000000..ae40488f43 --- /dev/null +++ b/packages/db/tests/comparison.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { ascComparator, defaultComparator } from '../src/utils/comparison' +import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' + +describe(`ascComparator - PostgreSQL float semantics for NaN`, () => { + const opts = DEFAULT_COMPARE_OPTIONS // nulls: `first` + + it(`orders NaN greater than every number`, () => { + expect(ascComparator(NaN, 5, opts)).toBeGreaterThan(0) + expect(ascComparator(5, NaN, opts)).toBeLessThan(0) + }) + + it(`treats NaN as equal to NaN`, () => { + expect(ascComparator(NaN, NaN, opts)).toBe(0) + }) + + it(`produces a stable total order with NaN sorting last`, () => { + const sorted = [3, NaN, 1, 5, NaN].sort((a, b) => defaultComparator(a, b)) + + expect(sorted.slice(0, 3)).toEqual([1, 3, 5]) + expect(sorted.slice(3).every((v) => Number.isNaN(v))).toBe(true) + }) + + it(`keeps null before non-null values regardless of NaN`, () => { + // nulls still sort first by default; NaN sorts last (greatest non-null) + const sorted = [5, NaN, null, 1].sort((a, b) => defaultComparator(a, b)) + + expect(sorted[0]).toBe(null) + expect(sorted[1]).toBe(1) + expect(sorted[2]).toBe(5) + expect(Number.isNaN(sorted[3])).toBe(true) + }) + + it(`orders an invalid Date greater than valid Dates`, () => { + const invalid = new Date(`not a date`) + const valid = new Date(`2023-01-01`) + + expect(ascComparator(invalid, valid, opts)).toBeGreaterThan(0) + expect(ascComparator(valid, invalid, opts)).toBeLessThan(0) + }) +}) diff --git a/packages/db/tests/nan-semantics.test.ts b/packages/db/tests/nan-semantics.test.ts new file mode 100644 index 0000000000..f81b5ca4e9 --- /dev/null +++ b/packages/db/tests/nan-semantics.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { eq, gt, inArray, lt } from '../src/query/builder/functions' +import { PropRef } from '../src/query/ir' +import { BTreeIndex } from '../src/indexes/btree-index.js' +import type { BasicExpression } from '../src/query/ir' + +/** + * The query engine follows PostgreSQL float semantics for `NaN`: it is equal to + * itself and greater than every other (non-null) value, so it has a + * well-defined order and behaves like a normal value in `where` clauses and + * `orderBy`. These tests also assert that querying with an index produces the + * same result as a full scan. + */ +interface Row { + id: string + score: number +} + +const data: Array = [ + { id: `nan`, score: NaN }, + { id: `one`, score: 1 }, + { id: `three`, score: 3 }, + { id: `five`, score: 5 }, + { id: `seven`, score: 7 }, +] + +function makeCollection() { + const collection = createCollection({ + getKey: (row) => row.id, + startSync: true, + autoIndex: `off`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const row of data) write({ type: `insert`, value: row }) + commit() + markReady() + }, + }, + }) + return collection +} + +async function queryBothPaths(where: BasicExpression) { + const indexed = makeCollection() + await indexed.stateWhenReady() + indexed.createIndex((row) => row.score) + const fullScan = makeCollection() + await fullScan.stateWhenReady() + + const indexedIds = indexed + .currentStateAsChanges({ where })! + .map((c) => c.value.id) + .sort() + const fullScanIds = fullScan + .currentStateAsChanges({ where })! + .map((c) => c.value.id) + .sort() + + // The chosen execution strategy must not change the result + expect(indexedIds).toEqual(fullScanIds) + return indexedIds +} + +describe(`NaN query semantics (PostgreSQL float semantics)`, () => { + it(`matches NaN rows for equality on NaN`, async () => { + expect(await queryBothPaths(eq(new PropRef([`score`]), NaN))).toEqual([`nan`]) + }) + + it(`treats NaN as greater than every number in a range query`, async () => { + // score > 2 matches 3, 5, 7 and NaN (NaN is greatest) + expect(await queryBothPaths(gt(new PropRef([`score`]), 2))).toEqual([ + `five`, + `nan`, + `seven`, + `three`, + ]) + }) + + it(`excludes NaN from a less-than range query`, async () => { + // score < 4 matches 1 and 3, but not NaN (NaN is greatest) + expect(await queryBothPaths(lt(new PropRef([`score`]), 4))).toEqual([ + `one`, + `three`, + ]) + }) + + it(`matches a NaN member of an IN list`, async () => { + expect( + await queryBothPaths(inArray(new PropRef([`score`]), [NaN, 3])), + ).toEqual([`nan`, `three`]) + }) + + it(`orders NaN last when sorting ascending`, async () => { + const collection = makeCollection() + await collection.stateWhenReady() + + const ordered = collection + .currentStateAsChanges({ + orderBy: [ + { + expression: new PropRef([`score`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + })! + .map((c) => c.value.id) + + expect(ordered).toEqual([`one`, `three`, `five`, `seven`, `nan`]) + }) +}) diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 69969de18a..190d4f0274 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -730,6 +730,75 @@ describe(`evaluators`, () => { expect(compiled({})).toBe(null) }) }) + + describe(`NaN (PostgreSQL float semantics)`, () => { + // Following PostgreSQL, NaN is equal to itself and greater than every + // other (non-null) value, so it has a well-defined order. + it(`treats NaN as equal to NaN`, () => { + const func = new Func(`eq`, [new Value(NaN), new Value(NaN)]) + expect(compileExpression(func)({})).toBe(true) + }) + + it(`treats NaN as not equal to a number`, () => { + const func = new Func(`eq`, [new Value(NaN), new Value(5)]) + expect(compileExpression(func)({})).toBe(false) + }) + + it(`still returns UNKNOWN when comparing NaN with null`, () => { + const func = new Func(`eq`, [new Value(NaN), new Value(null)]) + expect(compileExpression(func)({})).toBe(null) + }) + + it(`treats NaN as greater than every number`, () => { + expect( + compileExpression(new Func(`gt`, [new Value(NaN), new Value(5)]))({}), + ).toBe(true) + expect( + compileExpression(new Func(`gt`, [new Value(5), new Value(NaN)]))({}), + ).toBe(false) + expect( + compileExpression(new Func(`gt`, [new Value(NaN), new Value(NaN)]))( + {}, + ), + ).toBe(false) + }) + + it(`orders NaN with gte/lt/lte consistently`, () => { + // NaN >= anything (including NaN); nothing finite >= NaN + expect( + compileExpression(new Func(`gte`, [new Value(NaN), new Value(5)]))({}), + ).toBe(true) + expect( + compileExpression(new Func(`gte`, [new Value(NaN), new Value(NaN)]))( + {}, + ), + ).toBe(true) + // NaN < nothing; a finite value < NaN + expect( + compileExpression(new Func(`lt`, [new Value(NaN), new Value(5)]))({}), + ).toBe(false) + expect( + compileExpression(new Func(`lt`, [new Value(5), new Value(NaN)]))({}), + ).toBe(true) + // NaN <= NaN; a finite value <= NaN + expect( + compileExpression(new Func(`lte`, [new Value(NaN), new Value(NaN)]))( + {}, + ), + ).toBe(true) + expect( + compileExpression(new Func(`lte`, [new Value(5), new Value(NaN)]))({}), + ).toBe(true) + }) + + it(`matches NaN inside an IN list`, () => { + const func = new Func(`in`, [ + new Value(NaN), + new Value([NaN, 1, 2]), + ]) + expect(compileExpression(func)({})).toBe(true) + }) + }) }) describe(`boolean operators`, () => { From b024d51c51f2813e5ab71c4dca1f53ede9dac219 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 25 Jun 2026 10:11:01 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- .changeset/nan-postgres-semantics.md | 2 +- packages/db/tests/nan-semantics.test.ts | 4 +- .../tests/query/compiler/evaluators.test.ts | 42 ++++++++++++------- 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.changeset/nan-postgres-semantics.md b/.changeset/nan-postgres-semantics.md index 33ace19449..63d7d1b2b9 100644 --- a/.changeset/nan-postgres-semantics.md +++ b/.changeset/nan-postgres-semantics.md @@ -1,5 +1,5 @@ --- -"@tanstack/db": minor +'@tanstack/db': minor --- Adopt PostgreSQL float semantics for `NaN` in `where` clauses and ordering. diff --git a/packages/db/tests/nan-semantics.test.ts b/packages/db/tests/nan-semantics.test.ts index f81b5ca4e9..4566092f66 100644 --- a/packages/db/tests/nan-semantics.test.ts +++ b/packages/db/tests/nan-semantics.test.ts @@ -66,7 +66,9 @@ async function queryBothPaths(where: BasicExpression) { describe(`NaN query semantics (PostgreSQL float semantics)`, () => { it(`matches NaN rows for equality on NaN`, async () => { - expect(await queryBothPaths(eq(new PropRef([`score`]), NaN))).toEqual([`nan`]) + expect(await queryBothPaths(eq(new PropRef([`score`]), NaN))).toEqual([ + `nan`, + ]) }) it(`treats NaN as greater than every number in a range query`, async () => { diff --git a/packages/db/tests/query/compiler/evaluators.test.ts b/packages/db/tests/query/compiler/evaluators.test.ts index 190d4f0274..4c5acb78c1 100644 --- a/packages/db/tests/query/compiler/evaluators.test.ts +++ b/packages/db/tests/query/compiler/evaluators.test.ts @@ -751,43 +751,55 @@ describe(`evaluators`, () => { it(`treats NaN as greater than every number`, () => { expect( - compileExpression(new Func(`gt`, [new Value(NaN), new Value(5)]))({}), + compileExpression(new Func(`gt`, [new Value(NaN), new Value(5)]))( + {}, + ), ).toBe(true) expect( - compileExpression(new Func(`gt`, [new Value(5), new Value(NaN)]))({}), - ).toBe(false) - expect( - compileExpression(new Func(`gt`, [new Value(NaN), new Value(NaN)]))( + compileExpression(new Func(`gt`, [new Value(5), new Value(NaN)]))( {}, ), ).toBe(false) + expect( + compileExpression( + new Func(`gt`, [new Value(NaN), new Value(NaN)]), + )({}), + ).toBe(false) }) it(`orders NaN with gte/lt/lte consistently`, () => { // NaN >= anything (including NaN); nothing finite >= NaN expect( - compileExpression(new Func(`gte`, [new Value(NaN), new Value(5)]))({}), + compileExpression( + new Func(`gte`, [new Value(NaN), new Value(5)]), + )({}), ).toBe(true) expect( - compileExpression(new Func(`gte`, [new Value(NaN), new Value(NaN)]))( - {}, - ), + compileExpression( + new Func(`gte`, [new Value(NaN), new Value(NaN)]), + )({}), ).toBe(true) // NaN < nothing; a finite value < NaN expect( - compileExpression(new Func(`lt`, [new Value(NaN), new Value(5)]))({}), + compileExpression(new Func(`lt`, [new Value(NaN), new Value(5)]))( + {}, + ), ).toBe(false) expect( - compileExpression(new Func(`lt`, [new Value(5), new Value(NaN)]))({}), + compileExpression(new Func(`lt`, [new Value(5), new Value(NaN)]))( + {}, + ), ).toBe(true) // NaN <= NaN; a finite value <= NaN expect( - compileExpression(new Func(`lte`, [new Value(NaN), new Value(NaN)]))( - {}, - ), + compileExpression( + new Func(`lte`, [new Value(NaN), new Value(NaN)]), + )({}), ).toBe(true) expect( - compileExpression(new Func(`lte`, [new Value(5), new Value(NaN)]))({}), + compileExpression( + new Func(`lte`, [new Value(5), new Value(NaN)]), + )({}), ).toBe(true) })