A function returning QueryBuilder[T] defines an unevaluated read template.
The compiler splices it into each consumer and renders one prepared statement.
There is no runtime SQL string and no runtime QueryBuilder object.
schema travel
model Booking {
@id
id: int
customer_id: int
price: int
active: bool
created_at: int
}
fn active_bookings(): QueryBuilder[Booking] {
FROM Booking
WHERE active == true
ORDER BY created_at DESC
}
fn recent(cutoff: int): []Booking ! QueryError {
return active_bookings()
WHERE created_at >= cutoff
LIMIT 20?
}
fn main(): void {
match recent(1735689600) {
Ok(rows) => println("${rows.len()} recent active bookings")
Err(e) => println("query failed")
}
}The consumer’s WHERE and LIMIT merge into the producer’s read, and one
statement — WHERE active = ? AND created_at >= ? ORDER BY created_at DESC LIMIT 20 — reaches the database.
The producer body must be a tail expression
This is the mistake that costs the most time. A builder is recognised by its
body’s tail expression being a query. Writing return FROM ... leaves no
tail, so the function stops looking like a builder and every consumer fails:
schema travel
model Booking {
@id
id: int
price: int
}
// `return` makes the query a statement, not the body's tail.
fn base(): QueryBuilder[Booking] {
return FROM Booking WHERE price > 0
}
fn consume(): []Booking ! QueryError {
return base() WHERE price < 100?
}The diagnostic lands on the consumer: ATOLL3241: `base` is not a query builder. Delete the return from the producer and it all compiles. Note that
consumers still use return normally — the rule applies only to the builder
body.
Producers
The producer must be an unterminated FROM read whose row type agrees with
QueryBuilder[T]. Predicates and stable ordering are the useful producer
responsibilities — tenancy, visibility, soft deletes:
schema travel
model Booking {
@id
id: int
customer_id: int
price: int
created_at: int
deleted_at: int?
}
fn visible(): QueryBuilder[Booking] {
FROM Booking
WHERE deleted_at == None
ORDER BY created_at DESC
}
fn live(): []Booking ! QueryError {
return visible()?
}
fn main(): void {
match live() {
Ok(rows) => println("${rows.len()} live bookings")
Err(e) => println("query failed")
}
}Several shapes are rejected in a producer because they would decide the final
result before the consumer is known. Each has its own ATOLL3241 message:
| In a producer | Diagnostic |
|---|---|
LIMIT / OFFSET |
a query builder cannot carry a terminal LIMIT/OFFSET |
SELECT { ... } |
a SELECT { … } projection in a query builder is not yet supported |
JOIN |
a JOIN in a query builder is not yet supported |
ONE FROM |
a query builder body must be a FROM read, not One |
schema travel
model Booking {
@id
id: int
price: int
}
fn base(): QueryBuilder[Booking] {
FROM Booking WHERE price > 0 LIMIT 10
}
fn consume(): []Booking ! QueryError {
return base() WHERE price < 100?
}A builder is a policy, not a finished query. Keep it to one reusable concern. It does not bypass model visibility or datasource routing.
Parameters
Builders take ordinary parameters, and the guarded-clause if works inside
them:
schema travel
model Booking {
@id
id: int
customer_id: int
price: int
}
fn priced(minimum: int, enabled: bool): QueryBuilder[Booking] {
FROM Booking
WHERE price >= minimum if enabled
}
fn affordable(maximum: int): []Booking ! QueryError {
return priced(10, true)
WHERE price <= maximum?
}
fn everything(maximum: int): []Booking ! QueryError {
return priced(10, false)
WHERE price <= maximum?
}
fn main(): void {
match affordable(100) { Ok(rows) => println("floor on: ${rows.len()}") Err(e) => println("failed") }
match everything(100) { Ok(rows) => println("floor off: ${rows.len()}") Err(e) => println("failed") }
}Arguments are substituted by symbol identity, so a same-named local at the consumer cannot capture a producer parameter. The values become bound statement parameters; they are never rendered as SQL text. Each call site gets its own substitution, so two consumers may specialize the same builder differently without sharing state.
Consuming
? on a bare builder call executes the template as-is. Otherwise clauses keep
accumulating until a terminal or ? consumes it. The consumer may add
predicates, ordering, paging, grouping, projections — and, unlike the producer,
joins:
schema travel
model Customer {
@id
id: int
name: string
}
model Booking {
@id
id: int
customer_id: int
price: int
}
fn base(): QueryBuilder[Booking] {
FROM Booking WHERE price > 0
}
// Straight through.
fn all_paid(): []Booking ! QueryError {
return base()?
}
// Grouped and projected on the consumer side.
fn spend_per_customer(): []{ customer_id: int, spend: int } ! QueryError {
return base()
GROUP BY customer_id
SELECT { customer_id, spend: sum(price) }?
}
// Joined on the consumer side.
fn with_names(): []{ id: int, name: string } ! QueryError {
return base()
JOIN Customer ON Booking.customer_id == Customer.id
SELECT { id: Booking.id, name: Customer.name }?
}
// Paged.
fn page(offset: int): []Booking ! QueryError {
return base() ORDER BY id LIMIT 20 OFFSET offset?
}
fn main(): void {
match all_paid() { Ok(rows) => println("all: ${rows.len()}") Err(e) => println("failed") }
match spend_per_customer() { Ok(rows) => println("customers: ${rows.len()}") Err(e) => println("failed") }
match with_names() { Ok(rows) => println("named: ${rows.len()}") Err(e) => println("failed") }
match page(0) { Ok(rows) => println("page: ${rows.len()}") Err(e) => println("failed") }
}Clauses merge structurally: producer and consumer predicates both apply, and consumer ordering and paging follow the same rules as a query written in one place. Validation happens on the combined plan after expansion, so a perfectly good producer can still fail at one consumer whose added clauses make the whole plan unsupported.
Result typing
The builder’s T must match the model its producer reads. Consuming yields the
same shapes as a direct read — []T by default, the projected row type when
the consumer adds a SELECT, and single-row aggregate collapse when a checked
struct context calls for it (see Aggregates).
The declaration itself does not execute, suspend, or produce QueryError.
Those properties belong to the consuming expression, which behaves exactly like
a direct query.
Restrictions
Composition inherits every standalone query gate. Guarded joins, conditional
SET assignments, unsupported engine functions, and query shapes with no
runtime carrier all still diagnose at compile time. A template cannot conceal
an unrenderable fragment.
Recursive builder calls and dynamic selection among builders are not a runtime
query-construction mechanism. When an entire query must differ by storage
engine, use a when block; when
only values differ, pass parameters.
A worked example
schema travel
model Booking {
@id
id: int
customer_id: int
price: int
status: string
created_at: int
deleted_at: int?
}
/// One policy, applied everywhere: never show soft-deleted rows, newest first.
fn visible(): QueryBuilder[Booking] {
FROM Booking
WHERE deleted_at == None
ORDER BY created_at DESC
}
/// One tenancy filter layered on top, optional at the call site.
fn for_customer(wanted: int, scoped: bool): QueryBuilder[Booking] {
FROM Booking
WHERE deleted_at == None
WHERE customer_id == wanted if scoped
ORDER BY created_at DESC
}
fn dashboard(customer_id: int): []Booking ! QueryError {
return for_customer(customer_id, true)
WHERE status == "open"
LIMIT 25?
}
fn admin_feed(): []Booking ! QueryError {
return visible() LIMIT 100?
}
fn revenue(customer_id: int): []{ status: string, spend: int } ! QueryError {
return for_customer(customer_id, true)
GROUP BY status
SELECT { status, spend: sum(price) }?
}
fn main(): void {
match dashboard(7) {
Ok(rows) => println("${rows.len()} open bookings for customer 7")
Err(e) => println("dashboard failed")
}
match admin_feed() {
Ok(rows) => println("${rows.len()} rows in the admin feed")
Err(e) => println("feed failed")
}
match revenue(7) {
Ok(rows) => {
for row in rows {
println("${row.status}: ${row.spend}")
}
}
Err(e) => println("revenue failed")
}
}Three consumers, two builders, and one rendered statement per consumer — none of it assembled at runtime.