Skip to content

Commit 1d81fa3

Browse files
kevin-dpclaudeautofix-ci[bot]
authored
feat: add toArray() for includes subqueries (#1295)
* feat: add toArray() for includes subqueries toArray() wraps an includes subquery so the parent row contains Array<T> instead of Collection<T>. When children change, the parent row is re-emitted with a fresh array snapshot. - Add ToArrayWrapper class and toArray() function - Add materializeAsArray flag to IncludesSubquery IR node - Detect ToArrayWrapper in builder, pass flag through compiler - Re-emit parent rows on child changes for toArray entries - Add SelectValue type support for ToArrayWrapper - Add tests for basic toArray, reactivity, ordering, and limits Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Removed obsolete test * Small fix * Tests for changes to deeply nested queries * Fix changes being emitted on deeply nested collections * ci: apply automated fixes * Changeset * Add type-level tests for toArray() includes subqueries Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes * Rename Expected types in includes type tests to descriptive names Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix toArray() type inference in includes subqueries Make ToArrayWrapper generic so it carries the child query result type, and add a ToArrayWrapper branch in ResultTypeFromSelect to unwrap it to Array<T>. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix toArray re-emit to emit change events for subscribers The toArray re-emit in flushIncludesState mutated parent items in-place before writing them through parentSyncMethods.begin/write/commit. Since commitPendingTransactions captures "previous visible state" by reading syncedData.get(key) — which returns the already-mutated object — deepEquals always returned true and suppressed the change event. Replace the sync methods pattern with direct event emission: capture a shallow copy before mutation (for previousValue), mutate in-place (so collection.get() works), and emit UPDATE events directly via the parent collection's changes manager. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add change propagation tests for includes subqueries Test the reactive model difference between Collection and toArray includes: - Collection includes: child change does NOT re-emit the parent row (the child Collection updates in place) - toArray includes: child change DOES re-emit the parent row (the parent row is re-emitted with the updated array snapshot) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * ci: apply automated fixes --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent fc269d5 commit 1d81fa3

10 files changed

Lines changed: 1347 additions & 50 deletions

File tree

.changeset/includes-to-array.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/db': patch
3+
---
4+
5+
feat: add `toArray()` wrapper for includes subqueries to materialize child results as plain arrays instead of live Collections

packages/db/src/query/builder/functions.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ import { Aggregate, Func } from '../ir'
22
import { toExpression } from './ref-proxy.js'
33
import type { BasicExpression } from '../ir'
44
import type { RefProxy } from './ref-proxy.js'
5-
import type { RefLeaf } from './types.js'
5+
import type { Context, GetResult, RefLeaf } from './types.js'
6+
import type { QueryBuilder } from './index.js'
67

78
type StringRef =
89
| RefLeaf<string>
@@ -376,3 +377,14 @@ export const operators = [
376377
] as const
377378

378379
export type OperatorName = (typeof operators)[number]
380+
381+
export class ToArrayWrapper<T = any> {
382+
declare readonly _type: T
383+
constructor(public readonly query: QueryBuilder<any>) {}
384+
}
385+
386+
export function toArray<TContext extends Context>(
387+
query: QueryBuilder<TContext>,
388+
): ToArrayWrapper<GetResult<TContext>> {
389+
return new ToArrayWrapper(query)
390+
}

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

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
createRefProxyWithSelected,
2424
toExpression,
2525
} from './ref-proxy.js'
26+
import { ToArrayWrapper } from './functions.js'
2627
import type { NamespacedRow, SingleResult } from '../../types.js'
2728
import type {
2829
Aggregate,
@@ -863,7 +864,14 @@ function buildNestedSelect(obj: any, parentAliases: Array<string> = []): any {
863864
continue
864865
}
865866
if (v instanceof BaseQueryBuilder) {
866-
out[k] = buildIncludesSubquery(v, k, parentAliases)
867+
out[k] = buildIncludesSubquery(v, k, parentAliases, false)
868+
continue
869+
}
870+
if (v instanceof ToArrayWrapper) {
871+
if (!(v.query instanceof BaseQueryBuilder)) {
872+
throw new Error(`toArray() must wrap a subquery builder`)
873+
}
874+
out[k] = buildIncludesSubquery(v.query, k, parentAliases, true)
867875
continue
868876
}
869877
out[k] = buildNestedSelect(v, parentAliases)
@@ -880,6 +888,7 @@ function buildIncludesSubquery(
880888
childBuilder: BaseQueryBuilder,
881889
fieldName: string,
882890
parentAliases: Array<string>,
891+
materializeAsArray: boolean,
883892
): IncludesSubquery {
884893
const childQuery = childBuilder._getQuery()
885894

@@ -943,7 +952,13 @@ function buildIncludesSubquery(
943952
where: modifiedWhere.length > 0 ? modifiedWhere : undefined,
944953
}
945954

946-
return new IncludesSubquery(modifiedQuery, parentRef, childRef, fieldName)
955+
return new IncludesSubquery(
956+
modifiedQuery,
957+
parentRef,
958+
childRef,
959+
fieldName,
960+
materializeAsArray,
961+
)
947962
}
948963

949964
/**

packages/db/src/query/builder/types.ts

Lines changed: 28 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type {
99
Value,
1010
} from '../ir.js'
1111
import type { QueryBuilder } from './index.js'
12+
import type { ToArrayWrapper } from './functions.js'
1213

1314
/**
1415
* Context - The central state container for query builder operations
@@ -174,6 +175,7 @@ type SelectValue =
174175
| undefined // Optional values
175176
| { [key: string]: SelectValue }
176177
| Array<RefLeaf<any>>
178+
| ToArrayWrapper // toArray() wrapped subquery
177179

178180
// Recursive shape for select objects allowing nested projections
179181
type SelectShape = { [key: string]: SelectValue | SelectShape }
@@ -227,30 +229,32 @@ export type ResultTypeFromSelect<TSelectObject> = WithoutRefBrand<
227229
Prettify<{
228230
[K in keyof TSelectObject]: NeedsExtraction<TSelectObject[K]> extends true
229231
? ExtractExpressionType<TSelectObject[K]>
230-
: TSelectObject[K] extends Ref<infer _T>
231-
? ExtractRef<TSelectObject[K]>
232-
: TSelectObject[K] extends RefLeaf<infer T>
233-
? T
234-
: TSelectObject[K] extends RefLeaf<infer T> | undefined
235-
? T | undefined
236-
: TSelectObject[K] extends RefLeaf<infer T> | null
237-
? T | null
238-
: TSelectObject[K] extends Ref<infer _T> | undefined
239-
? ExtractRef<TSelectObject[K]> | undefined
240-
: TSelectObject[K] extends Ref<infer _T> | null
241-
? ExtractRef<TSelectObject[K]> | null
242-
: TSelectObject[K] extends Aggregate<infer T>
243-
? T
244-
: TSelectObject[K] extends
245-
| string
246-
| number
247-
| boolean
248-
| null
249-
| undefined
250-
? TSelectObject[K]
251-
: TSelectObject[K] extends Record<string, any>
252-
? ResultTypeFromSelect<TSelectObject[K]>
253-
: never
232+
: TSelectObject[K] extends ToArrayWrapper<infer T>
233+
? Array<T>
234+
: TSelectObject[K] extends Ref<infer _T>
235+
? ExtractRef<TSelectObject[K]>
236+
: TSelectObject[K] extends RefLeaf<infer T>
237+
? T
238+
: TSelectObject[K] extends RefLeaf<infer T> | undefined
239+
? T | undefined
240+
: TSelectObject[K] extends RefLeaf<infer T> | null
241+
? T | null
242+
: TSelectObject[K] extends Ref<infer _T> | undefined
243+
? ExtractRef<TSelectObject[K]> | undefined
244+
: TSelectObject[K] extends Ref<infer _T> | null
245+
? ExtractRef<TSelectObject[K]> | null
246+
: TSelectObject[K] extends Aggregate<infer T>
247+
? T
248+
: TSelectObject[K] extends
249+
| string
250+
| number
251+
| boolean
252+
| null
253+
| undefined
254+
? TSelectObject[K]
255+
: TSelectObject[K] extends Record<string, any>
256+
? ResultTypeFromSelect<TSelectObject[K]>
257+
: never
254258
}>
255259
>
256260

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface IncludesCompilationResult {
5555
hasOrderBy: boolean
5656
/** Full compilation result for the child query (for nested includes + alias tracking) */
5757
childCompilationResult: CompilationResult
58+
/** When true, the output layer materializes children as Array<T> instead of Collection<T> */
59+
materializeAsArray: boolean
5860
}
5961

6062
/**
@@ -320,6 +322,7 @@ export function compileQuery(
320322
subquery.query.orderBy && subquery.query.orderBy.length > 0
321323
),
322324
childCompilationResult: childResult,
325+
materializeAsArray: subquery.materializeAsArray,
323326
})
324327

325328
// Replace includes entry in select with a null placeholder

packages/db/src/query/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export {
6060
sum,
6161
min,
6262
max,
63+
// Includes helpers
64+
toArray,
6365
} from './builder/functions.js'
6466

6567
// Ref proxy utilities

packages/db/src/query/ir.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@ export class IncludesSubquery extends BaseExpression {
139139
public correlationField: PropRef, // Parent-side ref (e.g., project.id)
140140
public childCorrelationField: PropRef, // Child-side ref (e.g., issue.projectId)
141141
public fieldName: string, // Result field name (e.g., "issues")
142+
public materializeAsArray: boolean = false, // When true, parent gets Array<T> instead of Collection<T>
142143
) {
143144
super()
144145
}

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

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { RootStreamBuilder } from '@tanstack/db-ivm'
2323
import type { OrderByOptimizationInfo } from '../compiler/order-by.js'
2424
import type { Collection } from '../../collection/index.js'
2525
import type {
26+
ChangeMessage,
2627
CollectionConfigSingleRowOption,
2728
KeyedStream,
2829
ResultStream,
@@ -788,6 +789,7 @@ export class CollectionConfigBuilder<
788789
config.collection,
789790
this.id,
790791
hasParentChanges ? changesToApply : null,
792+
config,
791793
)
792794
}
793795

@@ -819,6 +821,7 @@ export class CollectionConfigBuilder<
819821
fieldName: entry.fieldName,
820822
childCorrelationField: entry.childCorrelationField,
821823
hasOrderBy: entry.hasOrderBy,
824+
materializeAsArray: entry.materializeAsArray,
822825
childRegistry: new Map(),
823826
pendingChildChanges: new Map(),
824827
correlationToParentKeys: new Map(),
@@ -1309,6 +1312,8 @@ type IncludesOutputState = {
13091312
childCorrelationField: PropRef
13101313
/** Whether the child query has an ORDER BY clause */
13111314
hasOrderBy: boolean
1315+
/** When true, parent gets Array<T> instead of Collection<T> */
1316+
materializeAsArray: boolean
13121317
/** Maps correlation key value → child Collection entry */
13131318
childRegistry: Map<unknown, ChildCollectionEntry>
13141319
/** Pending child changes: correlationKey → Map<childKey, Changes> */
@@ -1408,6 +1413,7 @@ function createPerEntryIncludesStates(
14081413
fieldName: setup.compilationResult.fieldName,
14091414
childCorrelationField: setup.compilationResult.childCorrelationField,
14101415
hasOrderBy: setup.compilationResult.hasOrderBy,
1416+
materializeAsArray: setup.compilationResult.materializeAsArray,
14111417
childRegistry: new Map(),
14121418
pendingChildChanges: new Map(),
14131419
correlationToParentKeys: new Map(),
@@ -1633,6 +1639,7 @@ function flushIncludesState(
16331639
parentCollection: Collection<any, any, any>,
16341640
parentId: string,
16351641
parentChanges: Map<unknown, Changes<any>> | null,
1642+
parentSyncMethods: SyncMethods<any> | null,
16361643
): void {
16371644
for (const state of includesState) {
16381645
// Phase 1: Parent INSERTs — ensure a child Collection exists for every parent
@@ -1664,21 +1671,31 @@ function flushIncludesState(
16641671
}
16651672
parentKeys.add(parentKey)
16661673

1667-
// Attach child Collection to the parent result
1668-
parentResult[state.fieldName] =
1669-
state.childRegistry.get(correlationKey)!.collection
1674+
// Attach child Collection (or array snapshot for toArray) to the parent result
1675+
if (state.materializeAsArray) {
1676+
parentResult[state.fieldName] = [
1677+
...state.childRegistry.get(correlationKey)!.collection.toArray,
1678+
]
1679+
} else {
1680+
parentResult[state.fieldName] =
1681+
state.childRegistry.get(correlationKey)!.collection
1682+
}
16701683
}
16711684
}
16721685
}
16731686
}
16741687

1688+
// Track affected correlation keys for toArray re-emit (before clearing pendingChildChanges)
1689+
const affectedCorrelationKeys = state.materializeAsArray
1690+
? new Set<unknown>(state.pendingChildChanges.keys())
1691+
: null
1692+
16751693
// Phase 2: Child changes — apply to child Collections
16761694
// Track which entries had child changes and capture their childChanges maps
16771695
const entriesWithChildChanges = new Map<
16781696
unknown,
16791697
{ entry: ChildCollectionEntry; childChanges: Map<unknown, Changes<any>> }
16801698
>()
1681-
16821699
if (state.pendingChildChanges.size > 0) {
16831700
for (const [correlationKey, childChanges] of state.pendingChildChanges) {
16841701
// Ensure child Collection exists for this correlation key
@@ -1694,14 +1711,17 @@ function flushIncludesState(
16941711
state.childRegistry.set(correlationKey, entry)
16951712
}
16961713

1697-
// Attach the child Collection to ANY parent that has this correlation key
1698-
attachChildCollectionToParent(
1699-
parentCollection,
1700-
state.fieldName,
1701-
correlationKey,
1702-
state.correlationToParentKeys,
1703-
entry.collection,
1704-
)
1714+
// For non-toArray: attach the child Collection to ANY parent that has this correlation key
1715+
// For toArray: skip — the array snapshot is set during re-emit below
1716+
if (!state.materializeAsArray) {
1717+
attachChildCollectionToParent(
1718+
parentCollection,
1719+
state.fieldName,
1720+
correlationKey,
1721+
state.correlationToParentKeys,
1722+
entry.collection,
1723+
)
1724+
}
17051725

17061726
// Apply child changes to the child Collection
17071727
if (entry.syncMethods) {
@@ -1748,6 +1768,7 @@ function flushIncludesState(
17481768
entry.collection,
17491769
entry.collection.id,
17501770
childChanges,
1771+
entry.syncMethods,
17511772
)
17521773
}
17531774
}
@@ -1761,10 +1782,57 @@ function flushIncludesState(
17611782
entry.collection,
17621783
entry.collection.id,
17631784
null,
1785+
entry.syncMethods,
17641786
)
17651787
}
17661788
}
17671789

1790+
// For toArray entries: re-emit affected parents with updated array snapshots.
1791+
// We mutate items in-place (so collection.get() reflects changes immediately)
1792+
// and emit UPDATE events directly. We bypass the sync methods because
1793+
// commitPendingTransactions compares previous vs new visible state using
1794+
// deepEquals, but in-place mutation means both sides reference the same
1795+
// object, so the comparison always returns true and suppresses the event.
1796+
const toArrayReEmitKeys = state.materializeAsArray
1797+
? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers])
1798+
: null
1799+
if (parentSyncMethods && toArrayReEmitKeys && toArrayReEmitKeys.size > 0) {
1800+
const events: Array<ChangeMessage<any>> = []
1801+
for (const correlationKey of toArrayReEmitKeys) {
1802+
const parentKeys = state.correlationToParentKeys.get(correlationKey)
1803+
if (!parentKeys) continue
1804+
const entry = state.childRegistry.get(correlationKey)
1805+
for (const parentKey of parentKeys) {
1806+
const item = parentCollection.get(parentKey as any)
1807+
if (item) {
1808+
const key = parentSyncMethods.collection.getKeyFromItem(item)
1809+
// Capture previous value before in-place mutation
1810+
const previousValue = { ...item }
1811+
if (entry) {
1812+
item[state.fieldName] = [...entry.collection.toArray]
1813+
}
1814+
events.push({
1815+
type: `update`,
1816+
key,
1817+
value: item,
1818+
previousValue,
1819+
})
1820+
}
1821+
}
1822+
}
1823+
if (events.length > 0) {
1824+
// Emit directly — the in-place mutation already updated the data in
1825+
// syncedData, so we only need to notify subscribers.
1826+
const changesManager = (parentCollection as any)._changes as {
1827+
emitEvents: (
1828+
changes: Array<ChangeMessage<any>>,
1829+
forceEmit?: boolean,
1830+
) => void
1831+
}
1832+
changesManager.emitEvents(events, true)
1833+
}
1834+
}
1835+
17681836
// Phase 5: Parent DELETEs — dispose child Collections and clean up
17691837
if (parentChanges) {
17701838
for (const [parentKey, changes] of parentChanges) {

0 commit comments

Comments
 (0)