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) + }) + }) }) })