Skip to content

Composition

Compose compile-time SQL templates and extend them at call sites.

Updated View as Markdown

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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close