Skip to content

Commit 0c54be0

Browse files
committed
fix: duplicate alias in sibling includes silently breaks nested children
When two sibling includes in .select() used the same alias (e.g., { i: issues } and { i: tags }), nested child collections silently produced empty results. The root cause was that all includes aliases were flattened into a single namespace — sharing one D2 graph input and one subscription, so the second sibling's collection data overwrote the first. Fix: give each includes subquery its own independent D2 input. - extractCollectionAliases no longer traverses into IncludesSubquery nodes, keeping collectionByAlias scoped to the top-level query. - compileQuery accepts a createInput factory; when processing includes, each child gets fresh inputs for its source aliases via collectAllSourceAliases + createInput(). - compileBasePipeline merges the new inputs into inputsCache and compiledAliasToCollectionId under unique keys (__inc_N_alias), so each gets its own subscription feeding the correct collection.
1 parent e8e029e commit 0c54be0

4 files changed

Lines changed: 141 additions & 6 deletions

File tree

packages/db/src/query/compiler/index.ts

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ export function compileQuery(
141141
// For includes: parent key stream to inner-join with this query's FROM
142142
parentKeyStream?: KeyedStream,
143143
childCorrelationField?: PropRef,
144+
// Factory to create fresh D2 inputs for includes subquery aliases.
145+
// Each includes subquery gets its own input to avoid alias collisions
146+
// between siblings (e.g., two siblings both using alias "i").
147+
// The factory also handles registering the input for subscription.
148+
createInput?: (alias: string, collectionId: string) => KeyedStream,
144149
): CompilationResult {
145150
// Check if the original raw query has already been compiled
146151
const cachedResult = cache.get(rawQuery)
@@ -391,10 +396,20 @@ export function compileQuery(
391396
}
392397
: subquery.query
393398

399+
// Create fresh D2 inputs for the child query's source aliases so that
400+
// sibling includes using the same alias letter get independent streams.
401+
const childAllInputs = { ...allInputs }
402+
if (createInput) {
403+
const childSourceAliases = collectAllSourceAliases(childQuery)
404+
for (const [alias, collectionId] of childSourceAliases) {
405+
childAllInputs[alias] = createInput(alias, collectionId)
406+
}
407+
}
408+
394409
// Recursively compile child query WITH the parent key stream
395410
const childResult = compileQuery(
396411
childQuery,
397-
allInputs,
412+
childAllInputs,
398413
collections,
399414
subscriptions,
400415
callbacks,
@@ -405,12 +420,9 @@ export function compileQuery(
405420
queryMapping,
406421
parentKeys,
407422
subquery.childCorrelationField,
423+
createInput,
408424
)
409425

410-
// Merge child's alias metadata into parent's
411-
Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
412-
Object.assign(aliasRemapping, childResult.aliasRemapping)
413-
414426
includesResults.push({
415427
pipeline: childResult.pipeline,
416428
fieldName: subquery.fieldName,
@@ -741,6 +753,36 @@ function collectDirectCollectionAliases(query: QueryIR): Set<string> {
741753
return aliases
742754
}
743755

756+
/**
757+
* Collects ALL source aliases (FROM + JOINs) from a query tree, recursively
758+
* following FROM/JOIN subqueries but NOT includes subqueries.
759+
* Returns a map of alias → collectionId for all collection refs found.
760+
*/
761+
function collectAllSourceAliases(query: QueryIR): Map<string, string> {
762+
const result = new Map<string, string>()
763+
764+
function walkFrom(from: CollectionRef | QueryRef) {
765+
if (from.type === `collectionRef`) {
766+
result.set(from.alias, from.collection.id)
767+
} else if (from.type === `queryRef`) {
768+
walkQuery(from.query)
769+
}
770+
}
771+
772+
function walkQuery(q: QueryIR) {
773+
walkFrom(q.from)
774+
if (q.join) {
775+
for (const join of q.join) {
776+
walkFrom(join.from)
777+
}
778+
}
779+
// Do NOT walk includes in select — they get their own inputs
780+
}
781+
782+
walkQuery(query)
783+
return result
784+
}
785+
744786
/**
745787
* Validates the structure of a query and its subqueries.
746788
* Checks that subqueries don't reuse collection aliases from parent queries.

packages/db/src/query/live/collection-config-builder.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,9 @@ export class CollectionConfigBuilder<
680680
]),
681681
)
682682

683+
let includesCounter = 0
684+
const includesAliases: Record<string, string> = {}
685+
683686
const compilation = compileQuery(
684687
this.query,
685688
this.inputsCache as Record<string, KeyedStream>,
@@ -691,12 +694,26 @@ export class CollectionConfigBuilder<
691694
(windowFn: (options: WindowOptions) => void) => {
692695
this.windowFn = windowFn
693696
},
697+
undefined, // cache
698+
undefined, // queryMapping
699+
undefined, // parentKeyStream
700+
undefined, // childCorrelationField
701+
// Factory for includes: creates a fresh D2 input per alias and registers
702+
// it for subscription under a unique key to avoid sibling alias collisions.
703+
(alias, collectionId) => {
704+
const uniqueKey = `__inc_${includesCounter++}_${alias}`
705+
const input = this.graphCache!.newInput<any>()
706+
;(this.inputsCache as Record<string, any>)[uniqueKey] = input
707+
includesAliases[uniqueKey] = collectionId
708+
return input
709+
},
694710
)
695711

696712
this.pipelineCache = compilation.pipeline
697713
this.sourceWhereClausesCache = compilation.sourceWhereClauses
698714
this.compiledAliasToCollectionId = compilation.aliasToCollectionId
699715
this.includesCache = compilation.includes
716+
Object.assign(this.compiledAliasToCollectionId, includesAliases)
700717

701718
// Defensive check: verify all compiled aliases have corresponding inputs
702719
// This should never happen since all aliases come from user declarations,

packages/db/src/query/live/utils.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,9 @@ export function extractCollectionAliases(
142142
if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
143143
continue
144144
}
145+
// Skip includes — their aliases are scoped independently via separate D2 inputs
145146
if (value instanceof IncludesSubquery) {
146-
traverse(value.query)
147+
continue
147148
} else if (isNestedSelectObject(value)) {
148149
traverseSelect(value)
149150
}

packages/db/tests/query/includes.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4061,4 +4061,79 @@ describe(`includes subqueries`, () => {
40614061
])
40624062
})
40634063
})
4064+
4065+
describe(`duplicate alias in sibling includes`, () => {
4066+
type Tag = {
4067+
id: number
4068+
projectId: number
4069+
label: string
4070+
}
4071+
4072+
const sampleTags: Array<Tag> = [
4073+
{ id: 1, projectId: 1, label: `urgent` },
4074+
{ id: 2, projectId: 1, label: `frontend` },
4075+
{ id: 3, projectId: 2, label: `backend` },
4076+
]
4077+
4078+
function createTagsCollection() {
4079+
return createCollection(
4080+
mockSyncCollectionOptions<Tag>({
4081+
id: `includes-tags`,
4082+
getKey: (t) => t.id,
4083+
initialData: sampleTags,
4084+
}),
4085+
)
4086+
}
4087+
4088+
it(`same alias in sibling includes does not break nested children`, async () => {
4089+
// Tags uses alias "i" — same as issues. Each sibling gets its own
4090+
// independent D2 input, so nested comments are still populated.
4091+
const tags = createTagsCollection()
4092+
4093+
const collection = createLiveQueryCollection((q) =>
4094+
q.from({ p: projects }).select(({ p }) => ({
4095+
id: p.id,
4096+
name: p.name,
4097+
issues: q
4098+
.from({ i: issues })
4099+
.where(({ i }) => eq(i.projectId, p.id))
4100+
.select(({ i }) => ({
4101+
id: i.id,
4102+
title: i.title,
4103+
comments: q
4104+
.from({ c: comments })
4105+
.where(({ c }) => eq(c.issueId, i.id))
4106+
.select(({ c }) => ({
4107+
id: c.id,
4108+
body: c.body,
4109+
})),
4110+
})),
4111+
tags: q
4112+
.from({ i: tags }) // same alias "i" as issues
4113+
.where(({ i }) => eq(i.projectId, p.id))
4114+
.select(({ i }) => ({
4115+
id: i.id,
4116+
label: i.label,
4117+
})),
4118+
})),
4119+
)
4120+
4121+
await collection.preload()
4122+
4123+
const alpha = collection.get(1) as any
4124+
4125+
// Tags should be populated
4126+
expect(childItems(alpha.tags)).toEqual([
4127+
{ id: 1, label: `urgent` },
4128+
{ id: 2, label: `frontend` },
4129+
])
4130+
4131+
// Nested comments should also be populated despite the duplicate alias "i"
4132+
const issue10 = alpha.issues.get(10)
4133+
expect(childItems(issue10.comments)).toEqual([
4134+
{ id: 100, body: `Looks bad` },
4135+
{ id: 101, body: `Fixed it` },
4136+
])
4137+
})
4138+
})
40644139
})

0 commit comments

Comments
 (0)