diff --git a/.changeset/fix-join-date-eq.md b/.changeset/fix-join-date-eq.md new file mode 100644 index 000000000..b2f9947f8 --- /dev/null +++ b/.changeset/fix-join-date-eq.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix `eq()` with Date objects in join conditions and `inArray()` with Date values in WHERE clauses by normalizing values via `normalizeValue` (#934) diff --git a/packages/db/src/query/compiler/evaluators.ts b/packages/db/src/query/compiler/evaluators.ts index 5e44e5bcd..1e2637a73 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)) { @@ -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) } } 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.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`, () => { 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`, () => {