Skip to content

Commit 309e89e

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 309e89e

File tree

4 files changed

+139
-6
lines changed

4 files changed

+139
-6
lines changed

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

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,9 @@ 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 a fresh D2 input for an includes subquery alias.
145+
// Each sibling gets its own input to avoid alias collisions.
146+
createInput?: (alias: string, collectionId: string) => KeyedStream,
144147
): CompilationResult {
145148
// Check if the original raw query has already been compiled
146149
const cachedResult = cache.get(rawQuery)
@@ -391,10 +394,16 @@ export function compileQuery(
391394
}
392395
: subquery.query
393396

397+
// Give each includes child its own D2 inputs so that sibling
398+
// subqueries using the same alias letter get independent streams.
399+
const childInputs = createInput
400+
? createInputsForSources(childQuery, allInputs, createInput)
401+
: allInputs
402+
394403
// Recursively compile child query WITH the parent key stream
395404
const childResult = compileQuery(
396405
childQuery,
397-
allInputs,
406+
childInputs,
398407
collections,
399408
subscriptions,
400409
callbacks,
@@ -405,12 +414,9 @@ export function compileQuery(
405414
queryMapping,
406415
parentKeys,
407416
subquery.childCorrelationField,
417+
createInput,
408418
)
409419

410-
// Merge child's alias metadata into parent's
411-
Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
412-
Object.assign(aliasRemapping, childResult.aliasRemapping)
413-
414420
includesResults.push({
415421
pipeline: childResult.pipeline,
416422
fieldName: subquery.fieldName,
@@ -741,6 +747,39 @@ function collectDirectCollectionAliases(query: QueryIR): Set<string> {
741747
return aliases
742748
}
743749

750+
/**
751+
* Creates fresh D2 inputs for all source aliases (FROM + JOINs) in a query,
752+
* following FROM/JOIN subqueries recursively but skipping includes.
753+
* Returns a copy of `parentInputs` with fresh inputs for the child's own aliases.
754+
*/
755+
function createInputsForSources(
756+
query: QueryIR,
757+
parentInputs: Record<string, KeyedStream>,
758+
createInput: (alias: string, collectionId: string) => KeyedStream,
759+
): Record<string, KeyedStream> {
760+
const inputs = { ...parentInputs }
761+
762+
function walkFrom(from: CollectionRef | QueryRef) {
763+
if (from.type === `collectionRef`) {
764+
inputs[from.alias] = createInput(from.alias, from.collection.id)
765+
} else if (from.type === `queryRef`) {
766+
walkQuery(from.query)
767+
}
768+
}
769+
770+
function walkQuery(q: QueryIR) {
771+
walkFrom(q.from)
772+
if (q.join) {
773+
for (const join of q.join) {
774+
walkFrom(join.from)
775+
}
776+
}
777+
}
778+
779+
walkQuery(query)
780+
return inputs
781+
}
782+
744783
/**
745784
* Validates the structure of a query and its subqueries.
746785
* Checks that subqueries don't reuse collection aliases from parent queries.

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

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

683+
// Each includes subquery gets a fresh D2 input under a unique key so
684+
// that sibling subqueries using the same alias don't share a stream.
685+
let includesInputCounter = 0
686+
const includesAliasById: Record<string, string> = {}
687+
const createIncludesInput = (alias: string, collectionId: string) => {
688+
const uniqueKey = `__inc_${includesInputCounter++}_${alias}`
689+
const input = this.graphCache!.newInput<unknown>()
690+
this.inputsCache![uniqueKey] = input
691+
includesAliasById[uniqueKey] = collectionId
692+
return input as KeyedStream
693+
}
694+
683695
const compilation = compileQuery(
684696
this.query,
685697
this.inputsCache as Record<string, KeyedStream>,
@@ -691,12 +703,18 @@ export class CollectionConfigBuilder<
691703
(windowFn: (options: WindowOptions) => void) => {
692704
this.windowFn = windowFn
693705
},
706+
undefined, // cache
707+
undefined, // queryMapping
708+
undefined, // parentKeyStream
709+
undefined, // childCorrelationField
710+
createIncludesInput,
694711
)
695712

696713
this.pipelineCache = compilation.pipeline
697714
this.sourceWhereClausesCache = compilation.sourceWhereClauses
698715
this.compiledAliasToCollectionId = compilation.aliasToCollectionId
699716
this.includesCache = compilation.includes
717+
Object.assign(this.compiledAliasToCollectionId, includesAliasById)
700718

701719
// Defensive check: verify all compiled aliases have corresponding inputs
702720
// 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)