From 6c0329e87f449a99b8662161d015ae6e3d8917c1 Mon Sep 17 00:00:00 2001 From: Jaime Resano Date: Thu, 1 Oct 2026 14:55:33 +0200 Subject: [PATCH] fix(db): preserve source identity in optimizer copies The optimizer copied CollectionRefs with a fresh sourceId. Since #1877 the compiler compiles the optimized subquery IR, so those copies no longer match any input and fall back to an alias lookup. When an included subquery reuses the alias, the join reads the included subquery's input and the live query returns no rows. Co-Authored-By: Claude Opus 5.5 --- .changeset/preserve-optimizer-source-ids.md | 5 ++ packages/db/src/query/ir.ts | 3 +- packages/db/src/query/optimizer.ts | 12 ++-- packages/db/tests/query/includes.test.ts | 67 +++++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) create mode 100644 .changeset/preserve-optimizer-source-ids.md diff --git a/.changeset/preserve-optimizer-source-ids.md b/.changeset/preserve-optimizer-source-ids.md new file mode 100644 index 000000000..bb1622390 --- /dev/null +++ b/.changeset/preserve-optimizer-source-ids.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Fix live queries returning no rows when a `from()` subquery joins a collection under an alias that is reused by an included (`materialize()`/includes) subquery. Optimizer copies of a collection source now keep its source identity, so the compiled subquery reads its own input instead of resolving another source's input by alias. diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index 90cba8ec3..8ae2accc9 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -93,10 +93,11 @@ export class CollectionRef extends BaseExpression { constructor( public collection: CollectionImpl, public alias: string, + sourceId?: string, ) { super() Object.defineProperty(this, `sourceId`, { - value: `source-${++nextCollectionSourceId}`, + value: sourceId ?? `source-${++nextCollectionSourceId}`, enumerable: false, }) } diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index b7487533e..6c7747ff0 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -497,7 +497,11 @@ function removeRedundantFromClause(from: From): From { // Return the inner query's FROM clause with this alias const innerFrom = removeRedundantFromClause(processedQuery.from) if (innerFrom.type === `collectionRef`) { - return new CollectionRefClass(innerFrom.collection, from.alias) + return new CollectionRefClass( + innerFrom.collection, + from.alias, + innerFrom.sourceId, + ) } else if (innerFrom.type === `queryRef`) { return new QueryRefClass(innerFrom.query, from.alias) } @@ -845,7 +849,7 @@ function deepCopyQuery(query: QueryIR): QueryIR { function deepCopyFrom(from: From): From { if (from.type === `collectionRef`) { - return new CollectionRefClass(from.collection, from.alias) + return new CollectionRefClass(from.collection, from.alias, from.sourceId) } if (from.type === `queryRef`) { @@ -932,7 +936,7 @@ function optimizeFromWithTracking( if (!whereClause) { // No optimization needed, but return a copy to maintain immutability if (from.type === `collectionRef`) { - return new CollectionRefClass(from.collection, from.alias) + return new CollectionRefClass(from.collection, from.alias, from.sourceId) } // Must be queryRef due to type system return new QueryRefClass(deepCopyQuery(from.query), from.alias) @@ -942,7 +946,7 @@ function optimizeFromWithTracking( // Create a new subquery with the WHERE clause for the collection // This is always safe since we're creating a new subquery const subQuery: QueryIR = { - from: new CollectionRefClass(from.collection, from.alias), + from: new CollectionRefClass(from.collection, from.alias, from.sourceId), where: [whereClause], } actuallyOptimized.add(from.alias) // Mark as successfully optimized diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index b4a9def14..899122b0e 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -7879,5 +7879,72 @@ describe(`includes subqueries`, () => { }, ]) }) + + describe(`materialize alias matching a join inside the parent's from() subquery`, () => { + // The parent comes from a subquery that left-joins `issues` as `i` and + // filters with more than one predicate. The included subquery reads the + // same collection under the same alias `i`. The aliases live in different + // scopes, so neither the parent rows nor the included rows may change. + const expectedAlpha = [ + { + id: 1, + name: `Alpha`, + issues: [ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ], + }, + ] + + it(`keeps parent rows when the included subquery reuses the join alias`, async () => { + const collection = createLiveQueryCollection((q) => { + const alphaWithBug = q + .from({ p: projects }) + .leftJoin({ i: issues }, ({ p, i }) => eq(i.projectId, p.id)) + .where(({ i }) => eq(i.title, `Bug in Alpha`)) + .where(({ p }) => eq(p.name, `Alpha`)) + .select(({ p }) => p) + + return q.from({ p: alphaWithBug }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ id: i.id, title: i.title })), + ), + })) + }) + await collection.preload() + + expect(toTree(collection)).toEqual(expectedAlpha) + }) + + it(`keeps parent rows when the included subquery uses a distinct alias`, async () => { + const collection = createLiveQueryCollection((q) => { + const alphaWithBug = q + .from({ p: projects }) + .leftJoin({ i: issues }, ({ p, i }) => eq(i.projectId, p.id)) + .where(({ i }) => eq(i.title, `Bug in Alpha`)) + .where(({ p }) => eq(p.name, `Alpha`)) + .select(({ p }) => p) + + return q.from({ p: alphaWithBug }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ issue: issues }) + .where(({ issue }) => eq(issue.projectId, p.id)) + .select(({ issue }) => ({ id: issue.id, title: issue.title })), + ), + })) + }) + await collection.preload() + + expect(toTree(collection)).toEqual(expectedAlpha) + }) + }) }) })