diff --git a/.changeset/fix-reused-subquery-placement.md b/.changeset/fix-reused-subquery-placement.md new file mode 100644 index 000000000..d1e6d7112 --- /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 000000000..de19bc55d --- /dev/null +++ b/packages/db/src/query/builder/clone-query.ts @@ -0,0 +1,137 @@ +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 c1097f9e9..f8ebb94f3 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) } @@ -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 a563f36d4..b2b1825f2 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) + }) + } + }) }) }