Skip to content
Merged
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
10 changes: 10 additions & 0 deletions .changeset/add-includes-to-framework-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/db': patch
'@tanstack/react-db': patch
'@tanstack/solid-db': patch
'@tanstack/vue-db': patch
'@tanstack/svelte-db': patch
'@tanstack/angular-db': patch
---

Add includes (hierarchical data) documentation to all framework SKILL.md files and fix inaccurate toArray scalar select constraint in db-core/live-queries skill.
223 changes: 220 additions & 3 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ query outputs automatically and should not be persisted back to storage.
- [Select Projections](#select)
- [Joins](#joins)
- [Subqueries](#subqueries)
- [Includes](#includes)
- [groupBy and Aggregations](#groupby-and-aggregations)
- [findOne](#findone)
- [Distinct](#distinct)
Expand Down Expand Up @@ -760,9 +761,8 @@ A `join` without a `select` will return row objects that are namespaced with the

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.

> [!NOTE]
> 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.
> See [this issue](https://github.com/TanStack/db/issues/288) for more details.
> [!TIP]
> If you need hierarchical results instead of flat joined rows (e.g., each project with its nested issues), see [Includes](#includes) below.

### Method Signature

Expand Down Expand Up @@ -1053,6 +1053,223 @@ const topUsers = createCollection(liveQueryCollectionOptions({
}))
```

## Includes

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.

```ts
import { createLiveQueryCollection, eq } from '@tanstack/db'

const projectsWithIssues = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
})),
)
```

Each project's `issues` field is a live `Collection` that updates incrementally as the underlying data changes.

### Correlation Condition

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.

```ts
// The correlation condition: links issues to their parent project
.where(({ i }) => eq(i.projectId, p.id))
```

The correlation condition can appear as a standalone `.where()`, or inside an `and()`:

```ts
// Also valid — correlation is extracted from inside and()
.where(({ i }) => and(eq(i.projectId, p.id), eq(i.status, 'open')))
```

The correlation field does not need to be included in the parent's `.select()`.

### Additional Filters

Child queries support additional `.where()` clauses beyond the correlation condition, including filters that reference parent fields:

```ts
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id)) // correlation
.where(({ i }) => eq(i.createdBy, p.createdBy)) // parent-referencing filter
.where(({ i }) => eq(i.status, 'open')) // pure child filter
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
}))
```

Parent-referencing filters are fully reactive — if a parent's field changes, the child results update automatically.

### Ordering and Limiting

Child queries support `.orderBy()` and `.limit()`, applied per parent:

```ts
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.orderBy(({ i }) => i.createdAt, 'desc')
.limit(5)
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
}))
```

Each project gets its own top-5 issues, not 5 issues shared across all projects.

### toArray

By default, each child result is a live `Collection`. If you want a plain array instead, wrap the child query with `toArray()`:

```ts
import { createLiveQueryCollection, eq, toArray } from '@tanstack/db'

const projectsWithIssues = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: toArray(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
),
})),
)
```

With `toArray()`, the project row is re-emitted whenever its issues change. Without it, the child `Collection` updates independently.

### Aggregates

You can use aggregate functions in child queries. Aggregates are computed per parent:

```ts
import { createLiveQueryCollection, eq, count } from '@tanstack/db'

const projectsWithCounts = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issueCount: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ total: count(i.id) })),
})),
)
```

Each project gets its own count. The count updates reactively as issues are added or removed.

### Nested Includes

Includes nest arbitrarily. For example, projects can include issues, which include comments:

```ts
const tree = createLiveQueryCollection((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
comments: q
.from({ c: commentsCollection })
.where(({ c }) => eq(c.issueId, i.id))
.select(({ c }) => ({
id: c.id,
body: c.body,
})),
})),
})),
)
```

Each level updates independently and incrementally — adding a comment to an issue does not re-process other issues or projects.

### Using Includes with React

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)`:

```tsx
import { useLiveQuery } from '@tanstack/react-db'
import { eq } from '@tanstack/db'

function ProjectList() {
const { data: projects } = useLiveQuery((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({
id: i.id,
title: i.title,
})),
})),
)

return (
<ul>
{projects.map((project) => (
<li key={project.id}>
{project.name}
{/* Pass the child collection to a subcomponent */}
<IssueList issuesCollection={project.issues} />
</li>
))}
</ul>
)
}

function IssueList({ issuesCollection }) {
// Subscribe to the child collection for reactive updates
const { data: issues } = useLiveQuery(issuesCollection)

return (
<ul>
{issues.map((issue) => (
<li key={issue.id}>{issue.title}</li>
))}
</ul>
)
}
```

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.

> [!NOTE]
> 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.

## groupBy and Aggregations

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.
Expand Down
70 changes: 70 additions & 0 deletions packages/angular-db/skills/angular-db/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,76 @@ Angular 16 structural directives:
<li *ngFor="let todo of query.data(); trackBy: trackById">{{ todo.text }}</li>
```

## Includes (Hierarchical Data)

When a query uses includes (subqueries in `select`), each child field is a live `Collection` by default. Subscribe to it with `injectLiveQuery` in a child component:

```typescript
@Component({
selector: 'app-project-list',
standalone: true,
imports: [IssueListComponent],
template: `
@for (project of query.data(); track project.id) {
<div>
{{ project.name }}
<app-issue-list [issuesCollection]="project.issues" />
</div>
}
`,
})
export class ProjectListComponent {
query = injectLiveQuery((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
})),
)
}

// Child component subscribes to the child Collection
@Component({
selector: 'app-issue-list',
standalone: true,
template: `
@for (issue of query.data(); track issue.id) {
<li>{{ issue.title }}</li>
}
`,
})
export class IssueListComponent {
issuesCollection = input.required<Collection>()

query = injectLiveQuery(this.issuesCollection())
}
```

With `toArray()`, child results are plain arrays and the parent re-emits on child changes:

```typescript
import { toArray, eq } from '@tanstack/angular-db'

query = injectLiveQuery((q) =>
q.from({ p: projectsCollection }).select(({ p }) => ({
id: p.id,
name: p.name,
issues: toArray(
q
.from({ i: issuesCollection })
.where(({ i }) => eq(i.projectId, p.id))
.select(({ i }) => ({ id: i.id, title: i.title })),
),
})),
)
// project.issues is a plain array — no child component subscription needed
```

See db-core/live-queries/SKILL.md for full includes rules (correlation conditions, nested includes, aggregates).

## Common Mistakes

### CRITICAL Using injectLiveQuery outside injection context
Expand Down
3 changes: 2 additions & 1 deletion packages/db/skills/db-core/live-queries/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,8 @@ const messagesWithContent = createLiveQueryCollection((q) =>
### Includes rules

- The subquery **must** have a `where` clause with an `eq()` correlating a parent alias with a child alias. The library extracts this automatically as the join condition.
- `toArray()` and `concat(toArray())` require the subquery to use a **scalar** `select` (e.g., `select(({ c }) => c.text)`), not an object select.
- `toArray()` works with both scalar selects (e.g., `select(({ c }) => c.text)` → `string[]`) and object selects (e.g., `select(({ c }) => ({ id: c.id, title: c.title }))` → `Array<{id, title}>`).
- `concat(toArray())` requires a **scalar** `select` to concatenate into a string.
- Collection includes (bare subquery) require an **object** `select`.
- Includes subqueries are compiled into the same incremental pipeline as the parent query -- they are not separate live queries.

Expand Down
Loading
Loading