Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/poor-walls-taste.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/db": patch
---

Pass all operators in where clauses to the collection's loadSubset function
70 changes: 23 additions & 47 deletions packages/db/src/query/compiler/expressions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,41 +2,22 @@ import { Func, PropRef, Value } from "../ir.js"
import type { BasicExpression, OrderBy } from "../ir.js"

/**
* Functions supported by the collection index system.
* These are the only functions that can be used in WHERE clauses
* that are pushed down to collection subscriptions for index optimization.
*/
export const SUPPORTED_COLLECTION_FUNCS = new Set([
`eq`,
`gt`,
`lt`,
`gte`,
`lte`,
`and`,
`or`,
`in`,
`isNull`,
`isUndefined`,
`not`,
])

/**
* Determines if a WHERE clause can be converted to collection-compatible BasicExpression format.
* This checks if the expression only uses functions supported by the collection index system.
* Determines if a WHERE clause is a valid BasicExpression that can be normalized
* and used for collection subscriptions.
*
* This function validates that the expression has a valid BasicExpression structure
* (values, references, or functions with valid arguments). All operators are allowed
* since downstream systems (filtering, indexing) handle unsupported operators gracefully.
*
* @param whereClause - The WHERE clause to check
* @returns True if the clause can be converted for collection index optimization
* @returns True if the clause is a valid BasicExpression structure
*/
export function isConvertibleToCollectionFilter(
whereClause: BasicExpression<boolean>
): boolean {
const tpe = whereClause.type
if (tpe === `func`) {
// Check if this function is supported
if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
return false
}
// Recursively check all arguments
// Recursively check all arguments are valid BasicExpressions
return whereClause.args.every((arg) =>
isConvertibleToCollectionFilter(arg as BasicExpression<boolean>)
)
Expand All @@ -45,18 +26,26 @@ export function isConvertibleToCollectionFilter(
}

/**
* Converts a WHERE clause to BasicExpression format compatible with collection indexes.
* This function creates proper BasicExpression class instances that the collection
* index system can understand.
* Normalizes a WHERE clause expression by removing table aliases from property references.
*
* This function recursively traverses an expression tree and creates new BasicExpression
* instances with normalized paths. The main transformation is removing the collection alias
* from property reference paths (e.g., `['user', 'id']` becomes `['id']` when `collectionAlias`
* is `'user'`), which is needed when converting query-level expressions to collection-level
* expressions for subscriptions.
*
* @param whereClause - The WHERE clause to convert
* @param collectionAlias - The alias of the collection being filtered
* @returns The converted BasicExpression or null if conversion fails
* @param whereClause - The WHERE clause expression to normalize
* @param collectionAlias - The alias of the collection being filtered (to strip from paths)
* @returns A new BasicExpression with normalized paths
*
* @example
* // Input: ref with path ['user', 'id'] where collectionAlias is 'user'
* // Output: ref with path ['id']
*/
export function convertToBasicExpression(
whereClause: BasicExpression<boolean>,
collectionAlias: string
): BasicExpression<boolean> | null {
): BasicExpression<boolean> {
const tpe = whereClause.type
if (tpe === `val`) {
return new Value(whereClause.value)
Expand All @@ -74,20 +63,13 @@ export function convertToBasicExpression(
// Fallback for non-array paths
return new PropRef(Array.isArray(path) ? path : [String(path)])
} else {
// Check if this function is supported
if (!SUPPORTED_COLLECTION_FUNCS.has(whereClause.name)) {
return null
}
// Recursively convert all arguments
const args: Array<BasicExpression> = []
for (const arg of whereClause.args) {
const convertedArg = convertToBasicExpression(
arg as BasicExpression<boolean>,
collectionAlias
)
if (convertedArg == null) {
return null
}
args.push(convertedArg)
}
return new Func(whereClause.name, args)
Expand All @@ -104,12 +86,6 @@ export function convertOrderByToBasicExpression(
collectionAlias
)

if (!basicExp) {
throw new Error(
`Failed to convert orderBy expression to a basic expression: ${clause.expression}`
)
}

return {
...clause,
expression: basicExp,
Expand Down
8 changes: 1 addition & 7 deletions packages/db/src/query/live/collection-subscriber.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
convertOrderByToBasicExpression,
convertToBasicExpression,
} from "../compiler/expressions.js"
import { WhereClauseConversionError } from "../../errors.js"
import type { MultiSetArray, RootStreamBuilder } from "@tanstack/db-ivm"
import type { Collection } from "../../collection/index.js"
import type { ChangeMessage } from "../../types.js"
Expand Down Expand Up @@ -42,12 +41,7 @@ export class CollectionSubscriber<

if (whereClause) {
const whereExpression = convertToBasicExpression(whereClause, this.alias)

if (whereExpression) {
return this.subscribeToChanges(whereExpression)
}

throw new WhereClauseConversionError(this.collectionId, this.alias)
return this.subscribeToChanges(whereExpression)
}

return this.subscribeToChanges()
Expand Down
105 changes: 104 additions & 1 deletion packages/db/tests/query/live-query-collection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,17 @@ import {
and,
createLiveQueryCollection,
eq,
ilike,
liveQueryCollectionOptions,
} from "../../src/query/index.js"
import { Query } from "../../src/query/builder/index.js"
import {
flushPromises,
mockSyncCollectionOptions,
mockSyncCollectionOptionsNoInitialState,
} from "../utils.js"
import { createDeferred } from "../../src/deferred"
import type { ChangeMessage } from "../../src/types.js"
import type { ChangeMessage, LoadSubsetOptions } from "../../src/types.js"

// Sample user type for tests
type User = {
Expand Down Expand Up @@ -1939,4 +1941,105 @@ describe(`createLiveQueryCollection`, () => {
throw new Error(`Expected DuplicateKeySyncError to be thrown`)
})
})

describe(`where clauses passed to loadSubset`, () => {
it(`passes eq where clause to loadSubset`, async () => {
const capturedOptions: Array<LoadSubsetOptions> = []
let resolveLoadSubset: () => void
const loadSubsetPromise = new Promise<void>((resolve) => {
resolveLoadSubset = resolve
})

const baseCollection = createCollection<{ id: number; name: string }>({
id: `test-base`,
getKey: (item) => item.id,
syncMode: `on-demand`,
sync: {
sync: ({ markReady }) => {
markReady()
return {
loadSubset: (options: LoadSubsetOptions) => {
capturedOptions.push(options)
return loadSubsetPromise
},
}
},
},
})

// Create a live query collection with a where clause
// This will go through convertToBasicExpression
const liveQueryCollection = createLiveQueryCollection((q) =>
q.from({ item: baseCollection }).where(({ item }) => eq(item.id, 2))
)

// Trigger sync which will call loadSubset
await liveQueryCollection.preload()
await flushPromises()

expect(capturedOptions.length).toBeGreaterThan(0)
const lastCall = capturedOptions[capturedOptions.length - 1]
expect(lastCall?.where).toBeDefined()
// The where clause should be normalized (alias removed), so it should be eq(ref(['id']), 2)
expect(lastCall?.where?.type).toBe(`func`)
if (lastCall?.where?.type === `func`) {
expect(lastCall.where.name).toBe(`eq`)
}

resolveLoadSubset!()
await flushPromises()
})

it(`passes ilike where clause to loadSubset`, async () => {
const capturedOptions: Array<LoadSubsetOptions> = []
let resolveLoadSubset: () => void
const loadSubsetPromise = new Promise<void>((resolve) => {
resolveLoadSubset = resolve
})

const baseCollection = createCollection<{ id: number; name: string }>({
id: `test-base`,
getKey: (item) => item.id,
syncMode: `on-demand`,
sync: {
sync: ({ markReady }) => {
markReady()
return {
loadSubset: (options: LoadSubsetOptions) => {
capturedOptions.push(options)
return loadSubsetPromise
},
}
},
},
})

// Create a live query collection with an ilike where clause
// This will go through convertToBasicExpression
const liveQueryCollection = createLiveQueryCollection((q) =>
q
.from({ item: baseCollection })
.where(({ item }) => ilike(item.name, `%test%`))
)

// Trigger sync which will call loadSubset
await liveQueryCollection.preload()
await flushPromises()

expect(capturedOptions.length).toBeGreaterThan(0)
const lastCall = capturedOptions[capturedOptions.length - 1]
// Without the fix: where would be undefined/null
// With the fix: where should be defined with the ilike expression
expect(lastCall?.where).toBeDefined()
expect(lastCall?.where).not.toBeNull()
// The where clause should be normalized (alias removed), so it should be ilike(ref(['name']), '%test%')
expect(lastCall?.where?.type).toBe(`func`)
if (lastCall?.where?.type === `func`) {
expect(lastCall.where.name).toBe(`ilike`)
}

resolveLoadSubset!()
await flushPromises()
})
})
})
Loading
Loading