Skip to content

Commit 6aa4f8e

Browse files
kevin-dpclaude
andauthored
Docs for includes (#1317)
* Document includes subqueries in live queries guide Add an Includes section to docs/guides/live-queries.md covering: - Basic includes with correlation conditions - Additional filters including parent-referencing WHERE clauses - Ordering and limiting per parent - toArray() for plain array results - Aggregates per parent - Nested includes Also add packages/db/INCLUDES.md with architectural documentation and update the V2 roadmap to reflect implemented features. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Improve includes docs: use concrete examples instead of generic "parent/child" terminology Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Document how to use includes with React via subcomponents with useLiveQuery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add React test for includes: child collection subscription via useLiveQuery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6343522 commit 6aa4f8e

File tree

2 files changed

+328
-3
lines changed

2 files changed

+328
-3
lines changed

docs/guides/live-queries.md

Lines changed: 220 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The result types are automatically inferred from your query structure, providing
3838
- [Select Projections](#select)
3939
- [Joins](#joins)
4040
- [Subqueries](#subqueries)
41+
- [Includes](#includes)
4142
- [groupBy and Aggregations](#groupby-and-aggregations)
4243
- [findOne](#findone)
4344
- [Distinct](#distinct)
@@ -747,9 +748,8 @@ A `join` without a `select` will return row objects that are namespaced with the
747748

748749
The result type of a join will take into account the join type, with the optionality of the joined fields being determined by the join type.
749750

750-
> [!NOTE]
751-
> We are working on an `include` system that will enable joins that project to a hierarchical object. For example an `issue` row could have a `comments` property that is an array of `comment` rows.
752-
> See [this issue](https://github.com/TanStack/db/issues/288) for more details.
751+
> [!TIP]
752+
> If you need hierarchical results instead of flat joined rows (e.g., each project with its nested issues), see [Includes](#includes) below.
753753
754754
### Method Signature
755755

@@ -1040,6 +1040,223 @@ const topUsers = createCollection(liveQueryCollectionOptions({
10401040
}))
10411041
```
10421042

1043+
## Includes
1044+
1045+
Includes let you nest subqueries inside `.select()` to produce hierarchical results. Instead of joins that flatten 1:N relationships into repeated rows, each parent row gets a nested collection of its related items.
1046+
1047+
```ts
1048+
import { createLiveQueryCollection, eq } from '@tanstack/db'
1049+
1050+
const projectsWithIssues = createLiveQueryCollection((q) =>
1051+
q.from({ p: projectsCollection }).select(({ p }) => ({
1052+
id: p.id,
1053+
name: p.name,
1054+
issues: q
1055+
.from({ i: issuesCollection })
1056+
.where(({ i }) => eq(i.projectId, p.id))
1057+
.select(({ i }) => ({
1058+
id: i.id,
1059+
title: i.title,
1060+
})),
1061+
})),
1062+
)
1063+
```
1064+
1065+
Each project's `issues` field is a live `Collection` that updates incrementally as the underlying data changes.
1066+
1067+
### Correlation Condition
1068+
1069+
The child query's `.where()` must contain an `eq()` that links a child field to a parent field — this is the **correlation condition**. It tells the system how children relate to parents.
1070+
1071+
```ts
1072+
// The correlation condition: links issues to their parent project
1073+
.where(({ i }) => eq(i.projectId, p.id))
1074+
```
1075+
1076+
The correlation condition can appear as a standalone `.where()`, or inside an `and()`:
1077+
1078+
```ts
1079+
// Also valid — correlation is extracted from inside and()
1080+
.where(({ i }) => and(eq(i.projectId, p.id), eq(i.status, 'open')))
1081+
```
1082+
1083+
The correlation field does not need to be included in the parent's `.select()`.
1084+
1085+
### Additional Filters
1086+
1087+
Child queries support additional `.where()` clauses beyond the correlation condition, including filters that reference parent fields:
1088+
1089+
```ts
1090+
q.from({ p: projectsCollection }).select(({ p }) => ({
1091+
id: p.id,
1092+
name: p.name,
1093+
issues: q
1094+
.from({ i: issuesCollection })
1095+
.where(({ i }) => eq(i.projectId, p.id)) // correlation
1096+
.where(({ i }) => eq(i.createdBy, p.createdBy)) // parent-referencing filter
1097+
.where(({ i }) => eq(i.status, 'open')) // pure child filter
1098+
.select(({ i }) => ({
1099+
id: i.id,
1100+
title: i.title,
1101+
})),
1102+
}))
1103+
```
1104+
1105+
Parent-referencing filters are fully reactive — if a parent's field changes, the child results update automatically.
1106+
1107+
### Ordering and Limiting
1108+
1109+
Child queries support `.orderBy()` and `.limit()`, applied per parent:
1110+
1111+
```ts
1112+
q.from({ p: projectsCollection }).select(({ p }) => ({
1113+
id: p.id,
1114+
name: p.name,
1115+
issues: q
1116+
.from({ i: issuesCollection })
1117+
.where(({ i }) => eq(i.projectId, p.id))
1118+
.orderBy(({ i }) => i.createdAt, 'desc')
1119+
.limit(5)
1120+
.select(({ i }) => ({
1121+
id: i.id,
1122+
title: i.title,
1123+
})),
1124+
}))
1125+
```
1126+
1127+
Each project gets its own top-5 issues, not 5 issues shared across all projects.
1128+
1129+
### toArray
1130+
1131+
By default, each child result is a live `Collection`. If you want a plain array instead, wrap the child query with `toArray()`:
1132+
1133+
```ts
1134+
import { createLiveQueryCollection, eq, toArray } from '@tanstack/db'
1135+
1136+
const projectsWithIssues = createLiveQueryCollection((q) =>
1137+
q.from({ p: projectsCollection }).select(({ p }) => ({
1138+
id: p.id,
1139+
name: p.name,
1140+
issues: toArray(
1141+
q
1142+
.from({ i: issuesCollection })
1143+
.where(({ i }) => eq(i.projectId, p.id))
1144+
.select(({ i }) => ({
1145+
id: i.id,
1146+
title: i.title,
1147+
})),
1148+
),
1149+
})),
1150+
)
1151+
```
1152+
1153+
With `toArray()`, the project row is re-emitted whenever its issues change. Without it, the child `Collection` updates independently.
1154+
1155+
### Aggregates
1156+
1157+
You can use aggregate functions in child queries. Aggregates are computed per parent:
1158+
1159+
```ts
1160+
import { createLiveQueryCollection, eq, count } from '@tanstack/db'
1161+
1162+
const projectsWithCounts = createLiveQueryCollection((q) =>
1163+
q.from({ p: projectsCollection }).select(({ p }) => ({
1164+
id: p.id,
1165+
name: p.name,
1166+
issueCount: q
1167+
.from({ i: issuesCollection })
1168+
.where(({ i }) => eq(i.projectId, p.id))
1169+
.select(({ i }) => ({ total: count(i.id) })),
1170+
})),
1171+
)
1172+
```
1173+
1174+
Each project gets its own count. The count updates reactively as issues are added or removed.
1175+
1176+
### Nested Includes
1177+
1178+
Includes nest arbitrarily. For example, projects can include issues, which include comments:
1179+
1180+
```ts
1181+
const tree = createLiveQueryCollection((q) =>
1182+
q.from({ p: projectsCollection }).select(({ p }) => ({
1183+
id: p.id,
1184+
name: p.name,
1185+
issues: q
1186+
.from({ i: issuesCollection })
1187+
.where(({ i }) => eq(i.projectId, p.id))
1188+
.select(({ i }) => ({
1189+
id: i.id,
1190+
title: i.title,
1191+
comments: q
1192+
.from({ c: commentsCollection })
1193+
.where(({ c }) => eq(c.issueId, i.id))
1194+
.select(({ c }) => ({
1195+
id: c.id,
1196+
body: c.body,
1197+
})),
1198+
})),
1199+
})),
1200+
)
1201+
```
1202+
1203+
Each level updates independently and incrementally — adding a comment to an issue does not re-process other issues or projects.
1204+
1205+
### Using Includes with React
1206+
1207+
When using includes with React, each child `Collection` needs its own `useLiveQuery` subscription to receive reactive updates. Pass the child collection to a subcomponent that calls `useLiveQuery(childCollection)`:
1208+
1209+
```tsx
1210+
import { useLiveQuery } from '@tanstack/react-db'
1211+
import { eq } from '@tanstack/db'
1212+
1213+
function ProjectList() {
1214+
const { data: projects } = useLiveQuery((q) =>
1215+
q.from({ p: projectsCollection }).select(({ p }) => ({
1216+
id: p.id,
1217+
name: p.name,
1218+
issues: q
1219+
.from({ i: issuesCollection })
1220+
.where(({ i }) => eq(i.projectId, p.id))
1221+
.select(({ i }) => ({
1222+
id: i.id,
1223+
title: i.title,
1224+
})),
1225+
})),
1226+
)
1227+
1228+
return (
1229+
<ul>
1230+
{projects.map((project) => (
1231+
<li key={project.id}>
1232+
{project.name}
1233+
{/* Pass the child collection to a subcomponent */}
1234+
<IssueList issuesCollection={project.issues} />
1235+
</li>
1236+
))}
1237+
</ul>
1238+
)
1239+
}
1240+
1241+
function IssueList({ issuesCollection }) {
1242+
// Subscribe to the child collection for reactive updates
1243+
const { data: issues } = useLiveQuery(issuesCollection)
1244+
1245+
return (
1246+
<ul>
1247+
{issues.map((issue) => (
1248+
<li key={issue.id}>{issue.title}</li>
1249+
))}
1250+
</ul>
1251+
)
1252+
}
1253+
```
1254+
1255+
Each `IssueList` component independently subscribes to its project's issues. When an issue is added or removed, only the affected `IssueList` re-renders — the parent `ProjectList` does not.
1256+
1257+
> [!NOTE]
1258+
> You must pass the child collection to a subcomponent and subscribe with `useLiveQuery`. Reading `project.issues` directly in the parent without subscribing will give you the collection object, but the component won't re-render when the child data changes.
1259+
10431260
## groupBy and Aggregations
10441261

10451262
Use `groupBy` to group your data and apply aggregate functions. When you use aggregates in `select` without `groupBy`, the entire result set is treated as a single group.

packages/react-db/tests/useLiveQuery.test.tsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2481,4 +2481,112 @@ describe(`Query Collections`, () => {
24812481
})
24822482
})
24832483
})
2484+
2485+
describe(`includes subqueries`, () => {
2486+
type Project = {
2487+
id: string
2488+
name: string
2489+
}
2490+
2491+
type ProjectIssue = {
2492+
id: string
2493+
title: string
2494+
projectId: string
2495+
}
2496+
2497+
const sampleProjects: Array<Project> = [
2498+
{ id: `p1`, name: `Alpha` },
2499+
{ id: `p2`, name: `Beta` },
2500+
]
2501+
2502+
const sampleProjectIssues: Array<ProjectIssue> = [
2503+
{ id: `i1`, title: `Bug in Alpha`, projectId: `p1` },
2504+
{ id: `i2`, title: `Feature for Alpha`, projectId: `p1` },
2505+
{ id: `i3`, title: `Bug in Beta`, projectId: `p2` },
2506+
]
2507+
2508+
it(`should render includes results and reactively update child collections`, async () => {
2509+
const projectsCollection = createCollection(
2510+
mockSyncCollectionOptions<Project>({
2511+
id: `includes-react-projects`,
2512+
getKey: (p) => p.id,
2513+
initialData: sampleProjects,
2514+
}),
2515+
)
2516+
2517+
const issuesCollection = createCollection(
2518+
mockSyncCollectionOptions<ProjectIssue>({
2519+
id: `includes-react-issues`,
2520+
getKey: (i) => i.id,
2521+
initialData: sampleProjectIssues,
2522+
}),
2523+
)
2524+
2525+
// Parent hook: runs includes query that produces child Collections
2526+
const { result: parentResult } = renderHook(() =>
2527+
useLiveQuery((q) =>
2528+
q.from({ p: projectsCollection }).select(({ p }) => ({
2529+
id: p.id,
2530+
name: p.name,
2531+
issues: q
2532+
.from({ i: issuesCollection })
2533+
.where(({ i }) => eq(i.projectId, p.id))
2534+
.select(({ i }) => ({
2535+
id: i.id,
2536+
title: i.title,
2537+
})),
2538+
})),
2539+
),
2540+
)
2541+
2542+
// Wait for parent to be ready
2543+
await waitFor(() => {
2544+
expect(parentResult.current.data).toHaveLength(2)
2545+
})
2546+
2547+
const alphaProject = parentResult.current.data.find(
2548+
(p: any) => p.id === `p1`,
2549+
)!
2550+
expect(alphaProject.name).toBe(`Alpha`)
2551+
2552+
// Child hook: subscribes to the child Collection from the parent row,
2553+
// simulating a subcomponent using useLiveQuery(project.issues)
2554+
const { result: childResult } = renderHook(() =>
2555+
useLiveQuery((alphaProject as any).issues),
2556+
)
2557+
2558+
await waitFor(() => {
2559+
expect(childResult.current.data).toHaveLength(2)
2560+
})
2561+
2562+
expect(childResult.current.data).toEqual(
2563+
expect.arrayContaining([
2564+
expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
2565+
expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
2566+
]),
2567+
)
2568+
2569+
// Add a new issue to Alpha — the child hook should reactively update
2570+
act(() => {
2571+
issuesCollection.utils.begin()
2572+
issuesCollection.utils.write({
2573+
type: `insert`,
2574+
value: { id: `i4`, title: `New Alpha issue`, projectId: `p1` },
2575+
})
2576+
issuesCollection.utils.commit()
2577+
})
2578+
2579+
await waitFor(() => {
2580+
expect(childResult.current.data).toHaveLength(3)
2581+
})
2582+
2583+
expect(childResult.current.data).toEqual(
2584+
expect.arrayContaining([
2585+
expect.objectContaining({ id: `i1`, title: `Bug in Alpha` }),
2586+
expect.objectContaining({ id: `i2`, title: `Feature for Alpha` }),
2587+
expect.objectContaining({ id: `i4`, title: `New Alpha issue` }),
2588+
]),
2589+
)
2590+
})
2591+
})
24842592
})

0 commit comments

Comments
 (0)