From b00d7db46156dcbc2d049ce5a554273a841db9ca Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 23 Sep 2026 17:49:13 +0100 Subject: [PATCH 1/2] 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 2/2] 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