Set operations (UNION / INTERSECT / EXCEPT)
Combine the results of multiple queries — UNION dedups, UNION ALL doesn't, INTERSECT keeps rows in both, EXCEPT subtracts.
union / unionAll / intersect / except combine two or more
queries that produce the same column shape. The result is one
unified row set you can sort, paginate, and aggregate against.
Quick start
import { union, eq, queryFor } from '@voltro/database'
// Active + archived tickets for an org, treated as one list
const all = await ctx.store.query(
union(
queryFor(database.activeTickets).where(eq('orgId', oid)),
queryFor(database.archivedTickets).where(eq('orgId', oid)),
).orderBy('createdAt', 'desc').limit(50).descriptor,
)Compiles to:
(SELECT * FROM "activeTickets" WHERE "orgId" = $1)
UNION
(SELECT * FROM "archivedTickets" WHERE "orgId" = $2)
ORDER BY "createdAt" DESC
LIMIT 50The outer .orderBy() / .limit() apply to the combined result —
each inner query keeps its own predicate but loses its own
ordering.
The four operations
| Helper | Semantics |
|---|---|
union(...) |
Rows from any input, deduplicated |
unionAll(...) |
Rows from any input, NO dedup (faster + preserves duplicates) |
intersect(...) |
Rows present in EVERY input |
except(...) |
Rows in the FIRST input, NOT in any subsequent input |
Each accepts 2+ queries. One input throws (requires at least two queries).
When to reach for each
union— "show this user's items from two different sources, deduped" (a notifications feed mixed with system messages).unionAll— same asunionbut you know there are no duplicates OR you specifically want to keep them. Skipping the dedup pass is meaningfully faster on large inputs.intersect— "users who exist in BOTH the paying-customers list AND the active-this-week list".except— "all users EXCEPT those who unsubscribed". UsenotInSubqueryif you only need a column-level check;exceptwhen you're operating on full row shapes.
Three-way and beyond
All four accept any number of inputs (≥ 2). The compiler chains them with the appropriate keyword:
const combined = union(
queryFor(database.eventsA),
queryFor(database.eventsB),
queryFor(database.eventsC),
)
// (SELECT * FROM "eventsA") UNION (SELECT * FROM "eventsB") UNION (SELECT * FROM "eventsC")Column shape requirement
Every input MUST produce the same column shape. The framework
doesn't enforce this at TypeScript level — the DB throws at query
time if shapes don't line up. To narrow each input, use
.select(...cols) on the inner queries so they project the same
column set.
Cross-dialect
Standard SQL — every dialect we ship supports the four set ops with identical syntax. No per-dialect dispatch.
Reactivity
Reactive — coarsely. The engine registers the subscription against every branch's source table, so a write to any branch (the UNION / INTERSECT / EXCEPT side) re-runs the combined query. It re-queries on any contributing-table change rather than pre-filtering per column, so keep the branches' result sets bounded.
See also
- Sub-queries —
notInSubqueryfor the column-level "in A but not in B" case - Aggregations —
count()etc. on a set-op result is a common pattern - CTEs — name a complex set-op result so you can reference it in a larger query