Skip to content

feat: Subqueries in SELECT for hierarchical data (includes)#1294

Open
kevin-dp wants to merge 32 commits intomainfrom
kevin/includes
Open

feat: Subqueries in SELECT for hierarchical data (includes)#1294
kevin-dp wants to merge 32 commits intomainfrom
kevin/includes

Conversation

@kevin-dp
Copy link
Contributor

Summary

  • Adds support for subqueries inside .select() that produce hierarchical results — each parent row gets a child Collection (e.g., projects with nested issues, issues with nested comments)
  • Child queries are inner-joined with the parent pipeline so only children matching filtered parents flow through
  • Supports ORDER BY and LIMIT/OFFSET on child queries (uses grouped ORDER BY so limits are per-parent)
  • Nested includes work recursively (projects → issues → comments)

Closes #288

Test plan

  • Basic includes: parent rows have child Collections with correct items
  • Reactivity: adding/removing children updates child Collections without touching parents
  • Parent remove + re-add: child Collection resets correctly
  • Inner join filtering: children only shown for parents matching WHERE
  • Nested includes: two levels deep (projects → issues → comments)
  • Ordered child queries: child Collections respect ORDER BY
  • Ordered + LIMIT: limit applied per parent, not globally; insertions displace correctly
  • All 1815 existing tests pass (no regressions)

🤖 Generated with Claude Code

@changeset-bot
Copy link

changeset-bot bot commented Feb 25, 2026

🦋 Changeset detected

Latest commit: 6343522

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 14 packages
Name Type
@tanstack/db Minor
@tanstack/angular-db Patch
@tanstack/electric-db-collection Patch
@tanstack/offline-transactions Patch
@tanstack/powersync-db-collection Patch
@tanstack/query-db-collection Patch
@tanstack/react-db Patch
@tanstack/rxdb-db-collection Patch
@tanstack/solid-db Patch
@tanstack/svelte-db Patch
@tanstack/trailbase-db-collection Patch
@tanstack/vue-db Patch
todos Patch
@tanstack/db-example-paced-mutations-demo Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link

pkg-pr-new bot commented Feb 25, 2026

More templates

@tanstack/angular-db

npm i https://pkg.pr.new/@tanstack/angular-db@1294

@tanstack/db

npm i https://pkg.pr.new/@tanstack/db@1294

@tanstack/db-ivm

npm i https://pkg.pr.new/@tanstack/db-ivm@1294

@tanstack/electric-db-collection

npm i https://pkg.pr.new/@tanstack/electric-db-collection@1294

@tanstack/offline-transactions

npm i https://pkg.pr.new/@tanstack/offline-transactions@1294

@tanstack/powersync-db-collection

npm i https://pkg.pr.new/@tanstack/powersync-db-collection@1294

@tanstack/query-db-collection

npm i https://pkg.pr.new/@tanstack/query-db-collection@1294

@tanstack/react-db

npm i https://pkg.pr.new/@tanstack/react-db@1294

@tanstack/rxdb-db-collection

npm i https://pkg.pr.new/@tanstack/rxdb-db-collection@1294

@tanstack/solid-db

npm i https://pkg.pr.new/@tanstack/solid-db@1294

@tanstack/svelte-db

npm i https://pkg.pr.new/@tanstack/svelte-db@1294

@tanstack/trailbase-db-collection

npm i https://pkg.pr.new/@tanstack/trailbase-db-collection@1294

@tanstack/vue-db

npm i https://pkg.pr.new/@tanstack/vue-db@1294

commit: febf98a

@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

Size Change: +5.38 kB (+5.78%) 🔍

Total Size: 98.6 kB

Filename Size Change
./packages/db/dist/esm/index.js 2.74 kB +15 B (+0.55%)
./packages/db/dist/esm/query/builder/functions.js 792 B +59 B (+8.05%) 🔍
./packages/db/dist/esm/query/builder/index.js 5.15 kB +1.05 kB (+25.55%) 🚨
./packages/db/dist/esm/query/compiler/group-by.js 2.35 kB +120 B (+5.37%) 🔍
./packages/db/dist/esm/query/compiler/index.js 3.5 kB +1.45 kB (+70.85%) 🆘
./packages/db/dist/esm/query/compiler/order-by.js 1.5 kB +52 B (+3.58%)
./packages/db/dist/esm/query/compiler/select.js 1.11 kB +20 B (+1.83%)
./packages/db/dist/esm/query/ir.js 784 B +111 B (+16.49%) ⚠️
./packages/db/dist/esm/query/live/collection-config-builder.js 8.05 kB +2.51 kB (+45.19%) 🚨
ℹ️ View Unchanged
Filename Size
./packages/db/dist/esm/collection/change-events.js 1.39 kB
./packages/db/dist/esm/collection/changes.js 1.22 kB
./packages/db/dist/esm/collection/events.js 388 B
./packages/db/dist/esm/collection/index.js 3.32 kB
./packages/db/dist/esm/collection/indexes.js 1.1 kB
./packages/db/dist/esm/collection/lifecycle.js 1.75 kB
./packages/db/dist/esm/collection/mutations.js 2.34 kB
./packages/db/dist/esm/collection/state.js 3.49 kB
./packages/db/dist/esm/collection/subscription.js 3.71 kB
./packages/db/dist/esm/collection/sync.js 2.41 kB
./packages/db/dist/esm/deferred.js 207 B
./packages/db/dist/esm/errors.js 4.83 kB
./packages/db/dist/esm/event-emitter.js 748 B
./packages/db/dist/esm/indexes/auto-index.js 742 B
./packages/db/dist/esm/indexes/base-index.js 766 B
./packages/db/dist/esm/indexes/btree-index.js 2.17 kB
./packages/db/dist/esm/indexes/lazy-index.js 1.1 kB
./packages/db/dist/esm/indexes/reverse-index.js 538 B
./packages/db/dist/esm/local-only.js 808 B
./packages/db/dist/esm/local-storage.js 2.1 kB
./packages/db/dist/esm/optimistic-action.js 359 B
./packages/db/dist/esm/paced-mutations.js 496 B
./packages/db/dist/esm/proxy.js 3.75 kB
./packages/db/dist/esm/query/builder/ref-proxy.js 1.05 kB
./packages/db/dist/esm/query/compiler/evaluators.js 1.43 kB
./packages/db/dist/esm/query/compiler/expressions.js 430 B
./packages/db/dist/esm/query/compiler/joins.js 2.11 kB
./packages/db/dist/esm/query/expression-helpers.js 1.43 kB
./packages/db/dist/esm/query/live-query-collection.js 360 B
./packages/db/dist/esm/query/live/collection-registry.js 264 B
./packages/db/dist/esm/query/live/collection-subscriber.js 2.42 kB
./packages/db/dist/esm/query/live/internal.js 145 B
./packages/db/dist/esm/query/optimizer.js 2.62 kB
./packages/db/dist/esm/query/predicate-utils.js 2.97 kB
./packages/db/dist/esm/query/query-once.js 359 B
./packages/db/dist/esm/query/subset-dedupe.js 927 B
./packages/db/dist/esm/scheduler.js 1.3 kB
./packages/db/dist/esm/SortedMap.js 1.3 kB
./packages/db/dist/esm/strategies/debounceStrategy.js 247 B
./packages/db/dist/esm/strategies/queueStrategy.js 428 B
./packages/db/dist/esm/strategies/throttleStrategy.js 246 B
./packages/db/dist/esm/transactions.js 2.9 kB
./packages/db/dist/esm/utils.js 924 B
./packages/db/dist/esm/utils/browser-polyfills.js 304 B
./packages/db/dist/esm/utils/btree.js 5.61 kB
./packages/db/dist/esm/utils/comparison.js 952 B
./packages/db/dist/esm/utils/cursor.js 457 B
./packages/db/dist/esm/utils/index-optimization.js 1.51 kB
./packages/db/dist/esm/utils/type-guards.js 157 B

compressed-size-action::db-package-size

@github-actions
Copy link
Contributor

github-actions bot commented Feb 25, 2026

Size Change: 0 B

Total Size: 3.85 kB

ℹ️ View Unchanged
Filename Size
./packages/react-db/dist/esm/index.js 225 B
./packages/react-db/dist/esm/useLiveInfiniteQuery.js 1.32 kB
./packages/react-db/dist/esm/useLiveQuery.js 1.34 kB
./packages/react-db/dist/esm/useLiveSuspenseQuery.js 559 B
./packages/react-db/dist/esm/usePacedMutations.js 401 B

compressed-size-action::react-db-package-size

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
kevin-dp and others added 5 commits February 25, 2026 11:16
Replace O(n) parent collection scans with a reverse index
(correlationKey → Set<parentKey>) for attaching child Collections
to parent rows. The index is populated during parent INSERTs
and cleaned up on parent DELETEs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Collaborator

@samwillis samwillis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really great. Awesome work!

Depending on if we are going to release before or after followup PRs it may make sense to add some defensive errors for unsupported queries (groupBy, referencing multiple fields on the parent)

? where.expression
: where

// Look for eq(a, b) where one side references parent and other references child
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is finding the first expression that references both sides, this is correct. We should consider what something like this does:

q.from({ p: projects }).select(({ p }) => ({
  id: p.id,
  name: p.name,
  issues: q
    .from({ i: issues })
    .where(({ i }) => and(eq(i.projectId, p.id)), eq(i.createdBy, p.createdBy))
    .select(({ i }) => ({
      id: i.id,
      title: i.title,
    })),
})),
)

I suspect it breaks at the moment, and so we may want to throw if there is more than one expression matching both sources.

I think it's possible to make this work though by pulling the parent project value temporarily into the child issue pipeline.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, it breaks right now because the parent row is not in the child pipeline. I added support for this in this PR: #1307

Comment on lines +213 to +226
// Re-add project Alpha — should get a fresh child collection
projects.utils.begin()
projects.utils.write({
type: `insert`,
value: { id: 1, name: `Alpha Reborn` },
})
projects.utils.commit()

const alpha = collection.get(1) as any
expect(alpha).toMatchObject({ id: 1, name: `Alpha Reborn` })
expect(childItems(alpha.issues)).toEqual([
{ id: 10, title: `Bug in Alpha` },
{ id: 11, title: `Feature for Alpha` },
])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥳

@samwillis
Copy link
Collaborator

ChatGPT review:


Here’s my review of TanStack/db PR #1294 (adds “includes” subqueries / nested child collections). ([GitHub]1)

What this PR is doing (as I understand it)

  • New “includes subquery” syntax: returning a QueryBuilder from inside a parent .select() field now becomes an IncludesSubquery IR node, by detecting a correlating eq(child.fk, parent.pk) in the child query’s where. ([GitHub]2)
  • Compiler support: the compiler extracts those IncludesSubquery nodes, compiles the child query “per parent correlation key”, and plumbs an extra correlationKey through the result tuples so the output layer can route rows into the correct child collection. ([GitHub]3)
  • Output/runtime support: live query builder wires child pipelines via output() callbacks, creates per-parent child Collections, attaches them onto parent rows, and handles nested includes via a shared-buffer + routing-index approach. ([GitHub]4)
  • Tests: good coverage for basic includes, reactivity (insert/delete), ordered children, per-parent limit, and 2-level nesting (projects → issues → comments). ([GitHub]5)
  • Related fix: containsAggregate is now defensive against nested Select objects (important because includes introduces nested select-shaped objects). ([GitHub]6)

Overall: the API is very ergonomic, and the nested routing solution is clever.


Things I like

  • The user-facing API is dead simple and reads like a real ORM include. ([GitHub]5)
  • The compiler approach (extract includes early, compile child with a parent-key stream, and then let the live layer attach real child collections) is a solid separation of concerns. ([GitHub]3)
  • The tests hit the highest-risk areas: ordering + limit per parent + nested includes. ([GitHub]5)

Key concerns / suggested changes

1) Alias collisions between parent and child queries (correctness bug risk)

extractCorrelation() decides “parent vs child” purely by membership in parentAliases / childAliases. If an alias name appears in both sets (e.g. user reuses p inside the child query), the correlation detection can mis-classify and/or silently do the wrong thing. ([GitHub]2)

Suggestion

  • Enforce disjoint alias sets for includes subqueries at build time:

    • If childAliases intersects parentAliases, throw a dedicated error (ideally the same family as DuplicateAliasInSubqueryError used elsewhere). ([GitHub]3)
  • Also consider extending validateQueryStructure to walk select and validate nested IncludesSubquery nodes too (right now it looks focused on from/join QueryRefs). ([GitHub]3)

2) Correlation extraction only matches top-level eq(ref, ref)

Right now the includes correlation must be a direct where(() => eq(child.fk, parent.pk)). If someone writes:

.where(({ i }) => and(eq(i.projectId, p.id), eq(i.status, 'open')))

…correlation won’t be found (because it doesn’t traverse and() trees). That’s fine for v1, but it needs to be explicit.

Suggestion

  • Either:

    1. document “correlation must be a top-level where(eq(...))”, and add a clearer error type/message; or
    2. traverse boolean expression trees (and/or) to find the first correlating eq.

The current thrown Error(...) is a bit “raw” for public API behavior. ([GitHub]2)

3) Compiler mutates the query IR’s select in-place

replaceIncludesInSelect(query.select, key) replaces includes entries with Val(null) so processSelect() doesn’t see them. That’s convenient, but in-place mutation of a query object that may be cached/reused is a footgun (especially if you ever recompile without cache, or if other passes expect to see includes). ([GitHub]3)

Suggestion

  • Treat IR as immutable here:

    • clone select into selectWithoutIncludes,
    • compile using the clone,
    • keep the original IR intact.

4) Parent “correlation key” changes on update aren’t handled cleanly

In flushIncludesState, Phase 1 runs on changes.inserts > 0, so it will also run for updates (which often show as delete+insert deltas internally). But Phase 5 only cleans up on “pure delete” (deletes > 0 && inserts === 0). ([GitHub]4)

If a parent row’s correlation value can change (even if rare), you can end up with:

  • stale correlationToParentKeys membership under the old key,
  • orphaned child collections / routing entries,
  • parent row still pointing at the old child collection.

Suggestion

  • Either explicitly state/validate that the correlation field must be stable (usually the parent primary key), or

  • Track the last correlationKey per parent key and on update:

    • remove old mapping + routing entries when the correlationKey changes,
    • attach the new child collection.

Given the current code already supports “multiple parents per correlationKey”, it’s close—just missing the “move” case. ([GitHub]4)

5) Child collection IDs use String(correlationKey)

id: ${parentId}-${fieldName}-${String(correlationKey)}

  • If correlationKey is an object/composite, String() becomes "[object Object]" → collisions.
  • If it contains awkward chars, IDs get messy.

Suggestion

  • If you want IDs to be stable + readable, consider serializeValue(correlationKey) (you’re already using serializeValue elsewhere) or a tiny hash of a stable serialization. ([GitHub]6)

6) Internal __correlationKey property naming

Compiler/runtime appears to rely on a magic __correlationKey on the source row for grouping/limit-per-parent logic. If user data can contain that field, there’s collision risk. ([GitHub]3)

Suggestion

  • Use a Symbol, or a namespaced internal key that can’t realistically collide, or store correlation metadata outside user rows.

Tests I’d add (small but high value)

  1. Alias collision: parent uses alias p, child also uses alias p → assert it throws a clear error.
  2. Correlation inside and(): demonstrate either supported traversal or a friendly “must be top-level eq” error.
  3. Parent update that changes correlation value (even if discouraged): verify the old child collection detaches and the new one attaches (or verify it’s rejected).
  4. Multiple parents share same correlationKey (since your reverse index supports it): ensure attach/update works for all parents in the set. ([GitHub]4)

Bottom line

This is a strong feature with a very nice API and solid initial test coverage. The biggest things I’d address before merging are:

  • alias overlap validation (likely correctness),
  • avoid mutating query IR in compiler,
  • define behavior/constraints for correlation extraction (top-level eq vs expression traversal),
  • and handle or forbid parent correlationKey changes.

@kevin-dp
Copy link
Contributor Author

@samwillis response to codex' review:

  1. Alias collisions between parent and child queries (correctness bug risk)

Valid concern but I don't think it's a real risk in practice. The child query is built via the builder API where the user explicitly declares aliases in .from({ i: issues }). If they reuse p as a child alias, it would shadow the parent's p in the closure scope — so p.id in the child's .where() would already refer to the child's p, not the parent's.

  1. Correlation extraction only matches top-level eq(ref, ref)

Fixed in #1307

  1. Compiler mutates the query IR’s select in-place

This is a fair observation but the compiler already runs on the output of optimizeQuery(), which returns a new object. And the cache is keyed by the raw query, with queryMapping linking optimized → raw. So in practice the mutation happens on a fresh optimized copy, not the user's original IR.

Still a valid code hygiene concern, but it's out of scope for this PR. If it were to be fixed, it should be its own cleanup.

  1. Parent “correlation key” changes on update aren’t handled cleanly

The correlation field is almost always the parent's primary key (e.g., p.id in eq(i.projectId, p.id)). PKs don't change by definition.

Could a user correlate on a non-PK field? Technically yes, but it would be semantically wrong — the correlation field determines how children are grouped to parents. If it's not stable, the entire grouping model is broken, not just the cleanup logic.

I'd lean toward the first suggestion: document/validate that the correlation field should be a stable key (which it naturally is in every real use case). The "move" case handling would add complexity for a scenario that doesn't really make sense to support.

  1. Child collection IDs use String(correlationKey)

True — the correlation field doesn't have to be the PK (which are restricted to string | number). Someone could correlate on any field, and arbitrary field values could be objects, arrays, dates, etc. Even though it's unusual, String() would silently produce "[object Object]" and cause collisions.

Using serializeValue is a cheap one-line fix that makes it robust. Will do.

  1. Internal __correlationKey property naming

This is fine. We have a couple of these reserved properties around the code. It's unlikely someone would use this name.

Regarding the additional tests:

  1. alias collisions: isn't needed because as we explained this is handled by standard shadowing in TS.
  2. correlation inside and(): these tests already exists in follow up PRs since we introduced support for this.
  3. Parent update that changes correlation value: As discussed, the correlation field is practically always the PK which doesn't change. Testing this would be testing undefined behavior. I'd rather document the constraint than test around it.
  4. Multiple parents sharing same correlationKey: This is a good one. It tests a real scenario (e.g., multiple
    projects with the same foreign key value). Added this one.

…hIncludesState reads from that stamp. The stamp is cleaned up at the end of flush so it never leaks to the user
@kevin-dp kevin-dp requested a review from samwillis February 26, 2026 15:06
@KyleAMathews
Copy link
Collaborator

Code Review

Bugs

1. __correlationKey leaks into child results when child query omits .select()
compiler/index.ts:211 stamps __correlationKey onto the child row. When the child has no explicit .select(), lines 376-377 set $selected = namespacedRow[mainSource] — the raw row including __correlationKey. It passes straight through unwrapValue at line 479 and becomes a visible property on every child Collection item. All tests use .select() on child queries so this path is untested.

Fix: strip __correlationKey from finalResults when parentKeyStream is present (similar to how __includesCorrelationKeys is cleaned up at lines 1791-1796 of collection-config-builder.ts).

2. replaceIncludesInSelect mutates the shared IR select object
compiler/index.ts:326 — the optimizer copies select by reference (optimizer.ts:754), so replaceIncludesInSelect(query.select, key) permanently replaces IncludesSubquery entries on both the optimized AND raw query. Today the compilation cache masks this (nobody re-reads rawQuery.select), but it violates the immutable-IR convention the rest of the codebase follows. A shallow clone before mutation would be cheap insurance:

const selectCopy = { ...query.select }
query = { ...query, select: selectCopy }

3. Nested IncludesSubquery in nested select silently produces null
compiler/index.ts:877-887extractIncludesFromSelect only checks top-level select entries. But the builder's buildNestedSelect converts BaseQueryBuilder at any depth, so .select(({p}) => ({ info: { issues: childQuery } })) produces a nested IncludesSubquery that the compiler never extracts. The child pipeline is never compiled; the user gets null with no error. Either make extraction recursive or throw when a nested IncludesSubquery is detected.

Test gaps

4. No test for updating an existing child row — Insert and delete are covered, but the update branch in flushIncludesState (collection-config-builder.ts:1714-1726) is never exercised. Updating children is the most common real-world mutation. A test that changes an issue's title and asserts the child collection reflects it would cover this.

5. No test for the error on missing/invalid correlationbuildIncludesSubquery (builder/index.ts:930-935) throws when no valid eq() correlation is found. This is the primary user-facing validation for the feature and has zero test coverage. Worth testing with: no WHERE at all, a WHERE without eq(), and an eq() referencing two child-side aliases.

6. No test for multiple sibling includes on the same parent — Every test uses one includes field. A parent with both issues and milestones would verify that multiple correlation stamps, child registries, and output callbacks operate independently.

Minor

7. Child collections not explicitly cleaned up on teardown — When the parent sync ends (collection-config-builder.ts:636), includesCache is nulled but child Collections created with startSync: true are never explicitly stopped. Probably fine via GC in practice, but something to watch in long-running apps with frequent parent churn.

8. Type system has no awareness of includesSelectValue doesn't include QueryBuilder and ResultTypeFromSelect has no branch for it. Users get zero autocomplete or type safety on child collection fields. Makes sense as a follow-up.

kevin-dp and others added 11 commits March 12, 2026 13:06
* 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>
Resolve conflict in ResultTypeFromSelect by combining toArray branch's
ToArrayWrapper handling with main's improved nullable ref typing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
When a child includes query has no explicit .select(), the raw row
(including the internal __correlationKey stamp) becomes the final result.
Strip this internal property before returning so it doesn't leak to users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
replaceIncludesInSelect mutates query.select in-place, but the optimizer
copies select by reference, so rawQuery.select === query.select. This
violates the immutable-IR convention. Shallow-clone select when includes
entries are found so the original IR is preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
extractIncludesFromSelect only checked top-level select entries. If a
user placed an includes subquery inside a nested select object (e.g.
select({ info: { issues: childQuery } })), the IncludesSubquery would
never be extracted and the child pipeline would never compile, silently
producing null. Now recursively checks nested objects and throws a clear
error when a nested includes is detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The update branch in flushIncludesState was untested. This test verifies
that updating a child's title is reflected in the parent's child collection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Test updating an existing child row (exercises the update branch in
  flushIncludesState)
- Test error on missing WHERE, non-eq WHERE, and self-referencing eq
- Test multiple sibling includes (issues + milestones on same parent)
  verifying independent child collections and independent reactivity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Skip __SPREAD_SENTINEL__ entries when checking for nested includes to
avoid infinite recursion on RefProxy objects. Add non-null assertion
for query.select after shallow clone (TypeScript loses narrowing after
reassignment).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add 4 type tests that verify the expected types for non-toArray includes:
- includes with select → Collection<{id, title}>
- includes without select → Collection<Issue>
- multiple sibling includes → independent Collection types
- nested includes → Collection<{..., comments: Collection<{...}>}>

These tests currently fail because SelectValue doesn't include QueryBuilder
and ResultTypeFromSelect has no branch for it, so includes fields resolve
to `never`.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add QueryBuilder<any> to the SelectValue union so TypeScript accepts
bare QueryBuilder instances in select callbacks. Add a branch in
ResultTypeFromSelect that maps QueryBuilder<TContext> to
Collection<GetResult<TContext>>, giving users proper autocomplete and
type safety on child collection fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@kevin-dp
Copy link
Contributor Author

Addressing review feedback from #1294 (comment)

Bugs fixed

1. __correlationKey leaks into child results when child query omits .select() — Fixed in f8327ed. Strip __correlationKey from finalResults before returning when parentKeyStream is present.

2. replaceIncludesInSelect mutates the shared IR select object — Fixed in 0e0e3cb. Shallow-clone query.select before mutating when includes entries are found, so the original IR is preserved.

3. Nested IncludesSubquery in nested select silently produces null — Fixed in 1ef6098 and 1ab02ba. extractIncludesFromSelect now recursively checks nested objects and throws a clear error when a nested IncludesSubquery is detected. Also skips __SPREAD_SENTINEL__ entries to avoid infinite recursion on RefProxy objects.

Test gaps filled

4. No test for updating an existing child row — Added in 29c34f5. Exercises the update branch in flushIncludesState.

5. No test for the error on missing/invalid correlation — Added in 7f1066a. Tests: no WHERE at all, WHERE without eq(), and eq() referencing two child-side aliases.

6. No test for multiple sibling includes on the same parent — Added in 7f1066a. Parent with both issues and milestones, verifying independent child collections and independent reactivity.

Minor items

7. Child collections not explicitly cleaned up on teardown — Agreed this is fine via GC for now, something to watch.

8. Type system has no awareness of includes — Fixed in 5db9ae0 and 50abd99. Added QueryBuilder<any> to SelectValue and a ResultTypeFromSelect branch mapping QueryBuilder<TChildContext>Collection<GetResult<TChildContext>>. Covered by 4 new type tests.

kevin-dp and others added 2 commits March 12, 2026 14:27
…1307)

* Unit tests for filtering on parent fields in child query

* ci: apply automated fixes

* Support parent-referencing WHERE filters in includes child queries

Allow child queries to have additional WHERE clauses that reference
parent fields (e.g., eq(i.createdBy, p.createdBy)) beyond the single
correlation eq(). Parent-referencing WHEREs are detected in the builder,
parent fields are projected into the key stream, and filters are
re-injected into the child query where parent context is available.
When no parent-referencing filters exist, behavior is unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: apply automated fixes

* Extract correlation condition from inside and() WHERE clauses

When users write a single .where() with and(eq(i.projectId, p.id), ...),
the correlation eq() is now found and extracted from inside the and().
The remaining args stay as WHERE clauses. This means users don't need
to know that the correlation must be a separate .where() call.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: apply automated fixes

* Some more tests

* changeset

* Add failing test for shared correlation key with distinct parent filter values

Two parents share the same correlation key (groupId) but have different
values for a parent-referenced filter field (createdBy). The test verifies
that each parent receives its own filtered child set rather than a shared
union.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: apply automated fixes

* Key child collections by composite routing key to fix shared correlation key collision

When multiple parents share the same correlation key but have different
parent-referenced filter values, child collections were incorrectly shared.
Fix by keying child collections by (correlationKey, parentFilterValues)
composite, and using composite child keys in the D2 stream to prevent
collisions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add failing test for shared correlation key with orderBy + limit

Reproduces the bug where grouped ordering for limit uses the raw
correlation key instead of the composite routing key, causing parents
that share a correlation key but differ on parent-referenced filters
to have their children merged before the limit is applied.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: apply automated fixes

* Use composite routing key for grouped ordering with limit/offset

The includesGroupKeyFn for orderBy + limit/offset was grouping by raw
correlationKey, causing parents sharing a correlation key but differing
on parent-referenced filters to have their children merged before the
limit was applied. Use the same composite key as the routing layer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add failing test for nested includes with parent-referencing filters at both levels

When both the child and grandchild includes use parent-referencing
filters, the grandchild collection comes back empty because the
nested routing index uses a different key than the nested buffer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Use composite routing key in nested routing index to match nested buffer keys

The nested routing index was keyed by raw correlationKey while nested
buffers use computeRoutingKey(correlationKey, parentContext). This
mismatch caused drainNestedBuffers lookups to fail, leaving grandchild
collections empty when parent-referencing filters exist at both levels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add test for three levels of nested includes with parent-referencing filters

Verifies that composite routing keys work at arbitrary nesting depth,
not just the first two levels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* ci: apply automated fixes

* Add test for deleting one parent preserving sibling parent's child collection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix shared correlation key: deduplicate parentKeyStream and defer child cleanup

Two fixes for when multiple parents share the same correlation key:

1. Add reduce operator on parentKeyStream to clamp multiplicities to 1,
   preventing the inner join from producing duplicate child entries that
   cause incorrect deletions when one parent is removed.

2. In Phase 5, only delete child registry entry when the last parent
   referencing it is removed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add test for spread select on child not leaking internal properties

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Strip internal __correlationKey and __parentContext from child results

These routing properties leak into user-visible results when the child
query uses a spread select (e.g. { ...i }).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* style: fix prettier formatting in compiler

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* 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

* Tests for changes to deeply nested queries

* Add type-level tests for toArray() includes subqueries

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>

* Test aggregates inside subqueries

* Take into account correlation key when aggregating in subqueries

* changeset

* 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Joins with a hierarchical projection (includes)

3 participants