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.