A model is a named persisted row shape. It looks like a struct declaration,
but its fields are also database columns and query-scope column names.
schema shop
model Booking {
@id
id: int
reference: string
passengers: int
notes: string?
}
fn all(): []Booking ! QueryError {
return FROM Booking?
}
fn main(): void {
match all() {
Ok(rows) => println("${rows.len()} bookings")
Err(e) => println("read failed")
}
}Model values are ordinary Atoll values. Constructing one does not touch a
database — SAVE, INSERT, and UPSERT are the persistence operations:
schema shop
model Booking {
@id
id: int
reference: string
passengers: int = 1
notes: string?
}
// Just an in-memory value. Nothing is written.
fn draft(reference: string): Booking {
return Booking { id: 0, reference: reference, notes: None }
}
// A default only fills an omitted field; it can still be overridden.
fn draft_group(reference: string): Booking {
return Booking { id: 0, reference: reference, passengers: 4, notes: None }
}
fn note_or_blank(b: Booking): string {
return b.notes ?? ""
}
fn main(): void {
solo := draft("BK-1")
group := draft_group("BK-2")
println("${solo.passengers} then ${group.passengers}")
println("notes: '${note_or_blank(solo)}'")
}Field defaults participate in Atoll construction. They are not a promise
about generated database DEFAULT clauses — do not rely on omitting a column
in a raw database write to apply the Atoll expression.
Field types
Not every Atoll type can be carried back out of a row. These read back from a whole-model query today:
| Category | Types |
|---|---|
| Integers | int, i32, i64, u32, u64 |
| Floats | float, f32, f64 |
| Other scalars | bool, string |
| Temporal | DateTime, Date, Time, Duration |
| Optional | any of the above with ? |
schema shop
model Reading {
@id
id: int
sensor: string
celsius: f64
ok: bool
taken_at: DateTime
note: string?
score: int?
}
fn recent(cutoff: DateTime): []Reading ! QueryError {
return FROM Reading
WHERE taken_at >= cutoff
ORDER BY taken_at DESC?
}
fn main(): void {
match recent(DateTime.from_epoch_seconds(0)) {
Ok(rows) => println("${rows.len()} readings")
Err(e) => println("read failed")
}
}Narrow widths (i8, i16, u8, u16), i128/u128, usize, char,
byte, bytes, and substring parse in a model declaration but have no row
encoding, so a whole-model read of such a model does not lower:
schema shop
model Reading {
@id
id: int
level: i8
}
fn f(): []Reading ! QueryError {
return FROM Reading?
}That reports ATOLL3244: this FROM query cannot be lowered to SQL. Either
widen the field to i32/int, or project only the carriable columns:
schema shop
model Reading {
@id
id: int
level: i8
celsius: f64
}
fn readable(): []{ id: int, celsius: f64 } ! QueryError {
return FROM Reading SELECT { id, celsius }?
}
fn main(): void {
match readable() {
Ok(rows) => println("${rows.len()}")
Err(e) => println("read failed")
}
}Native array columns ([]int, []string, []float) exist in the grammar but
are gated on engines with array support; the SQLite/Turso default rejects them
with ATOLL3232.
Optional fields map to nullable columns; a required Atoll field is expected in
every decoded row. Schema drift that removes a column or changes its storage
type surfaces as a QueryError.Encoding or an engine error, never as a
half-built model value.
Keys
@id marks a primary-key field. It drives GET, REMOVE, upsert conflict
targets, and generated DDL.
schema shop
model Booking {
@id
id: int
reference: string
}
fn find(booking_id: int): Booking? ! QueryError {
return GET Booking(booking_id)?
}
fn find_many(ids: []int): Map[int, Booking] ! QueryError {
return GET Booking(ids)?
}
fn drop_one(booking_id: int): bool ! QueryError {
return REMOVE Booking(booking_id)?
}
fn drop_many(ids: []int): int ! QueryError {
return REMOVE Booking(ids)?
}
fn main(): void {
match find(1) { Ok(b) => println("found?") Err(e) => println("failed") }
match find_many([1, 2]) { Ok(m) => println("${m.len()}") Err(e) => println("failed") }
match drop_one(1) { Ok(gone) => println("${gone}") Err(e) => println("failed") }
match drop_many([2, 3]) { Ok(n) => println("${n}") Err(e) => println("failed") }
}Repeat @id for a composite key. Components are passed to GET/REMOVE
positionally, in declaration order:
schema shop
model Membership {
@id
org_id: int
@id
user_id: int
role: string
}
fn membership(org: int, user: int): Membership? ! QueryError {
return GET Membership(org, user)?
}
fn admins(): []Membership ! QueryError {
return FROM Membership WHERE role == "admin"?
}
fn main(): void {
match membership(1, 2) { Ok(m) => println("looked up") Err(e) => println("failed") }
match admins() { Ok(rows) => println("${rows.len()} admins") Err(e) => println("failed") }
}Keep key fields stable once a row is persisted. Changing an in-memory key does not move the stored row; it makes the value refer to a different one.
When no field carries @id, a field named id is the conventional key.
GET and nullable columns
A whole-model GET builds its row encoding from every column, and that
encoding does not yet handle a nullable one. A model with any T? field is
readable by FROM but not by a bare GET:
schema shop
model Booking {
@id
id: int
reference: string
notes: string?
}
fn f(booking_id: int): Booking? ! QueryError {
return GET Booking(booking_id)?
}That reports ATOLL3244: this GET query cannot be lowered to SQL. FROM over
the same model is fine, so both workarounds are one line:
schema shop
model Booking {
@id
id: int
reference: string
notes: string?
}
type BookingRow = { id: int, reference: string, notes: string? }
// 1. Project the columns explicitly — `GET ... SELECT` lowers.
fn keyed(booking_id: int): BookingRow? ! QueryError {
return GET Booking(booking_id) SELECT { id, reference, notes }?
}
// 2. Or scan by key and take the first row, which keeps the whole model.
fn scanned(booking_id: int): Booking? ! QueryError {
rows := FROM Booking WHERE id == booking_id LIMIT 1?
return rows.get(0)
}
fn main(): void {
match keyed(1) { Ok(row) => println("${row?.reference ?? "-"}") Err(e) => println("failed") }
match scanned(1) { Ok(b) => println("${b?.reference ?? "-"}") Err(e) => println("failed") }
}@unique
@unique on a field becomes a single-column unique index, and a violation
arrives at run time as a portable QueryError class:
schema shop
model Booking {
@id
id: int
@unique
reference: string
}
fn store(booking_id: int, reference: string): Booking ! QueryError {
return SAVE Booking { id: booking_id, reference: reference }?
}
fn store_if_free(booking_id: int, reference: string): Booking? {
match store(booking_id, reference) {
Ok(b) => return Some(b)
Err(e) => {
// A duplicate `reference` reports UniqueViolation, which
// `is_constraint()` covers. Never parse the message for this.
if e.is_constraint() { return None }
return None
}
}
}
fn main(): void {
match store_if_free(1, "BK-1") {
Some(b) => println("stored ${b.reference}")
None => println("taken or failed")
}
}SQL identifiers
The table name defaults to the lowercased model name and each column name to
the Atoll field name. @sql.name("...") overrides either, on the model or on
one field:
schema shop
@sql.name("bookings_v2")
model Booking {
@id
id: int
@sql.name("booking_no")
booking_number: string
}
// Atoll code still says `booking_number`; only the rendered SQL changes.
fn by_number(wanted: string): []Booking ! QueryError {
return FROM Booking WHERE booking_number == wanted?
}
fn main(): void {
match by_number("BK-1") {
Ok(rows) => println("${rows.len()}")
Err(e) => println("failed")
}
}An override is an identifier, not an SQL fragment, and the renderer quotes it per dialect. Treat it as frozen once migrations have created the table.
Indexes
Indexes are clauses inside the model body, not decorators. INDEX and
UNIQUE INDEX each accept an optional name, a column list, and per-column
DESC:
schema shop
model Event {
@id
id: int
tenant_id: int
created_at: DateTime
payload: string
INDEX (tenant_id, created_at DESC)
UNIQUE INDEX event_key (tenant_id, id)
}
fn tenant_feed(tenant: int, n: int): []Event ! QueryError {
return FROM Event
WHERE tenant_id == tenant
ORDER BY created_at DESC, id DESC
LIMIT n?
}
fn main(): void {
match tenant_feed(1, 20) {
Ok(rows) => println("${rows.len()} events")
Err(e) => println("failed")
}
}The grammar also accepts USING, WITH (...), INCLUDE (...), and a partial
WHERE predicate:
schema shop
model Event {
@id
id: int
tenant_id: int
active: bool
payload: string
INDEX (tenant_id) USING BTREE
INDEX (tenant_id) INCLUDE (payload)
INDEX (tenant_id) WHERE active
}
fn live(tenant: int): []Event ! QueryError {
return FROM Event WHERE tenant_id == tenant WHERE active?
}
fn main(): void {
match live(1) {
Ok(rows) => println("${rows.len()} live")
Err(e) => println("failed")
}
}Index types are validated against the selected dialect. Core index DDL and migrations ship; covering columns and partial predicates are not complete on every path, so read the rendered migration before applying it.
Index order and direction affect planning. They never change the model’s Atoll field order.
Relations
Relation clauses record an association between models. They are metadata: they do not add a navigable field that loads related rows.
schema shop
model Customer {
@id
id: int
name: string
HAS MANY orders: Order VIA customer_id
}
model Order {
@id
id: int
customer_id: int
total: float
BELONGS TO customer: Customer VIA customer_id
}
// Retrieve related data with a checked join, not by reading `.customer`.
fn order_lines(): []{ id: int, who: string, total: float } ! QueryError {
return FROM Order
JOIN Customer
SELECT { id, who: Customer.name, total }?
}
fn main(): void {
match order_lines() {
Ok(rows) => println("${rows.len()} lines")
Err(e) => println("failed")
}
}BELONGS TO, HAS ONE, HAS MANY, and MANY TO MANY all parse. A
single-column BELONGS TO ... VIA can generate a foreign key and supplies the
implicit ON for a join between those two models. Composite foreign keys,
referential actions, and many-to-many join-table synthesis are not complete —
write the ON explicitly when a relation cannot supply it, and expect
ATOLL3244 from a join whose ON cannot be derived at all.
DDL and migrations
Registered models are the compiler’s desired schema. The atoll migrate
subcommands compare that against a live database:
| Command | What it does |
|---|---|
atoll migrate status |
reports whether the datasource matches the models (a CI drift gate) |
atoll migrate diff |
prints the statements that would bring the datasource in sync |
atoll migrate generate |
writes an up/down migration pair from the pending diff |
atoll migrate apply |
applies the pending migration (guarded; dry run by default) |
atoll migrate introspect |
goes the other way — generates Atoll model source from a live schema |
All of them run against the project’s atoll.toml route, so they need a
configured datasource.
This model:
schema shop
model Booking {
@id
id: int
title: string
notes: string?
}
fn all(): []Booking ! QueryError { return FROM Booking? }
fn main(): void {
match all() { Ok(rows) => println("${rows.len()}") Err(e) => println("failed") }
}renders for PostgreSQL as:
CREATE TABLE booking (
id BIGINT NOT NULL,
title TEXT NOT NULL,
notes TEXT,
PRIMARY KEY (id)
)Two things to read out of that. Column types are wire-faithful, not
domain-faithful: int becomes BIGINT on PostgreSQL and INTEGER on SQLite,
string becomes TEXT there and VARCHAR(255) on MySQL, and a DateTime
becomes the integer micros the SQL wire format carries. And optionality is the
only source of NOT NULL — notes: string? is the one nullable column.
Because the mapping is wire-faithful, atoll migrate introspect recovers
storage shape rather than every original domain type: a round trip through the
database gives you int, not the DateTime alias you started with.
Review generated migrations as database changes. A source rename with no explicit migration intent looks like a destructive drop-and-add, and some engine-specific type changes are not reversible.
What models are not
A model is a row shape plus its storage metadata. There is no inheritance, no
behavior block, no validation DSL, and no abstract/data model hierarchy — older
design documents describe those, and the current model declaration does not
implement them.
Put application behavior in ordinary functions, and put invariants in explicit constructors or database constraints:
schema shop
model Booking {
@id
id: int
reference: string
passengers: int
}
error BookingError {
NoPassengers
TooLarge { limit: int }
}
// Validation is a function you call, not something the model performs.
fn checked(booking_id: int, reference: string, passengers: int): Booking ! BookingError {
if passengers < 1 { error NoPassengers }
if passengers > 12 { error TooLarge { limit: 12 } }
return Booking { id: booking_id, reference: reference, passengers: passengers }
}
fn main(): void {
match checked(1, "BK-1", 4) {
Ok(b) => println("ok ${b.passengers}")
Err(e) => println("rejected")
}
match checked(2, "BK-2", 0) {
Ok(b) => println("ok ${b.passengers}")
Err(e) => println("rejected")
}
}