Unions and CTEs
unionAll
unionAll combines queries that share an output shape. TypeScript enforces the shape match,
and compilation also rejects mismatched keys at runtime. Each branch is emitted in the first
branch's alias order, so object insertion order cannot swap values. Nullable codecs are merged
across every branch, including when selecting through fromUnion.
const recent = CH.from(Events)
.select(($) => ({ name: $.Name }))
.where(($) => [$.OrgId.eq("org_123"), $.Timestamp.gte("2026-01-01 00:00:00")])
const archived = CH.from(Events)
.select(($) => ({ name: $.Name }))
.where(($) => [$.OrgId.eq("org_123"), $.Timestamp.lt("2026-01-01 00:00:00")])
const combined = CH.unionAll(recent, archived).orderBy(["name", "asc"]).limit(100)
const compiled = CH.compileUnionUnsafe(combined, {})
// SELECT * FROM ( … UNION ALL … ) ORDER BY name ASC LIMIT 100A CHUnionQuery is deliberately narrower than a CHQuery — it offers only orderBy, limit,
offset, and format. Those wrap the union in an outer SELECT * FROM (…); the branches
themselves keep whatever ordering they declared.
Unions compile with compileUnion, not compile.
Watch branch types. ClickHouse unifies column types across
UNION ALLbranches, and a branch pairing (say) aFloat64with aUInt64can be rejected outright depending on server settings. Cast explicitly in each branch when the types are not already identical.
(Backed by docs/unions-and-ctes.md > unionAll with an outer ORDER BY.)
fromUnion
To aggregate across a union, wrap it as the FROM source:
const outer = CH.fromUnion(combined, "branches")
.select(($) => ({ name: $.name, total: CH.count() }))
.groupBy("name")Like fromQuery, accessors are flat ($.name),
and the outer query inherits the union's tenant scope — a union of branches bound to the same tenant stays
scoped.
The classic use is stitching a sealed hourly rollup onto a live raw-table branch for the in-progress hour, then re-aggregating over both.
CTEs
withCTE(name, query) prepends a WITH clause. Pass the query itself — it is compiled at
compile time and its tenant scope is derived, so nobody has to assert it:
const Recent = CH.table("recent", { Name: T.string })
const cte = CH.from(Events)
.select(($) => ({ Name: $.Name }))
.where(($) => [$.OrgId.eq("org_123")])
const query = CH.from(Recent)
.withCTE("recent", cte)
.select(($) => ({ name: $.Name }))
const compiled = CH.compileUnsafe(query, {})
// WITH recent AS ( SELECT Name AS Name FROM events WHERE OrgId = 'org_123' )
// SELECT Name AS name FROM recent
compiled.tenantScope // "single-tenant" — read off the CTE, not declaredTo read a CTE, declare a table whose name matches it and start the query there. That is what gives you typed accessors over the CTE's columns.
(Backed by docs/unions-and-ctes.md > Selecting from a CTE.)
The string form declares its own scope
withCTE(name, sql, { tenantScope }) takes a pre-compiled body, for the rare case where that is
all you have:
const cteSql = "SELECT Name FROM events WHERE OrgId = 'org_123'"
CH.from(CH.table("recent", { Name: T.string }))
.withCTE("recent", cteSql, { tenantScope: "single-tenant" })
.select(($) => ({ name: $.Name }))The body is opaque here, so the builder cannot see the OrgId filter inside it. If the CTE is
the query's row source and you omit tenantScope, the compiled query reads as "cross-tenant"
even though its SQL is perfectly well filtered.
Two caveats worth internalising:
- The declaration is an assertion you are making, not something that gets verified. Passing
tenantScope: "single-tenant"for a CTE that does not filter by tenant defeats the mechanism. This is the reason to prefer the query form. - It only takes effect when the query's
FROMnames that CTE. A CTE attached to a query that reads from a different table contributes nothing to the scope.
(Backed by docs/unions-and-ctes.md > A CTE needs its tenantScope declared.)
See Tenant scoping for what tenantScope is for.