UNPKG

@tanstack/db

Version:

A reactive client store for building super fast apps on sync

828 lines (755 loc) 28.4 kB
/** * # Query Optimizer * * The query optimizer improves query performance by implementing predicate pushdown optimization. * It rewrites the intermediate representation (IR) to push WHERE clauses as close to the data * source as possible, reducing the amount of data processed during joins. * * ## How It Works * * The optimizer follows a 4-step process: * * ### 1. AND Clause Splitting * Splits AND clauses at the root level into separate WHERE clauses for granular optimization. * ```javascript * // Before: WHERE and(eq(users.department_id, 1), gt(users.age, 25)) * // After: WHERE eq(users.department_id, 1) + WHERE gt(users.age, 25) * ``` * * ### 2. Source Analysis * Analyzes each WHERE clause to determine which table sources it references: * - Single-source clauses: Touch only one table (e.g., `users.department_id = 1`) * - Multi-source clauses: Touch multiple tables (e.g., `users.id = posts.user_id`) * * ### 3. Clause Grouping * Groups WHERE clauses by the sources they touch: * - Single-source clauses are grouped by their respective table * - Multi-source clauses are combined for the main query * * ### 4. Subquery Creation * Lifts single-source WHERE clauses into subqueries that wrap the original table references. * * ## Safety & Edge Cases * * The optimizer includes targeted safety checks to prevent predicate pushdown when it could * break query semantics: * * ### Always Safe Operations * - **Creating new subqueries**: Wrapping collection references in subqueries with WHERE clauses * - **Main query optimizations**: Moving single-source WHERE clauses from main query to subqueries * - **Queries with aggregates/ORDER BY/HAVING**: Can still create new filtered subqueries * * ### Unsafe Operations (blocked by safety checks) * Pushing WHERE clauses **into existing subqueries** that have: * - **Aggregates**: GROUP BY, HAVING, or aggregate functions in SELECT (would change aggregation) * - **Ordering + Limits**: ORDER BY combined with LIMIT/OFFSET (would change result set) * - **Functional Operations**: fnSelect, fnWhere, fnHaving (potential side effects) * * The optimizer tracks which clauses were actually optimized and only removes those from the * main query. Subquery reuse is handled safely through immutable query copies. * * ## Example Optimizations * * ### Basic Query with Joins * **Original Query:** * ```javascript * query * .from({ users: usersCollection }) * .join({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.user_id)) * .where(({users}) => eq(users.department_id, 1)) * .where(({posts}) => gt(posts.views, 100)) * .where(({users, posts}) => eq(users.id, posts.author_id)) * ``` * * **Optimized Query:** * ```javascript * query * .from({ * users: subquery * .from({ users: usersCollection }) * .where(({users}) => eq(users.department_id, 1)) * }) * .join({ * posts: subquery * .from({ posts: postsCollection }) * .where(({posts}) => gt(posts.views, 100)) * }, ({users, posts}) => eq(users.id, posts.user_id)) * .where(({users, posts}) => eq(users.id, posts.author_id)) * ``` * * ### Query with Aggregates (Now Optimizable!) * **Original Query:** * ```javascript * query * .from({ users: usersCollection }) * .join({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.user_id)) * .where(({users}) => eq(users.department_id, 1)) * .groupBy(['users.department_id']) * .select({ count: agg('count', '*') }) * ``` * * **Optimized Query:** * ```javascript * query * .from({ * users: subquery * .from({ users: usersCollection }) * .where(({users}) => eq(users.department_id, 1)) * }) * .join({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.user_id)) * .groupBy(['users.department_id']) * .select({ count: agg('count', '*') }) * ``` * * ## Benefits * * - **Reduced Data Processing**: Filters applied before joins reduce intermediate result size * - **Better Performance**: Smaller datasets lead to faster query execution * - **Automatic Optimization**: No manual query rewriting required * - **Preserves Semantics**: Optimized queries return identical results * - **Safe by Design**: Comprehensive checks prevent semantic-breaking optimizations * * ## Integration * * The optimizer is automatically called during query compilation before the IR is * transformed into a D2Mini pipeline. */ import { deepEquals } from "../utils.js" import { CannotCombineEmptyExpressionListError } from "../errors.js" import { CollectionRef as CollectionRefClass, Func, QueryRef as QueryRefClass, } from "./ir.js" import { isConvertibleToCollectionFilter } from "./compiler/expressions.js" import type { BasicExpression, From, QueryIR } from "./ir.js" /** * Represents a WHERE clause after source analysis */ export interface AnalyzedWhereClause { /** The WHERE expression */ expression: BasicExpression<boolean> /** Set of table/source aliases that this WHERE clause touches */ touchedSources: Set<string> } /** * Represents WHERE clauses grouped by the sources they touch */ export interface GroupedWhereClauses { /** WHERE clauses that touch only a single source, grouped by source alias */ singleSource: Map<string, BasicExpression<boolean>> /** WHERE clauses that touch multiple sources, combined into one expression */ multiSource?: BasicExpression<boolean> } /** * Result of query optimization including both the optimized query and collection-specific WHERE clauses */ export interface OptimizationResult { /** The optimized query with WHERE clauses potentially moved to subqueries */ optimizedQuery: QueryIR /** Map of collection aliases to their extracted WHERE clauses for index optimization */ collectionWhereClauses: Map<string, BasicExpression<boolean>> } /** * Main query optimizer entry point that lifts WHERE clauses into subqueries. * * This function implements multi-level predicate pushdown optimization by recursively * moving WHERE clauses through nested subqueries to get them as close to the data * sources as possible, then removing redundant subqueries. * * @param query - The QueryIR to optimize * @returns An OptimizationResult with the optimized query and collection WHERE clause mapping * * @example * ```typescript * const originalQuery = { * from: new CollectionRef(users, 'u'), * join: [{ from: new CollectionRef(posts, 'p'), ... }], * where: [eq(u.dept_id, 1), gt(p.views, 100)] * } * * const { optimizedQuery, collectionWhereClauses } = optimizeQuery(originalQuery) * // Result: Single-source clauses moved to deepest possible subqueries * // collectionWhereClauses: Map { 'u' => eq(u.dept_id, 1), 'p' => gt(p.views, 100) } * ``` */ export function optimizeQuery(query: QueryIR): OptimizationResult { // First, extract collection WHERE clauses before optimization const collectionWhereClauses = extractCollectionWhereClauses(query) // Apply multi-level predicate pushdown with iterative convergence let optimized = query let previousOptimized: QueryIR | undefined let iterations = 0 const maxIterations = 10 // Prevent infinite loops // Keep optimizing until no more changes occur or max iterations reached while ( iterations < maxIterations && !deepEquals(optimized, previousOptimized) ) { previousOptimized = optimized optimized = applyRecursiveOptimization(optimized) iterations++ } // Remove redundant subqueries const cleaned = removeRedundantSubqueries(optimized) return { optimizedQuery: cleaned, collectionWhereClauses, } } /** * Extracts collection-specific WHERE clauses from a query for index optimization. * This analyzes the original query to identify WHERE clauses that can be pushed down * to specific collections, but only for simple queries without joins. * * @param query - The original QueryIR to analyze * @returns Map of collection aliases to their WHERE clauses */ function extractCollectionWhereClauses( query: QueryIR ): Map<string, BasicExpression<boolean>> { const collectionWhereClauses = new Map<string, BasicExpression<boolean>>() // Only analyze queries that have WHERE clauses if (!query.where || query.where.length === 0) { return collectionWhereClauses } // Split all AND clauses at the root level for granular analysis const splitWhereClauses = splitAndClauses(query.where) // Analyze each WHERE clause to determine which sources it touches const analyzedClauses = splitWhereClauses.map((clause) => analyzeWhereClause(clause) ) // Group clauses by single-source vs multi-source const groupedClauses = groupWhereClauses(analyzedClauses) // Only include single-source clauses that reference collections directly // and can be converted to BasicExpression format for collection indexes for (const [sourceAlias, whereClause] of groupedClauses.singleSource) { // Check if this source alias corresponds to a collection reference if (isCollectionReference(query, sourceAlias)) { // Check if the WHERE clause can be converted to collection-compatible format if (isConvertibleToCollectionFilter(whereClause)) { collectionWhereClauses.set(sourceAlias, whereClause) } } } return collectionWhereClauses } /** * Determines if a source alias refers to a collection reference (not a subquery). * This is used to identify WHERE clauses that can be pushed down to collection subscriptions. * * @param query - The query to analyze * @param sourceAlias - The source alias to check * @returns True if the alias refers to a collection reference */ function isCollectionReference(query: QueryIR, sourceAlias: string): boolean { // Check the FROM clause if (query.from.alias === sourceAlias) { return query.from.type === `collectionRef` } // Check JOIN clauses if (query.join) { for (const joinClause of query.join) { if (joinClause.from.alias === sourceAlias) { return joinClause.from.type === `collectionRef` } } } return false } /** * Applies recursive predicate pushdown optimization. * * @param query - The QueryIR to optimize * @returns A new QueryIR with optimizations applied */ function applyRecursiveOptimization(query: QueryIR): QueryIR { // First, recursively optimize any existing subqueries const subqueriesOptimized = { ...query, from: query.from.type === `queryRef` ? new QueryRefClass( applyRecursiveOptimization(query.from.query), query.from.alias ) : query.from, join: query.join?.map((joinClause) => ({ ...joinClause, from: joinClause.from.type === `queryRef` ? new QueryRefClass( applyRecursiveOptimization(joinClause.from.query), joinClause.from.alias ) : joinClause.from, })), } // Then apply single-level optimization to this query return applySingleLevelOptimization(subqueriesOptimized) } /** * Applies single-level predicate pushdown optimization (existing logic) */ function applySingleLevelOptimization(query: QueryIR): QueryIR { // Skip optimization if no WHERE clauses exist if (!query.where || query.where.length === 0) { return query } // Skip optimization if there are no joins - predicate pushdown only benefits joins // Single-table queries don't benefit from this optimization if (!query.join || query.join.length === 0) { return query } // Step 1: Split all AND clauses at the root level for granular optimization const splitWhereClauses = splitAndClauses(query.where) // Step 2: Analyze each WHERE clause to determine which sources it touches const analyzedClauses = splitWhereClauses.map((clause) => analyzeWhereClause(clause) ) // Step 3: Group clauses by single-source vs multi-source const groupedClauses = groupWhereClauses(analyzedClauses) // Step 4: Apply optimizations by lifting single-source clauses into subqueries return applyOptimizations(query, groupedClauses) } /** * Removes redundant subqueries that don't add value. * A subquery is redundant if it only wraps another query without adding * WHERE, SELECT, GROUP BY, HAVING, ORDER BY, or LIMIT/OFFSET clauses. * * @param query - The QueryIR to process * @returns A new QueryIR with redundant subqueries removed */ function removeRedundantSubqueries(query: QueryIR): QueryIR { return { ...query, from: removeRedundantFromClause(query.from), join: query.join?.map((joinClause) => ({ ...joinClause, from: removeRedundantFromClause(joinClause.from), })), } } /** * Removes redundant subqueries from a FROM clause. * * @param from - The FROM clause to process * @returns A FROM clause with redundant subqueries removed */ function removeRedundantFromClause(from: From): From { if (from.type === `collectionRef`) { return from } const processedQuery = removeRedundantSubqueries(from.query) // Check if this subquery is redundant if (isRedundantSubquery(processedQuery)) { // Return the inner query's FROM clause with this alias const innerFrom = removeRedundantFromClause(processedQuery.from) if (innerFrom.type === `collectionRef`) { return new CollectionRefClass(innerFrom.collection, from.alias) } else { return new QueryRefClass(innerFrom.query, from.alias) } } return new QueryRefClass(processedQuery, from.alias) } /** * Determines if a subquery is redundant (adds no value). * * @param query - The query to check * @returns True if the query is redundant and can be removed */ function isRedundantSubquery(query: QueryIR): boolean { return ( (!query.where || query.where.length === 0) && !query.select && (!query.groupBy || query.groupBy.length === 0) && (!query.having || query.having.length === 0) && (!query.orderBy || query.orderBy.length === 0) && (!query.join || query.join.length === 0) && query.limit === undefined && query.offset === undefined && !query.fnSelect && (!query.fnWhere || query.fnWhere.length === 0) && (!query.fnHaving || query.fnHaving.length === 0) ) } /** * Step 1: Split all AND clauses recursively into separate WHERE clauses. * * This enables more granular optimization by treating each condition independently. * OR clauses are preserved as they cannot be split without changing query semantics. * * @param whereClauses - Array of WHERE expressions to split * @returns Flattened array with AND clauses split into separate expressions * * @example * ```typescript * // Input: [and(eq(a, 1), gt(b, 2)), eq(c, 3)] * // Output: [eq(a, 1), gt(b, 2), eq(c, 3)] * ``` */ function splitAndClauses( whereClauses: Array<BasicExpression<boolean>> ): Array<BasicExpression<boolean>> { const result: Array<BasicExpression<boolean>> = [] for (const clause of whereClauses) { if (clause.type === `func` && clause.name === `and`) { // Recursively split nested AND clauses to handle complex expressions const splitArgs = splitAndClauses( clause.args as Array<BasicExpression<boolean>> ) result.push(...splitArgs) } else { // Preserve non-AND clauses as-is (including OR clauses) result.push(clause) } } return result } /** * Step 2: Analyze which table sources a WHERE clause touches. * * This determines whether a clause can be pushed down to a specific table * or must remain in the main query (for multi-source clauses like join conditions). * * @param clause - The WHERE expression to analyze * @returns Analysis result with the expression and touched source aliases * * @example * ```typescript * // eq(users.department_id, 1) -> touches ['users'] * // eq(users.id, posts.user_id) -> touches ['users', 'posts'] * ``` */ function analyzeWhereClause( clause: BasicExpression<boolean> ): AnalyzedWhereClause { const touchedSources = new Set<string>() /** * Recursively collect all table aliases referenced in an expression */ function collectSources(expr: BasicExpression | any): void { switch (expr.type) { case `ref`: // PropRef path has the table alias as the first element if (expr.path && expr.path.length > 0) { const firstElement = expr.path[0] if (firstElement) { touchedSources.add(firstElement) } } break case `func`: // Recursively analyze function arguments (e.g., eq, gt, and, or) if (expr.args) { expr.args.forEach(collectSources) } break case `val`: // Values don't reference any sources break case `agg`: // Aggregates can reference sources in their arguments if (expr.args) { expr.args.forEach(collectSources) } break } } collectSources(clause) return { expression: clause, touchedSources, } } /** * Step 3: Group WHERE clauses by the sources they touch. * * Single-source clauses can be pushed down to subqueries for optimization. * Multi-source clauses must remain in the main query to preserve join semantics. * * @param analyzedClauses - Array of analyzed WHERE clauses * @returns Grouped clauses ready for optimization */ function groupWhereClauses( analyzedClauses: Array<AnalyzedWhereClause> ): GroupedWhereClauses { const singleSource = new Map<string, Array<BasicExpression<boolean>>>() const multiSource: Array<BasicExpression<boolean>> = [] // Categorize each clause based on how many sources it touches for (const clause of analyzedClauses) { if (clause.touchedSources.size === 1) { // Single source clause - can be optimized const source = Array.from(clause.touchedSources)[0]! if (!singleSource.has(source)) { singleSource.set(source, []) } singleSource.get(source)!.push(clause.expression) } else if (clause.touchedSources.size > 1) { // Multi-source clause - must stay in main query multiSource.push(clause.expression) } // Skip clauses that touch no sources (constants) - they don't need optimization } // Combine multiple clauses for each source with AND const combinedSingleSource = new Map<string, BasicExpression<boolean>>() for (const [source, clauses] of singleSource) { combinedSingleSource.set(source, combineWithAnd(clauses)) } // Combine multi-source clauses with AND const combinedMultiSource = multiSource.length > 0 ? combineWithAnd(multiSource) : undefined return { singleSource: combinedSingleSource, multiSource: combinedMultiSource, } } /** * Step 4: Apply optimizations by lifting single-source clauses into subqueries. * * Creates a new QueryIR with single-source WHERE clauses moved to subqueries * that wrap the original table references. This ensures immutability and prevents * infinite recursion issues. * * @param query - Original QueryIR to optimize * @param groupedClauses - WHERE clauses grouped by optimization strategy * @returns New QueryIR with optimizations applied */ function applyOptimizations( query: QueryIR, groupedClauses: GroupedWhereClauses ): QueryIR { // Track which single-source clauses were actually optimized const actuallyOptimized = new Set<string>() // Optimize the main FROM clause and track what was optimized const optimizedFrom = optimizeFromWithTracking( query.from, groupedClauses.singleSource, actuallyOptimized ) // Optimize JOIN clauses and track what was optimized const optimizedJoins = query.join ? query.join.map((joinClause) => ({ ...joinClause, from: optimizeFromWithTracking( joinClause.from, groupedClauses.singleSource, actuallyOptimized ), })) : undefined // Build the remaining WHERE clauses: multi-source + any single-source that weren't optimized const remainingWhereClauses: Array<BasicExpression<boolean>> = [] // Add multi-source clauses if (groupedClauses.multiSource) { remainingWhereClauses.push(groupedClauses.multiSource) } // Add single-source clauses that weren't actually optimized for (const [source, clause] of groupedClauses.singleSource) { if (!actuallyOptimized.has(source)) { remainingWhereClauses.push(clause) } } // Create a completely new query object to ensure immutability const optimizedQuery: QueryIR = { // Copy all non-optimized fields as-is select: query.select, groupBy: query.groupBy ? [...query.groupBy] : undefined, having: query.having ? [...query.having] : undefined, orderBy: query.orderBy ? [...query.orderBy] : undefined, limit: query.limit, offset: query.offset, fnSelect: query.fnSelect, fnWhere: query.fnWhere ? [...query.fnWhere] : undefined, fnHaving: query.fnHaving ? [...query.fnHaving] : undefined, // Use the optimized FROM and JOIN clauses from: optimizedFrom, join: optimizedJoins, // Only include WHERE clauses that weren't successfully optimized where: remainingWhereClauses.length > 0 ? remainingWhereClauses : [], } return optimizedQuery } /** * Helper function to create a deep copy of a QueryIR object for immutability. * * This ensures that all optimizations create new objects rather than modifying * existing ones, preventing infinite recursion and shared reference issues. * * @param query - QueryIR to deep copy * @returns New QueryIR object with all nested objects copied */ function deepCopyQuery(query: QueryIR): QueryIR { return { // Recursively copy the FROM clause from: query.from.type === `collectionRef` ? new CollectionRefClass(query.from.collection, query.from.alias) : new QueryRefClass(deepCopyQuery(query.from.query), query.from.alias), // Copy all other fields, creating new arrays where necessary select: query.select, join: query.join ? query.join.map((joinClause) => ({ type: joinClause.type, left: joinClause.left, right: joinClause.right, from: joinClause.from.type === `collectionRef` ? new CollectionRefClass( joinClause.from.collection, joinClause.from.alias ) : new QueryRefClass( deepCopyQuery(joinClause.from.query), joinClause.from.alias ), })) : undefined, where: query.where ? [...query.where] : undefined, groupBy: query.groupBy ? [...query.groupBy] : undefined, having: query.having ? [...query.having] : undefined, orderBy: query.orderBy ? [...query.orderBy] : undefined, limit: query.limit, offset: query.offset, fnSelect: query.fnSelect, fnWhere: query.fnWhere ? [...query.fnWhere] : undefined, fnHaving: query.fnHaving ? [...query.fnHaving] : undefined, } } /** * Helper function to optimize a FROM clause while tracking what was actually optimized. * * @param from - FROM clause to optimize * @param singleSourceClauses - Map of source aliases to their WHERE clauses * @param actuallyOptimized - Set to track which sources were actually optimized * @returns New FROM clause, potentially wrapped in a subquery */ function optimizeFromWithTracking( from: From, singleSourceClauses: Map<string, BasicExpression<boolean>>, actuallyOptimized: Set<string> ): From { const whereClause = singleSourceClauses.get(from.alias) if (!whereClause) { // No optimization needed, but return a copy to maintain immutability if (from.type === `collectionRef`) { return new CollectionRefClass(from.collection, from.alias) } // Must be queryRef due to type system return new QueryRefClass(deepCopyQuery(from.query), from.alias) } if (from.type === `collectionRef`) { // Create a new subquery with the WHERE clause for the collection // This is always safe since we're creating a new subquery const subQuery: QueryIR = { from: new CollectionRefClass(from.collection, from.alias), where: [whereClause], } actuallyOptimized.add(from.alias) // Mark as successfully optimized return new QueryRefClass(subQuery, from.alias) } // Must be queryRef due to type system // SAFETY CHECK: Only check safety when pushing WHERE clauses into existing subqueries // We need to be careful about pushing WHERE clauses into subqueries that already have // aggregates, HAVING, or ORDER BY + LIMIT since that could change their semantics if (!isSafeToPushIntoExistingSubquery(from.query)) { // Return a copy without optimization to maintain immutability // Do NOT mark as optimized since we didn't actually optimize it return new QueryRefClass(deepCopyQuery(from.query), from.alias) } // Add the WHERE clause to the existing subquery // Create a deep copy to ensure immutability const existingWhere = from.query.where || [] const optimizedSubQuery: QueryIR = { ...deepCopyQuery(from.query), where: [...existingWhere, whereClause], } actuallyOptimized.add(from.alias) // Mark as successfully optimized return new QueryRefClass(optimizedSubQuery, from.alias) } /** * Determines if it's safe to push WHERE clauses into an existing subquery. * * Pushing WHERE clauses into existing subqueries can break semantics in several cases: * * 1. **Aggregates**: Pushing predicates before GROUP BY changes what gets aggregated * 2. **ORDER BY + LIMIT/OFFSET**: Pushing predicates before sorting+limiting changes the result set * 3. **HAVING clauses**: These operate on aggregated data, predicates should not be pushed past them * 4. **Functional operations**: fnSelect, fnWhere, fnHaving could have side effects * * Note: This safety check only applies when pushing WHERE clauses into existing subqueries. * Creating new subqueries from collection references is always safe. * * @param query - The existing subquery to check for safety * @returns True if it's safe to push WHERE clauses into this subquery, false otherwise * * @example * ```typescript * // UNSAFE: has GROUP BY - pushing WHERE could change aggregation * { from: users, groupBy: [dept], select: { count: agg('count', '*') } } * * // UNSAFE: has ORDER BY + LIMIT - pushing WHERE could change "top 10" * { from: users, orderBy: [salary desc], limit: 10 } * * // SAFE: plain SELECT without aggregates/limits * { from: users, select: { id, name } } * ``` */ function isSafeToPushIntoExistingSubquery(query: QueryIR): boolean { // Check for aggregates in SELECT clause if (query.select) { const hasAggregates = Object.values(query.select).some( (expr) => expr.type === `agg` ) if (hasAggregates) { return false } } // Check for GROUP BY clause if (query.groupBy && query.groupBy.length > 0) { return false } // Check for HAVING clause if (query.having && query.having.length > 0) { return false } // Check for ORDER BY with LIMIT or OFFSET (dangerous combination) if (query.orderBy && query.orderBy.length > 0) { if (query.limit !== undefined || query.offset !== undefined) { return false } } // Check for functional variants that might have side effects if ( query.fnSelect || (query.fnWhere && query.fnWhere.length > 0) || (query.fnHaving && query.fnHaving.length > 0) ) { return false } // If none of the unsafe conditions are present, it's safe to optimize return true } /** * Helper function to combine multiple expressions with AND. * * If there's only one expression, it's returned as-is. * If there are multiple expressions, they're combined with an AND function. * * @param expressions - Array of expressions to combine * @returns Single expression representing the AND combination * @throws Error if the expressions array is empty */ function combineWithAnd( expressions: Array<BasicExpression<boolean>> ): BasicExpression<boolean> { if (expressions.length === 0) { throw new CannotCombineEmptyExpressionListError() } if (expressions.length === 1) { return expressions[0]! } // Create an AND function with all expressions as arguments return new Func(`and`, expressions) }