Skip to content

Projections

Use SELECT to build a checked row shape instead of returning whole models.

Updated View as Markdown

SELECT { ... } chooses the result columns and mints a typed row shape for them. The result is an anonymous record type, not a partially filled model.

schema shop

model Booking {
    @id
    id: int
    reference: string
    passengers: int
    price_each: float
}

fn summaries(): []{ id: int, reference: string } ! QueryError {
    return FROM Booking SELECT { id, reference }?
}

fn main(): void {
    match summaries() {
        Ok(rows) => {
            for row in rows { println("${row.id}: ${row.reference}") }
        }
        Err(e) => println("read failed")
    }
}

Fields need commas

A projection body is a record literal. Fields are comma separated; newlines alone are not a separator, and forgetting the commas is the single most common projection mistake:

schema shop

model Booking {
    @id
    id: int
    reference: string
}

fn f(): []{ id: int, reference: string } ! QueryError {
    return FROM Booking SELECT {
        id
        reference
    }?
}

That reports ATOLL1006: Expected '}', followed by a cascade of unresolved-name errors from the rest of the query — the parser stopped after id. With commas — including an optional trailing one — the multi-line form is fine, and it is what you want once computed fields appear.

Bare fields and named fields

A bare field name keeps its own name and type. name: expression renames a column or introduces a computed one.

schema shop

model Booking {
    @id
    id: int
    reference: string
    passengers: int
    price_each: float
}

type Line = { id: int, code: string, total: float }

fn lines(): []Line ! QueryError {
    return FROM Booking
        SELECT {
            id,
            code: reference,
            total: price_each * passengers,
        }?
}

fn main(): void {
    match lines() {
        Ok(rows) => {
            for line in rows { println("${line.code} = ${line.total}") }
        }
        Err(e) => println("read failed")
    }
}

The row type here is spelled once as a type alias and reused as the return type. That is the cleanest way to give a projection a name — the compiler-minted row is structural, so any alias with matching field names and types unifies with it.

Rows are ordinary values once decoded:

schema shop

model Booking {
    @id
    id: int
    passengers: int
    price_each: float
}

fn revenue(): float ! QueryError {
    lines := FROM Booking SELECT { id, total: price_each * passengers }?
    mut sum := 0.0
    for line in lines { sum = sum + line.total }
    return sum
}

fn main(): void {
    match revenue() {
        Ok(total) => println("revenue ${total}")
        Err(e) => println("read failed")
    }
}

Projections keep the verb’s shape

Adding SELECT changes the row type, never the container:

schema shop

model Booking {
    @id
    id: int
    reference: string
    passengers: int
}

type Row = { id: int, reference: string }

fn many(): []Row ! QueryError {
    return FROM Booking SELECT { id, reference }?
}

fn one(minimum: int): Row? ! QueryError {
    return ONE FROM Booking WHERE passengers >= minimum SELECT { id, reference }?
}

fn keyed(booking: int): Row? ! QueryError {
    return GET Booking(booking) SELECT { id, reference }?
}

fn batched(ids: []int): Map[int, Row] ! QueryError {
    return GET Booking(ids) SELECT { id, reference }?
}

fn main(): void {
    match many() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match one(2) { Ok(row) => println("${row?.reference ?? "-"}") Err(e) => println("failed") }
    match keyed(1) { Ok(row) => println("${row?.reference ?? "-"}") Err(e) => println("failed") }
    match batched([1, 2]) { Ok(m) => println("${m.len()}") Err(e) => println("failed") }
}

ONE FROM is the one verb that requires a projection: without one it has no row encoding and does not lower. A GET over a model with a nullable column is in the same position, and GET ... SELECT is the fix. See Reads.

A projection is not a model

A projection row and a declared struct are different types even when their fields line up. The row is the anonymous record { id: int, reference: string } and it will not coerce to a nominal type:

schema shop

model Booking {
    @id
    id: int
    reference: string
}

struct Summary {
    id: int
    reference: string
}

fn f(): []Summary ! QueryError {
    return FROM Booking SELECT { id, reference }?
}

Use a type alias for the record shape instead of a struct, and build a struct explicitly if a nominal type is genuinely required at an API boundary:

schema shop

model Booking {
    @id
    id: int
    reference: string
}

type Row = { id: int, reference: string }

struct Summary {
    id: int
    reference: string
}

fn rows(): []Row ! QueryError {
    return FROM Booking SELECT { id, reference }?
}

fn summaries(): []Summary ! QueryError {
    mut out: []Summary = []
    for r in rows()? {
        out.add(Summary { id: r.id, reference: r.reference })
    }
    return out
}

fn main(): void {
    match summaries() {
        Ok(list) => println("${list.len()} summaries")
        Err(e) => println("read failed")
    }
}

An explicit type at an exported boundary is worth the extra step: it fixes the field spelling, optionality, and numeric types a caller sees, and lets the internal query grow predicates, joins, or unreturned columns without changing the public signature.

Column names are also checked against the model, so a typo is caught here rather than by the database:

schema shop

model Booking {
    @id
    id: int
    reference: string
}

fn f(): []{ id: int } ! QueryError {
    return FROM Booking SELECT { id, bogus }?
}

Computed values

Projection expressions may combine columns, literals, arithmetic, scalar functions, and aggregates. The compiler types each field from SQL expression rules — division may widen, and nullability propagates through optional operands.

schema shop

model Booking {
    @id
    id: int
    passengers: int
    price_each: float
}

type Priced = { id: int, kind: string, total: float }

fn priced(): []Priced ! QueryError {
    return FROM Booking
        SELECT {
            id,
            kind: "booking",
            total: price_each * passengers,
        }?
}

fn main(): void {
    match priced() {
        Ok(rows) => {
            for row in rows { println("${row.kind} ${row.id}: ${row.total}") }
        }
        Err(e) => println("read failed")
    }
}

Aggregates project like any other expression. A whole-table aggregate comes back as a one-row list, so read element zero:

schema shop

model Booking {
    @id
    id: int
    passengers: int
    price_each: float
}

fn stats() {
    return FROM Booking SELECT {
        n: count(),
        heads: sum(passengers),
        mean: avg(price_each),
        cheapest: min(price_each),
        dearest: max(price_each),
    }
}

fn live_count(): int ! QueryError {
    rows := FROM Booking SELECT { n: count() }?
    return rows.get(0)?.n ?? 0
}

fn main(): void {
    match stats() {
        Ok(rows) => println("${rows.len()} stat row(s)")
        Err(e) => println("failed")
    }
    match live_count() {
        Ok(n) => println("${n} bookings")
        Err(e) => println("failed")
    }
}

stats has no declared return type and no ?, so it returns the whole Result and its row type is inferred from the projection.

Add GROUP BY and the projection becomes one row per group — see Aggregates:

schema shop

model Booking {
    @id
    id: int
    status: string
    passengers: int
}

type Bucket = { status: string, heads: int }

fn by_status(): []Bucket ! QueryError {
    return FROM Booking
        GROUP BY status
        SELECT { status, heads: sum(passengers) }?
}

fn main(): void {
    match by_status() {
        Ok(buckets) => {
            for b in buckets { println("${b.status}: ${b.heads}") }
        }
        Err(e) => println("failed")
    }
}

Distinct rows

SELECT DISTINCT { ... } de-duplicates on the projected shape, not on the source model:

schema shop

model Booking {
    @id
    id: int
    status: string
}

fn statuses(): []{ status: string } ! QueryError {
    return FROM Booking SELECT DISTINCT { status } ORDER BY status?
}

fn main(): void {
    match statuses() {
        Ok(rows) => println("${rows.len()} distinct statuses")
        Err(e) => println("failed")
    }
}

Ordering is independent of distinctness, so state it explicitly when callers need a deterministic sequence.

Joins and name collisions

Two joined models can offer the same column name. Qualify with the model name and give each projected field a distinct name of its own:

schema shop

model Schedule {
    @id
    id: int
    vessel: string
}

model Booking {
    @id
    id: int
    schedule_id: int
    reference: string

    BELONGS TO schedule: Schedule VIA schedule_id
}

type ManifestRow = { booking_id: int, reference: string, vessel: string }

fn manifest(): []ManifestRow ! QueryError {
    return FROM Booking
        JOIN Schedule
        SELECT {
            booking_id: Booking.id,
            reference,
            vessel: Schedule.vessel,
        }?
}

fn main(): void {
    match manifest() {
        Ok(rows) => {
            for row in rows { println("${row.reference} on ${row.vessel}") }
        }
        Err(e) => println("failed")
    }
}

Both models have an id; the unqualified reference is unambiguous, so it stays bare. Some joined and advanced query shapes cannot materialize a whole-model carrier at all — if lowering reports that a row shape is unsupported, adding a SELECT with the exact columns the caller needs is usually the fix.

Cost

A projection narrows the row the guest decodes; it does not make the read lazy. A projected FROM still materializes its whole result list, computed expressions run in the engine, and conversion into Atoll fields happens during row decoding.

Select only what the caller consumes — especially for large text or binary columns — but never drop a sort key that callers need for paging or merging. Frames use the projection row as their compile-time schema, and a later collect() decodes host-resident columns into the same named fields.

A composed example

schema shop

model Schedule {
    @id
    id: int
    vessel: string
    departs_at: DateTime
}

model Booking {
    @id
    id: int
    schedule_id: int
    reference: string
    passengers: int
    price_each: float
    cancelled: bool

    BELONGS TO schedule: Schedule VIA schedule_id

    INDEX (schedule_id, reference)
}

type ManifestRow = {
    reference: string,
    vessel: string,
    passengers: int,
    revenue: float,
}

fn manifest(schedule: int, size: int): []ManifestRow ! QueryError {
    return FROM Booking
        JOIN Schedule
        WHERE Booking.schedule_id == schedule
        WHERE cancelled == false
        ORDER BY reference ASC
        LIMIT size
        SELECT {
            reference,
            vessel: Schedule.vessel,
            passengers,
            revenue: price_each * passengers,
        }?
}

fn total_revenue(schedule: int): float ! QueryError {
    mut sum := 0.0
    for row in manifest(schedule, 500)? {
        sum = sum + row.revenue
    }
    return sum
}

fn main(): void {
    match total_revenue(11) {
        Ok(total) => println("sailing revenue ${total}")
        Err(e) => println("manifest unavailable")
    }
}

Four columns cross the ABI per row instead of the full booking and schedule, and ManifestRow is the type every caller sees.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close