Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/fix-join-date-eq.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions packages/db/src/query/compiler/evaluators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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)
}
}

Expand Down
5 changes: 3 additions & 2 deletions packages/db/src/query/compiler/joins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 [
Expand All @@ -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 [
Expand Down
116 changes: 116 additions & 0 deletions packages/db/tests/query/join.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DateLeft> = [
{ id: 1, joinedAt: new Date(baseTimestamp), name: `left-1` },
]
const rightData: Array<DateRight> = [
{ 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<DateLeft>({
id: `join-date-left-${autoIndex}`,
getKey: (row) => row.id,
initialData: leftData,
autoIndex,
}),
)
const rightCollection = createCollection(
mockSyncCollectionOptions<DateRight>({
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<DateLeft>({
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<DateRight>({
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`, () => {
Expand Down
25 changes: 25 additions & 0 deletions packages/db/tests/query/where.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, () => {
Expand Down