Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
35acc7e
docs: add caseWhen operator plan
samwillis May 17, 2026
dba2be1
docs: decouple caseWhen plan from multi-source from
samwillis May 17, 2026
ddae6ba
feat(db): support multi-source from queries
samwillis May 17, 2026
fa07e9f
fix(db): close multi-source from edge cases
samwillis May 17, 2026
4a66a9f
test(db): cover multi-source subquery branches
samwillis May 17, 2026
2d5d12d
fix(db): lazy-load union subquery joins
samwillis May 17, 2026
f9200c3
fix(db): resolve lazy targets for subquery includes
samwillis May 18, 2026
8acea10
fix(db): propagate nested source include updates
samwillis May 18, 2026
f24835c
fix(db): address multi-source review feedback
samwillis May 18, 2026
180a2c3
fix(db): align multi-source branch with caseWhen head
samwillis May 18, 2026
8ad4441
ci: apply automated fixes
autofix-ci[bot] May 18, 2026
dcf0ca1
test(db): update subquery lazy alias expectation
samwillis May 18, 2026
c408003
refactor(db): expose source unions as unionAll
samwillis May 19, 2026
090a032
feat(db): support query branch unionAll
samwillis May 19, 2026
84b1371
fix(db): harden query branch unionAll
samwillis May 19, 2026
1820993
fix(db): clarify query branch unionAll boundary
samwillis May 19, 2026
ceb95b4
fix(db): materialize unionAll includes before fnSelect
samwillis May 20, 2026
c09359e
chore(db): remove planning docs from branch
samwillis May 20, 2026
13cf2e1
ci: apply automated fixes
autofix-ci[bot] May 20, 2026
3af2797
ci: apply automated fixes (attempt 2/3)
autofix-ci[bot] May 20, 2026
a667230
fix(db): address unionAll review feedback
samwillis May 20, 2026
414ea56
ci: apply automated fixes
autofix-ci[bot] May 20, 2026
00cd3d6
fix(db): address unionAll review comments
samwillis May 20, 2026
ae1c156
fix(db): address unionAll source review
samwillis May 21, 2026
ddd91dc
fix(db): harden unionAll review edges
samwillis May 21, 2026
7eee492
ci: apply automated fixes
autofix-ci[bot] May 21, 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
1 change: 1 addition & 0 deletions .changeset/tender-mugs-hear.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
---

Added the `caseWhen` query operator for scalar conditional expressions and conditional select projections with guarded includes.
Added `unionAll()` support to combine independent sources or built query branches in a single query.
120 changes: 118 additions & 2 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -485,16 +485,19 @@ This is particularly useful when you need to:

The foundation of every query is the `from` method, which specifies the source collection or subquery. You can alias the source using object syntax.

Use `from()` with a single source. To combine multiple independent sources
without a join, use [`unionAll()`](#source-level-unionall) instead.

### Method Signature

```ts
from({
[alias]: Collection | Query,
[alias]: Collection | Query
}): Query
```

**Parameters:**
- `[alias]` - A Collection or Query instance. Note that only a single aliased collection or subquery is allowed in the `from` clause.
- `[alias]` - A Collection or Query instance.

### Basic Usage

Expand Down Expand Up @@ -538,6 +541,90 @@ const userNames = createCollection(liveQueryCollectionOptions({
}))
```

### `unionAll`

Use `unionAll()` as a start method, in the same place you would use `from()`,
to combine independent sources without a join. It has two forms:

```ts
unionAll({
[alias]: Collection | Query,
[alias2]: Collection | Query
}): Query

unionAll(branchQuery, branchQuery2, ...branchQueries): Query
```

#### Source-Level `unionAll`

The object form combines collection or subquery sources. Conceptually, this
behaves like `UNION ALL`: each raw result row comes from exactly one source
alias, and inactive aliases are `undefined`.

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

const timeline = createLiveQueryCollection((q) =>
q
.unionAll({
message: messagesCollection,
toolCall: toolCallsCollection,
})
.orderBy(({ message, toolCall }) =>
coalesce(message.timestamp, toolCall.timestamp),
)
)
```

Without `select()`, the result type is an exclusive union:

```ts
type TimelineRow =
| { message: Message; toolCall?: undefined }
| { message?: undefined; toolCall: ToolCall }
```

Use subqueries when each branch needs its own filtering or shaping before the
sources are combined.

If you project branch values with `select()`, you control the inactive branch
shape. For example, `caseWhen()` projections use `null` when no branch matches
unless you provide a default value.

#### Query-Branch `unionAll`

You can also pass built queries directly. This form unions the result rows from
each branch query. Downstream clauses operate on that shared result shape, so
ordering can reference shared fields directly instead of using `coalesce()`.
A branch without `select()` keeps its normal query result shape; for example, a
joined branch enters the union as a namespaced row.

```ts
const timeline = createLiveQueryCollection((q) => {
const messageRows = q
.from({ message: messagesCollection })
.select(({ message }) => ({
type: `message` as const,
id: message.id,
body: message.text,
timestamp: message.timestamp,
}))

const toolCallRows = q
.from({ toolCall: toolCallsCollection })
.select(({ toolCall }) => ({
type: `toolCall` as const,
id: toolCall.id,
body: toolCall.name,
timestamp: toolCall.timestamp,
}))

return q
.unionAll(messageRows, toolCallRows)
.orderBy(({ timestamp }) => timestamp)
})
```

## Where Clauses

Use `where` clauses to filter your data based on conditions. You can chain multiple `where` calls - they are combined with `and` logic.
Expand Down Expand Up @@ -1661,6 +1748,35 @@ const sortedUsers = createLiveQueryCollection((q) =>
)
```

### `unionAll` Ordering

When ordering a source-level `unionAll` query, use a combined expression such as
`coalesce()` to produce one comparable value across branches. Query-branch
`unionAll()` can instead order by shared selected fields, as shown in the
[`unionAll()` examples](#unionall).

If the order expression sorts strings and the branch collections have different
default string collation settings, TanStack DB uses the first source collection's
collation as the default. Pass explicit `orderBy` compare options when you need
a specific string collation for the combined ordering:

```ts
const timeline = createLiveQueryCollection((q) =>
q
.unionAll({
message: messagesCollection,
toolCall: toolCallsCollection,
})
.select(({ message, toolCall }) => ({
label: coalesce(message.title, toolCall.name),
}))
.orderBy(({ $selected }) => $selected.label, {
stringSort: `locale`,
locale: `en-US`,
})
)
```

### Ordering by SELECT Fields

When you use `select()` with aggregates or computed values, you can order by those fields using the `$selected` namespace:
Expand Down
23 changes: 19 additions & 4 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -385,11 +385,26 @@ export class InvalidSourceError extends QueryBuilderError {
}
}

export type SourceClauseContext =
| `from clause`
| `unionAll clause`
| `join clause`

export class InvalidSourceTypeError extends QueryBuilderError {
constructor(context: string, type: string) {
super(
`Invalid source for ${context}: Expected an object with a single key-value pair like { alias: collection }. ` +
`For example: .from({ todos: todosCollection }). Got: ${type}`,
constructor(context: SourceClauseContext, type: string) {
const expected =
context === `unionAll clause`
? `an object with one or more key-value pairs like { alias: collection }`
: `an object with a single key-value pair like { alias: collection }`
const example =
context === `unionAll clause`
? `.unionAll({ todos: todosCollection, events: eventsCollection })`
: context === `join clause`
? `.join({ todos: todosCollection }, ({ todo, todos }) => eq(todo.id, todos.id))`
: `.from({ todos: todosCollection })`
super(
`Invalid source for ${context}: Expected ${expected}. ` +
`For example: ${example}. Got: ${type}`,
)
}
}
Expand Down
2 changes: 1 addition & 1 deletion packages/db/src/indexes/auto-index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DEFAULT_COMPARE_OPTIONS } from '../utils'
import { checkCollectionSizeForIndex, isDevModeEnabled } from './index-registry'
import { hasVirtualPropPath } from '../virtual-props'
import { checkCollectionSizeForIndex, isDevModeEnabled } from './index-registry'
import type { CompareOptions } from '../query/builder/types'
import type { BasicExpression } from '../query/ir'
import type { CollectionImpl } from '../collection/index.js'
Expand Down
Loading
Loading