diff --git a/.changeset/includes-aggregates.md b/.changeset/includes-aggregates.md new file mode 100644 index 000000000..c846a6a60 --- /dev/null +++ b/.changeset/includes-aggregates.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +fix: support aggregates (e.g. count) in child/includes subqueries with per-parent scoping diff --git a/packages/db/src/query/compiler/group-by.ts b/packages/db/src/query/compiler/group-by.ts index 595d277ae..0309816c8 100644 --- a/packages/db/src/query/compiler/group-by.ts +++ b/packages/db/src/query/compiler/group-by.ts @@ -80,6 +80,7 @@ export function processGroupBy( havingClauses?: Array, selectClause?: Select, fnHavingClauses?: Array<(row: any) => any>, + mainSource?: string, ): NamespacedAndKeyedStream { // Handle empty GROUP BY (single-group aggregation) if (groupByClause.length === 0) { @@ -110,8 +111,15 @@ export function processGroupBy( } } - // Use a constant key for single group - const keyExtractor = () => ({ __singleGroup: true }) + // Use a constant key for single group. + // When mainSource is set (includes mode), include __correlationKey so that + // rows from different parents aggregate separately. + const keyExtractor = mainSource + ? ([, row]: [string, NamespacedRow]) => ({ + __singleGroup: true, + __correlationKey: (row as any)?.[mainSource]?.__correlationKey, + }) + : () => ({ __singleGroup: true }) // Apply the groupBy operator with single group pipeline = pipeline.pipe( @@ -139,14 +147,24 @@ export function processGroupBy( ) } - // Use a single key for the result and update $selected - return [ - `single_group`, - { - ...aggregatedRow, - $selected: finalResults, - }, - ] as [unknown, Record] + // Use a single key for the result and update $selected. + // When in includes mode, restore the namespaced source structure with + // __correlationKey so output extraction can route results per-parent. + const correlationKey = mainSource + ? (aggregatedRow as any).__correlationKey + : undefined + const resultKey = + correlationKey !== undefined + ? `single_group_${serializeValue(correlationKey)}` + : `single_group` + const resultRow: Record = { + ...aggregatedRow, + $selected: finalResults, + } + if (mainSource && correlationKey !== undefined) { + resultRow[mainSource] = { __correlationKey: correlationKey } + } + return [resultKey, resultRow] as [unknown, Record] }), ) @@ -196,7 +214,9 @@ export function processGroupBy( compileExpression(e), ) - // Create a key extractor function using simple __key_X format + // Create a key extractor function using simple __key_X format. + // When mainSource is set (includes mode), include __correlationKey so that + // rows from different parents with the same group key aggregate separately. const keyExtractor = ([, row]: [ string, NamespacedRow & { $selected?: any }, @@ -214,6 +234,10 @@ export function processGroupBy( key[`__key_${i}`] = value } + if (mainSource) { + key.__correlationKey = (row as any)?.[mainSource]?.__correlationKey + } + return key } @@ -278,25 +302,32 @@ export function processGroupBy( } } - // Generate a simple key for the live collection using group values - let finalKey: unknown - if (groupByClause.length === 1) { - finalKey = aggregatedRow[`__key_0`] - } else { - const keyParts: Array = [] - for (let i = 0; i < groupByClause.length; i++) { - keyParts.push(aggregatedRow[`__key_${i}`]) - } - finalKey = serializeValue(keyParts) + // Generate a simple key for the live collection using group values. + // When in includes mode, include the correlation key so that groups + // from different parents don't collide. + const correlationKey = mainSource + ? (aggregatedRow as any).__correlationKey + : undefined + const keyParts: Array = [] + for (let i = 0; i < groupByClause.length; i++) { + keyParts.push(aggregatedRow[`__key_${i}`]) } - - return [ - finalKey, - { - ...aggregatedRow, - $selected: finalResults, - }, - ] as [unknown, Record] + if (correlationKey !== undefined) { + keyParts.push(correlationKey) + } + const finalKey = + keyParts.length === 1 ? keyParts[0] : serializeValue(keyParts) + + // When in includes mode, restore the namespaced source structure with + // __correlationKey so output extraction can route results per-parent. + const resultRow: Record = { + ...aggregatedRow, + $selected: finalResults, + } + if (mainSource && correlationKey !== undefined) { + resultRow[mainSource] = { __correlationKey: correlationKey } + } + return [finalKey, resultRow] as [unknown, Record] }), ) diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index f33837024..842300981 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -529,7 +529,10 @@ export function compileQuery( ) } - // Process the GROUP BY clause if it exists + // Process the GROUP BY clause if it exists. + // When in includes mode (parentKeyStream), pass mainSource so that groupBy + // preserves __correlationKey for per-parent aggregation. + const groupByMainSource = parentKeyStream ? mainSource : undefined if (query.groupBy && query.groupBy.length > 0) { pipeline = processGroupBy( pipeline, @@ -537,6 +540,7 @@ export function compileQuery( query.having, query.select, query.fnHaving, + groupByMainSource, ) } else if (query.select) { // Check if SELECT contains aggregates but no GROUP BY (implicit single-group aggregation) @@ -551,6 +555,7 @@ export function compileQuery( query.having, query.select, query.fnHaving, + groupByMainSource, ) } } diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 12fa9e0df..51f648bf4 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { and, + count, createLiveQueryCollection, eq, toArray, @@ -412,6 +413,93 @@ describe(`includes subqueries`, () => { }) }) + describe(`change propagation`, () => { + it(`Collection includes: child change does not re-emit the parent row`, async () => { + const collection = buildIncludesQuery() + await collection.preload() + + const changeCallback = vi.fn() + const subscription = collection.subscribeChanges(changeCallback, { + includeInitialState: false, + }) + changeCallback.mockClear() + + // Add a child issue to project Alpha + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 12, projectId: 1, title: `New Alpha issue` }, + }) + issues.utils.commit() + + // Wait for async change propagation + await new Promise((resolve) => setTimeout(resolve, 10)) + + // The child Collection updates in place — the parent row should NOT be re-emitted + expect(changeCallback).not.toHaveBeenCalled() + + // But the child data is there + expect(childItems((collection.get(1) as any).issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + { id: 12, title: `New Alpha issue` }, + ]) + + subscription.unsubscribe() + }) + + it(`toArray includes: child change re-emits the parent row`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + await collection.preload() + + const changeCallback = vi.fn() + const subscription = collection.subscribeChanges(changeCallback, { + includeInitialState: false, + }) + changeCallback.mockClear() + + // Add a child issue to project Alpha + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 12, projectId: 1, title: `New Alpha issue` }, + }) + issues.utils.commit() + + // Wait for async change propagation + await new Promise((resolve) => setTimeout(resolve, 10)) + + // The parent row SHOULD be re-emitted with the updated array + expect(changeCallback).toHaveBeenCalled() + + // Verify the parent row has the updated array + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(alpha.issues.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + { id: 12, title: `New Alpha issue` }, + ]) + + subscription.unsubscribe() + }) + }) + describe(`inner join filtering`, () => { it(`only shows children for parents matching a WHERE clause`, async () => { const collection = createLiveQueryCollection((q) => @@ -2755,4 +2843,1007 @@ describe(`includes subqueries`, () => { ]) }) }) + + describe(`toArray`, () => { + function buildToArrayQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + } + + it(`produces arrays on parent rows, not Collections`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(alpha.issues.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + + const beta = collection.get(2) as any + expect(Array.isArray(beta.issues)).toBe(true) + expect(beta.issues).toEqual([{ id: 20, title: `Bug in Beta` }]) + }) + + it(`empty parents get empty arrays`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + const gamma = collection.get(3) as any + expect(Array.isArray(gamma.issues)).toBe(true) + expect(gamma.issues).toEqual([]) + }) + + it(`adding a child re-emits the parent with updated array`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 12, projectId: 1, title: `New Alpha issue` }, + }) + issues.utils.commit() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(alpha.issues.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + { id: 12, title: `New Alpha issue` }, + ]) + }) + + it(`removing a child re-emits the parent with updated array`, async () => { + const collection = buildToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 10)!, + }) + issues.utils.commit() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([{ id: 11, title: `Feature for Alpha` }]) + }) + + it(`array respects ORDER BY`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + await collection.preload() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + }) + + it(`ordered toArray with limit applied per parent`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .orderBy(({ i }) => i.title, `asc`) + .limit(1) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + await collection.preload() + + const alpha = collection.get(1) as any + expect(alpha.issues).toEqual([{ id: 10, title: `Bug in Alpha` }]) + + const beta = collection.get(2) as any + expect(beta.issues).toEqual([{ id: 20, title: `Bug in Beta` }]) + + const gamma = collection.get(3) as any + expect(gamma.issues).toEqual([]) + }) + }) + + describe(`nested includes: Collection → toArray`, () => { + function buildCollectionToArrayQuery() { + return 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: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ + id: c.id, + body: c.body, + })), + ), + })), + })), + ) + } + + it(`initial load: issues are Collections, comments are arrays`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + // issues should be a Collection + expect(alpha.issues.toArray).toBeDefined() + + const issue10 = alpha.issues.get(10) + // comments should be an array + expect(Array.isArray(issue10.comments)).toBe(true) + expect(issue10.comments.sort((a: any, b: any) => a.id - b.id)).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + const issue11 = alpha.issues.get(11) + expect(Array.isArray(issue11.comments)).toBe(true) + expect(issue11.comments).toEqual([]) + }) + + it(`adding a comment (grandchild-only change) updates the issue's comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const issue11Before = (collection.get(1) as any).issues.get(11) + expect(issue11Before.comments).toEqual([]) + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + const issue11After = (collection.get(1) as any).issues.get(11) + expect(Array.isArray(issue11After.comments)).toBe(true) + expect(issue11After.comments).toEqual([ + { id: 110, body: `Great feature` }, + ]) + }) + + it(`removing a comment (grandchild-only change) updates the issue's comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + const issue10Before = (collection.get(1) as any).issues.get(10) + expect(issue10Before.comments).toHaveLength(2) + + comments.utils.begin() + comments.utils.write({ + type: `delete`, + value: sampleComments.find((c) => c.id === 100)!, + }) + comments.utils.commit() + + const issue10After = (collection.get(1) as any).issues.get(10) + expect(issue10After.comments).toEqual([{ id: 101, body: `Fixed it` }]) + }) + + it(`adding an issue (middle-level insert) creates a child with empty comments array`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [{ id: 30, title: `Gamma issue`, comments: [] }], + }, + ]) + }) + + it(`removing an issue (middle-level delete) removes it from the parent`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title (middle-level update) reflects in the parent`, async () => { + const collection = buildCollectionToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + }) + + describe(`nested includes: toArray → Collection`, () => { + function buildToArrayToCollectionQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + 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, + })), + })), + ), + })), + ) + } + + it(`initial load: issues are arrays, comments are Collections`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + + const sortedIssues = alpha.issues.sort((a: any, b: any) => a.id - b.id) + // comments should be Collections + expect(sortedIssues[0].comments.toArray).toBeDefined() + expect(childItems(sortedIssues[0].comments)).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + expect(sortedIssues[1].comments.toArray).toBeDefined() + expect(childItems(sortedIssues[1].comments)).toEqual([]) + }) + + it(`adding a comment updates the nested Collection (live reference)`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + const alpha = collection.get(1) as any + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(childItems(issue11.comments)).toEqual([]) + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + // The Collection reference on the issue object is live + expect(childItems(issue11.comments)).toEqual([ + { id: 110, body: `Great feature` }, + ]) + }) + + it(`adding an issue re-emits the parent with updated array including nested Collection`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + const gamma = collection.get(3) as any + expect(Array.isArray(gamma.issues)).toBe(true) + expect(gamma.issues).toHaveLength(1) + expect(gamma.issues[0].id).toBe(30) + expect(gamma.issues[0].comments.toArray).toBeDefined() + expect(childItems(gamma.issues[0].comments)).toEqual([]) + }) + + it(`removing an issue re-emits the parent with updated array`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title re-emits the parent with updated array`, async () => { + const collection = buildToArrayToCollectionQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + }) + + describe(`nested includes: toArray → toArray`, () => { + function buildToArrayToArrayQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + comments: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ + id: c.id, + body: c.body, + })), + ), + })), + ), + })), + ) + } + + it(`initial load: both levels are arrays`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + + const sortedIssues = alpha.issues.sort((a: any, b: any) => a.id - b.id) + expect(Array.isArray(sortedIssues[0].comments)).toBe(true) + expect( + sortedIssues[0].comments.sort((a: any, b: any) => a.id - b.id), + ).toEqual([ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ]) + + expect(sortedIssues[1].comments).toEqual([]) + }) + + it(`adding a comment (grandchild-only change) updates both array levels`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(Array.isArray(issue11.comments)).toBe(true) + expect(issue11.comments).toEqual([{ id: 110, body: `Great feature` }]) + }) + + it(`removing a comment (grandchild-only change) updates both array levels`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + comments.utils.begin() + comments.utils.write({ + type: `delete`, + value: sampleComments.find((c) => c.id === 100)!, + }) + comments.utils.commit() + + const alpha = collection.get(1) as any + const issue10 = alpha.issues.find((i: any) => i.id === 10) + expect(issue10.comments).toEqual([{ id: 101, body: `Fixed it` }]) + }) + + it(`adding an issue (middle-level insert) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [{ id: 30, title: `Gamma issue`, comments: [] }], + }, + ]) + }) + + it(`removing an issue (middle-level delete) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 11)!, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Bug in Alpha`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`updating an issue title (middle-level update) re-emits parent with updated array`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + issues.utils.begin() + issues.utils.write({ + type: `update`, + value: { id: 10, projectId: 1, title: `Renamed Bug` }, + }) + issues.utils.commit() + + expect(toTree(collection)).toEqual([ + { + id: 1, + name: `Alpha`, + issues: [ + { + id: 10, + title: `Renamed Bug`, + comments: [ + { id: 100, body: `Looks bad` }, + { id: 101, body: `Fixed it` }, + ], + }, + { id: 11, title: `Feature for Alpha`, comments: [] }, + ], + }, + { + id: 2, + name: `Beta`, + issues: [ + { + id: 20, + title: `Bug in Beta`, + comments: [{ id: 200, body: `Same bug` }], + }, + ], + }, + { + id: 3, + name: `Gamma`, + issues: [], + }, + ]) + }) + + it(`concurrent child + grandchild changes in the same transaction`, async () => { + const collection = buildToArrayToArrayQuery() + await collection.preload() + + // Add a new issue AND a comment on an existing issue in one transaction + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + comments.utils.begin() + comments.utils.write({ + type: `insert`, + value: { id: 110, issueId: 11, body: `Great feature` }, + }) + comments.utils.commit() + + // Gamma should have the new issue with empty comments + const gamma = collection.get(3) as any + expect(gamma.issues).toHaveLength(1) + expect(gamma.issues[0].id).toBe(30) + expect(gamma.issues[0].comments).toEqual([]) + + // Alpha's issue 11 should have the new comment + const alpha = collection.get(1) as any + const issue11 = alpha.issues.find((i: any) => i.id === 11) + expect(issue11.comments).toEqual([{ id: 110, body: `Great feature` }]) + }) + }) + + // Aggregates in child queries: the aggregate (e.g. count) should be computed + // per-parent, not globally across all parents. Currently, the correlation key + // is lost after GROUP BY, causing all child rows to aggregate into a single + // global result rather than per-parent results. + describe(`aggregates in child queries`, () => { + describe(`single-group aggregate: count issues per project (as Collection)`, () => { + function buildAggregateQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issueCount: q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ total: count(i.id) })), + })), + ) + } + + it(`each project gets its own aggregate result`, async () => { + const collection = buildAggregateQuery() + await collection.preload() + + // Alpha has 2 issues + const alpha = collection.get(1) as any + expect(childItems(alpha.issueCount, `total`)).toEqual([{ total: 2 }]) + + // Beta has 1 issue + const beta = collection.get(2) as any + expect(childItems(beta.issueCount, `total`)).toEqual([{ total: 1 }]) + + // Gamma has 0 issues — no matching rows means empty Collection + const gamma = collection.get(3) as any + expect(childItems(gamma.issueCount, `total`)).toEqual([]) + }) + + it(`adding an issue updates the count for that parent`, async () => { + const collection = buildAggregateQuery() + await collection.preload() + + // Gamma starts with 0 issues + expect( + childItems((collection.get(3) as any).issueCount, `total`), + ).toEqual([]) + + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 30, projectId: 3, title: `Gamma issue` }, + }) + issues.utils.commit() + + // Gamma now has 1 issue + expect( + childItems((collection.get(3) as any).issueCount, `total`), + ).toEqual([{ total: 1 }]) + + // Alpha should still have 2 + expect( + childItems((collection.get(1) as any).issueCount, `total`), + ).toEqual([{ total: 2 }]) + }) + + it(`removing an issue updates the count for that parent`, async () => { + const collection = buildAggregateQuery() + await collection.preload() + + // Alpha starts with 2 issues + expect( + childItems((collection.get(1) as any).issueCount, `total`), + ).toEqual([{ total: 2 }]) + + issues.utils.begin() + issues.utils.write({ + type: `delete`, + value: sampleIssues.find((i) => i.id === 10)!, + }) + issues.utils.commit() + + // Alpha now has 1 issue + expect( + childItems((collection.get(1) as any).issueCount, `total`), + ).toEqual([{ total: 1 }]) + + // Beta should still have 1 + expect( + childItems((collection.get(2) as any).issueCount, `total`), + ).toEqual([{ total: 1 }]) + }) + }) + + describe(`single-group aggregate: count issues per project (as toArray)`, () => { + function buildAggregateToArrayQuery() { + return createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issueCount: toArray( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ total: count(i.id) })), + ), + })), + ) + } + + it(`each project gets its own aggregate result as an array`, async () => { + const collection = buildAggregateToArrayQuery() + await collection.preload() + + // Alpha has 2 issues + const alpha = collection.get(1) as any + expect(alpha.issueCount).toEqual([{ total: 2 }]) + + // Beta has 1 issue + const beta = collection.get(2) as any + expect(beta.issueCount).toEqual([{ total: 1 }]) + + // Gamma has 0 issues — empty array + const gamma = collection.get(3) as any + expect(gamma.issueCount).toEqual([]) + }) + }) + + describe(`nested aggregate: count comments per issue (as Collection)`, () => { + function buildNestedAggregateQuery() { + return 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, + commentCount: q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ total: count(c.id) })), + })), + })), + ) + } + + it(`each issue gets its own comment count`, async () => { + const collection = buildNestedAggregateQuery() + await collection.preload() + + // Alpha's issues + const alpha = collection.get(1) as any + const issue10 = alpha.issues.get(10) + expect(childItems(issue10.commentCount, `total`)).toEqual([ + { total: 2 }, + ]) + + const issue11 = alpha.issues.get(11) + // Issue 11 has 0 comments — empty Collection + expect(childItems(issue11.commentCount, `total`)).toEqual([]) + + // Beta's issue + const beta = collection.get(2) as any + const issue20 = beta.issues.get(20) + expect(childItems(issue20.commentCount, `total`)).toEqual([ + { total: 1 }, + ]) + }) + }) + + describe(`nested aggregate: count comments per issue (as toArray)`, () => { + function buildNestedAggregateToArrayQuery() { + return 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, + commentCount: toArray( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ total: count(c.id) })), + ), + })), + })), + ) + } + + it(`each issue gets its own comment count as an array`, async () => { + const collection = buildNestedAggregateToArrayQuery() + await collection.preload() + + // Alpha's issues + const alpha = collection.get(1) as any + const issue10 = alpha.issues.get(10) + expect(issue10.commentCount).toEqual([{ total: 2 }]) + + const issue11 = alpha.issues.get(11) + // Issue 11 has 0 comments — empty array + expect(issue11.commentCount).toEqual([]) + + // Beta's issue + const beta = collection.get(2) as any + const issue20 = beta.issues.get(20) + expect(issue20.commentCount).toEqual([{ total: 1 }]) + }) + }) + }) })