Two brands flow through the DSL. An Expr<T> is anything that evaluates to a value — a
column, a literal, a function call. A Condition is a boolean predicate, which is what
where collects. Comparison methods turn an Expr into a Condition.
Comparing a column against a plain value encodes that value through the column's own type, so
the literal is whatever ClickHouse expects for that column rather than whatever the JavaScript
value looks like:
.eq(null) emits = NULL; it does not test whether a value is missing. Use
isNull (or isNotNull for present values), declared with defineCondFn:
null-filter.ts
import * as CH from "@maple-dev/effect-clickhouse"import * as T from "@maple-dev/effect-clickhouse/types"const Notes = CH.table("notes", { Note: T.nullable(T.string) })const isNull = CH.defineCondFn<[CH.Expr<string | null>]>("isNull")export const compiled = CH.compileUnsafe( CH.from(Notes).select("Note").where(($) => [isNull($.Note)]), {},)console.log(compiled.sql) // SELECT Note AS Note FROM notes WHERE isNull(Note)
A value the column cannot hold fails while the SQL is being built:
$.Count.eq("lots")// QueryBuilderError { code: "InvalidLiteral" }: column Count: string "lots" is not a valid value
Expressions with no type to read — untypedExpr, an untyped dynamicColumn — fall back to
guessing from the JavaScript value, which handles strings, numbers, booleans and dates but
nothing structured.
.where(($) => [ $.OrgId.eq("org_123"), $.Name.eq("checkout").or($.Name.eq("cart")),])// … WHERE OrgId = 'org_123' AND (Name = 'checkout' OR Name = 'cart')
.and() / .or() parenthesise their result, so precedence is explicit. CH.not(condition) wraps
in NOT (…) and is available from the root and /expr subpath.
The where array is AND-joined. Tenant scoping preserves evidence
through both separate entries and .and(); .or() discards it.
(Backed by docs/expressions.md > Combining conditions with and/or.)
when and whenTrue return Condition | undefined, and where drops undefined entries.
This is how you build filters from optional inputs without string-concatenating SQL:
const build = (nameFilter?: string) => CH.from(Events) .select(($) => ({ name: $.Name })) .where(($) => [$.OrgId.eq("org_123"), CH.when(nameFilter, (n) => $.Name.eq(n))])// build("checkout") -> … WHERE OrgId = 'org_123' AND Name = 'checkout'// build() -> … WHERE OrgId = 'org_123'
when skips undefined, null, and false, and narrows the value for the callback.
whenTrue(flag, () => cond) is the variant for a plain boolean gate.
(Backed by docs/expressions.md > Optional predicates with when.)
Decide what an empty filter list means before building the query: omit it for “no filter”, or
return no rows for “nothing selected”. Do not accidentally turn an empty selection into an
unbounded read. The optional-filter recipe shows
an explicit choice.
Escaping SQL strings does not escape LIKE wildcards. % and _ in a .like() argument are
patterns; use .eq() for exact text. Keep table names, aliases, and raw SQL under application
control: value escaping is not an allowlist for identifiers.
Order the calls so precedence works in your favour, or bind an intermediate alias in a
subquery. This is a deliberate trade — the emitted SQL stays readable — but it is the most
common source of quietly wrong numbers.
(Backed by docs/expressions.md > Arithmetic does not parenthesise.)
.div() and .mod() decode nullably, whatever their operands are. ClickHouse renders 1 / 0 as
inf and 0 / 0 as nan, and both come back as JSON null — so a division that meets a zero
denominator returns a null the column type has to accept, or the row fails to decode.
Both operators return Expr<number | null> — unless the divisor is a numeric literal of
magnitude 1 or more. $.Duration.div(1_000_000) cannot manufacture a null from a finite
dividend, so it stays as nullable as $.Duration (and is exactly rounded, where
.mul(0.000001) drifts by an ulp on a third of integer inputs). A zero, a literal below 1
(1 / 5e-324 overflows to inf), a plain number, or another expression as the divisor
makes the result nullable. Modulo by zero can also raise a ClickHouse error; nullable
decoding does not suppress server errors.
When the output must be numeric, guard both non-finite numbers and SQL NULL:
CH.ifNotFinite(expr, fallback) replaces nan/inf, but SQL NULL passes through unchanged.
CH.ifNull supplies the remaining fallback. CH.nullIf(expr, value) returns Expr<T | null>;
for example, CH.sum(x).div(CH.nullIf(CH.sum(y), 0)) keeps a null for an absent denominator.
Addition, subtraction, and multiplication preserve nullable operands in their types and codecs.
(Backed by docs/expressions.md > division decodes nullably and ifNotFinite guards it.)
lit(value) — an explicit Expr from a string or number. You rarely need it, since
comparison methods accept raw values directly.
rawExpr(sql, type) — an Expr from a SQL string, with the column type it produces.
untypedExpr<T>(sql) — the same with no type declared; selecting one costs the query its
row schema, so it is a separate name rather than an omitted argument.
rawCond(sql) — a Condition from a SQL string.
Raw helpers interpolate nothing and escape nothing. Never build one from user input. See
Extending the DSL.
So are the parametric funnel aggregates — the window / pattern is a parameter,
the timestamp and step conditions are the arguments:
CH.windowFunnel(3600)($.Timestamp, $.Name.eq("view"), $.Name.eq("signup"))// windowFunnel(3600)(Timestamp, Name = 'view', Name = 'signup')CH.windowFunnel(3600, "strict_order")($.Timestamp, …)CH.sequenceMatch("(?1)(?t<3600)(?2)")($.Timestamp, $.Name.eq("view"), $.Name.eq("signup"))
windowFunnel takes Date, DateTime or an unsigned integer for the timestamp
(not DateTime64) and the window is in that column's unit.
(Backed by docs/expressions.md > Conditional aggregation.)
avg, avgIf, and quantile return Expr<number | null> because empty input produces NaN,
which ClickHouse serializes as JSON null. Use the guards above when the empty result should be zero.
String, numeric, date/time, array, map, JSON, and window functions are catalogued in the
API reference. Anything not wrapped can be declared in
one line with defineFn.