Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-subquery-predicate-compilation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Preserve outer predicates pushed into joined and FROM subqueries during query compilation, including when the outer and inner aliases differ.
23 changes: 14 additions & 9 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { containsAggregate, processGroupBy } from './group-by.js'
import { getLazyLoadTargets } from './lazy-targets.js'
import { processOrderBy } from './order-by.js'
import { crossJoinParentRoutes } from './parent-routes.js'
import { queriesMatchForCaching } from './query-equivalence.js'
import {
INCLUDES_PUBLIC_KEY,
INCLUDES_ROUTING,
Expand Down Expand Up @@ -358,7 +359,7 @@ function getCompilationValueIdentity(cache: QueryCache): ValueIdentity {
* @param lazySources Set of source identities that should load data lazily
* @param optimizableOrderByCollections Map of source IDs to order-by optimization info
* @param cache Optional cache for compiled subqueries (used internally for recursion)
* @param queryMapping Optional mapping from optimized queries to original queries
* @param queryMapping Optional lineage from optimized queries to user-defined queries
* @returns A CompilationResult with the pipeline, source WHERE clauses, and alias metadata
*/
export function compileQuery(
Expand Down Expand Up @@ -1749,12 +1750,16 @@ function processFrom(
}
}
case `queryRef`: {
// Find the original query for caching purposes
const originalQuery = queryMapping.get(from.query) || from.query

// Recursively compile the sub-query with cache
// Preserve the user-defined query as the cache key when optimization
// only copied it. If the optimizer changed the query, compile that IR;
// substituting its origin would discard pushed predicates.
const originalQuery = queryMapping.get(from.query)
const queryToCompile =
originalQuery && queriesMatchForCaching(from.query, originalQuery)
? originalQuery
: from.query
const subQueryResult = compileQuery(
originalQuery,
queryToCompile,
allInputs,
collections,
subscriptions,
Expand Down Expand Up @@ -1918,9 +1923,9 @@ function getIncludesPublicKey(
}

/**
* Recursively maps optimized subqueries to their original queries for proper caching.
* This ensures that when we encounter the same QueryRef object in different contexts,
* we can find the original query to check the cache.
* Recursively records the user-defined origin of optimized subqueries.
* Compilation still consumes the optimized query; the mapping is lineage used
* to distinguish user-defined subqueries from optimizer-created wrappers.
*/
function mapNestedQueries(
optimizedQuery: QueryIR,
Expand Down
15 changes: 10 additions & 5 deletions packages/db/src/query/compiler/joins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { compileExpression } from './evaluators.js'
import { getSourceAliasesFromExpression } from './expressions.js'
import { getLazyLoadTargets } from './lazy-targets.js'
import { crossJoinParentRoutes } from './parent-routes.js'
import { queriesMatchForCaching } from './query-equivalence.js'
import {
INCLUDES_PUBLIC_KEY,
attachRouteMetadata,
Expand Down Expand Up @@ -632,12 +633,16 @@ function processJoinSource(
}
}
case `queryRef`: {
// Find the original query for caching purposes
const originalQuery = queryMapping.get(from.query) || from.query

// Recursively compile the sub-query with cache
// Preserve the user-defined query as the cache key when optimization
// only copied it. If the optimizer changed the query, compile that IR;
// substituting its origin would discard pushed predicates.
const originalQuery = queryMapping.get(from.query)
const queryToCompile =
originalQuery && queriesMatchForCaching(from.query, originalQuery)
? originalQuery
: from.query
const subQueryResult = onCompileSubquery(
originalQuery,
queryToCompile,
allInputs,
collections,
subscriptions,
Expand Down
61 changes: 61 additions & 0 deletions packages/db/src/query/compiler/query-equivalence.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { deepEquals } from '../../utils.js'
import type { From, QueryIR } from '../ir.js'

/**
* Compares query meaning while ignoring whether optional IR fields are omitted
* or explicitly set to undefined by an optimizer copy.
*/
export function queriesMatchForCaching(a: QueryIR, b: QueryIR): boolean {
return deepEquals(normalizeQuery(a), normalizeQuery(b))
}

function normalizeQuery(query: QueryIR): Record<string, unknown> {
return {
from: normalizeFrom(query.from),
select: query.select,
join: query.join?.map((join) => ({
from: normalizeFrom(join.from),
type: join.type,
left: join.left,
right: join.right,
})),
where: query.where,
groupBy: query.groupBy,
having: query.having,
orderBy: query.orderBy,
limit: query.limit,
offset: query.offset,
distinct: query.distinct,
singleResult: query.singleResult,
fnSelect: query.fnSelect,
fnWhere: query.fnWhere,
fnHaving: query.fnHaving,
}
}

function normalizeFrom(from: From): Record<string, unknown> {
switch (from.type) {
case `collectionRef`:
return {
type: from.type,
collection: from.collection,
alias: from.alias,
}
case `queryRef`:
return {
type: from.type,
query: normalizeQuery(from.query),
alias: from.alias,
}
case `unionFrom`:
return {
type: from.type,
sources: from.sources.map(normalizeFrom),
}
case `unionAll`:
return {
type: from.type,
queries: from.queries.map(normalizeQuery),
}
}
}
6 changes: 5 additions & 1 deletion packages/db/src/query/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import type { CompilationResult } from './index.js'
export type QueryCache = WeakMap<QueryIR, CompilationResult>

/**
* Mapping from optimized queries back to their original queries for caching
* Lineage from optimized queries back to their user-defined queries.
*
* When optimization only copies a query, its user-defined identity remains a
* valid cache key. When optimization changes the query, compilation must use
* the optimized IR so pushed predicates are not lost.
*/
export type QueryMapping = WeakMap<QueryIR, QueryIR>

Expand Down
62 changes: 59 additions & 3 deletions packages/db/src/query/optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -976,14 +976,63 @@ function optimizeFromWithTracking(
// Add the WHERE clause to the existing subquery
// Create a deep copy to ensure immutability
const existingWhere = from.query.where || []
const remappedWhere = remapWhereForSubquery(
from.query,
whereClause,
from.alias,
)
if (remappedWhere === undefined) {
return new QueryRefClass(deepCopyQuery(from.query), from.alias)
}
const optimizedSubQuery: QueryIR = {
...deepCopyQuery(from.query),
where: [...existingWhere, whereClause],
where: [...existingWhere, remappedWhere],
}
actuallyOptimized.add(from.alias) // Mark as successfully optimized
return new QueryRefClass(optimizedSubQuery, from.alias)
}

/**
* Rewrites references to an outer QueryRef alias so a pushed predicate can be
* evaluated inside the subquery's namespace. Pass-through SELECT fields use
* their projected source path; unprojected rows use the first source alias.
*/
function remapWhereForSubquery(
subquery: QueryIR,
whereClause: BasicExpression<boolean>,
outerAlias: string,
): BasicExpression<boolean> | undefined {
const firstFromAlias = getFirstFromAlias(subquery)
if (firstFromAlias === undefined) return undefined

const remapExpression = (expression: BasicExpression): BasicExpression => {
if (expression instanceof PropRef) {
if (expression.path[0] !== outerAlias) return expression

const field = expression.path[1]
const projected = field ? subquery.select?.[field] : undefined
const hasNamespacedResult =
subquery.join !== undefined || subquery.from.type === `unionFrom`
const innerPath =
projected instanceof PropRef
? [...projected.path, ...expression.path.slice(2)]
: hasNamespacedResult
? expression.path.slice(1)
: [firstFromAlias, ...expression.path.slice(1)]
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return new PropRef(innerPath)
}

if (expression instanceof Func) {
return new Func(expression.name, expression.args.map(remapExpression))
}

return expression
}

return remapExpression(whereClause) as BasicExpression<boolean>
}

function optimizeJoinFromWithTracking(
from: CollectionRefClass | QueryRefClass,
singleSourceClauses: Map<string, BasicExpression<boolean>>,
Expand Down Expand Up @@ -1163,6 +1212,9 @@ function referencesAliasWithRemappedSelect(
if (!select) {
return false
}
const hasSpreadProjection = Object.keys(select).some((key) =>
key.startsWith(`__SPREAD_SENTINEL__`),
)

for (const ref of refs) {
const path = ref.path
Expand All @@ -1171,8 +1223,12 @@ function referencesAliasWithRemappedSelect(
if (path[0] !== outerAlias) continue

const projected = select[path[1]!]
// Unselected fields can't be remapped, so skip - only care about fields in the SELECT.
if (!projected) continue
// A spread-selected field has no direct projection entry to remap.
// Keep its predicate outside rather than guessing its input source.
if (!projected) {
if (hasSpreadProjection) return true
continue
}

// Non-PropRef projections are computed values; cannot push down.
if (!(projected instanceof PropRef)) {
Expand Down
31 changes: 30 additions & 1 deletion packages/db/tests/query/compiler/subquery-caching.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import { describe, expect, it } from 'vitest'
import { D2 } from '@tanstack/db-ivm'
import { compileQuery } from '../../../src/query/compiler/index.js'
import { CollectionRef, PropRef, QueryRef } from '../../../src/query/ir.js'
import { queriesMatchForCaching } from '../../../src/query/compiler/query-equivalence.js'
import {
CollectionRef,
Func,
PropRef,
QueryRef,
Value,
} from '../../../src/query/ir.js'
import type { QueryIR } from '../../../src/query/ir.js'
import type { CollectionImpl } from '../../../src/collection/index.js'

Expand All @@ -20,6 +27,28 @@ function createMockCollection(id: string): CollectionImpl {
}

describe(`Subquery Caching`, () => {
it(`reuses cache identity only when optimizer copies preserve query meaning`, () => {
const usersCollection = createMockCollection(`users`)
const original: QueryIR = {
from: new CollectionRef(usersCollection, `u`),
select: { id: new PropRef([`u`, `id`]) },
}
const copied: QueryIR = {
...original,
join: undefined,
where: undefined,
}
const filtered: QueryIR = {
...copied,
where: [
new Func(`eq`, [new PropRef([`u`, `status`]), new Value(`active`)]),
],
}

expect(queriesMatchForCaching(copied, original)).toBe(true)
expect(queriesMatchForCaching(filtered, original)).toBe(false)
})

it(`should cache compiled subqueries and avoid duplicate compilation`, () => {
// Create a mock collection
const usersCollection = createMockCollection(`users`)
Expand Down
Loading
Loading