Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/nan-postgres-semantics.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 7 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 36 additions & 4 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -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`: {
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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))
}
}

Expand Down
25 changes: 25 additions & 0 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`) {
Expand Down
41 changes: 41 additions & 0 deletions packages/db/tests/comparison.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
115 changes: 115 additions & 0 deletions packages/db/tests/nan-semantics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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<Row> = [
{ 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<Row, string>({
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<boolean>) {
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`])
})
})
Loading
Loading