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
5 changes: 5 additions & 0 deletions .changeset/includes-to-array.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
Expand Down Expand Up @@ -376,3 +377,11 @@ export const operators = [
] as const

export type OperatorName = (typeof operators)[number]

export class ToArrayWrapper {
constructor(public readonly query: QueryBuilder<any>) {}
}

export function toArray(query: QueryBuilder<any>): ToArrayWrapper {
return new ToArrayWrapper(query)
}
19 changes: 17 additions & 2 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -863,7 +864,14 @@ function buildNestedSelect(obj: any, parentAliases: Array<string> = []): 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)
Expand All @@ -880,6 +888,7 @@ function buildIncludesSubquery(
childBuilder: BaseQueryBuilder,
fieldName: string,
parentAliases: Array<string>,
materializeAsArray: boolean,
): IncludesSubquery {
const childQuery = childBuilder._getQuery()

Expand Down Expand Up @@ -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,
)
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -174,6 +175,7 @@ type SelectValue =
| undefined // Optional values
| { [key: string]: SelectValue }
| Array<RefLeaf<any>>
| ToArrayWrapper // toArray() wrapped subquery

// Recursive shape for select objects allowing nested projections
type SelectShape = { [key: string]: SelectValue | SelectShape }
Expand Down
3 changes: 3 additions & 0 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> instead of Collection<T> */
materializeAsArray: boolean
}

/**
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions packages/db/src/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export {
sum,
min,
max,
// Includes helpers
toArray,
} from './builder/functions.js'

// Ref proxy utilities
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T> instead of Collection<T>
) {
super()
}
Expand Down
68 changes: 56 additions & 12 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -788,6 +788,7 @@ export class CollectionConfigBuilder<
config.collection,
this.id,
hasParentChanges ? changesToApply : null,
config,
)
}

Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1311,6 +1313,8 @@ type IncludesOutputState = {
childCorrelationField: PropRef
/** Whether the child query has an ORDER BY clause */
hasOrderBy: boolean
/** When true, parent gets Array<T> instead of Collection<T> */
materializeAsArray: boolean
/** Maps correlation key value → child Collection entry */
childRegistry: Map<unknown, ChildCollectionEntry>
/** Pending child changes: correlationKey → Map<childKey, Changes> */
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -1641,6 +1646,7 @@ function flushIncludesState(
parentCollection: Collection<any, any, any>,
parentId: string,
parentChanges: Map<unknown, Changes<any>> | null,
parentSyncMethods: SyncMethods<any> | null,
): void {
for (const state of includesState) {
// Phase 1: Parent INSERTs — ensure a child Collection exists for every parent
Expand Down Expand Up @@ -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<unknown>(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<unknown, Changes<any>> }
>()

if (state.pendingChildChanges.size > 0) {
for (const [correlationKey, childChanges] of state.pendingChildChanges) {
// Ensure child Collection exists for this correlation key
Expand All @@ -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) {
Expand Down Expand Up @@ -1760,6 +1779,7 @@ function flushIncludesState(
entry.collection,
entry.collection.id,
childChanges,
entry.syncMethods,
)
}
}
Expand All @@ -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)
Expand Down
Loading
Loading