UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

520 lines (476 loc) • 17.4 kB
import { compileSingleRowExpression, toBooleanPredicate, } from '../query/compiler/evaluators.js' import { findIndexForField, optimizeExpressionWithIndexes, } from '../utils/index-optimization.js' import { ensureIndexForField } from '../indexes/auto-index.js' import { getPropRefPropertyPath } from '../query/ir.js' import { isVirtualPropName } from '../virtual-props.js' import { makeComparator } from '../utils/comparison.js' import { buildCompareOptions } from '../query/compiler/order-by' import type { ChangeMessage, CollectionLike, CurrentStateAsChangesOptions, SubscribeChangesOptions, } from '../types' import type { CollectionImpl } from './index.js' import type { BasicExpression, OrderBy } from '../query/ir.js' import type { WithVirtualProps } from '../virtual-props.js' /** * Yields visible entries, enriched with virtual properties, whose stored row * passes `prefilter`. */ export type StoredRowScan<T extends object, TKey extends string | number> = ( prefilter: (row: object) => boolean, ) => Iterable<[TKey, WithVirtualProps<T, TKey>]> /** * Returns the current state of the collection as an array of changes * @param collection - The collection to get changes from * @param options - Options including optional where filter, orderBy, and limit * @returns An array of changes * @example * // Get all items as changes * const allChanges = currentStateAsChanges(collection) * * // Get only items matching a condition * const activeChanges = currentStateAsChanges(collection, { * where: (row) => row.status === 'active' * }) * * // Get only items using a pre-compiled expression * const activeChanges = currentStateAsChanges(collection, { * where: eq(row.status, 'active') * }) * * // Get items ordered by name with limit * const topUsers = currentStateAsChanges(collection, { * orderBy: [{ expression: row.name, compareOptions: { direction: 'asc' } }], * limit: 10 * }) * * // Get active users ordered by score (highest score first) * const topActiveUsers = currentStateAsChanges(collection, { * where: eq(row.status, 'active'), * orderBy: [{ expression: row.score, compareOptions: { direction: 'desc' } }], * }) */ export function currentStateAsChanges< T extends object, TKey extends string | number, >( collection: CollectionLike<WithVirtualProps<T, TKey>, TKey>, options: CurrentStateAsChangesOptions = {}, scanStoredRows?: StoredRowScan<T, TKey>, ): Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> | void { // Helper function to collect filtered results const collectFilteredResults = ( filterFn?: (value: WithVirtualProps<T, TKey>) => boolean, ): Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> => { const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = [] // Reject rows by one stored field before copying them to add virtual // properties. Survivors still pass through the full predicate. const prefilter = scanStoredRows && options.where && compileEqualityPrefilter(options.where) if (filterFn && scanStoredRows && prefilter) { for (const [key, value] of scanStoredRows(prefilter)) { if (filterFn(value)) result.push({ type: `insert`, key, value }) } return result } for (const [key, value] of collection.entries()) { // If no filter function is provided, include all items if (filterFn?.(value) ?? true) { result.push({ type: `insert`, key, value, }) } } return result } // Validate that limit without orderBy doesn't happen if (options.limit !== undefined && !options.orderBy) { throw new Error(`limit cannot be used without orderBy`) } // First check if orderBy is present (optionally with limit) if (options.orderBy) { // Create where filter function if present const whereFilter = options.where ? createFilterFunctionFromExpression(options.where) : undefined // Get ordered keys using index optimization when possible const orderedKeys = getOrderedKeys( collection, options.orderBy, options.limit, whereFilter, options.optimizedOnly, ) if (orderedKeys === undefined) { // `getOrderedKeys` returned undefined because we asked for `optimizedOnly` and there was no index to use return } // Convert keys to change messages const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = [] for (const key of orderedKeys) { const value = collection.get(key) if (value !== undefined) { result.push({ type: `insert`, key, value, }) } } return result } // If no orderBy OR orderBy optimization failed, use where clause optimization if (!options.where) { // No filtering, return all items return collectFilteredResults() } // There's a where clause, let's see if we can use an index try { const expression: BasicExpression<boolean> = options.where // Try to optimize the query using indexes const optimizationResult = optimizeExpressionWithIndexes( expression, collection, ) if (optimizationResult.canOptimize) { // Use index optimization. When the index lookup is inexact, the keys // are a superset of the true result (some conditions could not be // served by an index), so re-check each row against the full expression. const filterFn = optimizationResult.isExact ? undefined : createFilterFunctionFromExpression(expression) const result: Array<ChangeMessage<WithVirtualProps<T, TKey>, TKey>> = [] for (const key of optimizationResult.matchingKeys) { const value = collection.get(key) if (value !== undefined && (filterFn?.(value) ?? true)) { result.push({ type: `insert`, key, value, }) } } return result } else { if (options.optimizedOnly) { return } const filterFn = createFilterFunctionFromExpression(expression) return collectFilteredResults(filterFn) } } catch (error) { // If anything goes wrong with the where clause, fall back to full scan console.warn( `${collection.id ? `[${collection.id}] ` : ``}Error processing where clause, falling back to full scan:`, error, ) const filterFn = createFilterFunctionFromExpression(options.where) if (options.optimizedOnly) { return } return collectFilteredResults(filterFn) } } /** * Creates a filter function from a pre-compiled expression * @param expression - The pre-compiled expression to evaluate * @returns A function that takes an item and returns true if it matches the filter */ export function createFilterFunctionFromExpression<T extends object>( expression: BasicExpression<boolean>, ): (item: T) => boolean { // Compile expression once when filter function is created, not on every invocation const evaluator = compileSingleRowExpression(expression) return (item: T): boolean => { try { const result = evaluator(item as Record<string, unknown>) return toBooleanPredicate(result) } catch { // If evaluation fails, exclude the item return false } } } /** A field and the string or boolean literal a top-level `eq` requires. */ export type EqualityRoute = { path: Array<string> /** Stable identity of `path`, for grouping routes by field. */ pathKey: string expected: string | boolean } /** Read result for a route path whose property access threw. */ export const UNREADABLE_ROUTE_VALUE: unique symbol = Symbol( `unreadable route value`, ) /** * Finds a cheap necessary condition for `expression` to be TRUE, or returns * undefined when the expression has none. * * A top-level conjunct `eq(field, literal)` with a string or boolean literal is * TRUE only when the field holds the identical string or boolean: equality * normalization never maps another type onto a plain string or boolean. A * row whose field holds anything else therefore fails the whole expression. * * With `storedRows`, the condition is read from a stored row instead of its * enriched copy, so conjuncts on virtual fields are skipped: stored rows need * not carry them. */ export function findEqualityRoute( expression: BasicExpression<boolean>, { storedRows = false }: { storedRows?: boolean } = {}, ): EqualityRoute | undefined { const conjuncts: Array<BasicExpression> = [] const collect = (node: BasicExpression) => { if (node.type === `func` && node.name === `and`) node.args.forEach(collect) else conjuncts.push(node) } collect(expression) // A string literal usually rejects more rows than a boolean one. let best: EqualityRoute | undefined for (const conjunct of conjuncts) { if (conjunct.type !== `func` || conjunct.name !== `eq`) continue const [left, right] = conjunct.args const ref = left?.type === `ref` ? left : right?.type === `ref` ? right : undefined const literal = left?.type === `val` ? left : right?.type === `val` ? right : undefined if (!ref || !literal) continue const expected: unknown = literal.value if (typeof expected !== `string` && typeof expected !== `boolean`) continue const path = getPropRefPropertyPath(ref) if (storedRows && (path.length === 0 || isVirtualPropName(path[0]!))) { continue } if (best === undefined || typeof best.expected === `boolean`) { best = { path, pathKey: JSON.stringify(path), expected } } if (typeof expected === `string`) break } return best } /** * Reads a route field the way the single-row evaluator does. A throwing read * returns UNREADABLE_ROUTE_VALUE so callers leave the decision to the full * predicate. */ export function readRouteValue( row: unknown, path: ReadonlyArray<string>, ): unknown { try { let value: unknown = row for (const segment of path) { if (value === null || value === undefined) return undefined value = (value as Record<string, unknown>)[segment] } return value } catch { return UNREADABLE_ROUTE_VALUE } } /** * Compiles the route of `expression` as a row test that is false only when * the full predicate must be false. * * The test reads a stored row instead of its enriched copy. The copy holds * each enumerable own root property of the stored row and lacks the others, so its field is either the stored value or `undefined`, * which never equals the literal. A read that throws passes the row to the * full predicate. */ export function compileEqualityPrefilter( expression: BasicExpression<boolean>, ): ((row: object) => boolean) | undefined { const route = findEqualityRoute(expression, { storedRows: true }) if (route === undefined) return undefined const { path, expected } = route // Most routes name one top-level field; read it without walking a path. if (path.length === 1) { const field = path[0]! return (row) => { try { return (row as Record<string, unknown>)[field] === expected } catch { return true } } } return (row) => { const value = readRouteValue(row, path) return value === UNREADABLE_ROUTE_VALUE || value === expected } } /** * Creates a filtered callback that only calls the original callback with changes that match the where clause * @param originalCallback - The original callback to filter * @param options - The subscription options containing the where clause * @returns A filtered callback function */ export function createFilteredCallback< T extends object, TKey extends string | number = string | number, >( originalCallback: (changes: Array<ChangeMessage<T>>) => void, options: SubscribeChangesOptions<T, TKey>, ): (changes: Array<ChangeMessage<T>>) => boolean { const filterFn = createFilterFunctionFromExpression(options.whereExpression!) return (changes: Array<ChangeMessage<T>>) => { const filteredChanges: Array<ChangeMessage<T>> = [] for (const change of changes) { if (change.type === `insert`) { // For inserts, check if the new value matches the filter if (filterFn(change.value)) { filteredChanges.push(change) } } else if (change.type === `update`) { // For updates, we need to check both old and new values const newValueMatches = filterFn(change.value) const oldValueMatches = change.previousValue ? filterFn(change.previousValue) : false if (newValueMatches && oldValueMatches) { // Both old and new match: emit update filteredChanges.push(change) } else if (newValueMatches && !oldValueMatches) { // New matches but old didn't: emit insert filteredChanges.push({ ...change, type: `insert`, }) } else if (!newValueMatches && oldValueMatches) { // Old matched but new doesn't: emit delete filteredChanges.push({ ...change, type: `delete`, value: change.previousValue!, // Use the previous value for the delete }) } // If neither matches, don't emit anything } else { // For deletes, include if the previous value would have matched // (so subscribers know something they were tracking was deleted) if (filterFn(change.value)) { filteredChanges.push(change) } } } // Always call the original callback if we have filtered changes OR // if the original changes array was empty (which indicates a ready signal) if (filteredChanges.length > 0 || changes.length === 0) { originalCallback(filteredChanges) return true } return false } } /** * Gets ordered keys from a collection using index optimization when possible * @param collection - The collection to get keys from * @param orderBy - The order by clause * @param limit - Optional limit on number of keys to return * @param whereFilter - Optional filter function to apply while traversing * @returns Array of keys in sorted order */ function getOrderedKeys<T extends object, TKey extends string | number>( collection: CollectionLike<T, TKey>, orderBy: OrderBy, limit?: number, whereFilter?: (item: T) => boolean, optimizedOnly?: boolean, ): Array<TKey> | undefined { // For single-column orderBy on a ref expression, try index optimization if (orderBy.length === 1) { const clause = orderBy[0]! const orderByExpression = clause.expression if (orderByExpression.type === `ref`) { const propRef = orderByExpression const fieldPath = propRef.path const compareOpts = buildCompareOptions(clause, collection) // Ensure index exists for this field ensureIndexForField( fieldPath[0]!, fieldPath, collection as CollectionImpl<T, TKey>, compareOpts, ) // Find the index const index = findIndexForField(collection, fieldPath, compareOpts) if (index && index.supports(`gt`)) { // Use index optimization const filterFn = (key: TKey): boolean => { const value = collection.get(key) if (value === undefined) { return false } return whereFilter?.(value) ?? true } // Take the keys that match the filter and limit // if no limit is provided `index.keyCount` is used, // i.e. we will take all keys that match the filter return index.takeFromStart(limit ?? index.keyCount, filterFn) } } } if (optimizedOnly) { return } // Fallback: collect all items and sort in memory const allItems: Array<{ key: TKey; value: T }> = [] for (const [key, value] of collection.entries()) { if (whereFilter?.(value) ?? true) { allItems.push({ key, value }) } } const clauses = orderBy.map((clause) => ({ expression: clause.expression, compare: makeComparator(buildCompareOptions(clause, collection)), })) const compare = (a: { key: TKey; value: T }, b: { key: TKey; value: T }) => { for (const clause of clauses) { // Extract values for comparison const aValue = extractValueFromItem(a.value, clause.expression) const bValue = extractValueFromItem(b.value, clause.expression) const result = clause.compare(aValue, bValue) if (result !== 0) { return result } } return 0 } allItems.sort(compare) const sortedKeys = allItems.map((item) => item.key) // Apply limit if provided if (limit !== undefined) { return sortedKeys.slice(0, limit) } // if no limit is provided, we will return all keys return sortedKeys } /** * Helper function to extract a value from an item based on an expression */ function extractValueFromItem(item: any, expression: BasicExpression): any { if (expression.type === `ref`) { const propRef = expression let value = item for (const pathPart of propRef.path) { value = value?.[pathPart] } return value } else if (expression.type === `val`) { return expression.value } else { // It must be a function const evaluator = compileSingleRowExpression(expression) return evaluator(item as Record<string, unknown>) } }