Skip to content

Commit 0141159

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 0141159

File tree

4 files changed

+168
-5
lines changed

4 files changed

+168
-5
lines changed

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

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,18 @@ export interface CompilationResult {
112112

113113
/** Child pipelines for includes subqueries */
114114
includes?: Array<IncludesCompilationResult>
115+
116+
/**
117+
* D2 inputs created for includes subquery aliases during compilation.
118+
* Each includes subquery gets its own independent D2 input, even if sibling
119+
* subqueries use the same alias letter. Keyed by a unique internal alias
120+
* (e.g., `__inc_0_i`), with the corresponding collection ID.
121+
*/
122+
includesInputs?: Array<{
123+
uniqueKey: string
124+
input: KeyedStream
125+
collectionId: string
126+
}>
115127
}
116128

117129
/**
@@ -141,6 +153,10 @@ export function compileQuery(
141153
// For includes: parent key stream to inner-join with this query's FROM
142154
parentKeyStream?: KeyedStream,
143155
childCorrelationField?: PropRef,
156+
// Factory to create fresh D2 inputs for includes subquery aliases.
157+
// Each includes subquery gets its own input to avoid alias collisions
158+
// between siblings (e.g., two siblings both using alias "i").
159+
createInput?: () => KeyedStream,
144160
): CompilationResult {
145161
// Check if the original raw query has already been compiled
146162
const cachedResult = cache.get(rawQuery)
@@ -316,6 +332,10 @@ export function compileQuery(
316332
// This must happen AFTER WHERE (so parent pipeline is filtered) but BEFORE processSelect
317333
// (so IncludesSubquery nodes are stripped before select compilation).
318334
const includesResults: Array<IncludesCompilationResult> = []
335+
const includesInputEntries: NonNullable<
336+
CompilationResult[`includesInputs`]
337+
> = []
338+
let includesInputCounter = 0
319339
const includesRoutingFns: Array<{
320340
fieldName: string
321341
getRouting: (nsRow: any) => {
@@ -391,10 +411,23 @@ export function compileQuery(
391411
}
392412
: subquery.query
393413

414+
// Create fresh D2 inputs for the child query's source aliases so that
415+
// sibling includes using the same alias letter get independent streams.
416+
const childAllInputs = { ...allInputs }
417+
if (createInput) {
418+
const childSourceAliases = collectAllSourceAliases(childQuery)
419+
for (const [alias, collectionId] of childSourceAliases) {
420+
const uniqueKey = `__inc_${includesInputCounter++}_${alias}`
421+
const freshInput = createInput()
422+
childAllInputs[alias] = freshInput
423+
includesInputEntries.push({ uniqueKey, input: freshInput, collectionId })
424+
}
425+
}
426+
394427
// Recursively compile child query WITH the parent key stream
395428
const childResult = compileQuery(
396429
childQuery,
397-
allInputs,
430+
childAllInputs,
398431
collections,
399432
subscriptions,
400433
callbacks,
@@ -405,11 +438,13 @@ export function compileQuery(
405438
queryMapping,
406439
parentKeys,
407440
subquery.childCorrelationField,
441+
createInput,
408442
)
409443

410-
// Merge child's alias metadata into parent's
411-
Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
412-
Object.assign(aliasRemapping, childResult.aliasRemapping)
444+
// Collect includes inputs from nested levels (grandchild includes etc.)
445+
if (childResult.includesInputs) {
446+
includesInputEntries.push(...childResult.includesInputs)
447+
}
413448

414449
includesResults.push({
415450
pipeline: childResult.pipeline,
@@ -710,6 +745,8 @@ export function compileQuery(
710745
aliasToCollectionId,
711746
aliasRemapping,
712747
includes: includesResults.length > 0 ? includesResults : undefined,
748+
includesInputs:
749+
includesInputEntries.length > 0 ? includesInputEntries : undefined,
713750
}
714751
cache.set(rawQuery, compilationResult)
715752

@@ -741,6 +778,38 @@ function collectDirectCollectionAliases(query: QueryIR): Set<string> {
741778
return aliases
742779
}
743780

781+
/**
782+
* Collects ALL source aliases (FROM + JOINs) from a query tree, recursively
783+
* following FROM/JOIN subqueries but NOT includes subqueries.
784+
* Returns a map of alias → collectionId for all collection refs found.
785+
*/
786+
function collectAllSourceAliases(
787+
query: QueryIR,
788+
): Map<string, string> {
789+
const result = new Map<string, string>()
790+
791+
function walkFrom(from: CollectionRef | QueryRef) {
792+
if (from.type === `collectionRef`) {
793+
result.set(from.alias, from.collection.id)
794+
} else if (from.type === `queryRef`) {
795+
walkQuery(from.query)
796+
}
797+
}
798+
799+
function walkQuery(q: QueryIR) {
800+
walkFrom(q.from)
801+
if (q.join) {
802+
for (const join of q.join) {
803+
walkFrom(join.from)
804+
}
805+
}
806+
// Do NOT walk includes in select — they get their own inputs
807+
}
808+
809+
walkQuery(query)
810+
return result
811+
}
812+
744813
/**
745814
* Validates the structure of a query and its subqueries.
746815
* Checks that subqueries don't reuse collection aliases from parent queries.

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -691,13 +691,28 @@ export class CollectionConfigBuilder<
691691
(windowFn: (options: WindowOptions) => void) => {
692692
this.windowFn = windowFn
693693
},
694+
undefined, // cache
695+
undefined, // queryMapping
696+
undefined, // parentKeyStream
697+
undefined, // childCorrelationField
698+
() => this.graphCache!.newInput<any>(), // createInput factory for includes
694699
)
695700

696701
this.pipelineCache = compilation.pipeline
697702
this.sourceWhereClausesCache = compilation.sourceWhereClauses
698703
this.compiledAliasToCollectionId = compilation.aliasToCollectionId
699704
this.includesCache = compilation.includes
700705

706+
// Merge includes inputs into the top-level input and alias maps.
707+
// Each includes subquery gets its own D2 input under a unique key,
708+
// ensuring sibling subqueries with the same alias don't collide.
709+
if (compilation.includesInputs) {
710+
for (const { uniqueKey, input, collectionId } of compilation.includesInputs) {
711+
;(this.inputsCache as Record<string, any>)[uniqueKey] = input
712+
this.compiledAliasToCollectionId[uniqueKey] = collectionId
713+
}
714+
}
715+
701716
// Defensive check: verify all compiled aliases have corresponding inputs
702717
// This should never happen since all aliases come from user declarations,
703718
// but catch it early if the assumption is violated in the future.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,12 @@ export function extractCollectionAliases(
142142
if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
143143
continue
144144
}
145+
// Do NOT traverse into IncludesSubquery nodes — their aliases are scoped
146+
// independently and handled via separate D2 inputs created during compilation.
147+
// Traversing here would flatten sibling aliases into a single namespace,
148+
// causing collisions when siblings use the same alias letter.
145149
if (value instanceof IncludesSubquery) {
146-
traverse(value.query)
150+
continue
147151
} else if (isNestedSelectObject(value)) {
148152
traverseSelect(value)
149153
}

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)