diff --git a/.changeset/expression-helpers-queryfn.md b/.changeset/expression-helpers-queryfn.md new file mode 100644 index 000000000..5a0629caa --- /dev/null +++ b/.changeset/expression-helpers-queryfn.md @@ -0,0 +1,39 @@ +--- +"@tanstack/db": patch +"@tanstack/query-db-collection": patch +--- + +Add expression helper utilities for parsing LoadSubsetOptions in queryFn. + +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 +- `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/db" +// or from "@tanstack/query-db-collection" (re-exported for convenience) + +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. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 91a0f7dea..85f980406 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -398,3 +398,309 @@ 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. TanStack DB provides helper functions to make this easy. + +### Expression Helpers + +```typescript +import { + parseWhereExpression, + parseOrderByExpression, + extractSimpleComparisons, + parseLoadSubsetOptions, +} 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. + +### Quick Start: Simple REST API + +```typescript +import { createCollection } from '@tanstack/react-db' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { parseLoadSubsetOptions } from '@tanstack/db' +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 + +```typescript +queryFn: async (ctx) => { + const { where, orderBy, limit } = ctx.meta.loadSubsetOptions + + // Convert to a GraphQL 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 a GraphQL 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 diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index c58c7a5d5..638e21514 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -22,6 +22,10 @@ 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 } +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/db/src/query/expression-helpers.ts b/packages/db/src/query/expression-helpers.ts new file mode 100644 index 000000000..febcdf843 --- /dev/null +++ b/packages/db/src/query/expression-helpers.ts @@ -0,0 +1,443 @@ +/** + * 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 + * expression trees without having to implement your own parser. + * + * @example + * ```typescript + * import { parseWhereExpression, parseOrderByExpression } from '@tanstack/db' + * + * 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 { IR, OperatorName } from "../index.js" + +type BasicExpression = IR.BasicExpression +type OrderBy = IR.OrderBy + +/** + * 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 + +/** + * 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. + * + * 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: { + [K in OperatorName]?: (...args: Array) => T + } & { + [key: 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` + /** 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 +} + +/** + * 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: (node: BasicExpression) => void +): void { + if (!expr) return + + visitor(expr) + + if (expr.type === `func`) { + expr.args.forEach((arg: BasicExpression) => 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 as unknown as T + } + + // Handle property references + if (expr.type === `ref`) { + return expr.path as unknown as T + } + + // Handle function expressions + // 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(`, `)}` + ) + } + + // 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) +} + +/** + * 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: IR.OrderByClause) => { + const field = extractFieldPath(clause.expression) + + if (!field) { + throw new Error( + `ORDER BY expression must be a field reference, got: ${clause.expression.type}` + ) + } + + const { direction, nulls } = clause.compareOptions + const result: ParsedOrderBy = { + field, + direction, + nulls, + } + + // 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 + }) +} + +/** + * 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. 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 + * 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: BasicExpression) => extract(arg)) + 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)) { + 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, + }) + } 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}'` + ) + } + } + } + + 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/db/tests/query/expression-helpers.test.ts b/packages/db/tests/query/expression-helpers.test.ts new file mode 100644 index 000000000..aab1f0949 --- /dev/null +++ b/packages/db/tests/query/expression-helpers.test.ts @@ -0,0 +1,597 @@ +import { describe, expect, it } from "vitest" +import { + extractFieldPath, + extractSimpleComparisons, + extractValue, + parseLoadSubsetOptions, + parseOrderByExpression, + parseWhereExpression, + walkExpression, +} 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 + +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`)]), + ]), + ]) + + 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 }), + 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`, + }, + }, + ] + + 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`, + }, + }, + { + expression: new PropRef([`price`]), + compareOptions: { + direction: `desc`, + nulls: `first`, + }, + }, + ] + + 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`, + }, + }, + ] + + 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`, + }, + }, + ] + + 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 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`)]), + ]) + + // 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`, () => { + 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`, + }, + }, + ] + + 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`, + }, + }, + ] + + 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`, + }, + }, + { + expression: new PropRef([`name`]), + compareOptions: { + direction: `desc`, + nulls: `last`, + }, + }, + ] + + // 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` }, + ]) + }) + }) +}) diff --git a/packages/query-db-collection/src/index.ts b/packages/query-db-collection/src/index.ts index 42b47e419..1a3169b3f 100644 --- a/packages/query-db-collection/src/index.ts +++ b/packages/query-db-collection/src/index.ts @@ -6,3 +6,18 @@ export { } from "./query" export * from "./errors" + +// Re-export expression helpers from @tanstack/db +export { + parseWhereExpression, + parseOrderByExpression, + extractSimpleComparisons, + parseLoadSubsetOptions, + extractFieldPath, + extractValue, + walkExpression, + type FieldPath, + type SimpleComparison, + type ParseWhereOptions, + type ParsedOrderBy, +} from "@tanstack/db" 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