Skip to content

Reads

Read rows by scan, by predicate, or by primary key, and keep "no row" separate from "query failed".

Updated View as Markdown

A read is an expression that evaluates to Result[T, QueryError]. FROM scans, ONE FROM takes at most one row, and GET looks up by primary key.

schema seaport

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

fn all_bookings(): []Booking ! QueryError {
    return FROM Booking?
}

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

The execution call can suspend while the host waits for a connection or a response. That suspension and the QueryError both propagate through the enclosing function like any other typed host operation — there is no separate async query syntax.

Result shapes

Query Success type after ?
FROM Model []Model
FROM Model SELECT { .. } []Row
ONE FROM Model SELECT { .. } Row? — the SELECT is mandatory
GET Model(key) Model?
GET Model(keys) Map[Key, Model]
GET Model(key) SELECT { .. } Row?
... AS FRAME Frame[Row] — see Frames

Without ?, wrap each of those in Result[..., QueryError].

schema seaport

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

fn scan(): []Booking ! QueryError {
    return FROM Booking?
}

fn single(booking: int): Booking? ! QueryError {
    return GET Booking(booking)?
}

fn batch(ids: []int): Map[int, Booking] ! QueryError {
    return GET Booking(ids)?
}

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

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

fn main(): void {
    match scan() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match single(1) { Ok(b) => println("${b?.reference ?? "-"}") Err(e) => println("failed") }
    match batch([1, 2]) { Ok(m) => println("${m.len()}") Err(e) => println("failed") }
    match projected() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match first_match(2) { Ok(row) => println("${row?.reference ?? "-"}") Err(e) => println("failed") }
}

ONE FROM needs a projection

ONE FROM is FROM with an implicit LIMIT 1 and an optional result. Today it lowers only with a SELECT projection — a bare ONE FROM Model has no row encoding and is rejected:

schema seaport

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

fn by_reference(wanted: string): Booking? ! QueryError {
    return ONE FROM Booking WHERE reference == wanted?
}

That reports ATOLL3244: this ONE query cannot be lowered to SQL. Note also what the result type is not: even with a SELECT listing every column, a ONE FROM gives you an anonymous row, never a Model?. Only GET carries a whole model. There are three ways to write what you meant:

schema seaport

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

type BookingRow = { id: int, reference: string, passengers: int }

// 1. Project the columns you want — list them all to recover the full row.
fn by_reference(wanted: string): BookingRow? ! QueryError {
    return ONE FROM Booking
        WHERE reference == wanted
        SELECT { id, reference, passengers }?
}

// 2. Look up by primary key instead; `GET` carries the whole model.
fn by_key(booking: int): Booking? ! QueryError {
    return GET Booking(booking)?
}

// 3. Scan with an explicit order and `LIMIT 1`, and read the first element.
fn newest(): Booking? ! QueryError {
    rows := FROM Booking ORDER BY id DESC LIMIT 1?
    return rows.get(0)
}

fn main(): void {
    match by_reference("BK-1") { Ok(r) => println("${r?.passengers ?? 0}") Err(e) => println("failed") }
    match by_key(1) { Ok(b) => println("${b?.reference ?? "-"}") Err(e) => println("failed") }
    match newest() { Ok(b) => println("${b?.id ?? -1}") Err(e) => println("failed") }
}

Use ONE FROM only where uniqueness is guaranteed by a key or constraint, or where an explicit ORDER BY defines which matching row is wanted. Without an order the engine may return any matching row, and a plan or data change can silently pick a different one.

Primary-key reads

GET is primary-key lookup sugar. A single key gives an optional model; a list of keys gives a map.

schema seaport

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

fn one(booking: int): Booking? ! QueryError {
    return GET Booking(booking)?
}

fn many(ids: []int): Map[int, Booking] ! QueryError {
    return GET Booking(ids)?
}

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

For a composite key, pass each component positionally in declaration order:

schema seaport

model Berth {
    @id
    vessel_id: int
    @id
    slot: int
    occupied: bool
}

fn berth(vessel: int, slot: int): Berth? ! QueryError {
    return GET Berth(vessel, slot)?
}

fn main(): void {
    match berth(4, 2) {
        Ok(b) => println("${b?.occupied ?? false}")
        Err(e) => println("failed")
    }
}

A whole-model GET builds its row encoding from every column, and that encoding does not yet cover a nullable one. A model with any T? field is readable by FROM but not by a bare GET:

schema seaport

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

fn f(booking: int): Booking? ! QueryError {
    return GET Booking(booking)?
}

Add a SELECT to the GET, or scan by key with LIMIT 1 — see Models.

A batch result is keyed for lookup, not ordered. Missing keys are simply absent, and duplicate input keys collapse to one entry. When the output must follow the input order, iterate the key list and query the map:

schema seaport

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

fn references_in_order(ids: []int): []string ! QueryError {
    by_id := GET Booking(ids)?
    mut out: []string = []
    for wanted in ids {
        match by_id.get(wanted) {
            Some(b) => out.add(b.reference)
            None => out.add("")
        }
    }
    return out
}

fn main(): void {
    match references_in_order([3, 1, 2]) {
        Ok(refs) => println("${refs.len()} in request order")
        Err(e) => println("failed")
    }
}

Three outcomes, not two

A single-row read distinguishes “the row is not there” from “the query did not run”. Keep them apart:

schema seaport

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

fn describe(booking: int): string {
    match GET Booking(booking) {
        Ok(Some(b)) => return b.reference
        Ok(None) => return "no such booking"
        Err(e) => return "lookup failed"
    }
}

fn main(): void {
    println(describe(1))
}

Bind the failure as e or err. error is a keyword in Atoll and cannot be used as an identifier, so Err(error) does not parse.

The same distinction holds for a scan: an empty list means the query succeeded and matched nothing.

schema seaport

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

fn large_count(minimum: int): int {
    match FROM Booking WHERE passengers >= minimum {
        Ok(rows) => return rows.len()
        Err(e) => return -1
    }
}

fn main(): void {
    n := large_count(4)
    if n < 0 { println("query failed") } else { println("${n} large bookings") }
}

Collapsing [], None, and QueryError into one sentinel throws away the only information a caller has for deciding between “show nothing”, “show a 404”, and “retry”.

Ordering and paging

Row order is part of a query’s contract only when ORDER BY says so. That matters beyond presentation: a LIMIT without a stable order selects an unspecified subset, and paging can repeat or skip rows when equal sort keys are not broken by a unique column.

schema seaport

model Booking {
    @id
    id: int
    created_at: DateTime
}

// Offset paging: simple, but concurrent inserts shift later pages.
fn offset_page(size: int, skip: int): []Booking ! QueryError {
    return FROM Booking ORDER BY id ASC LIMIT size OFFSET skip?
}

// Keyset paging: the sort ends on a unique column, and the predicate uses
// the same fields, so pages stay stable under concurrent writes.
fn keyset_page(cursor: DateTime, size: int): []Booking ! QueryError {
    return FROM Booking
        WHERE created_at <= cursor
        ORDER BY created_at DESC, id DESC
        LIMIT size?
}

fn main(): void {
    match offset_page(20, 40) { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match keyset_page(DateTime.now(), 20) { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
}

Parameters

Any in-scope local or parameter used inside a query becomes a prepared parameter, retaining its Atoll type and encoded through the SQL ABI:

schema seaport

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

fn search(minimum: int, prefix: string, n: int): []Booking ! QueryError {
    return FROM Booking
        WHERE passengers >= minimum
        WHERE reference.starts_with(prefix)
        ORDER BY id
        LIMIT n?
}

fn main(): void {
    match search(2, "BK-", 50) {
        Ok(rows) => println("${rows.len()} hits")
        Err(e) => println("failed")
    }
}

The binding has to be a plain name. A constant or a field path is not a parameter form the planner recognises, and the query fails to lower:

schema seaport

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

const MINIMUM: int = 4

fn f(): []Booking ! QueryError {
    return FROM Booking WHERE passengers >= MINIMUM?
}

Bind it to a local first — the extra line costs nothing and the query renders identically:

schema seaport

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

const MINIMUM: int = 4

struct Filter { minimum: int }

fn by_const(): []Booking ! QueryError {
    minimum := MINIMUM
    return FROM Booking WHERE passengers >= minimum?
}

fn by_field(filter: Filter): []Booking ! QueryError {
    minimum := filter.minimum
    return FROM Booking WHERE passengers >= minimum?
}

fn main(): void {
    match by_const() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
    match by_field(Filter { minimum: 6 }) { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
}

Literals are fine directly in a predicate; they render as bound parameters too.

Query boundaries

A query expression starts at its verb and swallows the query clauses that follow, so the postfix ? binds to the whole thing. Parenthesize when you want to continue with ordinary Atoll member access:

schema seaport

model Booking {
    @id
    id: int
    active: bool
}

fn counted(): int ! QueryError {
    rows := FROM Booking WHERE active?
    return rows.size()
}

fn counted_inline(): int ! QueryError {
    return (FROM Booking WHERE active?).size()
}

fn main(): void {
    match counted() { Ok(n) => println("${n}") Err(e) => println("failed") }
    match counted_inline() { Ok(n) => println("${n}") Err(e) => println("failed") }
}

Formatting one clause per line makes both the clause boundary and the trailing ? obvious in review. Both .len() and .size() give the row count.

Materialization

An ordinary read completes only after its rows have been decoded into the success carrier. The list, optional row, or map is then a plain owned Atoll value: it does not borrow a cursor or hold a connection open.

That makes lifetimes simple and makes cost proportional to the materialized result. A wide FROM over a large table builds the whole list in guest memory — use predicates, projections, and limits when a caller does not need every column or row, and use AS FRAME when you want a host-resident analytical plan instead.

Decoding is part of the fallible operation. A response whose cells cannot be represented by the checked carrier yields a QueryError.Encoding rather than a partially initialized model; no success value is published until the runtime has accepted the whole result shape.

A composed example

A manifest read: an indexed predicate, a stable order, a named projection row, and a caller that handles the failure channel explicitly.

schema seaport

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

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

    BELONGS TO schedule: Schedule VIA schedule_id

    INDEX (schedule_id, id)
}

type ManifestRow = { reference: string, passengers: int }

fn manifest(schedule: int): []ManifestRow ! QueryError {
    return FROM Booking
        WHERE schedule_id == schedule
        WHERE cancelled == false
        ORDER BY reference ASC
        SELECT { reference, passengers }?
}

fn headcount(schedule: int): int {
    match manifest(schedule) {
        Ok(rows) => {
            mut total := 0
            for row in rows { total = total + row.passengers }
            return total
        }
        // -1 marks "could not read", which is not the same as an empty sailing.
        Err(e) => return -1
    }
}

fn main(): void {
    heads := headcount(11)
    if heads < 0 { println("manifest unavailable") } else { println("${heads} aboard") }
}

Failure classes

QueryError carries portable classification helpers: is_constraint(), is_conflict(), is_retryable(), and is_not_found(). Engine keeps the portable SqlErrorCode, the engine’s native code, and a human message; Encoding reports a result-carrier problem; Backend reports missing or malformed runtime execution support.

Branch on the portable class first and reserve native() for engine-specific policy. The runtime does not retry application queries for you — retry only operations that are safe to repeat.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close