Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/preserve-optimizer-source-ids.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}
Expand Down
12 changes: 8 additions & 4 deletions packages/db/src/query/optimizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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`) {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
67 changes: 67 additions & 0 deletions packages/db/tests/query/includes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
})