Skip to content
Open
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
49 changes: 44 additions & 5 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@ export function compileQuery(
// For includes: parent key stream to inner-join with this query's FROM
parentKeyStream?: KeyedStream,
childCorrelationField?: PropRef,
// Factory to create a fresh D2 input for an includes subquery alias.
// Each sibling gets its own input to avoid alias collisions.
createInput?: (alias: string, collectionId: string) => KeyedStream,
): CompilationResult {
// Check if the original raw query has already been compiled
const cachedResult = cache.get(rawQuery)
Expand Down Expand Up @@ -391,10 +394,16 @@ export function compileQuery(
}
: subquery.query

// Give each includes child its own D2 inputs so that sibling
// subqueries using the same alias letter get independent streams.
const childInputs = createInput
? createInputsForSources(childQuery, allInputs, createInput)
: allInputs

// Recursively compile child query WITH the parent key stream
const childResult = compileQuery(
childQuery,
allInputs,
childInputs,
collections,
subscriptions,
callbacks,
Expand All @@ -405,12 +414,9 @@ export function compileQuery(
queryMapping,
parentKeys,
subquery.childCorrelationField,
createInput,
)

// Merge child's alias metadata into parent's
Object.assign(aliasToCollectionId, childResult.aliasToCollectionId)
Object.assign(aliasRemapping, childResult.aliasRemapping)

includesResults.push({
pipeline: childResult.pipeline,
fieldName: subquery.fieldName,
Expand Down Expand Up @@ -741,6 +747,39 @@ function collectDirectCollectionAliases(query: QueryIR): Set<string> {
return aliases
}

/**
* Creates fresh D2 inputs for all source aliases (FROM + JOINs) in a query,
* following FROM/JOIN subqueries recursively but skipping includes.
* Returns a copy of `parentInputs` with fresh inputs for the child's own aliases.
*/
function createInputsForSources(
query: QueryIR,
parentInputs: Record<string, KeyedStream>,
createInput: (alias: string, collectionId: string) => KeyedStream,
): Record<string, KeyedStream> {
const inputs = { ...parentInputs }

function walkFrom(from: CollectionRef | QueryRef) {
if (from.type === `collectionRef`) {
inputs[from.alias] = createInput(from.alias, from.collection.id)
} else if (from.type === `queryRef`) {
walkQuery(from.query)
}
}

function walkQuery(q: QueryIR) {
walkFrom(q.from)
if (q.join) {
for (const join of q.join) {
walkFrom(join.from)
}
}
}

walkQuery(query)
return inputs
}

/**
* Validates the structure of a query and its subqueries.
* Checks that subqueries don't reuse collection aliases from parent queries.
Expand Down
18 changes: 18 additions & 0 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -680,6 +680,18 @@ export class CollectionConfigBuilder<
]),
)

// Each includes subquery gets a fresh D2 input under a unique key so
// that sibling subqueries using the same alias don't share a stream.
let includesInputCounter = 0
const includesAliasById: Record<string, string> = {}
const createIncludesInput = (alias: string, collectionId: string) => {
const uniqueKey = `__inc_${includesInputCounter++}_${alias}`
const input = this.graphCache!.newInput<unknown>()
this.inputsCache![uniqueKey] = input
includesAliasById[uniqueKey] = collectionId
return input as KeyedStream
}

const compilation = compileQuery(
this.query,
this.inputsCache as Record<string, KeyedStream>,
Expand All @@ -691,12 +703,18 @@ export class CollectionConfigBuilder<
(windowFn: (options: WindowOptions) => void) => {
this.windowFn = windowFn
},
undefined, // cache
undefined, // queryMapping
undefined, // parentKeyStream
undefined, // childCorrelationField
createIncludesInput,
)

this.pipelineCache = compilation.pipeline
this.sourceWhereClausesCache = compilation.sourceWhereClauses
this.compiledAliasToCollectionId = compilation.aliasToCollectionId
this.includesCache = compilation.includes
Object.assign(this.compiledAliasToCollectionId, includesAliasById)

// Defensive check: verify all compiled aliases have corresponding inputs
// This should never happen since all aliases come from user declarations,
Expand Down
3 changes: 2 additions & 1 deletion packages/db/src/query/live/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,8 +142,9 @@ export function extractCollectionAliases(
if (typeof key === `string` && key.startsWith(`__SPREAD_SENTINEL__`)) {
continue
}
// Skip includes — their aliases are scoped independently via separate D2 inputs
if (value instanceof IncludesSubquery) {
traverse(value.query)
continue
} else if (isNestedSelectObject(value)) {
traverseSelect(value)
}
Expand Down
75 changes: 75 additions & 0 deletions packages/db/tests/query/includes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4061,4 +4061,79 @@ describe(`includes subqueries`, () => {
])
})
})

describe(`duplicate alias in sibling includes`, () => {
type Tag = {
id: number
projectId: number
label: string
}

const sampleTags: Array<Tag> = [
{ id: 1, projectId: 1, label: `urgent` },
{ id: 2, projectId: 1, label: `frontend` },
{ id: 3, projectId: 2, label: `backend` },
]

function createTagsCollection() {
return createCollection(
mockSyncCollectionOptions<Tag>({
id: `includes-tags`,
getKey: (t) => t.id,
initialData: sampleTags,
}),
)
}

it(`same alias in sibling includes does not break nested children`, async () => {
// Tags uses alias "i" — same as issues. Each sibling gets its own
// independent D2 input, so nested comments are still populated.
const tags = createTagsCollection()

const collection = createLiveQueryCollection((q) =>
q.from({ p: projects }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issues })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
comments: q
.from({ c: comments })
.where(({ c }) => eq(c.issueId, i.id))
.select(({ c }) => ({
id: c.id,
body: c.body,
})),
})),
tags: q
.from({ i: tags }) // same alias "i" as issues
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
label: i.label,
})),
})),
)

await collection.preload()

const alpha = collection.get(1) as any

// Tags should be populated
expect(childItems(alpha.tags)).toEqual([
{ id: 1, label: `urgent` },
{ id: 2, label: `frontend` },
])

// Nested comments should also be populated despite the duplicate alias "i"
const issue10 = alpha.issues.get(10)
expect(childItems(issue10.comments)).toEqual([
{ id: 100, body: `Looks bad` },
{ id: 101, body: `Fixed it` },
])
})
})
})