Skip to content
Open
12 changes: 12 additions & 0 deletions .changeset/temporal-comparators.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/db': patch
---

Support Temporal values in the `gt`/`gte`/`lt`/`lte` query operators. Comparisons now dispatch to the Temporal types' static `compare()` instead of the native relational operators, which throw on Temporal objects (`valueOf()` is designed to throw). `orderBy` uses the same logic, so filtering and ordering now agree — previously `orderBy` compared Temporal values lexicographically by their `toString()`, which mis-ordered equivalent `Duration` forms (`PT60M` vs `PT1H`) and same-instant `ZonedDateTime` values in different zones.

Note two intentional behavior changes for `orderBy` over Temporal columns:

- Ordering `Temporal.PlainMonthDay` values now throws a `TypeError`, since the type has no defined ordering (previously they were silently ordered by string).
- Ordering mixed Temporal types (e.g. `PlainDate` vs `PlainDateTime`) now throws a `TypeError` instead of comparing their string forms.

Equality (`eq`) is unchanged: `ZonedDateTime` equality still treats the zone as part of identity, and equivalent `Duration` forms remain unequal, mirroring Temporal's `.equals()` vs `.compare()` semantics.
11 changes: 5 additions & 6 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
} from '../../errors.js'
import {
areValuesEqual,
compareValues,
isUnorderable,
normalizeValue,
} from '../../utils/comparison.js'
Expand Down Expand Up @@ -265,12 +266,10 @@ 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
return compareValues(a, b) > 0
}
}
case `gte`: {
Expand All @@ -286,7 +285,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
if (isUnorderable(a) || isUnorderable(b)) {
return isUnorderable(a)
}
return a >= b
return compareValues(a, b) >= 0
}
}
case `lt`: {
Expand All @@ -302,7 +301,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
if (isUnorderable(a) || isUnorderable(b)) {
return isUnorderable(b) && !isUnorderable(a)
}
return a < b
return compareValues(a, b) < 0
}
}
case `lte`: {
Expand All @@ -318,7 +317,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any {
if (isUnorderable(a) || isUnorderable(b)) {
return isUnorderable(b)
}
return a <= b
return compareValues(a, b) <= 0
}
}

Expand Down
70 changes: 64 additions & 6 deletions packages/db/src/utils/comparison.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,9 @@ export const ascComparator = (a: any, b: any, opts: CompareOptions): number => {
return a.getTime() - b.getTime()
}

// If both are Temporal objects of the same type, compare by string representation
// If both are Temporal objects, use compareTemporalValues for correct semantic ordering
if (isTemporal(a) && isTemporal(b)) {
const aStr = a.toString()
const bStr = b.toString()
if (aStr < bStr) return -1
if (aStr > bStr) return 1
return 0
return compareTemporalValues(a, b)
}

// If at least one of the values is an object, use stable IDs for comparison
Expand Down Expand Up @@ -237,6 +233,68 @@ export function denormalizeUndefined(value: any): any {
return value
}

// Cached map from Symbol.toStringTag → static compare function (null = none defined).
// Populated lazily on first encounter of each Temporal type so we never access
// `.constructor` more than once per type, and dispatch is keyed on the already-
// computed brand tag rather than on the constructor itself.
const temporalCompareByTag = new Map<
string,
((a: unknown, b: unknown) => number) | null
>()

/**
* Compare two Temporal values of the same type, returning -1, 0, or 1.
*
* Dispatch is keyed on `Symbol.toStringTag` (the brand already checked by
* `isTemporal`) rather than `a.constructor`, making it robust across realms
* and resistant to a shadowed `constructor` property. Types without a static
* `.compare` (e.g. `PlainMonthDay`) throw rather than fall back to string
* comparison, matching Temporal's design intent.
*
* Callers must ensure both arguments are Temporal objects; mixed types throw.
*/
export function compareTemporalValues(a: unknown, b: unknown): number {
const aTag = (a as Record<symbol, unknown>)[Symbol.toStringTag] as string
const bTag = (b as Record<symbol, unknown>)[Symbol.toStringTag] as string
if (aTag !== bTag) {
throw new TypeError(
`Cannot order Temporal values of different types: ${aTag} vs ${bTag}`,
)
}
let compare = temporalCompareByTag.get(aTag)
if (compare === undefined) {
const fn = (
(a as { constructor: unknown }).constructor as {
compare?: (x: unknown, y: unknown) => number
}
).compare
compare = typeof fn === `function` ? fn : null
temporalCompareByTag.set(aTag, compare)
}
if (compare === null) {
throw new TypeError(`${aTag} has no defined ordering`)
}
return compare(a, b)
}

/**
* Order two non-null values, returning -1, 0, or 1.
*
* Temporal types intentionally throw from `valueOf` to prevent silent
* miscomparison via the native relational operators — delegate to
* `compareTemporalValues` for them. For everything else (numbers, strings,
* Dates via `valueOf`, etc.) the native operators do the right thing.
*
* Callers must handle null/undefined themselves — this helper assumes both
* arguments are non-null.
*/
export function compareValues(a: unknown, b: unknown): number {
if (isTemporal(a) && isTemporal(b)) {
return compareTemporalValues(a, b)
}
return (a as any) < (b as any) ? -1 : (a as any) > (b as any) ? 1 : 0
}

/**
* Compare two values for equality, with special handling for Uint8Arrays and Buffers
*/
Expand Down
42 changes: 41 additions & 1 deletion packages/db/tests/comparison.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest'
import { ascComparator, defaultComparator } from '../src/utils/comparison'
import { Temporal } from 'temporal-polyfill'
import {
ascComparator,
compareValues,
defaultComparator,
} from '../src/utils/comparison'
import { DEFAULT_COMPARE_OPTIONS } from '../src/utils'

describe(`ascComparator - PostgreSQL float semantics for NaN`, () => {
Expand Down Expand Up @@ -39,3 +44,38 @@ describe(`ascComparator - PostgreSQL float semantics for NaN`, () => {
expect(ascComparator(valid, invalid, opts)).toBeLessThan(0)
})
})

describe(`ascComparator - Temporal values`, () => {
const opts = DEFAULT_COMPARE_OPTIONS

it(`orders PlainDate values by calendar date`, () => {
const earlier = new Temporal.PlainDate(2024, 1, 1)
const later = new Temporal.PlainDate(2024, 6, 1)
expect(ascComparator(earlier, later, opts)).toBeLessThan(0)
expect(ascComparator(later, earlier, opts)).toBeGreaterThan(0)
expect(
ascComparator(earlier, new Temporal.PlainDate(2024, 1, 1), opts),
).toBe(0)
})

it(`treats ZonedDateTime values at the same instant as equal regardless of zone`, () => {
// 2024-01-15T06:30Z and 2024-01-15T12:00+05:30 are the same instant;
// lexicographic toString comparison would order them differently.
const utc = Temporal.ZonedDateTime.from(`2024-01-15T06:30:00+00:00[+00:00]`)
const ist = Temporal.ZonedDateTime.from(`2024-01-15T12:00:00+05:30[+05:30]`)
expect(ascComparator(utc, ist, opts)).toBe(0)
})
})

describe(`compareValues - NaN behavior`, () => {
// NaN satisfies neither < nor >, so the fallback returns 0. In practice
// gt/gte/lt/lte catch NaN via isUnorderable before reaching compareValues.
it(`treats NaN as equal to NaN`, () => {
expect(compareValues(NaN, NaN)).toBe(0)
})

it(`returns 0 for NaN vs a finite number — neither < nor > holds for NaN`, () => {
expect(compareValues(NaN, 5)).toBe(0)
expect(compareValues(5, NaN)).toBe(0)
})
})
Loading
Loading