Skip to content

Commit 846a830

Browse files
authored
Always pass where clause to loadSubset (#851)
* Unit test to reproduce problem with operators not being passed to loadSubset * Do not filter non-indexable operators in convertToBasicExpression * Changeset * Unit tests in query collection to check that where clause is passed through to the query function's context * Revert changes to lockfile * Remove isConvertibleToCollectionFilter function * Rename convertToBasicExpression to normalizeExpressionPaths and convertOrderByToBasicExpression to normalizeOrderByPaths
1 parent db764b7 commit 846a830

6 files changed

Lines changed: 262 additions & 91 deletions

File tree

.changeset/poor-walls-taste.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Pass all operators in where clauses to the collection's loadSubset function

packages/db/src/query/compiler/expressions.ts

Lines changed: 18 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,61 +2,26 @@ import { Func, PropRef, Value } from "../ir.js"
22
import type { BasicExpression, OrderBy } from "../ir.js"
33

44
/**
5-
* Functions supported by the collection index system.
6-
* These are the only functions that can be used in WHERE clauses
7-
* that are pushed down to collection subscriptions for index optimization.
8-
*/
9-
export const SUPPORTED_COLLECTION_FUNCS = new Set([
10-
`eq`,
11-
`gt`,
12-
`lt`,
13-
`gte`,
14-
`lte`,
15-
`and`,
16-
`or`,
17-
`in`,
18-
`isNull`,
19-
`isUndefined`,
20-
`not`,
21-
])
22-
23-
/**
24-
* Determines if a WHERE clause can be converted to collection-compatible BasicExpression format.
25-
* This checks if the expression only uses functions supported by the collection index system.
5+
* Normalizes a WHERE clause expression by removing table aliases from property references.
266
*
27-
* @param whereClause - The WHERE clause to check
28-
* @returns True if the clause can be converted for collection index optimization
29-
*/
30-
export function isConvertibleToCollectionFilter(
31-
whereClause: BasicExpression<boolean>
32-
): boolean {
33-
const tpe = whereClause.type
34-
if (tpe === `func`) {
35-
// Check if this function is supported
36-
if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
37-
return false
38-
}
39-
// Recursively check all arguments
40-
return whereClause.args.every((arg) =>
41-
isConvertibleToCollectionFilter(arg as BasicExpression<boolean>)
42-
)
43-
}
44-
return [`val`, `ref`].includes(tpe)
45-
}
46-
47-
/**
48-
* Converts a WHERE clause to BasicExpression format compatible with collection indexes.
49-
* This function creates proper BasicExpression class instances that the collection
50-
* index system can understand.
7+
* This function recursively traverses an expression tree and creates new BasicExpression
8+
* instances with normalized paths. The main transformation is removing the collection alias
9+
* from property reference paths (e.g., `['user', 'id']` becomes `['id']` when `collectionAlias`
10+
* is `'user'`), which is needed when converting query-level expressions to collection-level
11+
* expressions for subscriptions.
5112
*
52-
* @param whereClause - The WHERE clause to convert
53-
* @param collectionAlias - The alias of the collection being filtered
54-
* @returns The converted BasicExpression or null if conversion fails
13+
* @param whereClause - The WHERE clause expression to normalize
14+
* @param collectionAlias - The alias of the collection being filtered (to strip from paths)
15+
* @returns A new BasicExpression with normalized paths
16+
*
17+
* @example
18+
* // Input: ref with path ['user', 'id'] where collectionAlias is 'user'
19+
* // Output: ref with path ['id']
5520
*/
56-
export function convertToBasicExpression(
21+
export function normalizeExpressionPaths(
5722
whereClause: BasicExpression<boolean>,
5823
collectionAlias: string
59-
): BasicExpression<boolean> | null {
24+
): BasicExpression<boolean> {
6025
const tpe = whereClause.type
6126
if (tpe === `val`) {
6227
return new Value(whereClause.value)
@@ -74,42 +39,29 @@ export function convertToBasicExpression(
7439
// Fallback for non-array paths
7540
return new PropRef(Array.isArray(path) ? path : [String(path)])
7641
} else {
77-
// Check if this function is supported
78-
if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
79-
return null
80-
}
8142
// Recursively convert all arguments
8243
const args: Array<BasicExpression> = []
8344
for (const arg of whereClause.args) {
84-
const convertedArg = convertToBasicExpression(
45+
const convertedArg = normalizeExpressionPaths(
8546
arg as BasicExpression<boolean>,
8647
collectionAlias
8748
)
88-
if (convertedArg == null) {
89-
return null
90-
}
9149
args.push(convertedArg)
9250
}
9351
return new Func(whereClause.name, args)
9452
}
9553
}
9654

97-
export function convertOrderByToBasicExpression(
55+
export function normalizeOrderByPaths(
9856
orderBy: OrderBy,
9957
collectionAlias: string
10058
): OrderBy {
10159
const normalizedOrderBy = orderBy.map((clause) => {
102-
const basicExp = convertToBasicExpression(
60+
const basicExp = normalizeExpressionPaths(
10361
clause.expression,
10462
collectionAlias
10563
)
10664

107-
if (!basicExp) {
108-
throw new Error(
109-
`Failed to convert orderBy expression to a basic expression: ${clause.expression}`
110-
)
111-
}
112-
11365
return {
11466
...clause,
11567
expression: basicExp,

packages/db/src/query/live/collection-subscriber.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import { MultiSet } from "@tanstack/db-ivm"
22
import {
3-
convertOrderByToBasicExpression,
4-
convertToBasicExpression,
3+
normalizeExpressionPaths,
4+
normalizeOrderByPaths,
55
} from "../compiler/expressions.js"
6-
import { WhereClauseConversionError } from "../../errors.js"
76
import type { MultiSetArray, RootStreamBuilder } from "@tanstack/db-ivm"
87
import type { Collection } from "../../collection/index.js"
98
import type { ChangeMessage } from "../../types.js"
@@ -41,13 +40,8 @@ export class CollectionSubscriber<
4140
const whereClause = this.getWhereClauseForAlias()
4241

4342
if (whereClause) {
44-
const whereExpression = convertToBasicExpression(whereClause, this.alias)
45-
46-
if (whereExpression) {
47-
return this.subscribeToChanges(whereExpression)
48-
}
49-
50-
throw new WhereClauseConversionError(this.collectionId, this.alias)
43+
const whereExpression = normalizeExpressionPaths(whereClause, this.alias)
44+
return this.subscribeToChanges(whereExpression)
5145
}
5246

5347
return this.subscribeToChanges()
@@ -199,10 +193,7 @@ export class CollectionSubscriber<
199193
subscription.setOrderByIndex(index)
200194

201195
// Normalize the orderBy clauses such that the references are relative to the collection
202-
const normalizedOrderBy = convertOrderByToBasicExpression(
203-
orderBy,
204-
this.alias
205-
)
196+
const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
206197

207198
// Load the first `offset + limit` values from the index
208199
// i.e. the K items from the collection that fall into the requested range: [offset, offset + limit[
@@ -289,10 +280,7 @@ export class CollectionSubscriber<
289280
: biggestSentRow
290281

291282
// Normalize the orderBy clauses such that the references are relative to the collection
292-
const normalizedOrderBy = convertOrderByToBasicExpression(
293-
orderBy,
294-
this.alias
295-
)
283+
const normalizedOrderBy = normalizeOrderByPaths(orderBy, this.alias)
296284

297285
// Take the `n` items after the biggest sent value
298286
subscription.requestLimitedSnapshot({

packages/db/src/query/optimizer.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,6 @@ import {
131131
getWhereExpression,
132132
isResidualWhere,
133133
} from "./ir.js"
134-
import { isConvertibleToCollectionFilter } from "./compiler/expressions.js"
135134
import type { BasicExpression, From, QueryIR, Select, Where } from "./ir.js"
136135

137136
/**
@@ -248,14 +247,10 @@ function extractSourceWhereClauses(
248247
const groupedClauses = groupWhereClauses(analyzedClauses)
249248

250249
// Only include single-source clauses that reference collections directly
251-
// and can be converted to BasicExpression format for collection indexes
252250
for (const [sourceAlias, whereClause] of groupedClauses.singleSource) {
253251
// Check if this source alias corresponds to a collection reference
254252
if (isCollectionReference(query, sourceAlias)) {
255-
// Check if the WHERE clause can be converted to collection-compatible format
256-
if (isConvertibleToCollectionFilter(whereClause)) {
257-
sourceWhereClauses.set(sourceAlias, whereClause)
258-
}
253+
sourceWhereClauses.set(sourceAlias, whereClause)
259254
}
260255
}
261256

packages/db/tests/query/live-query-collection.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,17 @@ import {
55
and,
66
createLiveQueryCollection,
77
eq,
8+
ilike,
89
liveQueryCollectionOptions,
910
} from "../../src/query/index.js"
1011
import { Query } from "../../src/query/builder/index.js"
1112
import {
13+
flushPromises,
1214
mockSyncCollectionOptions,
1315
mockSyncCollectionOptionsNoInitialState,
1416
} from "../utils.js"
1517
import { createDeferred } from "../../src/deferred"
16-
import type { ChangeMessage } from "../../src/types.js"
18+
import type { ChangeMessage, LoadSubsetOptions } from "../../src/types.js"
1719

1820
// Sample user type for tests
1921
type User = {
@@ -1939,4 +1941,105 @@ describe(`createLiveQueryCollection`, () => {
19391941
throw new Error(`Expected DuplicateKeySyncError to be thrown`)
19401942
})
19411943
})
1944+
1945+
describe(`where clauses passed to loadSubset`, () => {
1946+
it(`passes eq where clause to loadSubset`, async () => {
1947+
const capturedOptions: Array<LoadSubsetOptions> = []
1948+
let resolveLoadSubset: () => void
1949+
const loadSubsetPromise = new Promise<void>((resolve) => {
1950+
resolveLoadSubset = resolve
1951+
})
1952+
1953+
const baseCollection = createCollection<{ id: number; name: string }>({
1954+
id: `test-base`,
1955+
getKey: (item) => item.id,
1956+
syncMode: `on-demand`,
1957+
sync: {
1958+
sync: ({ markReady }) => {
1959+
markReady()
1960+
return {
1961+
loadSubset: (options: LoadSubsetOptions) => {
1962+
capturedOptions.push(options)
1963+
return loadSubsetPromise
1964+
},
1965+
}
1966+
},
1967+
},
1968+
})
1969+
1970+
// Create a live query collection with a where clause
1971+
// This will go through convertToBasicExpression
1972+
const liveQueryCollection = createLiveQueryCollection((q) =>
1973+
q.from({ item: baseCollection }).where(({ item }) => eq(item.id, 2))
1974+
)
1975+
1976+
// Trigger sync which will call loadSubset
1977+
await liveQueryCollection.preload()
1978+
await flushPromises()
1979+
1980+
expect(capturedOptions.length).toBeGreaterThan(0)
1981+
const lastCall = capturedOptions[capturedOptions.length - 1]
1982+
expect(lastCall?.where).toBeDefined()
1983+
// The where clause should be normalized (alias removed), so it should be eq(ref(['id']), 2)
1984+
expect(lastCall?.where?.type).toBe(`func`)
1985+
if (lastCall?.where?.type === `func`) {
1986+
expect(lastCall.where.name).toBe(`eq`)
1987+
}
1988+
1989+
resolveLoadSubset!()
1990+
await flushPromises()
1991+
})
1992+
1993+
it(`passes ilike where clause to loadSubset`, async () => {
1994+
const capturedOptions: Array<LoadSubsetOptions> = []
1995+
let resolveLoadSubset: () => void
1996+
const loadSubsetPromise = new Promise<void>((resolve) => {
1997+
resolveLoadSubset = resolve
1998+
})
1999+
2000+
const baseCollection = createCollection<{ id: number; name: string }>({
2001+
id: `test-base`,
2002+
getKey: (item) => item.id,
2003+
syncMode: `on-demand`,
2004+
sync: {
2005+
sync: ({ markReady }) => {
2006+
markReady()
2007+
return {
2008+
loadSubset: (options: LoadSubsetOptions) => {
2009+
capturedOptions.push(options)
2010+
return loadSubsetPromise
2011+
},
2012+
}
2013+
},
2014+
},
2015+
})
2016+
2017+
// Create a live query collection with an ilike where clause
2018+
// This will go through convertToBasicExpression
2019+
const liveQueryCollection = createLiveQueryCollection((q) =>
2020+
q
2021+
.from({ item: baseCollection })
2022+
.where(({ item }) => ilike(item.name, `%test%`))
2023+
)
2024+
2025+
// Trigger sync which will call loadSubset
2026+
await liveQueryCollection.preload()
2027+
await flushPromises()
2028+
2029+
expect(capturedOptions.length).toBeGreaterThan(0)
2030+
const lastCall = capturedOptions[capturedOptions.length - 1]
2031+
// Without the fix: where would be undefined/null
2032+
// With the fix: where should be defined with the ilike expression
2033+
expect(lastCall?.where).toBeDefined()
2034+
expect(lastCall?.where).not.toBeNull()
2035+
// The where clause should be normalized (alias removed), so it should be ilike(ref(['name']), '%test%')
2036+
expect(lastCall?.where?.type).toBe(`func`)
2037+
if (lastCall?.where?.type === `func`) {
2038+
expect(lastCall.where.name).toBe(`ilike`)
2039+
}
2040+
2041+
resolveLoadSubset!()
2042+
await flushPromises()
2043+
})
2044+
})
19422045
})

0 commit comments

Comments
 (0)