From e600d13c01860900e709a658f792abe21363496d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:08:03 -0800 Subject: [PATCH 01/14] feat: add expression helpers for parsing LoadSubsetOptions in queryFn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add reusable expression parsing utilities to help developers translate TanStack DB predicates (where, orderBy, limit) into their API's format. - Add expression-helpers.ts with generic parsing utilities - parseWhereExpression: Parse where clauses with custom handlers - parseOrderByExpression: Parse order by into simple array - extractSimpleComparisons: Extract simple AND-ed filters - parseLoadSubsetOptions: Convenience function for all options - walkExpression, extractFieldPath, extractValue: Lower-level helpers - Export helpers from query-db-collection package - Add comprehensive documentation to query-collection.md - How LoadSubsetOptions are passed via ctx.meta - Expression helper usage with REST and GraphQL examples - API reference for all helper functions - Tips and best practices This makes it much easier to implement query collections with predicate push-down without having to manually parse expression AST trees. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/collections/query-collection.md | 305 ++++++++++++++ .../src/expression-helpers.ts | 388 ++++++++++++++++++ packages/query-db-collection/src/index.ts | 14 + 3 files changed, 707 insertions(+) create mode 100644 packages/query-db-collection/src/expression-helpers.ts diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 91a0f7dea..6d813006c 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -398,3 +398,308 @@ All direct write methods are available on `collection.utils`: - `writeUpsert(data)`: Insert or update one or more items directly - `writeBatch(callback)`: Perform multiple operations atomically - `refetch(opts?)`: Manually trigger a refetch of the query + +## QueryFn and Predicate Push-Down + +When using `syncMode: 'on-demand'`, the collection automatically pushes down query predicates (where clauses, orderBy, and limit) to your `queryFn`. This allows you to fetch only the data needed for each specific query, rather than fetching the entire dataset. + +### How LoadSubsetOptions Are Passed + +LoadSubsetOptions are passed to your `queryFn` via the query context's `meta` property: + +```typescript +queryFn: async (ctx) => { + // Extract LoadSubsetOptions from the context + const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} + + // Use these to fetch only the data you need + // ... +} +``` + +The `where` and `orderBy` fields are expression trees (AST - Abstract Syntax Tree) that need to be parsed. The `@tanstack/query-db-collection` package provides helper functions to make this easy. + +### Expression Helpers + +```typescript +import { + parseWhereExpression, + parseOrderByExpression, + extractSimpleComparisons, + parseLoadSubsetOptions, +} from '@tanstack/query-db-collection' +``` + +These helpers allow you to parse expression trees without manually traversing complex AST structures. + +### Quick Start: Simple REST API + +```typescript +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions, parseLoadSubsetOptions } from '@tanstack/query-db-collection' +import { QueryClient } from '@tanstack/query-core' + +const queryClient = new QueryClient() + +const productsCollection = createCollection( + queryCollectionOptions({ + id: 'products', + queryKey: ['products'], + queryClient, + getKey: (item) => item.id, + syncMode: 'on-demand', // Enable predicate push-down + + queryFn: async (ctx) => { + const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} + + // Parse the expressions into simple format + const parsed = parseLoadSubsetOptions({ where, orderBy, limit }) + + // Build query parameters from parsed filters + const params = new URLSearchParams() + + // Add filters + parsed.filters.forEach(({ field, operator, value }) => { + const fieldName = field.join('.') + if (operator === 'eq') { + params.set(fieldName, String(value)) + } else if (operator === 'lt') { + params.set(`${fieldName}_lt`, String(value)) + } else if (operator === 'gt') { + params.set(`${fieldName}_gt`, String(value)) + } + }) + + // Add sorting + if (parsed.sorts.length > 0) { + const sortParam = parsed.sorts + .map(s => `${s.field.join('.')}:${s.direction}`) + .join(',') + params.set('sort', sortParam) + } + + // Add limit + if (parsed.limit) { + params.set('limit', String(parsed.limit)) + } + + const response = await fetch(`/api/products?${params}`) + return response.json() + }, + }) +) + +// Usage with live queries +import { createLiveQueryCollection } from '@tanstack/react-db' +import { eq, lt, and } from '@tanstack/db' + +const affordableElectronics = createLiveQueryCollection({ + query: (q) => + q.from({ product: productsCollection }) + .where(({ product }) => and( + eq(product.category, 'electronics'), + lt(product.price, 100) + )) + .orderBy(({ product }) => product.price, 'asc') + .limit(10) + .select(({ product }) => product) +}) + +// This triggers a queryFn call with: +// GET /api/products?category=electronics&price_lt=100&sort=price:asc&limit=10 +``` + +### Custom Handlers for Complex APIs + +For APIs with specific formats, use custom handlers: + +```typescript +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + + // Use custom handlers to match your API's format + const filters = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ + field: field.join('.'), + op: 'equals', + value + }), + lt: (field, value) => ({ + field: field.join('.'), + op: 'lessThan', + value + }), + and: (...conditions) => ({ + operator: 'AND', + conditions + }), + or: (...conditions) => ({ + operator: 'OR', + conditions + }), + } + }) + + const sorts = parseOrderByExpression(orderBy) + + return api.query({ + filters, + sort: sorts.map(s => ({ + field: s.field.join('.'), + order: s.direction.toUpperCase() + })), + limit + }) +} +``` + +### GraphQL Example (Hasura-Style) + +```typescript +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + + // Convert to Hasura where clause format + const whereClause = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ + [field.join('_')]: { _eq: value } + }), + lt: (field, value) => ({ + [field.join('_')]: { _lt: value } + }), + and: (...conditions) => ({ _and: conditions }), + or: (...conditions) => ({ _or: conditions }), + } + }) + + // Convert to Hasura order_by format + const sorts = parseOrderByExpression(orderBy) + const orderByClause = sorts.map(s => ({ + [s.field.join('_')]: s.direction + })) + + const { data } = await graphqlClient.query({ + query: gql` + query GetProducts($where: product_bool_exp, $orderBy: [product_order_by!], $limit: Int) { + product(where: $where, order_by: $orderBy, limit: $limit) { + id + name + category + price + } + } + `, + variables: { + where: whereClause, + orderBy: orderByClause, + limit + } + }) + + return data.product +} +``` + +### Expression Helper API Reference + +#### `parseLoadSubsetOptions(options)` + +Convenience function that parses all LoadSubsetOptions at once. Good for simple use cases. + +```typescript +const { filters, sorts, limit } = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) +// filters: [{ field: ['category'], operator: 'eq', value: 'electronics' }] +// sorts: [{ field: ['price'], direction: 'asc', nulls: 'last' }] +// limit: 10 +``` + +#### `parseWhereExpression(expr, options)` + +Parses a WHERE expression using custom handlers for each operator. Use this for complete control over the output format. + +```typescript +const filters = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ [field.join('.')]: value }), + lt: (field, value) => ({ [`${field.join('.')}_lt`]: value }), + and: (...filters) => Object.assign({}, ...filters) + }, + onUnknownOperator: (operator, args) => { + console.warn(`Unsupported operator: ${operator}`) + return null + } +}) +``` + +#### `parseOrderByExpression(orderBy)` + +Parses an ORDER BY expression into a simple array. + +```typescript +const sorts = parseOrderByExpression(orderBy) +// Returns: [{ field: ['price'], direction: 'asc', nulls: 'last' }] +``` + +#### `extractSimpleComparisons(expr)` + +Extracts simple AND-ed comparisons from a WHERE expression. Note: Only works for simple AND conditions. + +```typescript +const comparisons = extractSimpleComparisons(where) +// Returns: [ +// { field: ['category'], operator: 'eq', value: 'electronics' }, +// { field: ['price'], operator: 'lt', value: 100 } +// ] +``` + +### Supported Operators + +- `eq` - Equality (=) +- `gt` - Greater than (>) +- `gte` - Greater than or equal (>=) +- `lt` - Less than (<) +- `lte` - Less than or equal (<=) +- `and` - Logical AND +- `or` - Logical OR +- `in` - IN clause + +### Using Query Key Builders + +Create different cache entries for different filter combinations: + +```typescript +const productsCollection = createCollection( + queryCollectionOptions({ + id: 'products', + // Dynamic query key based on filters + queryKey: (opts) => { + const parsed = parseLoadSubsetOptions(opts) + const cacheKey = ['products'] + + parsed.filters.forEach(f => { + cacheKey.push(`${f.field.join('.')}-${f.operator}-${f.value}`) + }) + + if (parsed.limit) { + cacheKey.push(`limit-${parsed.limit}`) + } + + return cacheKey + }, + queryClient, + getKey: (item) => item.id, + syncMode: 'on-demand', + queryFn: async (ctx) => { /* ... */ }, + }) +) +``` + +### Tips + +1. **Start with `parseLoadSubsetOptions`** for simple use cases +2. **Use custom handlers** via `parseWhereExpression` for APIs with specific formats +3. **Handle unsupported operators** with the `onUnknownOperator` callback +4. **Log parsed results** during development to verify correctness +5. **Reference Electric DB Collection** (`packages/electric-db-collection/src/sql-compiler.ts`) for a complete SQL compilation example diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/query-db-collection/src/expression-helpers.ts new file mode 100644 index 000000000..dcac062c6 --- /dev/null +++ b/packages/query-db-collection/src/expression-helpers.ts @@ -0,0 +1,388 @@ +/** + * Expression Helpers for Query Collections + * + * These utilities help parse LoadSubsetOptions (where, orderBy, limit) from TanStack DB + * into formats suitable for your API backend. They provide a generic way to traverse + * expression trees without having to implement your own parser. + * + * @example + * ```typescript + * import { parseWhereExpression, parseOrderByExpression } from '@tanstack/query-db-collection' + * + * queryFn: async (ctx) => { + * const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} + * + * // Convert expression tree to filters + * const filters = parseWhereExpression(where, { + * eq: (field, value) => ({ [field]: value }), + * lt: (field, value) => ({ [`${field}_lt`]: value }), + * and: (filters) => Object.assign({}, ...filters) + * }) + * + * // Extract sort information + * const sort = parseOrderByExpression(orderBy) + * + * return api.getProducts({ ...filters, sort, limit }) + * } + * ``` + */ + +import type { BasicExpression, OrderBy } from "@tanstack/db" + +/** + * Represents a simple field path extracted from an expression + */ +export type FieldPath = Array + +/** + * Represents a simple comparison operation + */ +export interface SimpleComparison { + field: FieldPath + operator: string + value: any +} + +/** + * Options for customizing how WHERE expressions are parsed + */ +export interface ParseWhereOptions { + /** + * Handler functions for different operators. + * Each handler receives the parsed field path(s) and value(s) and returns your custom format. + * + * Common operators: + * - eq: equality (=) + * - gt: greater than (>) + * - gte: greater than or equal (>=) + * - lt: less than (<) + * - lte: less than or equal (<=) + * - and: logical AND + * - or: logical OR + * - in: IN clause + */ + handlers: { + [operator: string]: (...args: Array) => T + } + /** + * Optional handler for when an unknown operator is encountered. + * If not provided, unknown operators throw an error. + */ + onUnknownOperator?: (operator: string, args: Array) => T +} + +/** + * Result of parsing an ORDER BY expression + */ +export interface ParsedOrderBy { + field: FieldPath + direction: `asc` | `desc` + nulls?: `first` | `last` +} + +/** + * Extracts the field path from a PropRef expression. + * Returns null for non-ref expressions. + * + * @param expr - The expression to extract from + * @returns The field path array, or null + * + * @example + * ```typescript + * const field = extractFieldPath(someExpression) + * // Returns: ['product', 'category'] + * ``` + */ +export function extractFieldPath(expr: BasicExpression): FieldPath | null { + if (expr.type === `ref`) { + return expr.path + } + return null +} + +/** + * Extracts the value from a Value expression. + * Returns undefined for non-value expressions. + * + * @param expr - The expression to extract from + * @returns The extracted value + * + * @example + * ```typescript + * const val = extractValue(someExpression) + * // Returns: 'electronics' + * ``` + */ +export function extractValue(expr: BasicExpression): any { + if (expr.type === `val`) { + return expr.value + } + return undefined +} + +/** + * Generic expression tree walker that visits each node in the expression. + * Useful for implementing custom parsing logic. + * + * @param expr - The expression to walk + * @param visitor - Visitor function called for each node + * + * @example + * ```typescript + * walkExpression(whereExpr, (node) => { + * if (node.type === 'func' && node.name === 'eq') { + * console.log('Found equality comparison') + * } + * }) + * ``` + */ +export function walkExpression( + expr: BasicExpression | undefined | null, + visitor: (expr: BasicExpression) => void +): void { + if (!expr) return + + visitor(expr) + + if (expr.type === `func`) { + expr.args.forEach((arg) => walkExpression(arg, visitor)) + } +} + +/** + * Parses a WHERE expression into a custom format using provided handlers. + * + * This is the main helper for converting TanStack DB where clauses into your API's filter format. + * You provide handlers for each operator, and this function traverses the expression tree + * and calls the appropriate handlers. + * + * @param expr - The WHERE expression to parse + * @param options - Configuration with handler functions for each operator + * @returns The parsed result in your custom format + * + * @example + * ```typescript + * // REST API with query parameters + * const filters = parseWhereExpression(where, { + * handlers: { + * eq: (field, value) => ({ [field.join('.')]: value }), + * lt: (field, value) => ({ [`${field.join('.')}_lt`]: value }), + * gt: (field, value) => ({ [`${field.join('.')}_gt`]: value }), + * and: (...filters) => Object.assign({}, ...filters), + * or: (...filters) => ({ $or: filters }) + * } + * }) + * // Returns: { category: 'electronics', price_lt: 100 } + * ``` + * + * @example + * ```typescript + * // GraphQL where clause + * const where = parseWhereExpression(whereExpr, { + * handlers: { + * eq: (field, value) => ({ [field.join('_')]: { _eq: value } }), + * lt: (field, value) => ({ [field.join('_')]: { _lt: value } }), + * and: (...filters) => ({ _and: filters }) + * } + * }) + * ``` + */ +export function parseWhereExpression( + expr: BasicExpression | undefined | null, + options: ParseWhereOptions +): T | null { + if (!expr) return null + + const { handlers, onUnknownOperator } = options + + // Handle value expressions + if (expr.type === `val`) { + return expr.value + } + + // Handle property references + if (expr.type === `ref`) { + return expr.path as any + } + + // Handle function expressions + if (expr.type === `func`) { + const { name, args } = expr + const handler = handlers[name] + + if (!handler) { + if (onUnknownOperator) { + return onUnknownOperator(name, args) + } + throw new Error( + `No handler provided for operator: ${name}. Available handlers: ${Object.keys(handlers).join(`, `)}` + ) + } + + // Parse arguments recursively + const parsedArgs = args.map((arg) => { + // For refs, extract the field path + if (arg.type === `ref`) { + return arg.path + } + // For values, extract the value + if (arg.type === `val`) { + return arg.value + } + // For nested functions, recurse + if (arg.type === `func`) { + return parseWhereExpression(arg, options) + } + return arg + }) + + return handler(...parsedArgs) + } + + return null +} + +/** + * Parses an ORDER BY expression into a simple array of sort specifications. + * + * @param orderBy - The ORDER BY expression array + * @returns Array of parsed order by specifications + * + * @example + * ```typescript + * const sorts = parseOrderByExpression(orderBy) + * // Returns: [ + * // { field: ['category'], direction: 'asc', nulls: 'last' }, + * // { field: ['price'], direction: 'desc', nulls: 'last' } + * // ] + * ``` + */ +export function parseOrderByExpression( + orderBy: OrderBy | undefined | null +): Array { + if (!orderBy || orderBy.length === 0) { + return [] + } + + return orderBy.map((clause) => { + const field = extractFieldPath(clause.expression) + + if (!field) { + throw new Error( + `ORDER BY expression must be a field reference, got: ${clause.expression.type}` + ) + } + + return { + field, + direction: clause.compareOptions.direction, + nulls: clause.compareOptions.nulls, + } + }) +} + +/** + * Extracts all simple comparisons from a WHERE expression. + * This is useful for simple APIs that only support basic filters. + * + * Note: This only works for simple AND-ed conditions. Complex OR/nested conditions + * will require using parseWhereExpression with custom handlers. + * + * @param expr - The WHERE expression to parse + * @returns Array of simple comparisons + * + * @example + * ```typescript + * const comparisons = extractSimpleComparisons(where) + * // Returns: [ + * // { field: ['category'], operator: 'eq', value: 'electronics' }, + * // { field: ['price'], operator: 'lt', value: 100 } + * // ] + * ``` + */ +export function extractSimpleComparisons( + expr: BasicExpression | undefined | null +): Array { + if (!expr) return [] + + const comparisons: Array = [] + + function extract(e: BasicExpression): void { + if (e.type === `func`) { + // Handle AND - recurse into both sides + if (e.name === `and`) { + e.args.forEach((arg) => extract(arg as BasicExpression)) + return + } + + // Handle comparison operators + const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `in`] + if (comparisonOps.includes(e.name)) { + const [leftArg, rightArg] = e.args + + // Extract field and value + const field = leftArg?.type === `ref` ? leftArg.path : null + const value = rightArg?.type === `val` ? rightArg.value : null + + if (field && value !== undefined) { + comparisons.push({ + field, + operator: e.name, + value, + }) + } + } + } + } + + extract(expr) + return comparisons +} + +/** + * Convenience function to get all LoadSubsetOptions in a pre-parsed format. + * Good starting point for simple use cases. + * + * @param options - The LoadSubsetOptions from ctx.meta + * @returns Pre-parsed filters, sorts, and limit + * + * @example + * ```typescript + * queryFn: async (ctx) => { + * const parsed = parseLoadSubsetOptions(ctx.meta?.loadSubsetOptions) + * + * // Convert to your API format + * return api.getProducts({ + * ...Object.fromEntries( + * parsed.filters.map(f => [`${f.field.join('.')}_${f.operator}`, f.value]) + * ), + * sort: parsed.sorts.map(s => `${s.field.join('.')}:${s.direction}`).join(','), + * limit: parsed.limit + * }) + * } + * ``` + */ +export function parseLoadSubsetOptions( + options: + | { + where?: BasicExpression + orderBy?: OrderBy + limit?: number + } + | undefined + | null +): { + filters: Array + sorts: Array + limit?: number +} { + if (!options) { + return { filters: [], sorts: [] } + } + + return { + filters: extractSimpleComparisons(options.where), + sorts: parseOrderByExpression(options.orderBy), + limit: options.limit, + } +} diff --git a/packages/query-db-collection/src/index.ts b/packages/query-db-collection/src/index.ts index 42b47e419..8aa6858bd 100644 --- a/packages/query-db-collection/src/index.ts +++ b/packages/query-db-collection/src/index.ts @@ -6,3 +6,17 @@ export { } from "./query" export * from "./errors" + +export { + parseWhereExpression, + parseOrderByExpression, + extractSimpleComparisons, + parseLoadSubsetOptions, + extractFieldPath, + extractValue, + walkExpression, + type FieldPath, + type SimpleComparison, + type ParseWhereOptions, + type ParsedOrderBy, +} from "./expression-helpers" From 0e755bb1c783abd7b85f060326ea14290f8b7b03 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:15:46 -0800 Subject: [PATCH 02/14] test: add comprehensive tests for expression helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 35 test cases covering all helper functions - Tests for parseWhereExpression with various operators - Tests for parseOrderByExpression with sorting options - Tests for extractSimpleComparisons - Tests for parseLoadSubsetOptions - Tests for low-level helpers (extractFieldPath, extractValue, walkExpression) - Integration test for complex real-world query scenarios All tests passing with 93.65% coverage of expression-helpers.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../tests/expression-helpers.test.ts | 608 ++++++++++++++++++ 1 file changed, 608 insertions(+) create mode 100644 packages/query-db-collection/tests/expression-helpers.test.ts diff --git a/packages/query-db-collection/tests/expression-helpers.test.ts b/packages/query-db-collection/tests/expression-helpers.test.ts new file mode 100644 index 000000000..9986e9c88 --- /dev/null +++ b/packages/query-db-collection/tests/expression-helpers.test.ts @@ -0,0 +1,608 @@ +import { describe, expect, it } from "vitest" +import { + extractFieldPath, + extractSimpleComparisons, + extractValue, + parseLoadSubsetOptions, + parseOrderByExpression, + parseWhereExpression, + walkExpression, +} from "../src/expression-helpers" +import { Func, PropRef, Value } from "../../db/src/query/ir.js" +import type { OrderBy } from "@tanstack/db" + +describe(`Expression Helpers`, () => { + describe(`extractFieldPath`, () => { + it(`should extract field path from PropRef`, () => { + const expr = new PropRef([`product`, `category`]) + const result = extractFieldPath(expr) + expect(result).toEqual([`product`, `category`]) + }) + + it(`should return null for non-ref expressions`, () => { + const expr = new Value(`electronics`) + const result = extractFieldPath(expr) + expect(result).toBeNull() + }) + }) + + describe(`extractValue`, () => { + it(`should extract value from Value expression`, () => { + const expr = new Value(`electronics`) + const result = extractValue(expr) + expect(result).toBe(`electronics`) + }) + + it(`should return undefined for non-value expressions`, () => { + const expr = new PropRef([`category`]) + const result = extractValue(expr) + expect(result).toBeUndefined() + }) + }) + + describe(`walkExpression`, () => { + it(`should visit all nodes in expression tree`, () => { + const visited: Array = [] + + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + ]) + + walkExpression(expr, (node) => { + visited.push(node.type) + }) + + expect(visited).toEqual([ + `func`, + `func`, + `ref`, + `val`, + `func`, + `ref`, + `val`, + ]) + }) + + it(`should handle null/undefined expressions`, () => { + const visited: Array = [] + + walkExpression(null, (node) => { + visited.push(node.type) + }) + + expect(visited).toEqual([]) + }) + }) + + describe(`parseWhereExpression`, () => { + it(`should parse simple equality expression`, () => { + const expr = new Func(`eq`, [ + new PropRef([`category`]), + new Value(`electronics`), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + }, + }) + + expect(result).toEqual({ category: `electronics` }) + }) + + it(`should parse less than expression`, () => { + const expr = new Func(`lt`, [new PropRef([`price`]), new Value(100)]) + + const result = parseWhereExpression(expr, { + handlers: { + lt: (field, value) => ({ [`${field.join(`.`)}_lt`]: value }), + }, + }) + + expect(result).toEqual({ price_lt: 100 }) + }) + + it(`should parse AND expression with multiple conditions`, () => { + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + lt: (field, value) => ({ [`${field.join(`.`)}_lt`]: value }), + and: (...filters) => Object.assign({}, ...filters), + }, + }) + + expect(result).toEqual({ category: `electronics`, price_lt: 100 }) + }) + + it(`should parse OR expression`, () => { + const expr = new Func(`or`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`eq`, [new PropRef([`category`]), new Value(`books`)]), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + or: (...filters) => ({ $or: filters }), + }, + }) + + expect(result).toEqual({ + $or: [{ category: `electronics` }, { category: `books` }], + }) + }) + + it(`should handle nested field paths`, () => { + const expr = new Func(`eq`, [ + new PropRef([`product`, `metadata`, `tags`]), + new Value(`featured`), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + }, + }) + + expect(result).toEqual({ [`product.metadata.tags`]: `featured` }) + }) + + it(`should throw error for unknown operator without handler`, () => { + const expr = new Func(`customOp`, [ + new PropRef([`field`]), + new Value(`value`), + ]) + + expect(() => { + parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + }, + }) + }).toThrow( + `No handler provided for operator: customOp. Available handlers: eq` + ) + }) + + it(`should use onUnknownOperator callback for unknown operators`, () => { + const expr = new Func(`customOp`, [ + new PropRef([`field`]), + new Value(`value`), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + }, + onUnknownOperator: (operator) => { + return { custom: operator } + }, + }) + + expect(result).toEqual({ custom: `customOp` }) + }) + + it(`should handle null/undefined expressions`, () => { + const result = parseWhereExpression(null, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + }, + }) + + expect(result).toBeNull() + }) + + it(`should handle deeply nested AND/OR expressions`, () => { + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`inStock`]), new Value(true)]), + new Func(`or`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`eq`, [new PropRef([`category`]), new Value(`books`)]), + ]), + ]) + + const result = parseWhereExpression(expr, { + handlers: { + eq: (field, value) => ({ field: field.join(`.`), value }), + and: (...filters) => ({ AND: filters }), + or: (...filters) => ({ OR: filters }), + }, + }) + + expect(result).toEqual({ + AND: [ + { field: `inStock`, value: true }, + { + OR: [ + { field: `category`, value: `electronics` }, + { field: `category`, value: `books` }, + ], + }, + ], + }) + }) + }) + + describe(`parseOrderByExpression`, () => { + it(`should parse single orderBy clause`, () => { + const orderBy: OrderBy = [ + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `asc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + const result = parseOrderByExpression(orderBy) + + expect(result).toEqual([ + { field: [`price`], direction: `asc`, nulls: `last` }, + ]) + }) + + it(`should parse multiple orderBy clauses`, () => { + const orderBy: OrderBy = [ + { + expression: new PropRef([`category`]), + compareOptions: { + direction: `asc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `desc`, + nulls: `first`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + const result = parseOrderByExpression(orderBy) + + expect(result).toEqual([ + { field: [`category`], direction: `asc`, nulls: `last` }, + { field: [`price`], direction: `desc`, nulls: `first` }, + ]) + }) + + it(`should handle nested field paths`, () => { + const orderBy: OrderBy = [ + { + expression: new PropRef([`product`, `metadata`, `rating`]), + compareOptions: { + direction: `desc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + const result = parseOrderByExpression(orderBy) + + expect(result).toEqual([ + { + field: [`product`, `metadata`, `rating`], + direction: `desc`, + nulls: `last`, + }, + ]) + }) + + it(`should handle null/undefined orderBy`, () => { + expect(parseOrderByExpression(null)).toEqual([]) + expect(parseOrderByExpression(undefined)).toEqual([]) + }) + + it(`should handle empty orderBy array`, () => { + expect(parseOrderByExpression([])).toEqual([]) + }) + + it(`should throw error for non-ref expressions`, () => { + const orderBy: OrderBy = [ + { + expression: new Value(`invalid`) as any, + compareOptions: { + direction: `asc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + expect(() => parseOrderByExpression(orderBy)).toThrow( + `ORDER BY expression must be a field reference, got: val` + ) + }) + }) + + describe(`extractSimpleComparisons`, () => { + it(`should extract single equality comparison`, () => { + const expr = new Func(`eq`, [ + new PropRef([`category`]), + new Value(`electronics`), + ]) + + const result = extractSimpleComparisons(expr) + + expect(result).toEqual([ + { field: [`category`], operator: `eq`, value: `electronics` }, + ]) + }) + + it(`should extract multiple AND-ed comparisons`, () => { + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + new Func(`eq`, [new PropRef([`inStock`]), new Value(true)]), + ]) + + const result = extractSimpleComparisons(expr) + + expect(result).toEqual([ + { field: [`category`], operator: `eq`, value: `electronics` }, + { field: [`price`], operator: `lt`, value: 100 }, + { field: [`inStock`], operator: `eq`, value: true }, + ]) + }) + + it(`should handle all comparison operators`, () => { + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`a`]), new Value(1)]), + new Func(`gt`, [new PropRef([`b`]), new Value(2)]), + new Func(`gte`, [new PropRef([`c`]), new Value(3)]), + new Func(`lt`, [new PropRef([`d`]), new Value(4)]), + new Func(`lte`, [new PropRef([`e`]), new Value(5)]), + new Func(`in`, [new PropRef([`f`]), new Value([6, 7])]), + ]) + + const result = extractSimpleComparisons(expr) + + expect(result).toEqual([ + { field: [`a`], operator: `eq`, value: 1 }, + { field: [`b`], operator: `gt`, value: 2 }, + { field: [`c`], operator: `gte`, value: 3 }, + { field: [`d`], operator: `lt`, value: 4 }, + { field: [`e`], operator: `lte`, value: 5 }, + { field: [`f`], operator: `in`, value: [6, 7] }, + ]) + }) + + it(`should handle nested AND expressions`, () => { + const expr = new Func(`and`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`and`, [ + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + new Func(`eq`, [new PropRef([`inStock`]), new Value(true)]), + ]), + ]) + + const result = extractSimpleComparisons(expr) + + expect(result).toEqual([ + { field: [`category`], operator: `eq`, value: `electronics` }, + { field: [`price`], operator: `lt`, value: 100 }, + { field: [`inStock`], operator: `eq`, value: true }, + ]) + }) + + it(`should handle null/undefined expressions`, () => { + expect(extractSimpleComparisons(null)).toEqual([]) + expect(extractSimpleComparisons(undefined)).toEqual([]) + }) + + it(`should skip OR expressions (not simple)`, () => { + const expr = new Func(`or`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`eq`, [new PropRef([`category`]), new Value(`books`)]), + ]) + + const result = extractSimpleComparisons(expr) + + // OR is not handled by extractSimpleComparisons, so it returns empty + expect(result).toEqual([]) + }) + + it(`should handle nested field paths`, () => { + const expr = new Func(`eq`, [ + new PropRef([`product`, `metadata`, `tags`]), + new Value(`featured`), + ]) + + const result = extractSimpleComparisons(expr) + + expect(result).toEqual([ + { + field: [`product`, `metadata`, `tags`], + operator: `eq`, + value: `featured`, + }, + ]) + }) + }) + + describe(`parseLoadSubsetOptions`, () => { + it(`should parse all options together`, () => { + const where = new Func(`and`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + ]) + + const orderBy: OrderBy = [ + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `asc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + const result = parseLoadSubsetOptions({ + where, + orderBy, + limit: 10, + }) + + expect(result).toEqual({ + filters: [ + { field: [`category`], operator: `eq`, value: `electronics` }, + { field: [`price`], operator: `lt`, value: 100 }, + ], + sorts: [{ field: [`price`], direction: `asc`, nulls: `last` }], + limit: 10, + }) + }) + + it(`should handle missing options`, () => { + const result = parseLoadSubsetOptions({}) + + expect(result).toEqual({ + filters: [], + sorts: [], + limit: undefined, + }) + }) + + it(`should handle null/undefined options`, () => { + expect(parseLoadSubsetOptions(null)).toEqual({ + filters: [], + sorts: [], + }) + expect(parseLoadSubsetOptions(undefined)).toEqual({ + filters: [], + sorts: [], + }) + }) + + it(`should handle only where clause`, () => { + const where = new Func(`eq`, [ + new PropRef([`category`]), + new Value(`electronics`), + ]) + + const result = parseLoadSubsetOptions({ where }) + + expect(result).toEqual({ + filters: [ + { field: [`category`], operator: `eq`, value: `electronics` }, + ], + sorts: [], + limit: undefined, + }) + }) + + it(`should handle only orderBy clause`, () => { + const orderBy: OrderBy = [ + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `desc`, + nulls: `first`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + const result = parseLoadSubsetOptions({ orderBy }) + + expect(result).toEqual({ + filters: [], + sorts: [{ field: [`price`], direction: `desc`, nulls: `first` }], + limit: undefined, + }) + }) + + it(`should handle only limit`, () => { + const result = parseLoadSubsetOptions({ limit: 20 }) + + expect(result).toEqual({ + filters: [], + sorts: [], + limit: 20, + }) + }) + }) + + describe(`Integration tests`, () => { + it(`should handle complex real-world query`, () => { + // Simulate: WHERE (category = 'electronics' OR category = 'books') + // AND price < 100 + // AND inStock = true + // ORDER BY price ASC, name DESC + // LIMIT 25 + + const where = new Func(`and`, [ + new Func(`or`, [ + new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), + new Func(`eq`, [new PropRef([`category`]), new Value(`books`)]), + ]), + new Func(`lt`, [new PropRef([`price`]), new Value(100)]), + new Func(`eq`, [new PropRef([`inStock`]), new Value(true)]), + ]) + + const orderBy: OrderBy = [ + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `asc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + { + expression: new PropRef([`name`]), + compareOptions: { + direction: `desc`, + nulls: `last`, + sensitivity: `base`, + locale: undefined, + }, + }, + ] + + // Use custom handlers to build JSON:API style query + const filters = parseWhereExpression(where, { + handlers: { + eq: (field, value) => ({ [field.join(`.`)]: value }), + lt: (field, value) => ({ [`${field.join(`.`)}_lt`]: value }), + and: (...conditions) => Object.assign({}, ...conditions), + or: (...conditions) => ({ _or: conditions }), + }, + }) + + const sorts = parseOrderByExpression(orderBy) + + expect(filters).toEqual({ + _or: [{ category: `electronics` }, { category: `books` }], + price_lt: 100, + inStock: true, + }) + + expect(sorts).toEqual([ + { field: [`price`], direction: `asc`, nulls: `last` }, + { field: [`name`], direction: `desc`, nulls: `last` }, + ]) + }) + }) +}) From d2ca26e4893d38cb6aa72aa76b789d0f9397ab2a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:19:19 -0800 Subject: [PATCH 03/14] chore: add changeset for expression helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/expression-helpers-queryfn.md | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .changeset/expression-helpers-queryfn.md diff --git a/.changeset/expression-helpers-queryfn.md b/.changeset/expression-helpers-queryfn.md new file mode 100644 index 000000000..2b910db1b --- /dev/null +++ b/.changeset/expression-helpers-queryfn.md @@ -0,0 +1,37 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Add expression helper utilities for parsing LoadSubsetOptions in queryFn. + +When using `syncMode: 'on-demand'`, query collections now provide helper functions to easily parse where clauses, orderBy, and limit predicates into your API's format: + +- `parseWhereExpression`: Parse where clauses with custom handlers for each operator +- `parseOrderByExpression`: Parse order by into simple array format +- `extractSimpleComparisons`: Extract simple AND-ed filters +- `parseLoadSubsetOptions`: Convenience function to parse all options at once +- `walkExpression`, `extractFieldPath`, `extractValue`: Lower-level helpers + +**Example:** + +```typescript +import { parseLoadSubsetOptions } from '@tanstack/query-db-collection' + +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + + const parsed = parseLoadSubsetOptions({ where, orderBy, limit }) + + // Build API request from parsed filters + const params = new URLSearchParams() + parsed.filters.forEach(({ field, operator, value }) => { + if (operator === 'eq') { + params.set(field.join('.'), String(value)) + } + }) + + return fetch(`/api/products?${params}`).then(r => r.json()) +} +``` + +This eliminates the need to manually traverse expression AST trees when implementing predicate push-down. From 6bffd234bb18ceb58353cba8b0cd7d09ec46bbcf Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:21:20 -0800 Subject: [PATCH 04/14] docs: make GraphQL example more generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove Hasura-specific emphasis in GraphQL example section. The underscore-prefixed operators are common GraphQL conventions. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/collections/query-collection.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 6d813006c..5cb23d760 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -554,13 +554,13 @@ queryFn: async (ctx) => { } ``` -### GraphQL Example (Hasura-Style) +### GraphQL Example ```typescript queryFn: async (ctx) => { const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} - // Convert to Hasura where clause format + // Convert to GraphQL where clause format const whereClause = parseWhereExpression(where, { handlers: { eq: (field, value) => ({ @@ -574,7 +574,7 @@ queryFn: async (ctx) => { } }) - // Convert to Hasura order_by format + // Convert to GraphQL order_by format const sorts = parseOrderByExpression(orderBy) const orderByClause = sorts.map(s => ({ [s.field.join('_')]: s.direction From c2f47e2bbdc2ee23032e7b33576e2350fbaa516d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:21:50 -0800 Subject: [PATCH 05/14] docs: clarify GraphQL format is not prescriptive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change 'the GraphQL format' to 'a GraphQL format' to avoid implying this is the only way to structure GraphQL queries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/collections/query-collection.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 5cb23d760..fc17b7d94 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -560,7 +560,7 @@ queryFn: async (ctx) => { queryFn: async (ctx) => { const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} - // Convert to GraphQL where clause format + // Convert to a GraphQL where clause format const whereClause = parseWhereExpression(where, { handlers: { eq: (field, value) => ({ @@ -574,7 +574,7 @@ queryFn: async (ctx) => { } }) - // Convert to GraphQL order_by format + // Convert to a GraphQL order_by format const sorts = parseOrderByExpression(orderBy) const orderByClause = sorts.map(s => ({ [s.field.join('_')]: s.direction From 9677f8621774cd0628d710d394e0c12dccd51594 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:24:20 -0800 Subject: [PATCH 06/14] docs: remove Electric DB reference from tips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove implementation-specific reference to Electric DB Collection from the tips section. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- docs/collections/query-collection.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index fc17b7d94..288946c1b 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -702,4 +702,3 @@ const productsCollection = createCollection( 2. **Use custom handlers** via `parseWhereExpression` for APIs with specific formats 3. **Handle unsupported operators** with the `onUnknownOperator` callback 4. **Log parsed results** during development to verify correctness -5. **Reference Electric DB Collection** (`packages/electric-db-collection/src/sql-compiler.ts`) for a complete SQL compilation example From ac3259816f8d8511f33e5053b2ba29aa8f4875d9 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:32:03 -0800 Subject: [PATCH 07/14] refactor: make loadSubsetOptions always an object MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add default empty object parameter to createQueryFromOpts - Remove ?? {} pattern from all documentation - Update changeset example - Update PR description This makes the API cleaner - users can now access ctx.meta.loadSubsetOptions directly without null coalescing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/expression-helpers-queryfn.md | 2 +- docs/collections/query-collection.md | 8 ++++---- packages/query-db-collection/src/expression-helpers.ts | 4 ++-- packages/query-db-collection/src/query.ts | 4 ++-- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.changeset/expression-helpers-queryfn.md b/.changeset/expression-helpers-queryfn.md index 2b910db1b..32f84c326 100644 --- a/.changeset/expression-helpers-queryfn.md +++ b/.changeset/expression-helpers-queryfn.md @@ -18,7 +18,7 @@ When using `syncMode: 'on-demand'`, query collections now provide helper functio import { parseLoadSubsetOptions } from '@tanstack/query-db-collection' queryFn: async (ctx) => { - const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions const parsed = parseLoadSubsetOptions({ where, orderBy, limit }) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 288946c1b..d2bbb1233 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -410,7 +410,7 @@ LoadSubsetOptions are passed to your `queryFn` via the query context's `meta` pr ```typescript queryFn: async (ctx) => { // Extract LoadSubsetOptions from the context - const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} + const { limit, where, orderBy } = ctx.meta.loadSubsetOptions // Use these to fetch only the data you need // ... @@ -450,7 +450,7 @@ const productsCollection = createCollection( syncMode: 'on-demand', // Enable predicate push-down queryFn: async (ctx) => { - const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} + const { limit, where, orderBy } = ctx.meta.loadSubsetOptions // Parse the expressions into simple format const parsed = parseLoadSubsetOptions({ where, orderBy, limit }) @@ -515,7 +515,7 @@ For APIs with specific formats, use custom handlers: ```typescript queryFn: async (ctx) => { - const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions // Use custom handlers to match your API's format const filters = parseWhereExpression(where, { @@ -558,7 +558,7 @@ queryFn: async (ctx) => { ```typescript queryFn: async (ctx) => { - const { where, orderBy, limit } = ctx.meta?.loadSubsetOptions ?? {} + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions // Convert to a GraphQL where clause format const whereClause = parseWhereExpression(where, { diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/query-db-collection/src/expression-helpers.ts index dcac062c6..72faefcaa 100644 --- a/packages/query-db-collection/src/expression-helpers.ts +++ b/packages/query-db-collection/src/expression-helpers.ts @@ -197,12 +197,12 @@ export function parseWhereExpression( // Handle value expressions if (expr.type === `val`) { - return expr.value + return expr.value as unknown as T } // Handle property references if (expr.type === `ref`) { - return expr.path as any + return expr.path as unknown as T } // Handle function expressions diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 35a7f8ae8..ee915aabf 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -19,12 +19,12 @@ import type { UtilsRecord, } from "@tanstack/db" import type { + FetchStatus, QueryClient, QueryFunctionContext, QueryKey, QueryObserverOptions, QueryObserverResult, - FetchStatus, } from "@tanstack/query-core" import type { StandardSchemaV1 } from "@standard-schema/spec" @@ -623,7 +623,7 @@ export function queryCollectionOptions( let syncStarted = false const createQueryFromOpts = ( - opts: LoadSubsetOptions, + opts: LoadSubsetOptions = {}, queryFunction: typeof queryFn = queryFn ): true | Promise => { // Push the predicates down to the queryKey and queryFn From 62f72bc591895797c09ea3fe9a2ed191d657c4db Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:38:28 -0800 Subject: [PATCH 08/14] fix: add explicit type annotations to avoid implicit any MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Import BasicExpression and OrderBy from IR namespace - Add type annotations to all lambda parameters - Remove unnecessary type checks that were always true - Fix variable shadowing in walkExpression visitor parameter - Fixes build type errors and eslint warnings in expression-helpers.ts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../src/expression-helpers.ts | 67 +++++++++---------- 1 file changed, 32 insertions(+), 35 deletions(-) diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/query-db-collection/src/expression-helpers.ts index 72faefcaa..fbf49c037 100644 --- a/packages/query-db-collection/src/expression-helpers.ts +++ b/packages/query-db-collection/src/expression-helpers.ts @@ -27,7 +27,10 @@ * ``` */ -import type { BasicExpression, OrderBy } from "@tanstack/db" +import type { IR } from "@tanstack/db" + +type BasicExpression = IR.BasicExpression +type OrderBy = IR.OrderBy /** * Represents a simple field path extracted from an expression @@ -138,14 +141,14 @@ export function extractValue(expr: BasicExpression): any { */ export function walkExpression( expr: BasicExpression | undefined | null, - visitor: (expr: BasicExpression) => void + visitor: (node: BasicExpression) => void ): void { if (!expr) return visitor(expr) if (expr.type === `func`) { - expr.args.forEach((arg) => walkExpression(arg, visitor)) + expr.args.forEach((arg: BasicExpression) => walkExpression(arg, visitor)) } } @@ -206,40 +209,34 @@ export function parseWhereExpression( } // Handle function expressions - if (expr.type === `func`) { - const { name, args } = expr - const handler = handlers[name] + // After checking val and ref, expr must be func + const { name, args } = expr + const handler = handlers[name] - if (!handler) { - if (onUnknownOperator) { - return onUnknownOperator(name, args) - } - throw new Error( - `No handler provided for operator: ${name}. Available handlers: ${Object.keys(handlers).join(`, `)}` - ) + if (!handler) { + if (onUnknownOperator) { + return onUnknownOperator(name, args) } - - // Parse arguments recursively - const parsedArgs = args.map((arg) => { - // For refs, extract the field path - if (arg.type === `ref`) { - return arg.path - } - // For values, extract the value - if (arg.type === `val`) { - return arg.value - } - // For nested functions, recurse - if (arg.type === `func`) { - return parseWhereExpression(arg, options) - } - return arg - }) - - return handler(...parsedArgs) + throw new Error( + `No handler provided for operator: ${name}. Available handlers: ${Object.keys(handlers).join(`, `)}` + ) } - return null + // Parse arguments recursively + const parsedArgs = args.map((arg: BasicExpression) => { + // For refs, extract the field path + if (arg.type === `ref`) { + return arg.path + } + // For values, extract the value + if (arg.type === `val`) { + return arg.value + } + // For nested functions, recurse (after checking ref and val, must be func) + return parseWhereExpression(arg, options) + }) + + return handler(...parsedArgs) } /** @@ -264,7 +261,7 @@ export function parseOrderByExpression( return [] } - return orderBy.map((clause) => { + return orderBy.map((clause: IR.OrderByClause) => { const field = extractFieldPath(clause.expression) if (!field) { @@ -311,7 +308,7 @@ export function extractSimpleComparisons( if (e.type === `func`) { // Handle AND - recurse into both sides if (e.name === `and`) { - e.args.forEach((arg) => extract(arg as BasicExpression)) + e.args.forEach((arg: BasicExpression) => extract(arg)) return } From a273bb10ee10ac73de7fe0a52572b409615b44c3 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 09:41:32 -0800 Subject: [PATCH 09/14] style: fix prettier formatting in changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/expression-helpers-queryfn.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/expression-helpers-queryfn.md b/.changeset/expression-helpers-queryfn.md index 32f84c326..e47500eee 100644 --- a/.changeset/expression-helpers-queryfn.md +++ b/.changeset/expression-helpers-queryfn.md @@ -15,7 +15,7 @@ When using `syncMode: 'on-demand'`, query collections now provide helper functio **Example:** ```typescript -import { parseLoadSubsetOptions } from '@tanstack/query-db-collection' +import { parseLoadSubsetOptions } from "@tanstack/query-db-collection" queryFn: async (ctx) => { const { where, orderBy, limit } = ctx.meta.loadSubsetOptions @@ -25,12 +25,12 @@ queryFn: async (ctx) => { // Build API request from parsed filters const params = new URLSearchParams() parsed.filters.forEach(({ field, operator, value }) => { - if (operator === 'eq') { - params.set(field.join('.'), String(value)) + if (operator === "eq") { + params.set(field.join("."), String(value)) } }) - return fetch(`/api/products?${params}`).then(r => r.json()) + return fetch(`/api/products?${params}`).then((r) => r.json()) } ``` From 1146f49bc7b9807a189f94665ff8a2246e788976 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 13:30:18 -0800 Subject: [PATCH 10/14] fix: correct TypeScript types in expression-helpers tests --- .../tests/expression-helpers.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/query-db-collection/tests/expression-helpers.test.ts b/packages/query-db-collection/tests/expression-helpers.test.ts index 9986e9c88..eed76b427 100644 --- a/packages/query-db-collection/tests/expression-helpers.test.ts +++ b/packages/query-db-collection/tests/expression-helpers.test.ts @@ -9,7 +9,9 @@ import { walkExpression, } from "../src/expression-helpers" import { Func, PropRef, Value } from "../../db/src/query/ir.js" -import type { OrderBy } from "@tanstack/db" +import type { IR } from "@tanstack/db" + +type OrderBy = IR.OrderBy describe(`Expression Helpers`, () => { describe(`extractFieldPath`, () => { @@ -207,7 +209,12 @@ describe(`Expression Helpers`, () => { ]), ]) - const result = parseWhereExpression(expr, { + type FilterResult = + | { field: string; value: unknown } + | { AND: Array } + | { OR: Array } + + const result = parseWhereExpression(expr, { handlers: { eq: (field, value) => ({ field: field.join(`.`), value }), and: (...filters) => ({ AND: filters }), From ff621e21f34a5166533a085d573efc4fd0ff2db6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 13:36:22 -0800 Subject: [PATCH 11/14] feat: address Sam's review feedback - Add OperatorName type and operators constant to db package - Improve ParseWhereOptions handlers type to know IR operators - Support array indices in FieldPath (string | number) - Add locale/string collation options to ParsedOrderBy - Make extractSimpleComparisons throw on unsupported operations (or, not, etc.) - Update tests to expect throws instead of silent skipping --- packages/db/src/index.ts | 1 + packages/db/src/query/builder/functions.ts | 39 +++++++++ .../src/expression-helpers.ts | 86 +++++++++++++++---- .../tests/expression-helpers.test.ts | 10 +-- 4 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index c58c7a5d5..f5a84c85e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -25,3 +25,4 @@ export { type IndexOptions } from "./indexes/index-options.js" // Re-export some stuff explicitly to ensure the type & value is exported export type { Collection } from "./collection/index.js" export { IR } +export { operators, type OperatorName } from "./query/builder/functions.js" diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 402c6de97..eca3172c0 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -337,3 +337,42 @@ export const comparisonFunctions = [ `like`, `ilike`, ] as const + +/** + * All supported operator names in TanStack DB expressions + */ +export const operators = [ + // Comparison operators + `eq`, + `gt`, + `gte`, + `lt`, + `lte`, + `in`, + `like`, + `ilike`, + // Logical operators + `and`, + `or`, + `not`, + // Null checking + `isNull`, + `isUndefined`, + // String functions + `upper`, + `lower`, + `length`, + `concat`, + // Numeric functions + `add`, + // Utility functions + `coalesce`, + // Aggregate functions + `count`, + `avg`, + `sum`, + `min`, + `max`, +] as const + +export type OperatorName = (typeof operators)[number] diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/query-db-collection/src/expression-helpers.ts index fbf49c037..4dfd9a693 100644 --- a/packages/query-db-collection/src/expression-helpers.ts +++ b/packages/query-db-collection/src/expression-helpers.ts @@ -27,15 +27,16 @@ * ``` */ -import type { IR } from "@tanstack/db" +import type { IR, OperatorName } from "@tanstack/db" type BasicExpression = IR.BasicExpression type OrderBy = IR.OrderBy /** - * Represents a simple field path extracted from an expression + * Represents a simple field path extracted from an expression. + * Can include string keys for object properties and numbers for array indices. */ -export type FieldPath = Array +export type FieldPath = Array /** * Represents a simple comparison operation @@ -54,18 +55,19 @@ export interface ParseWhereOptions { * Handler functions for different operators. * Each handler receives the parsed field path(s) and value(s) and returns your custom format. * - * Common operators: - * - eq: equality (=) - * - gt: greater than (>) - * - gte: greater than or equal (>=) - * - lt: less than (<) - * - lte: less than or equal (<=) - * - and: logical AND - * - or: logical OR - * - in: IN clause + * Supported operators from TanStack DB: + * - Comparison: eq, gt, gte, lt, lte, in, like, ilike + * - Logical: and, or, not + * - Null checking: isNull, isUndefined + * - String functions: upper, lower, length, concat + * - Numeric: add + * - Utility: coalesce + * - Aggregates: count, avg, sum, min, max */ handlers: { - [operator: string]: (...args: Array) => T + [K in OperatorName]?: (...args: Array) => T + } & { + [key: string]: (...args: Array) => T } /** * Optional handler for when an unknown operator is encountered. @@ -80,7 +82,13 @@ export interface ParseWhereOptions { export interface ParsedOrderBy { field: FieldPath direction: `asc` | `desc` - nulls?: `first` | `last` + nulls: `first` | `last` + /** String sorting method: 'lexical' (default) or 'locale' (locale-aware) */ + stringSort?: `lexical` | `locale` + /** Locale for locale-aware string sorting (e.g., 'en-US') */ + locale?: string + /** Additional options for locale-aware sorting */ + localeOptions?: object } /** @@ -270,10 +278,16 @@ export function parseOrderByExpression( ) } + const { direction, nulls, stringSort, locale, localeOptions } = + clause.compareOptions + return { field, - direction: clause.compareOptions.direction, - nulls: clause.compareOptions.nulls, + direction, + nulls, + ...(stringSort && { stringSort }), + ...(locale && { locale }), + ...(localeOptions && { localeOptions }), } }) } @@ -282,11 +296,12 @@ export function parseOrderByExpression( * Extracts all simple comparisons from a WHERE expression. * This is useful for simple APIs that only support basic filters. * - * Note: This only works for simple AND-ed conditions. Complex OR/nested conditions - * will require using parseWhereExpression with custom handlers. + * Note: This only works for simple AND-ed conditions. Throws an error if it encounters + * unsupported operations like OR, NOT, or complex nested expressions. * * @param expr - The WHERE expression to parse * @returns Array of simple comparisons + * @throws Error if expression contains OR, NOT, or other unsupported operations * * @example * ```typescript @@ -312,6 +327,32 @@ export function extractSimpleComparisons( return } + // Throw on unsupported operations + const unsupportedOps = [ + `or`, + `not`, + `isNull`, + `isUndefined`, + `like`, + `ilike`, + `upper`, + `lower`, + `length`, + `concat`, + `add`, + `coalesce`, + `count`, + `avg`, + `sum`, + `min`, + `max`, + ] + if (unsupportedOps.includes(e.name)) { + throw new Error( + `extractSimpleComparisons does not support '${e.name}' operator. Use parseWhereExpression with custom handlers for complex expressions.` + ) + } + // Handle comparison operators const comparisonOps = [`eq`, `gt`, `gte`, `lt`, `lte`, `in`] if (comparisonOps.includes(e.name)) { @@ -327,7 +368,16 @@ export function extractSimpleComparisons( operator: e.name, value, }) + } else { + throw new Error( + `extractSimpleComparisons requires simple field-value comparisons. Found complex expression for '${e.name}' operator.` + ) } + } else { + // Unknown operator + throw new Error( + `extractSimpleComparisons encountered unknown operator: '${e.name}'` + ) } } } diff --git a/packages/query-db-collection/tests/expression-helpers.test.ts b/packages/query-db-collection/tests/expression-helpers.test.ts index eed76b427..50987b6be 100644 --- a/packages/query-db-collection/tests/expression-helpers.test.ts +++ b/packages/query-db-collection/tests/expression-helpers.test.ts @@ -414,16 +414,16 @@ describe(`Expression Helpers`, () => { expect(extractSimpleComparisons(undefined)).toEqual([]) }) - it(`should skip OR expressions (not simple)`, () => { + it(`should throw on OR expressions (not simple)`, () => { const expr = new Func(`or`, [ new Func(`eq`, [new PropRef([`category`]), new Value(`electronics`)]), new Func(`eq`, [new PropRef([`category`]), new Value(`books`)]), ]) - const result = extractSimpleComparisons(expr) - - // OR is not handled by extractSimpleComparisons, so it returns empty - expect(result).toEqual([]) + // OR is not supported by extractSimpleComparisons, so it throws + expect(() => extractSimpleComparisons(expr)).toThrow( + `extractSimpleComparisons does not support 'or' operator` + ) }) it(`should handle nested field paths`, () => { From 8648e52151b35ee664b5129a166fa8cbeb64831c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 13:37:29 -0800 Subject: [PATCH 12/14] fix: remove invalid sensitivity/locale fields from test mocks CompareOptions uses StringCollationConfig which has stringSort, locale, and localeOptions fields, not sensitivity and locale at the top level. --- .../tests/expression-helpers.test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/query-db-collection/tests/expression-helpers.test.ts b/packages/query-db-collection/tests/expression-helpers.test.ts index 50987b6be..e1abd4c49 100644 --- a/packages/query-db-collection/tests/expression-helpers.test.ts +++ b/packages/query-db-collection/tests/expression-helpers.test.ts @@ -244,8 +244,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `asc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -264,8 +262,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `asc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, { @@ -273,8 +269,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `desc`, nulls: `first`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -294,8 +288,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `desc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -327,8 +319,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `asc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -457,8 +447,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `asc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -524,8 +512,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `desc`, nulls: `first`, - sensitivity: `base`, - locale: undefined, }, }, ] @@ -573,8 +559,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `asc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, { @@ -582,8 +566,6 @@ describe(`Expression Helpers`, () => { compareOptions: { direction: `desc`, nulls: `last`, - sensitivity: `base`, - locale: undefined, }, }, ] From a32b3631644a2dd7e4a1aa48b34e345baf8df8c2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 10 Nov 2025 16:42:49 -0800 Subject: [PATCH 13/14] fix: handle discriminated union for CompareOptions properly Use 'in' operator to check for optional fields (locale, localeOptions) that only exist when stringSort is 'locale' --- .../src/expression-helpers.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/query-db-collection/src/expression-helpers.ts index 4dfd9a693..08abca5cf 100644 --- a/packages/query-db-collection/src/expression-helpers.ts +++ b/packages/query-db-collection/src/expression-helpers.ts @@ -278,17 +278,25 @@ export function parseOrderByExpression( ) } - const { direction, nulls, stringSort, locale, localeOptions } = - clause.compareOptions - - return { + const { direction, nulls } = clause.compareOptions + const result: ParsedOrderBy = { field, direction, nulls, - ...(stringSort && { stringSort }), - ...(locale && { locale }), - ...(localeOptions && { localeOptions }), } + + // Add string collation options if present (discriminated union) + if (`stringSort` in clause.compareOptions) { + result.stringSort = clause.compareOptions.stringSort + } + if (`locale` in clause.compareOptions) { + result.locale = clause.compareOptions.locale + } + if (`localeOptions` in clause.compareOptions) { + result.localeOptions = clause.compareOptions.localeOptions + } + + return result }) } From d0377c67e2e519218aaaad9418d43585ce26d7a0 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Tue, 11 Nov 2025 11:21:40 -0800 Subject: [PATCH 14/14] move helpers to main db package --- .changeset/expression-helpers-queryfn.md | 6 ++++-- docs/collections/query-collection.md | 8 +++++--- packages/db/src/index.ts | 3 +++ .../src => db/src/query}/expression-helpers.ts | 6 +++--- .../tests => db/tests/query}/expression-helpers.test.ts | 6 +++--- packages/query-db-collection/src/index.ts | 3 ++- 6 files changed, 20 insertions(+), 12 deletions(-) rename packages/{query-db-collection/src => db/src/query}/expression-helpers.ts (98%) rename packages/{query-db-collection/tests => db/tests/query}/expression-helpers.test.ts (99%) diff --git a/.changeset/expression-helpers-queryfn.md b/.changeset/expression-helpers-queryfn.md index e47500eee..5a0629caa 100644 --- a/.changeset/expression-helpers-queryfn.md +++ b/.changeset/expression-helpers-queryfn.md @@ -1,10 +1,11 @@ --- +"@tanstack/db": patch "@tanstack/query-db-collection": patch --- Add expression helper utilities for parsing LoadSubsetOptions in queryFn. -When using `syncMode: 'on-demand'`, query collections now provide helper functions to easily parse where clauses, orderBy, and limit predicates into your API's format: +When using `syncMode: 'on-demand'`, TanStack DB now provides helper functions to easily parse where clauses, orderBy, and limit predicates into your API's format: - `parseWhereExpression`: Parse where clauses with custom handlers for each operator - `parseOrderByExpression`: Parse order by into simple array format @@ -15,7 +16,8 @@ When using `syncMode: 'on-demand'`, query collections now provide helper functio **Example:** ```typescript -import { parseLoadSubsetOptions } from "@tanstack/query-db-collection" +import { parseLoadSubsetOptions } from "@tanstack/db" +// or from "@tanstack/query-db-collection" (re-exported for convenience) queryFn: async (ctx) => { const { where, orderBy, limit } = ctx.meta.loadSubsetOptions diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index d2bbb1233..85f980406 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -417,7 +417,7 @@ queryFn: async (ctx) => { } ``` -The `where` and `orderBy` fields are expression trees (AST - Abstract Syntax Tree) that need to be parsed. The `@tanstack/query-db-collection` package provides helper functions to make this easy. +The `where` and `orderBy` fields are expression trees (AST - Abstract Syntax Tree) that need to be parsed. TanStack DB provides helper functions to make this easy. ### Expression Helpers @@ -427,7 +427,8 @@ import { parseOrderByExpression, extractSimpleComparisons, parseLoadSubsetOptions, -} from '@tanstack/query-db-collection' +} from '@tanstack/db' +// Or from '@tanstack/query-db-collection' (re-exported for convenience) ``` These helpers allow you to parse expression trees without manually traversing complex AST structures. @@ -436,7 +437,8 @@ These helpers allow you to parse expression trees without manually traversing co ```typescript import { createCollection } from '@tanstack/react-db' -import { queryCollectionOptions, parseLoadSubsetOptions } from '@tanstack/query-db-collection' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { parseLoadSubsetOptions } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' const queryClient = new QueryClient() diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index f5a84c85e..638e21514 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -22,6 +22,9 @@ export * from "./indexes/btree-index.js" export * from "./indexes/lazy-index.js" export { type IndexOptions } from "./indexes/index-options.js" +// Expression helpers +export * from "./query/expression-helpers.js" + // Re-export some stuff explicitly to ensure the type & value is exported export type { Collection } from "./collection/index.js" export { IR } diff --git a/packages/query-db-collection/src/expression-helpers.ts b/packages/db/src/query/expression-helpers.ts similarity index 98% rename from packages/query-db-collection/src/expression-helpers.ts rename to packages/db/src/query/expression-helpers.ts index 08abca5cf..febcdf843 100644 --- a/packages/query-db-collection/src/expression-helpers.ts +++ b/packages/db/src/query/expression-helpers.ts @@ -1,5 +1,5 @@ /** - * Expression Helpers for Query Collections + * Expression Helpers for TanStack DB * * These utilities help parse LoadSubsetOptions (where, orderBy, limit) from TanStack DB * into formats suitable for your API backend. They provide a generic way to traverse @@ -7,7 +7,7 @@ * * @example * ```typescript - * import { parseWhereExpression, parseOrderByExpression } from '@tanstack/query-db-collection' + * import { parseWhereExpression, parseOrderByExpression } from '@tanstack/db' * * queryFn: async (ctx) => { * const { limit, where, orderBy } = ctx.meta?.loadSubsetOptions ?? {} @@ -27,7 +27,7 @@ * ``` */ -import type { IR, OperatorName } from "@tanstack/db" +import type { IR, OperatorName } from "../index.js" type BasicExpression = IR.BasicExpression type OrderBy = IR.OrderBy diff --git a/packages/query-db-collection/tests/expression-helpers.test.ts b/packages/db/tests/query/expression-helpers.test.ts similarity index 99% rename from packages/query-db-collection/tests/expression-helpers.test.ts rename to packages/db/tests/query/expression-helpers.test.ts index e1abd4c49..aab1f0949 100644 --- a/packages/query-db-collection/tests/expression-helpers.test.ts +++ b/packages/db/tests/query/expression-helpers.test.ts @@ -7,9 +7,9 @@ import { parseOrderByExpression, parseWhereExpression, walkExpression, -} from "../src/expression-helpers" -import { Func, PropRef, Value } from "../../db/src/query/ir.js" -import type { IR } from "@tanstack/db" +} from "../../src/query/expression-helpers" +import { Func, PropRef, Value } from "../../src/query/ir.js" +import type { IR } from "../../src/index.js" type OrderBy = IR.OrderBy diff --git a/packages/query-db-collection/src/index.ts b/packages/query-db-collection/src/index.ts index 8aa6858bd..1a3169b3f 100644 --- a/packages/query-db-collection/src/index.ts +++ b/packages/query-db-collection/src/index.ts @@ -7,6 +7,7 @@ export { export * from "./errors" +// Re-export expression helpers from @tanstack/db export { parseWhereExpression, parseOrderByExpression, @@ -19,4 +20,4 @@ export { type SimpleComparison, type ParseWhereOptions, type ParsedOrderBy, -} from "./expression-helpers" +} from "@tanstack/db"