From 30b1bc329140ef51cefe9d63fcf5dbd11199def8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hi=E1=BA=BFu=20Nguy=E1=BB=85n=20Minh?= Date: Tue, 10 Feb 2026 10:20:40 +0700 Subject: [PATCH 1/7] fix: normalize keys before join operation --- packages/db/src/query/compiler/joins.ts | 5 +- packages/db/tests/query/join-date.test.ts | 125 ++++++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 packages/db/tests/query/join-date.test.ts diff --git a/packages/db/src/query/compiler/joins.ts b/packages/db/src/query/compiler/joins.ts index 5dcb7ed39..69b833cf0 100644 --- a/packages/db/src/query/compiler/joins.ts +++ b/packages/db/src/query/compiler/joins.ts @@ -11,6 +11,7 @@ import { UnsupportedJoinSourceTypeError, UnsupportedJoinTypeError, } from '../../errors.js' +import { normalizeValue } from '../../utils/comparison.js' import { ensureIndexForField } from '../../indexes/auto-index.js' import { PropRef, followRef } from '../ir.js' import { inArray } from '../builder/functions.js' @@ -188,7 +189,7 @@ function processJoin( let mainPipeline = pipeline.pipe( map(([currentKey, namespacedRow]) => { // Extract the join key from the main source expression - const mainKey = compiledMainExpr(namespacedRow) + const mainKey = normalizeValue(compiledMainExpr(namespacedRow)) // Return [joinKey, [originalKey, namespacedRow]] return [mainKey, [currentKey, namespacedRow]] as [ @@ -205,7 +206,7 @@ function processJoin( const namespacedRow: NamespacedRow = { [joinedSource]: row } // Extract the join key from the joined source expression - const joinedKey = compiledJoinedExpr(namespacedRow) + const joinedKey = normalizeValue(compiledJoinedExpr(namespacedRow)) // Return [joinKey, [originalKey, namespacedRow]] return [joinedKey, [currentKey, namespacedRow]] as [ diff --git a/packages/db/tests/query/join-date.test.ts b/packages/db/tests/query/join-date.test.ts new file mode 100644 index 000000000..dfb532695 --- /dev/null +++ b/packages/db/tests/query/join-date.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +type LeftRow = { + id: number + joinedAt: Date + name: string +} + +type RightRow = { + id: number + joinedAt: Date + label: string +} + +function createLeftCollection( + autoIndex: `off` | `eager`, + initialData: Array, +) { + return createCollection( + mockSyncCollectionOptions({ + id: `join-date-left-${autoIndex}`, + getKey: (row) => row.id, + initialData, + autoIndex, + }), + ) +} + +function createRightCollection( + autoIndex: `off` | `eager`, + initialData: Array, +) { + return createCollection( + mockSyncCollectionOptions({ + id: `join-date-right-${autoIndex}`, + getKey: (row) => row.id, + initialData, + autoIndex, + }), + ) +} + +describe.each([`off`, `eager`] as const)( + `Date joins with autoIndex %s`, + (autoIndex) => { + const baseTimestamp = Date.parse(`2025-01-15T12:34:56.789Z`) + + test(`matches Date join keys by timestamp instead of object reference`, () => { + const leftData: Array = [ + { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, + ] + const rightData: Array = [ + { id: 10, joinedAt: new Date(baseTimestamp), label: `right-10` }, + ] + + // Guard against accidentally sharing the same Date object instance. + expect(leftData[0]!.joinedAt).not.toBe(rightData[0]!.joinedAt) + expect(leftData[0]!.joinedAt.getTime()).toBe( + rightData[0]!.joinedAt.getTime(), + ) + + const leftCollection = createLeftCollection(autoIndex, leftData) + const rightCollection = createRightCollection(autoIndex, rightData) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: leftCollection }) + .innerJoin({ right: rightCollection }, ({ left, right }) => + eq(left.joinedAt, right.joinedAt), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) + }) + + test(`updates Date join matches when timestamp changes`, () => { + const leftCollection = createLeftCollection(autoIndex, [ + { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, + ]) + const rightCollection = createRightCollection(autoIndex, [ + { + id: 10, + joinedAt: new Date(baseTimestamp + 1), + label: `right-10`, + }, + ]) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: leftCollection }) + .innerJoin({ right: rightCollection }, ({ left, right }) => + eq(left.joinedAt, right.joinedAt), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + expect(query.toArray).toHaveLength(0) + + rightCollection.utils.begin() + rightCollection.utils.write({ + type: `update`, + value: { id: 10, joinedAt: new Date(baseTimestamp), label: `right-10` }, + }) + rightCollection.utils.commit() + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) + }) + }, +) From 2610a5cc076d75109f6d2b953d62b28cd2d4586b Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 14:23:44 +0100 Subject: [PATCH 2/7] fix: normalize value in `in` evaluator for join lazy-loading optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The join lazy-loading path passes normalized join keys (e.g. Date→timestamp) into `inArray`, but the `in` evaluator compared raw field values against them using `Array.includes` (strict equality). This meant the optimization silently fell back to loading the full collection for Date-keyed joins. Normalize the value in the `in` evaluator, consistent with how `eq` already normalizes both operands. Co-Authored-By: Claude Opus 4.6 --- packages/db/src/query/compiler/evaluators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 5e44e5bcd..0bcff0272 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -336,7 +336,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { const valueEvaluator = compiledArgs[0]! const arrayEvaluator = compiledArgs[1]! return (data) => { - const value = valueEvaluator(data) + const value = normalizeValue(valueEvaluator(data)) const array = arrayEvaluator(data) // In 3-valued logic, if the value is null/undefined, return UNKNOWN if (isUnknown(value)) { From 36c98a0a2c4d047595188a04bce925dd53499ecf Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 14:34:10 +0100 Subject: [PATCH 3/7] refactor: move Date join tests into join.test.ts Move the two Date join tests from the separate join-date.test.ts file into the existing Complex Join Scenarios block in join.test.ts, keeping all join tests in one place. Co-Authored-By: Claude Opus 4.6 --- packages/db/tests/query/join-date.test.ts | 125 ---------------------- packages/db/tests/query/join.test.ts | 116 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 125 deletions(-) delete mode 100644 packages/db/tests/query/join-date.test.ts diff --git a/packages/db/tests/query/join-date.test.ts b/packages/db/tests/query/join-date.test.ts deleted file mode 100644 index dfb532695..000000000 --- a/packages/db/tests/query/join-date.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { createCollection } from '../../src/collection/index.js' -import { createLiveQueryCollection, eq } from '../../src/query/index.js' -import { mockSyncCollectionOptions } from '../utils.js' - -type LeftRow = { - id: number - joinedAt: Date - name: string -} - -type RightRow = { - id: number - joinedAt: Date - label: string -} - -function createLeftCollection( - autoIndex: `off` | `eager`, - initialData: Array, -) { - return createCollection( - mockSyncCollectionOptions({ - id: `join-date-left-${autoIndex}`, - getKey: (row) => row.id, - initialData, - autoIndex, - }), - ) -} - -function createRightCollection( - autoIndex: `off` | `eager`, - initialData: Array, -) { - return createCollection( - mockSyncCollectionOptions({ - id: `join-date-right-${autoIndex}`, - getKey: (row) => row.id, - initialData, - autoIndex, - }), - ) -} - -describe.each([`off`, `eager`] as const)( - `Date joins with autoIndex %s`, - (autoIndex) => { - const baseTimestamp = Date.parse(`2025-01-15T12:34:56.789Z`) - - test(`matches Date join keys by timestamp instead of object reference`, () => { - const leftData: Array = [ - { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, - ] - const rightData: Array = [ - { id: 10, joinedAt: new Date(baseTimestamp), label: `right-10` }, - ] - - // Guard against accidentally sharing the same Date object instance. - expect(leftData[0]!.joinedAt).not.toBe(rightData[0]!.joinedAt) - expect(leftData[0]!.joinedAt.getTime()).toBe( - rightData[0]!.joinedAt.getTime(), - ) - - const leftCollection = createLeftCollection(autoIndex, leftData) - const rightCollection = createRightCollection(autoIndex, rightData) - - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .innerJoin({ right: rightCollection }, ({ left, right }) => - eq(left.joinedAt, right.joinedAt), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - - expect(query.toArray).toHaveLength(1) - expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) - }) - - test(`updates Date join matches when timestamp changes`, () => { - const leftCollection = createLeftCollection(autoIndex, [ - { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, - ]) - const rightCollection = createRightCollection(autoIndex, [ - { - id: 10, - joinedAt: new Date(baseTimestamp + 1), - label: `right-10`, - }, - ]) - - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left: leftCollection }) - .innerJoin({ right: rightCollection }, ({ left, right }) => - eq(left.joinedAt, right.joinedAt), - ) - .select(({ left, right }) => ({ - leftId: left.id, - rightId: right.id, - })), - }) - - expect(query.toArray).toHaveLength(0) - - rightCollection.utils.begin() - rightCollection.utils.write({ - type: `update`, - value: { id: 10, joinedAt: new Date(baseTimestamp), label: `right-10` }, - }) - rightCollection.utils.commit() - - expect(query.toArray).toHaveLength(1) - expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) - }) - }, -) diff --git a/packages/db/tests/query/join.test.ts b/packages/db/tests/query/join.test.ts index edb632c30..d0918564e 100644 --- a/packages/db/tests/query/join.test.ts +++ b/packages/db/tests/query/join.test.ts @@ -982,6 +982,122 @@ function createJoinTests(autoIndex: `off` | `eager`): void { expect(joinQuery.size).toBe(3) }) + + test(`should match Date join keys by timestamp instead of object reference`, () => { + type DateLeft = { id: number; joinedAt: Date; name: string } + type DateRight = { id: number; joinedAt: Date; label: string } + + const baseTimestamp = Date.parse(`2025-01-15T12:34:56.789Z`) + + const leftData: Array = [ + { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, + ] + const rightData: Array = [ + { id: 10, joinedAt: new Date(baseTimestamp), label: `right-10` }, + ] + + // Guard against accidentally sharing the same Date object instance. + expect(leftData[0]!.joinedAt).not.toBe(rightData[0]!.joinedAt) + expect(leftData[0]!.joinedAt.getTime()).toBe( + rightData[0]!.joinedAt.getTime(), + ) + + const leftCollection = createCollection( + mockSyncCollectionOptions({ + id: `join-date-left-${autoIndex}`, + getKey: (row) => row.id, + initialData: leftData, + autoIndex, + }), + ) + const rightCollection = createCollection( + mockSyncCollectionOptions({ + id: `join-date-right-${autoIndex}`, + getKey: (row) => row.id, + initialData: rightData, + autoIndex, + }), + ) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: leftCollection }) + .innerJoin({ right: rightCollection }, ({ left, right }) => + eq(left.joinedAt, right.joinedAt), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) + }) + + test(`should update Date join matches when timestamp changes`, () => { + type DateLeft = { id: number; joinedAt: Date; name: string } + type DateRight = { id: number; joinedAt: Date; label: string } + + const baseTimestamp = Date.parse(`2025-01-15T12:34:56.789Z`) + + const leftCollection = createCollection( + mockSyncCollectionOptions({ + id: `join-date-update-left-${autoIndex}`, + getKey: (row) => row.id, + initialData: [ + { id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` }, + ], + autoIndex, + }), + ) + const rightCollection = createCollection( + mockSyncCollectionOptions({ + id: `join-date-update-right-${autoIndex}`, + getKey: (row) => row.id, + initialData: [ + { + id: 10, + joinedAt: new Date(baseTimestamp + 1), + label: `right-10`, + }, + ], + autoIndex, + }), + ) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left: leftCollection }) + .innerJoin({ right: rightCollection }, ({ left, right }) => + eq(left.joinedAt, right.joinedAt), + ) + .select(({ left, right }) => ({ + leftId: left.id, + rightId: right.id, + })), + }) + + expect(query.toArray).toHaveLength(0) + + rightCollection.utils.begin() + rightCollection.utils.write({ + type: `update`, + value: { + id: 10, + joinedAt: new Date(baseTimestamp), + label: `right-10`, + }, + }) + rightCollection.utils.commit() + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]).toEqual({ leftId: 1, rightId: 10 }) + }) }) test(`should handle chained joins with incremental updates`, () => { From 4ca8430d70bbb6263b956401652f935f12fd6079 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 14:35:39 +0100 Subject: [PATCH 4/7] Add changeset for Date join eq fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-join-date-eq.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-join-date-eq.md diff --git a/.changeset/fix-join-date-eq.md b/.changeset/fix-join-date-eq.md new file mode 100644 index 000000000..cd707ae46 --- /dev/null +++ b/.changeset/fix-join-date-eq.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix `eq()` with Date objects in join conditions by normalizing join keys via `normalizeValue` (#934) From f57873c76d2c36e27f7245da1d34f6d8e89a98bb Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 14:48:42 +0100 Subject: [PATCH 5/7] test: add inArray with Date fields test in WHERE clause Adds a test verifying that inArray correctly filters rows by Date field values when the array contains Date objects. Co-Authored-By: Claude Opus 4.6 --- packages/db/tests/query/where.test.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/db/tests/query/where.test.ts b/packages/db/tests/query/where.test.ts index 2e20da187..dda75c0b4 100644 --- a/packages/db/tests/query/where.test.ts +++ b/packages/db/tests/query/where.test.ts @@ -785,6 +785,31 @@ function createWhereTests(autoIndex: `off` | `eager`): void { expect(salaryRanges.size).toBe(3) // Alice (75k), Diana (95k), Eve (55k) }) + + test(`inArray operator - filters Date fields by timestamp value`, () => { + // Alice and Bob both have hire_date 2020-01-15, Charlie has 2018-07-10 + const hiredOnDates = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ emp: employeesCollection }) + .where(({ emp }) => + inArray(emp.hire_date, [ + new Date(`2020-01-15`), + new Date(`2018-07-10`), + ]), + ) + .select(({ emp }) => ({ + id: emp.id, + name: emp.name, + hire_date: emp.hire_date, + })), + }) + + expect(hiredOnDates.size).toBe(3) // Alice, Bob, Charlie + const names = hiredOnDates.toArray.map((e) => e.name).sort() + expect(names).toEqual([`Alice Johnson`, `Bob Smith`, `Charlie Brown`]) + }) }) describe(`Null Handling`, () => { From 3a23b3064be91e8bf3f4a8c3838cecb146814bd9 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 14:55:26 +0100 Subject: [PATCH 6/7] fix: normalize both value and array elements in `in` evaluator The `in` evaluator used `array.includes(value)` which relies on strict equality. When either the value or the array elements are Date objects (or other types that normalizeValue handles), reference equality fails even when the underlying values are the same. Normalize array elements via `array.some(item => normalizeValue(item) === value)` so that both sides are compared as primitives, consistent with how `eq` already normalizes both operands. Co-Authored-By: Claude Opus 4.6 --- packages/db/src/query/compiler/evaluators.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 0bcff0272..1e2637a73 100644 --- a/packages/db/src/query/compiler/evaluators.ts +++ b/packages/db/src/query/compiler/evaluators.ts @@ -345,7 +345,7 @@ function compileFunction(func: Func, isSingleRow: boolean): (data: any) => any { if (!Array.isArray(array)) { return false } - return array.includes(value) + return array.some((item) => normalizeValue(item) === value) } } From 5bcb5799c7011a8685493bdd57a09672033f5538 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 12 Feb 2026 15:07:53 +0100 Subject: [PATCH 7/7] Update changeset to include inArray Date fix Co-Authored-By: Claude Opus 4.6 --- .changeset/fix-join-date-eq.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fix-join-date-eq.md b/.changeset/fix-join-date-eq.md index cd707ae46..b2f9947f8 100644 --- a/.changeset/fix-join-date-eq.md +++ b/.changeset/fix-join-date-eq.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -Fix `eq()` with Date objects in join conditions by normalizing join keys via `normalizeValue` (#934) +Fix `eq()` with Date objects in join conditions and `inArray()` with Date values in WHERE clauses by normalizing values via `normalizeValue` (#934)