Skip to content

Models

Declare SQL-backed row shapes with keys, defaults, indexes, and relations.

Updated View as Markdown

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")
    }
}
Navigation

Type to search…

↑↓ navigate↵ selectEsc close