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 b00d7db46156dcbc2d049ce5a554273a841db9ca Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 17:49:13 +0100 Subject: [PATCH 2/4] fix(db): isolate reused subquery placements --- .changeset/fix-reused-subquery-placement.md | 5 + packages/db/src/query/builder/clone-query.ts | 142 ++++++++++++++++++ packages/db/src/query/builder/index.ts | 9 +- packages/db/tests/query/join-subquery.test.ts | 48 ++++++ 4 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-reused-subquery-placement.md create mode 100644 packages/db/src/query/builder/clone-query.ts diff --git a/.changeset/fix-reused-subquery-placement.md b/.changeset/fix-reused-subquery-placement.md new file mode 100644 index 0000000000..d1e6d71122 --- /dev/null +++ b/.changeset/fix-reused-subquery-placement.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Give each placement of a reused subquery builder an independent source identity so self-joins produce the correct rows. diff --git a/packages/db/src/query/builder/clone-query.ts b/packages/db/src/query/builder/clone-query.ts new file mode 100644 index 0000000000..46ab1d7e5d --- /dev/null +++ b/packages/db/src/query/builder/clone-query.ts @@ -0,0 +1,142 @@ +import { + CollectionRef, + ConditionalSelect, + IncludesSubquery, + QueryRef, + UnionAll, + UnionFrom, + isExpressionLike, +} from '../ir.js' +import type { + From, + QueryIR, + Select, + SelectValueExpression, +} from '../ir.js' + +/** + * Gives every query-source placement its own runtime source identities. + * + * A reused builder describes the same query meaning, but each FROM, JOIN, + * UNION, or include placement owns an independent position in the dataflow + * graph. Expressions are immutable and can remain shared; CollectionRefs + * cannot because their sourceId identifies that lexical position. + */ +export function cloneQueryForPlacement(query: QueryIR): QueryIR { + return cloneQuery(query, new WeakMap()) +} + +function cloneQuery(query: QueryIR, clones: WeakMap): QueryIR { + const existing = clones.get(query) + if (existing) return existing as QueryIR + + const cloned: QueryIR = { + ...query, + } + clones.set(query, cloned) + + cloned.from = cloneFromForPlacement(query.from, clones) + cloned.join = query.join?.map((join) => ({ + ...join, + from: cloneSourceForPlacement(join.from, clones), + })) + cloned.select = query.select + ? cloneSelectForPlacement(query.select, clones) + : undefined + return cloned +} + +function cloneFromForPlacement( + from: From, + clones: WeakMap, +): From { + if (from.type === `unionFrom`) { + return new UnionFrom( + from.sources.map((source) => cloneSourceForPlacement(source, clones)), + ) + } + + if (from.type === `unionAll`) { + return new UnionAll(from.queries.map((query) => cloneQuery(query, clones))) + } + + return cloneSourceForPlacement(from, clones) +} + +function cloneSourceForPlacement( + source: CollectionRef | QueryRef, + clones: WeakMap, +): CollectionRef | QueryRef { + if (source.type === `collectionRef`) { + return new CollectionRef(source.collection, source.alias) + } + + return new QueryRef(cloneQuery(source.query, clones), source.alias) +} + +function cloneSelectForPlacement( + select: Select, + clones: WeakMap, +): Select { + const existing = clones.get(select) + if (existing) return existing as Select + + const cloned: Select = {} + clones.set(select, cloned) + for (const [field, value] of Object.entries(select)) { + cloned[field] = cloneSelectValueForPlacement(value, clones) + } + return cloned +} + +function cloneSelectValueForPlacement( + value: unknown, + clones: WeakMap, +): SelectValueExpression { + if (value instanceof IncludesSubquery) { + const existing = clones.get(value) + if (existing) return existing as IncludesSubquery + + const cloned = new IncludesSubquery( + cloneQuery(value.query, clones), + value.correlationField, + value.childCorrelationField, + value.fieldName, + value.parentFilters, + value.parentProjection, + value.materialization, + value.scalarField, + ) + clones.set(value, cloned) + return cloned + } + + if (value instanceof ConditionalSelect) { + const existing = clones.get(value) + if (existing) return existing as ConditionalSelect + + const cloned = new ConditionalSelect( + value.branches.map((branch) => ({ + ...branch, + value: cloneSelectValueForPlacement(branch.value, clones), + })), + value.defaultValue !== undefined + ? cloneSelectValueForPlacement(value.defaultValue, clones) + : undefined, + ) + clones.set(value, cloned) + return cloned + } + + if (value === null || typeof value !== `object` || Array.isArray(value)) { + return value as SelectValueExpression + } + + if ((value as { __refProxy?: boolean }).__refProxy === true) { + return value as SelectValueExpression + } + + return isExpressionLike(value) + ? (value as SelectValueExpression) + : cloneSelectForPlacement(value as Select, clones) +} diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index c1097f9e9b..268d49cba0 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -24,6 +24,7 @@ import { SubQueryMustHaveFromClauseError, } from '../../errors.js' import { getQueryIR } from './query-ir.js' +import { cloneQueryForPlacement } from './clone-query.js' import { createRefProxy, createRefProxyWithSelected, @@ -215,7 +216,7 @@ export class BaseQueryBuilder { } ref = new CollectionRef(this.resolveCollection(sourceValue), alias) } else if (sourceValue instanceof BaseQueryBuilder) { - const subQuery = sourceValue._getQuery() + const subQuery = cloneQueryForPlacement(sourceValue._getQuery()) if (!(subQuery as Partial).from) { throw new SubQueryMustHaveFromClauseError(context) } @@ -286,8 +287,8 @@ export class BaseQueryBuilder { return this._clone({ ...this.query, from: new UnionAll( - [sourceOrBranch, ...branches].map((branch) => - (branch as unknown as BaseQueryBuilder)._getQuery(), + [sourceOrBranch, ...branches].map( + (branch) => (branch as unknown as BaseQueryBuilder)._getQuery(), ), ), }) as any @@ -1370,7 +1371,7 @@ function buildIncludesSubquery( parentAliases: Array, materialization: IncludesMaterialization, ): IncludesSubquery { - const childQuery = childBuilder._getQuery() + const childQuery = cloneQueryForPlacement(childBuilder._getQuery()) // Collect child's own aliases const childAliases = collectQueryAliases(childQuery) diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index a563f36d41..b2b1825f27 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -980,6 +980,54 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { }) }) }) + + describe(`reused subquery builders`, () => { + let usersCollection: ReturnType + + beforeEach(() => { + usersCollection = createUsersCollection(autoIndex) + }) + + const cases = [ + { shared: true, filterRight: false, expected: [1, 2, 4] }, + { shared: true, filterRight: true, expected: [2] }, + { shared: false, filterRight: false, expected: [1, 2, 4] }, + { shared: false, filterRight: true, expected: [2] }, + ] as const + + for (const { shared, filterRight, expected } of cases) { + test(`${shared ? `shared` : `separate`} builders with${ + filterRight ? `` : `out` + } a right-side predicate`, () => { + const joinQuery = createLiveQueryCollection({ + startSync: true, + query: (q) => { + const activeUsers = () => + q + .from({ user: usersCollection }) + .where(({ user }) => eq(user.status, `active`)) + const left = activeUsers() + const right = shared ? left : activeUsers() + let query = q + .from({ leftUser: left }) + .innerJoin({ rightUser: right }, ({ leftUser, rightUser }) => + eq(leftUser.id, rightUser.id), + ) + + if (filterRight) { + query = query.where(({ rightUser }) => + eq(rightUser.name, `Bob`), + ) + } + + return query.select(({ leftUser }) => ({ id: leftUser.id })) + }, + }) + + expect(joinQuery.toArray.map((row) => row.id)).toEqual(expected) + }) + } + }) }) } From 6560ad0e2725674133372b2b31208ae735538797 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 16:52:35 +0000 Subject: [PATCH 3/4] ci: apply automated fixes --- packages/db/src/query/builder/clone-query.ts | 7 +------ packages/db/src/query/builder/index.ts | 4 ++-- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/db/src/query/builder/clone-query.ts b/packages/db/src/query/builder/clone-query.ts index 46ab1d7e5d..de19bc55dc 100644 --- a/packages/db/src/query/builder/clone-query.ts +++ b/packages/db/src/query/builder/clone-query.ts @@ -7,12 +7,7 @@ import { UnionFrom, isExpressionLike, } from '../ir.js' -import type { - From, - QueryIR, - Select, - SelectValueExpression, -} from '../ir.js' +import type { From, QueryIR, Select, SelectValueExpression } from '../ir.js' /** * Gives every query-source placement its own runtime source identities. diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 268d49cba0..f8ebb94f39 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -287,8 +287,8 @@ export class BaseQueryBuilder { return this._clone({ ...this.query, from: new UnionAll( - [sourceOrBranch, ...branches].map( - (branch) => (branch as unknown as BaseQueryBuilder)._getQuery(), + [sourceOrBranch, ...branches].map((branch) => + (branch as unknown as BaseQueryBuilder)._getQuery(), ), ), }) as any From ef2a3c94989b8892bee667889771f7ba7c5ed0d4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 24 Sep 2026 12:08:53 +0100 Subject: [PATCH 4/4] fix(db): preserve scalar selects during placement cloning --- packages/db/src/query/builder/clone-query.ts | 7 +-- packages/db/tests/query/clone-query.test.ts | 45 ++++++++++++++++++ packages/db/tests/query/includes.test.ts | 50 ++++++++++++++++++++ 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 packages/db/tests/query/clone-query.test.ts diff --git a/packages/db/src/query/builder/clone-query.ts b/packages/db/src/query/builder/clone-query.ts index de19bc55dc..39177e5c86 100644 --- a/packages/db/src/query/builder/clone-query.ts +++ b/packages/db/src/query/builder/clone-query.ts @@ -35,9 +35,10 @@ function cloneQuery(query: QueryIR, clones: WeakMap): QueryIR { ...join, from: cloneSourceForPlacement(join.from, clones), })) - cloned.select = query.select - ? cloneSelectForPlacement(query.select, clones) - : undefined + cloned.select = + query.select === undefined || isExpressionLike(query.select) + ? query.select + : cloneSelectForPlacement(query.select, clones) return cloned } diff --git a/packages/db/tests/query/clone-query.test.ts b/packages/db/tests/query/clone-query.test.ts new file mode 100644 index 0000000000..b4caf8a88d --- /dev/null +++ b/packages/db/tests/query/clone-query.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { cloneQueryForPlacement } from '../../src/query/builder/clone-query.js' +import { + Aggregate, + CollectionRef, + ConditionalSelect, + Func, + PropRef, + Value, +} from '../../src/query/ir.js' +import type { QueryIR, Select } from '../../src/query/ir.js' + +describe(`cloneQueryForPlacement`, () => { + it.each([ + new PropRef([`u`, `name`]), + new Func(`upper`, [new PropRef([`u`, `name`])]), + new Aggregate(`count`, [new PropRef([`u`, `id`])]), + new ConditionalSelect( + [ + { + condition: new Func(`eq`, [ + new PropRef([`u`, `active`]), + new Value(true), + ]), + value: new Value(`active`), + }, + ], + new Value(`inactive`), + ), + ])(`preserves an immutable scalar select expression %#`, (select) => { + const query: QueryIR = { + from: new CollectionRef({} as never, `u`), + select: select as unknown as Select, + } + + const cloned = cloneQueryForPlacement(query) + + expect(cloned).not.toBe(query) + expect(cloned.from).not.toBe(query.from) + expect(cloned.select).toBe(select) + expect(Object.getPrototypeOf(cloned.select)).toBe( + Object.getPrototypeOf(select), + ) + }) +}) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 85f6d6d4e7..b4a9def149 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -3970,6 +3970,56 @@ describe(`includes subqueries`, () => { ]) }) + it(`keeps reused child builders independent across include placements`, async () => { + // This is a bounded query-plan-shape regression: the existing generated + // includes oracles vary rows and histories for a fixed plan, while + // generating builder-placement graphs would require a separate AST + // grammar and oracle driver. + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => { + const child = q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + + return { + id: p.id, + all: toArray(child.select(({ i }) => ({ id: i.id }))), + active: toArray( + child + .where(({ i }) => gte(i.id, 11)) + .select(({ i }) => ({ id: i.id })), + ), + } + }), + ) + + await collection.preload() + + expect(toTree(collection)).toEqual([ + { id: 1, all: [{ id: 10 }, { id: 11 }], active: [{ id: 11 }] }, + { id: 2, all: [{ id: 20 }], active: [{ id: 20 }] }, + { id: 3, all: [], active: [] }, + ]) + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 12, projectId: 1, title: `New Alpha issue` }, + }) + issues.utils.commit() + await flushPromises() + + expect(toTree(collection)).toEqual([ + { + id: 1, + all: [{ id: 10 }, { id: 11 }, { id: 12 }], + active: [{ id: 11 }, { id: 12 }], + }, + { id: 2, all: [{ id: 20 }], active: [{ id: 20 }] }, + { id: 3, all: [], active: [] }, + ]) + }) + it(`adding a child to one sibling does not affect the other`, async () => { const milestones = createMilestonesCollection()