Quarry
Concepts

Architecture

How Quarry turns a chained builder call into ClickHouse SQL.

Quarry is structured as a four-stage pipeline:

your code  →  builder  →  AST  →  compiler  →  SQL + params

You only ever interact with the builder layer. The AST and compiler are implementation details, but they are also small enough that you can read the whole compiler in one sitting if you want to know exactly what is happening.

The four stages

1. Builder

The builder is the public API: SelectQueryBuilder, InsertQueryBuilder, ExpressionBuilder, plus the small helper classes like TableSourceBuilder and AliasedQuery.

Builders are immutable. Every method returns a new instance with a new internal AST node, leaving the original untouched. That is why this works:

const base = db.selectFrom("users as u").select("u.id", "u.email");

const recent = base.orderBy("u.id", "desc").limit(10);
const filtered = base.where("u.email", "!=", "");

// `base` is unchanged. `recent` and `filtered` are independent.

The chained methods carry the query's type-level state through three parameters — Sources, Scope, and Output — which is what gives you type-checked column references, type-safe joins, and an inferred result row type. See Scopes and aliases for the details.

2. AST

The AST is a plain TypeScript data structure. No methods, no inheritance, just nested interfaces. The whole thing lives in src/ast/query.ts and is around 110 lines.

The top-level node for a select query looks like this:

interface SelectQueryNode {
  with: CteNode[];
  from?: SourceNode;
  selections: SelectionNode[];
  joins: JoinNode[];
  prewhere?: ExprNode;
  where?: ExprNode;
  having?: ExprNode;
  groupBy: ExprNode[];
  orderBy: OrderByNode[];
  limit?: number;
  offset?: number;
  settings: Record<string, string | number | boolean>;
}

Expressions are a discriminated union with seven shapes:

kindWhat it represents
refA column reference like u.email
valueA bound parameter value (becomes a {pN:Type} placeholder)
rawA literal SQL fragment, no escaping
functionA function call like count() or toInt32(x)
subqueryExprA subquery used as an expression (e.g. inside IN)
binaryA binary operator like =, <, IS
logicalAn AND or OR over a list of conditions

Sources are a smaller union:

kindWhat it represents
tablename, optional alias, optional final
subqueryA nested SelectQueryNode plus a required alias

Each builder method appends or replaces fields on a SelectQueryNode. For example, where("col", "=", value) builds a BinaryNode and folds it into the existing where field via appendCondition, which AND-merges new conditions onto whatever was there before.

3. Compiler

The compiler is a single pass that walks a SelectQueryNode and produces a SQL string plus a parameter map. The whole thing is around 170 lines in src/compiler/query-compiler.ts.

The two important pieces:

compileExpr — the recursive expression compiler. It pattern-matches on the expression kind and builds the SQL fragment. Values are bound through a CompileContext that maintains a counter (p0, p1, ...) and a params map.

compileQuerySql — the top-level walker. It assembles the final query in a fixed order:

WITH ... ?
SELECT ...
FROM ...
INNER JOIN ... ?
LEFT JOIN ... ?
PREWHERE ... ?
WHERE ... ?
GROUP BY ... ?
HAVING ... ?
ORDER BY ... ?
LIMIT ... ?
OFFSET ... ?
SETTINGS ... ?

The order is fixed because ClickHouse's clause order is fixed.

4. SQL + params

The output of the compiler is just:

interface CompiledQuery {
  query: string;
  params: Record<string, unknown>;
}

This is what you get back from toSQL(). It is also what execute() passes to @clickhouse/client.query({ ... }) — Quarry never strings together the values inline, they always travel through the driver's query_params channel.

Parameter type inference

When you write where("col", "=", value) and the value is not wrapped in param(...), Quarry has to choose a ClickHouse type for the placeholder. The rules in inferClickHouseType are intentionally simple:

JS valueClickHouse type
Array<T>Array(<inferred member type>)
booleanBool
number (integer)Int64
number (float)Float64
bigintInt64
DateDateTime
anything elseString

When inference is wrong or ambiguous — especially around dates, unsigned integers, and decimal types — reach for param(value, "Type") to be explicit. See the ExpressionBuilder reference for the full story.

Inserts skip the AST

There is one exception to the four-stage pipeline: InsertQueryBuilder does not go through the AST and compiler. Inserts in ClickHouse are simple enough that the builder constructs the SQL directly in toSQL():

return {
  query: `INSERT INTO ${this.table} FORMAT JSONEachRow`,
  values: structuredClone(this.rows),
};

The actual row data is sent to ClickHouse via the driver's insert() API, not as query parameters. That is why insert queries can stream very large batches efficiently — the values never have to be serialized into the query string.

Where to read the source

If you want to understand any of this in more depth, the relevant files are small enough to read end-to-end:

The whole library is under 1700 lines of source.

On this page