diff --git a/.changeset/includes-to-array.md b/.changeset/includes-to-array.md new file mode 100644 index 000000000..fd59eaea3 --- /dev/null +++ b/.changeset/includes-to-array.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +feat: add `toArray()` wrapper for includes subqueries to materialize child results as plain arrays instead of live Collections diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 887c1468d..93147fd08 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -3,6 +3,7 @@ import { toExpression } from './ref-proxy.js' import type { BasicExpression } from '../ir' import type { RefProxy } from './ref-proxy.js' import type { RefLeaf } from './types.js' +import type { QueryBuilder } from './index.js' type StringRef = | RefLeaf @@ -376,3 +377,11 @@ export const operators = [ ] as const export type OperatorName = (typeof operators)[number] + +export class ToArrayWrapper { + constructor(public readonly query: QueryBuilder) {} +} + +export function toArray(query: QueryBuilder): ToArrayWrapper { + return new ToArrayWrapper(query) +} diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 7b7b6d3e8..81713865e 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -23,6 +23,7 @@ import { createRefProxyWithSelected, toExpression, } from './ref-proxy.js' +import { ToArrayWrapper } from './functions.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, @@ -863,7 +864,14 @@ function buildNestedSelect(obj: any, parentAliases: Array = []): any { continue } if (v instanceof BaseQueryBuilder) { - out[k] = buildIncludesSubquery(v, k, parentAliases) + out[k] = buildIncludesSubquery(v, k, parentAliases, false) + continue + } + if (v instanceof ToArrayWrapper) { + if (!(v.query instanceof BaseQueryBuilder)) { + throw new Error(`toArray() must wrap a subquery builder`) + } + out[k] = buildIncludesSubquery(v.query, k, parentAliases, true) continue } out[k] = buildNestedSelect(v, parentAliases) @@ -880,6 +888,7 @@ function buildIncludesSubquery( childBuilder: BaseQueryBuilder, fieldName: string, parentAliases: Array, + materializeAsArray: boolean, ): IncludesSubquery { const childQuery = childBuilder._getQuery() @@ -943,7 +952,13 @@ function buildIncludesSubquery( where: modifiedWhere.length > 0 ? modifiedWhere : undefined, } - return new IncludesSubquery(modifiedQuery, parentRef, childRef, fieldName) + return new IncludesSubquery( + modifiedQuery, + parentRef, + childRef, + fieldName, + materializeAsArray, + ) } /** diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 11360dd82..42d13bc8f 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -9,6 +9,7 @@ import type { Value, } from '../ir.js' import type { QueryBuilder } from './index.js' +import type { ToArrayWrapper } from './functions.js' /** * Context - The central state container for query builder operations @@ -174,6 +175,7 @@ type SelectValue = | undefined // Optional values | { [key: string]: SelectValue } | Array> + | ToArrayWrapper // toArray() wrapped subquery // Recursive shape for select objects allowing nested projections type SelectShape = { [key: string]: SelectValue | SelectShape } diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index d749e6e3a..6834fea0d 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -55,6 +55,8 @@ export interface IncludesCompilationResult { hasOrderBy: boolean /** Full compilation result for the child query (for nested includes + alias tracking) */ childCompilationResult: CompilationResult + /** When true, the output layer materializes children as Array instead of Collection */ + materializeAsArray: boolean } /** @@ -320,6 +322,7 @@ export function compileQuery( subquery.query.orderBy && subquery.query.orderBy.length > 0 ), childCompilationResult: childResult, + materializeAsArray: subquery.materializeAsArray, }) // Replace includes entry in select with a null placeholder diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index c8a8d5e38..810cb0cf2 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -60,6 +60,8 @@ export { sum, min, max, + // Includes helpers + toArray, } from './builder/functions.js' // Ref proxy utilities diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index 64ddd22c7..04c28afd7 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -139,6 +139,7 @@ export class IncludesSubquery extends BaseExpression { public correlationField: PropRef, // Parent-side ref (e.g., project.id) public childCorrelationField: PropRef, // Child-side ref (e.g., issue.projectId) public fieldName: string, // Result field name (e.g., "issues") + public materializeAsArray: boolean = false, // When true, parent gets Array instead of Collection ) { super() } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index c2d7387d8..f539436e8 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -788,6 +788,7 @@ export class CollectionConfigBuilder< config.collection, this.id, hasParentChanges ? changesToApply : null, + config, ) } @@ -820,6 +821,7 @@ export class CollectionConfigBuilder< correlationField: entry.correlationField, childCorrelationField: entry.childCorrelationField, hasOrderBy: entry.hasOrderBy, + materializeAsArray: entry.materializeAsArray, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1311,6 +1313,8 @@ type IncludesOutputState = { childCorrelationField: PropRef /** Whether the child query has an ORDER BY clause */ hasOrderBy: boolean + /** When true, parent gets Array instead of Collection */ + materializeAsArray: boolean /** Maps correlation key value → child Collection entry */ childRegistry: Map /** Pending child changes: correlationKey → Map */ @@ -1411,6 +1415,7 @@ function createPerEntryIncludesStates( correlationField: setup.compilationResult.correlationField, childCorrelationField: setup.compilationResult.childCorrelationField, hasOrderBy: setup.compilationResult.hasOrderBy, + materializeAsArray: setup.compilationResult.materializeAsArray, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1641,6 +1646,7 @@ function flushIncludesState( parentCollection: Collection, parentId: string, parentChanges: Map> | null, + parentSyncMethods: SyncMethods | null, ): void { for (const state of includesState) { // Phase 1: Parent INSERTs — ensure a child Collection exists for every parent @@ -1676,21 +1682,31 @@ function flushIncludesState( } parentKeys.add(parentKey) - // Attach child Collection to the parent result - parentResult[state.fieldName] = - state.childRegistry.get(correlationKey)!.collection + // Attach child Collection (or array snapshot for toArray) to the parent result + if (state.materializeAsArray) { + parentResult[state.fieldName] = [ + ...state.childRegistry.get(correlationKey)!.collection.toArray, + ] + } else { + parentResult[state.fieldName] = + state.childRegistry.get(correlationKey)!.collection + } } } } } + // Track affected correlation keys for toArray re-emit (before clearing pendingChildChanges) + const affectedCorrelationKeys = state.materializeAsArray + ? new Set(state.pendingChildChanges.keys()) + : null + // Phase 2: Child changes — apply to child Collections // Track which entries had child changes and capture their childChanges maps const entriesWithChildChanges = new Map< unknown, { entry: ChildCollectionEntry; childChanges: Map> } >() - if (state.pendingChildChanges.size > 0) { for (const [correlationKey, childChanges] of state.pendingChildChanges) { // Ensure child Collection exists for this correlation key @@ -1706,14 +1722,17 @@ function flushIncludesState( state.childRegistry.set(correlationKey, entry) } - // Attach the child Collection to ANY parent that has this correlation key - attachChildCollectionToParent( - parentCollection, - state.fieldName, - correlationKey, - state.correlationToParentKeys, - entry.collection, - ) + // For non-toArray: attach the child Collection to ANY parent that has this correlation key + // For toArray: skip — the array snapshot is set during re-emit below + if (!state.materializeAsArray) { + attachChildCollectionToParent( + parentCollection, + state.fieldName, + correlationKey, + state.correlationToParentKeys, + entry.collection, + ) + } // Apply child changes to the child Collection if (entry.syncMethods) { @@ -1760,6 +1779,7 @@ function flushIncludesState( entry.collection, entry.collection.id, childChanges, + entry.syncMethods, ) } } @@ -1773,10 +1793,34 @@ function flushIncludesState( entry.collection, entry.collection.id, null, + entry.syncMethods, ) } } + // For toArray entries: re-emit affected parents with updated array snapshots + const toArrayReEmitKeys = state.materializeAsArray + ? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers]) + : null + if (parentSyncMethods && toArrayReEmitKeys && toArrayReEmitKeys.size > 0) { + parentSyncMethods.begin() + for (const correlationKey of toArrayReEmitKeys) { + const parentKeys = state.correlationToParentKeys.get(correlationKey) + if (!parentKeys) continue + const entry = state.childRegistry.get(correlationKey) + for (const parentKey of parentKeys) { + const item = parentCollection.get(parentKey as any) + if (item) { + if (entry) { + item[state.fieldName] = [...entry.collection.toArray] + } + parentSyncMethods.write({ value: item, type: `update` }) + } + } + } + parentSyncMethods.commit() + } + // Phase 5: Parent DELETEs — dispose child Collections and clean up if (parentChanges) { const fieldPath = state.correlationField.path.slice(1) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 07a76a5c8..c3b912242 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it } from 'vitest' -import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { + createLiveQueryCollection, + eq, + toArray, +} from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' @@ -84,17 +88,27 @@ function childItems(collection: any, sortKey = `id`): Array { * plain sorted array, turning any nested child Collections into nested arrays. * This lets tests compare the full hierarchical result as a single literal. */ -function toTree(collection: any, sortKey = `id`): Array { - const rows = [...collection.toArray].sort( - (a: any, b: any) => a[sortKey] - b[sortKey], - ) +function toTree(collectionOrArray: any, sortKey = `id`): Array { + const rows = ( + Array.isArray(collectionOrArray) + ? [...collectionOrArray] + : [...collectionOrArray.toArray] + ).sort((a: any, b: any) => a[sortKey] - b[sortKey]) return rows.map((row: any) => { + if (typeof row !== `object` || row === null) return row const out: Record = {} for (const [key, value] of Object.entries(row)) { - out[key] = - value && typeof value === `object` && `toArray` in (value as any) - ? toTree(value, sortKey) - : value + if (Array.isArray(value)) { + out[key] = toTree(value, sortKey) + } else if ( + value && + typeof value === `object` && + `toArray` in (value as any) + ) { + out[key] = toTree(value, sortKey) + } else { + out[key] = value + } } return out }) @@ -453,7 +467,14 @@ describe(`includes subqueries`, () => { }) }) - describe(`nested includes`, () => { + // Nested includes: two-level parent → child → grandchild (Project → Issue → Comment). + // Each level (Issue/Comment) can be materialized as a live Collection or a plain array (via toArray). + // We test all four combinations: + // Collection → Collection — both levels are live Collections + // Collection → toArray — issues are Collections, comments are arrays + // toArray → Collection — issues are arrays, comments are Collections + // toArray → toArray — both levels are plain arrays + describe(`nested includes: Collection → Collection`, () => { function buildNestedQuery() { return createLiveQueryCollection((q) => q.from({ p: projects }).select(({ p }) => ({ @@ -564,5 +585,931 @@ describe(`includes subqueries`, () => { { id: 101, body: `Fixed it` }, ]) }) + + it(`adding an issue (middle-level insert) creates a child with empty comments`, async () => { + const collection = buildNestedQuery() + 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 = buildNestedQuery() + 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 = buildNestedQuery() + 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(`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` }]) + }) }) })