Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
45f1065
Add support for subqueries in select
kevin-dp Feb 24, 2026
2467505
Unit tests for includes
kevin-dp Feb 24, 2026
e702f80
Unit tests for ordered subqueries
kevin-dp Feb 24, 2026
f592b81
Add support for ordered subquery
kevin-dp Feb 24, 2026
697502c
Unit tests for subqueries with limit
kevin-dp Feb 24, 2026
1c2728c
Support LIMIT and OFFSET in subqueries
kevin-dp Feb 24, 2026
16ac62b
ci: apply automated fixes
autofix-ci[bot] Feb 25, 2026
d117fea
Add changeset for includes subqueries
kevin-dp Feb 25, 2026
e210f23
Use reverse index for parent lookup in includes
kevin-dp Feb 25, 2026
54830d4
ci: apply automated fixes
autofix-ci[bot] Feb 25, 2026
d324941
Unit tests for changes to deeply nested collections
kevin-dp Feb 25, 2026
f809526
Move fro top-down to bottom-up approach for flushing changes to neste…
kevin-dp Feb 25, 2026
15b9862
ci: apply automated fixes
autofix-ci[bot] Feb 25, 2026
ec21dbf
Prefix child collection names to avoid clashes
kevin-dp Feb 26, 2026
3d74cf5
Properly serialize correlation key before using it in collection ID
kevin-dp Feb 26, 2026
f590917
Additional test as suggested by Codex review
kevin-dp Feb 26, 2026
3ea52ef
Unit test to ensure that correlation field does not need to be in the…
kevin-dp Feb 26, 2026
3ca70b9
Stamp __includesCorrelationKeys on the result before output, and flus…
kevin-dp Feb 26, 2026
fc269d5
ci: apply automated fixes
autofix-ci[bot] Feb 26, 2026
1d81fa3
feat: add toArray() for includes subqueries (#1295)
kevin-dp Mar 12, 2026
b5885e8
Merge origin/main into kevin/includes
kevin-dp Mar 12, 2026
f8327ed
fix: strip __correlationKey from child results when child omits select()
kevin-dp Mar 12, 2026
0e0e3cb
fix: shallow-clone select before mutating for includes placeholders
kevin-dp Mar 12, 2026
1ef6098
fix: throw on nested IncludesSubquery in select
kevin-dp Mar 12, 2026
29c34f5
test: add test for updating an existing child row in includes
kevin-dp Mar 12, 2026
7f1066a
test: add tests for validation errors, updates, and sibling includes
kevin-dp Mar 12, 2026
1ab02ba
fix: handle spread sentinels and type narrowing in includes extraction
kevin-dp Mar 12, 2026
5db9ae0
test: add type tests for Collection-based includes (currently failing)
kevin-dp Mar 12, 2026
50abd99
feat: add type support for Collection-based includes in select
kevin-dp Mar 12, 2026
99fb817
ci: apply automated fixes
autofix-ci[bot] Mar 12, 2026
4c25370
Support parent-referencing WHERE filters in includes child queries (#…
kevin-dp Mar 12, 2026
6343522
feat: support aggregates in subqueries (#1298)
kevin-dp Mar 12, 2026
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-aggregates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

fix: support aggregates (e.g. count) in child/includes subqueries with per-parent scoping
5 changes: 5 additions & 0 deletions .changeset/includes-parent-referencing-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

feat: support parent-referencing WHERE filters in includes child queries
5 changes: 5 additions & 0 deletions .changeset/includes-subqueries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': minor
---

feat: support for subqueries for including hierarchical data in live queries
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
14 changes: 13 additions & 1 deletion packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Aggregate, Func } from '../ir'
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 { Context, GetResult, RefLeaf } from './types.js'
import type { QueryBuilder } from './index.js'

type StringRef =
| RefLeaf<string>
Expand Down Expand Up @@ -376,3 +377,14 @@ export const operators = [
] as const

export type OperatorName = (typeof operators)[number]

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

export function toArray<TContext extends Context>(
query: QueryBuilder<TContext>,
): ToArrayWrapper<GetResult<TContext>> {
return new ToArrayWrapper(query)
}
265 changes: 262 additions & 3 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Aggregate as AggregateExpr,
CollectionRef,
Func as FuncExpr,
IncludesSubquery,
PropRef,
QueryRef,
Value as ValueExpr,
Expand All @@ -23,6 +24,7 @@ import {
isRefProxy,
toExpression,
} from './ref-proxy.js'
import { ToArrayWrapper } from './functions.js'
import type { NamespacedRow, SingleResult } from '../../types.js'
import type {
Aggregate,
Expand All @@ -31,6 +33,7 @@ import type {
OrderBy,
OrderByDirection,
QueryIR,
Where,
} from '../ir.js'
import type {
CompareOptions,
Expand Down Expand Up @@ -491,7 +494,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
const aliases = this._getCurrentAliases()
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
const selectObject = callback(refProxy)
const select = buildNestedSelect(selectObject)
const select = buildNestedSelect(selectObject, aliases)

return new BaseQueryBuilder({
...this.query,
Expand Down Expand Up @@ -867,7 +870,7 @@ function isPlainObject(value: any): value is Record<string, any> {
)
}

function buildNestedSelect(obj: any): any {
function buildNestedSelect(obj: any, parentAliases: Array<string> = []): any {
if (!isPlainObject(obj)) return toExpr(obj)
const out: Record<string, any> = {}
for (const [k, v] of Object.entries(obj)) {
Expand All @@ -876,11 +879,267 @@ function buildNestedSelect(obj: any): any {
out[k] = v
continue
}
out[k] = buildNestedSelect(v)
if (v instanceof BaseQueryBuilder) {
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)
}
return out
}

/**
* Recursively collects all PropRef nodes from an expression tree.
*/
function collectRefsFromExpression(expr: BasicExpression): Array<PropRef> {
const refs: Array<PropRef> = []
switch (expr.type) {
case `ref`:
refs.push(expr)
break
case `func`:
for (const arg of (expr as any).args ?? []) {
refs.push(...collectRefsFromExpression(arg))
}
break
default:
break
}
return refs
}

/**
* Checks whether a WHERE clause references any parent alias.
*/
function referencesParent(where: Where, parentAliases: Array<string>): boolean {
const expr =
typeof where === `object` && `expression` in where
? where.expression
: where
return collectRefsFromExpression(expr).some(
(ref) => ref.path[0] != null && parentAliases.includes(ref.path[0]),
)
}

/**
* Builds an IncludesSubquery IR node from a child query builder.
* Extracts the correlation condition from the child's WHERE clauses by finding
* an eq() predicate that references both a parent alias and a child alias.
*/
function buildIncludesSubquery(
childBuilder: BaseQueryBuilder,
fieldName: string,
parentAliases: Array<string>,
materializeAsArray: boolean,
): IncludesSubquery {
const childQuery = childBuilder._getQuery()

// Collect child's own aliases
const childAliases: Array<string> = [childQuery.from.alias]
if (childQuery.join) {
for (const j of childQuery.join) {
childAliases.push(j.from.alias)
}
}

// Walk child's WHERE clauses to find the correlation condition.
// The correlation eq() may be a standalone WHERE or nested inside a top-level and().
let parentRef: PropRef | undefined
let childRef: PropRef | undefined
let correlationWhereIndex = -1
let correlationAndArgIndex = -1 // >= 0 when found inside an and()

if (childQuery.where) {
for (let i = 0; i < childQuery.where.length; i++) {
const where = childQuery.where[i]!
const expr =
typeof where === `object` && `expression` in where
? where.expression
: where

// Try standalone eq()
if (
expr.type === `func` &&
expr.name === `eq` &&
expr.args.length === 2
) {
const result = extractCorrelation(
expr.args[0]!,
expr.args[1]!,
parentAliases,
childAliases,
)
if (result) {
parentRef = result.parentRef
childRef = result.childRef
correlationWhereIndex = i
break
}
}

// Try inside top-level and()
if (
expr.type === `func` &&
expr.name === `and` &&
expr.args.length >= 2
) {
for (let j = 0; j < expr.args.length; j++) {
const arg = expr.args[j]!
if (
arg.type === `func` &&
arg.name === `eq` &&
arg.args.length === 2
) {
const result = extractCorrelation(
arg.args[0]!,
arg.args[1]!,
parentAliases,
childAliases,
)
if (result) {
parentRef = result.parentRef
childRef = result.childRef
correlationWhereIndex = i
correlationAndArgIndex = j
break
}
}
}
if (parentRef) break
}
}
}

if (!parentRef || !childRef || correlationWhereIndex === -1) {
throw new Error(
`Includes subquery for "${fieldName}" must have a WHERE clause with an eq() condition ` +
`that correlates a parent field with a child field. ` +
`Example: .where(({child}) => eq(child.parentId, parent.id))`,
)
}

// Remove the correlation eq() from the child query's WHERE clauses.
// If it was inside an and(), remove just that arg (collapsing the and() if needed).
const modifiedWhere = [...childQuery.where!]
if (correlationAndArgIndex >= 0) {
const where = modifiedWhere[correlationWhereIndex]!
const expr =
typeof where === `object` && `expression` in where
? where.expression
: where
const remainingArgs = (expr as any).args.filter(
(_: any, idx: number) => idx !== correlationAndArgIndex,
)
if (remainingArgs.length === 1) {
// Collapse and() with single remaining arg to just that expression
const isResidual =
typeof where === `object` && `expression` in where && where.residual
modifiedWhere[correlationWhereIndex] = isResidual
? { expression: remainingArgs[0], residual: true }
: remainingArgs[0]
} else {
// Rebuild and() without the extracted arg
const newAnd = new FuncExpr(`and`, remainingArgs)
const isResidual =
typeof where === `object` && `expression` in where && where.residual
modifiedWhere[correlationWhereIndex] = isResidual
? { expression: newAnd, residual: true }
: newAnd
}
} else {
modifiedWhere.splice(correlationWhereIndex, 1)
}

// Separate remaining WHEREs into pure-child vs parent-referencing
const pureChildWhere: Array<Where> = []
const parentFilters: Array<Where> = []
for (const w of modifiedWhere) {
if (referencesParent(w, parentAliases)) {
parentFilters.push(w)
} else {
pureChildWhere.push(w)
}
}

// Collect distinct parent PropRefs from parent-referencing filters
let parentProjection: Array<PropRef> | undefined
if (parentFilters.length > 0) {
const seen = new Set<string>()
parentProjection = []
for (const w of parentFilters) {
const expr = typeof w === `object` && `expression` in w ? w.expression : w
for (const ref of collectRefsFromExpression(expr)) {
if (
ref.path[0] != null &&
parentAliases.includes(ref.path[0]) &&
!seen.has(ref.path.join(`.`))
) {
seen.add(ref.path.join(`.`))
parentProjection.push(ref)
}
}
}
}

const modifiedQuery: QueryIR = {
...childQuery,
where: pureChildWhere.length > 0 ? pureChildWhere : undefined,
}

return new IncludesSubquery(
modifiedQuery,
parentRef,
childRef,
fieldName,
parentFilters.length > 0 ? parentFilters : undefined,
parentProjection,
materializeAsArray,
)
}

/**
* Checks if two eq() arguments form a parent-child correlation.
* Returns the parent and child PropRefs if found, undefined otherwise.
*/
function extractCorrelation(
argA: BasicExpression,
argB: BasicExpression,
parentAliases: Array<string>,
childAliases: Array<string>,
): { parentRef: PropRef; childRef: PropRef } | undefined {
if (argA.type === `ref` && argB.type === `ref`) {
const aAlias = argA.path[0]
const bAlias = argB.path[0]

if (
aAlias &&
bAlias &&
parentAliases.includes(aAlias) &&
childAliases.includes(bAlias)
) {
return { parentRef: argA, childRef: argB }
}

if (
aAlias &&
bAlias &&
parentAliases.includes(bAlias) &&
childAliases.includes(aAlias)
) {
return { parentRef: argB, childRef: argA }
}
}

return undefined
}

// Internal function to build a query from a callback
// used by liveQueryCollectionOptions.query
export function buildQuery<TContext extends Context>(
Expand Down
Loading
Loading