From abb2a90f318a1be2ffcf2a26019acfdd61690b7e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 17:11:40 +0100 Subject: [PATCH 1/4] fix(db): preserve pushed subquery predicates --- .../fix-subquery-predicate-compilation.md | 5 + packages/db/src/query/compiler/index.ts | 23 ++-- packages/db/src/query/compiler/joins.ts | 15 ++- .../src/query/compiler/query-equivalence.ts | 61 ++++++++++ packages/db/src/query/compiler/types.ts | 6 +- packages/db/src/query/optimizer.ts | 44 ++++++- .../query/compiler/subquery-caching.test.ts | 31 ++++- packages/db/tests/query/join-subquery.test.ts | 108 ++++++++++++++++++ packages/db/tests/query/optimizer.test.ts | 22 +--- 9 files changed, 281 insertions(+), 34 deletions(-) create mode 100644 .changeset/fix-subquery-predicate-compilation.md create mode 100644 packages/db/src/query/compiler/query-equivalence.ts diff --git a/.changeset/fix-subquery-predicate-compilation.md b/.changeset/fix-subquery-predicate-compilation.md new file mode 100644 index 0000000000..b6fd1745bb --- /dev/null +++ b/.changeset/fix-subquery-predicate-compilation.md @@ -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. diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index cc64fde363..0efaca921b 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -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, @@ -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( @@ -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, @@ -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, diff --git a/packages/db/src/query/compiler/joins.ts b/packages/db/src/query/compiler/joins.ts index a1c5ba5c16..67816a3f26 100644 --- a/packages/db/src/query/compiler/joins.ts +++ b/packages/db/src/query/compiler/joins.ts @@ -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, @@ -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, diff --git a/packages/db/src/query/compiler/query-equivalence.ts b/packages/db/src/query/compiler/query-equivalence.ts new file mode 100644 index 0000000000..7d73e877c6 --- /dev/null +++ b/packages/db/src/query/compiler/query-equivalence.ts @@ -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 { + 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 { + 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), + } + } +} diff --git a/packages/db/src/query/compiler/types.ts b/packages/db/src/query/compiler/types.ts index bbce9eaaf1..83992e512d 100644 --- a/packages/db/src/query/compiler/types.ts +++ b/packages/db/src/query/compiler/types.ts @@ -7,7 +7,11 @@ import type { CompilationResult } from './index.js' export type QueryCache = WeakMap /** - * 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 diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index 19ce43b40f..e891556e1b 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -976,14 +976,56 @@ 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, + ) 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, + outerAlias: string, +): BasicExpression { + const firstFromAlias = getFirstFromAlias(subquery) + if (firstFromAlias === undefined) return whereClause + + 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 innerPath = + projected instanceof PropRef + ? projected.path + : [firstFromAlias, ...expression.path.slice(1)] + + return new PropRef([...innerPath, ...expression.path.slice(2)]) + } + + if (expression instanceof Func) { + return new Func(expression.name, expression.args.map(remapExpression)) + } + + return expression + } + + return remapExpression(whereClause) as BasicExpression +} + function optimizeJoinFromWithTracking( from: CollectionRefClass | QueryRefClass, singleSourceClauses: Map>, diff --git a/packages/db/tests/query/compiler/subquery-caching.test.ts b/packages/db/tests/query/compiler/subquery-caching.test.ts index 991e78c0d4..445f256cd5 100644 --- a/packages/db/tests/query/compiler/subquery-caching.test.ts +++ b/packages/db/tests/query/compiler/subquery-caching.test.ts @@ -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' @@ -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`) diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index dfbc2adf11..a563f36d41 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -394,6 +394,91 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(charlieIssue).toBeUndefined() }) + test.each([ + [`inner`, `collection`], + [`inner`, `subquery`], + [`left`, `collection`], + [`left`, `subquery`], + ] as const)( + `applies an outer joined-alias filter for a %s join with a %s source`, + (joinType, sourceKind) => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + if (sourceKind === `subquery`) { + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)) + + return q + .from({ issue: issuesCollection }) + .join( + { user: activeUsers }, + ({ issue, user }) => eq(issue.userId, user.id), + joinType, + ) + .where(({ user }) => eq(user.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + } + + return q + .from({ issue: issuesCollection }) + .join( + { user: usersCollection }, + ({ issue, user }) => eq(issue.userId, user.id), + joinType, + ) + .where(({ user }) => eq(user.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) + }, + ) + + test(`applies an outer joined-alias filter through innerJoin`, () => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)) + + return q + .from({ issue: issuesCollection }) + .innerJoin({ user: activeUsers }, ({ issue, user }) => + eq(issue.userId, user.id), + ) + .where(({ user }) => eq(user.name, `Charlie`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray).toEqual([]) + }) + + test(`remaps a pushed predicate to a subquery's inner alias`, () => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)) + + return q + .from({ issue: issuesCollection }) + .innerJoin({ member: activeUsers }, ({ issue, member }) => + eq(issue.userId, member.id), + ) + .where(({ member }) => eq(member.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) + }) + test(`should use subquery in JOIN clause - left join`, () => { const joinQuery = createLiveQueryCollection({ startSync: true, @@ -660,6 +745,29 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { }) }) + test(`applies an outer filter pushed into a FROM subquery`, () => { + const issuesCollection = createIssuesCollection(autoIndex) + const usersCollection = createUsersCollection(autoIndex) + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const activeUsers = q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)) + + return q + .from({ user: activeUsers }) + .innerJoin({ issue: issuesCollection }, ({ user, issue }) => + eq(user.id, issue.userId), + ) + .where(({ user }) => eq(user.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) + }) + describe(`nested subqueries with joins (alias remapping)`, () => { let issuesCollection: ReturnType let usersCollection: ReturnType diff --git a/packages/db/tests/query/optimizer.test.ts b/packages/db/tests/query/optimizer.test.ts index 0952ac2ba2..d7599c7c30 100644 --- a/packages/db/tests/query/optimizer.test.ts +++ b/packages/db/tests/query/optimizer.test.ts @@ -1145,10 +1145,7 @@ describe(`Query Optimizer`, () => { expect(optimized.from.type).toBe(`queryRef`) if (optimized.from.type === `queryRef`) { expect(optimized.from.query.where).toContainEqual( - createEq( - createPropRef(`main_users`, `department_id`), - createValue(1), - ), + createEq(createPropRef(`u`, `department_id`), createValue(1)), ) } @@ -1159,10 +1156,7 @@ describe(`Query Optimizer`, () => { expect(joinClause.from.type).toBe(`queryRef`) if (joinClause.from.type === `queryRef`) { expect(joinClause.from.query.where).toContainEqual( - createEq( - createPropRef(`other_users`, `department_id`), - createValue(2), - ), + createEq(createPropRef(`u`, `department_id`), createValue(2)), ) } } @@ -1279,10 +1273,7 @@ describe(`Query Optimizer`, () => { expect(optimized.from.type).toBe(`queryRef`) if (optimized.from.type === `queryRef`) { expect(optimized.from.query.where).toContainEqual( - createEq( - createPropRef(`filtered_users`, `department_id`), - createValue(1), - ), + createEq(createPropRef(`u`, `department_id`), createValue(1)), ) } }) @@ -1407,10 +1398,7 @@ describe(`Query Optimizer`, () => { expect(optimized.from.type).toBe(`queryRef`) if (optimized.from.type === `queryRef`) { expect(optimized.from.query.where).toContainEqual( - createEq( - createPropRef(`sorted_users`, `department_id`), - createValue(1), - ), + createEq(createPropRef(`u`, `department_id`), createValue(1)), ) } }) @@ -1463,7 +1451,7 @@ describe(`Query Optimizer`, () => { expect(optimized.from.type).toBe(`queryRef`) if (optimized.from.type === `queryRef`) { expect(optimized.from.query.where).toContainEqual( - createEq(createPropRef(`users`, `department_id`), createValue(1)), + createEq(createPropRef(`u`, `department_id`), createValue(1)), ) } }) From 3af426b58a6b2389f3794e037c2e8d556cb0437a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 18:07:31 +0100 Subject: [PATCH 2/4] fix(db): remap joined subquery predicates --- packages/db/src/query/optimizer.ts | 12 ++++++--- packages/db/tests/query/join-subquery.test.ts | 25 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index e891556e1b..727d973717 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -1008,12 +1008,18 @@ function remapWhereForSubquery( const field = expression.path[1] const projected = field ? subquery.select?.[field] : undefined + const hasNamespacedResult = + subquery.join !== undefined || + subquery.groupBy !== undefined || + subquery.from.type === `unionFrom` const innerPath = projected instanceof PropRef - ? projected.path - : [firstFromAlias, ...expression.path.slice(1)] + ? [...projected.path, ...expression.path.slice(2)] + : hasNamespacedResult + ? expression.path.slice(1) + : [firstFromAlias, ...expression.path.slice(1)] - return new PropRef([...innerPath, ...expression.path.slice(2)]) + return new PropRef(innerPath) } if (expression instanceof Func) { diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index a563f36d41..4fee3f18b1 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -348,12 +348,14 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { describe(`subqueries in JOIN clause`, () => { let issuesCollection: ReturnType let usersCollection: ReturnType + let profilesCollection: ReturnType let productsCollection: ReturnType let trialsCollection: ReturnType beforeEach(() => { issuesCollection = createIssuesCollection(autoIndex) usersCollection = createUsersCollection(autoIndex) + profilesCollection = createProfilesCollection(autoIndex) productsCollection = createProductsCollection(autoIndex) trialsCollection = createTrialsCollection(autoIndex) }) @@ -479,6 +481,29 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) }) + test(`remaps a pushed predicate through a joined subquery result`, () => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const usersWithProfiles = q + .from({ user: usersCollection }) + .innerJoin({ profile: profilesCollection }, ({ user, profile }) => + eq(user.id, profile.userId), + ) + + return q + .from({ issue: issuesCollection }) + .innerJoin({ member: usersWithProfiles }, ({ issue, member }) => + eq(issue.userId, member.user.id), + ) + .where(({ member }) => eq(member.user.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) + }) + test(`should use subquery in JOIN clause - left join`, () => { const joinQuery = createLiveQueryCollection({ startSync: true, From cc4f7c231da721c9abea79b02e52b5c5c3a3c7ab Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 18:24:40 +0100 Subject: [PATCH 3/4] fix(db): preserve spread-selected subquery filters --- packages/db/src/query/optimizer.ts | 11 +++++++-- packages/db/tests/query/join-subquery.test.ts | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index 727d973717..701ed5db15 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -1211,6 +1211,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 @@ -1219,8 +1222,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)) { diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index 4fee3f18b1..465a6c4e97 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -504,6 +504,30 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) }) + test(`preserves a predicate on a spread-selected join result`, () => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const usersWithProfiles = q + .from({ user: usersCollection }) + .innerJoin({ profile: profilesCollection }, ({ user, profile }) => + eq(user.id, profile.userId), + ) + .select(({ user }) => user) + + return q + .from({ issue: issuesCollection }) + .innerJoin({ member: usersWithProfiles }, ({ issue, member }) => + eq(issue.userId, member.id), + ) + .where(({ member }) => eq(member.name, `Bob`)) + .select(({ issue }) => ({ id: issue.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) + }) + test(`should use subquery in JOIN clause - left join`, () => { const joinQuery = createLiveQueryCollection({ startSync: true, From aee5c3cbbbc06603c5b1605fa7f722abe15a7105 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 24 Sep 2026 10:48:41 +0100 Subject: [PATCH 4/4] fix(db): preserve union subquery filters --- packages/db/src/query/optimizer.ts | 11 +++--- packages/db/tests/query/join-subquery.test.ts | 37 +++++++++++++++++++ 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index 701ed5db15..2996258a6e 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -981,6 +981,9 @@ function optimizeFromWithTracking( whereClause, from.alias, ) + if (remappedWhere === undefined) { + return new QueryRefClass(deepCopyQuery(from.query), from.alias) + } const optimizedSubQuery: QueryIR = { ...deepCopyQuery(from.query), where: [...existingWhere, remappedWhere], @@ -998,9 +1001,9 @@ function remapWhereForSubquery( subquery: QueryIR, whereClause: BasicExpression, outerAlias: string, -): BasicExpression { +): BasicExpression | undefined { const firstFromAlias = getFirstFromAlias(subquery) - if (firstFromAlias === undefined) return whereClause + if (firstFromAlias === undefined) return undefined const remapExpression = (expression: BasicExpression): BasicExpression => { if (expression instanceof PropRef) { @@ -1009,9 +1012,7 @@ function remapWhereForSubquery( const field = expression.path[1] const projected = field ? subquery.select?.[field] : undefined const hasNamespacedResult = - subquery.join !== undefined || - subquery.groupBy !== undefined || - subquery.from.type === `unionFrom` + subquery.join !== undefined || subquery.from.type === `unionFrom` const innerPath = projected instanceof PropRef ? [...projected.path, ...expression.path.slice(2)] diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index 465a6c4e97..903ed0fbef 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -817,6 +817,43 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(joinQuery.toArray.map((row) => row.id).sort()).toEqual([2, 5]) }) + test(`keeps an outer filter above a unionAll subquery`, () => { + const issuesCollection = createIssuesCollection(autoIndex) + const usersCollection = createUsersCollection(autoIndex) + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const projectOneIssues = q + .from({ projectOneIssue: issuesCollection }) + .where(({ projectOneIssue }) => eq(projectOneIssue.projectId, 1)) + .select(({ projectOneIssue }) => ({ + id: projectOneIssue.id, + status: projectOneIssue.status, + userId: projectOneIssue.userId, + })) + const projectTwoIssues = q + .from({ projectTwoIssue: issuesCollection }) + .where(({ projectTwoIssue }) => eq(projectTwoIssue.projectId, 2)) + .select(({ projectTwoIssue }) => ({ + id: projectTwoIssue.id, + status: projectTwoIssue.status, + userId: projectTwoIssue.userId, + })) + const allIssues = q.unionAll(projectOneIssues, projectTwoIssues) + + return q + .from({ row: allIssues }) + .innerJoin({ user: usersCollection }, ({ row, user }) => + eq(row.userId, user.id), + ) + .where(({ row }) => eq(row.status, `open`)) + .select(({ row }) => ({ id: row.id })) + }, + }) + + expect(query.toArray.map((row) => row.id).sort()).toEqual([1, 4]) + }) + describe(`nested subqueries with joins (alias remapping)`, () => { let issuesCollection: ReturnType let usersCollection: ReturnType