Skip to content

Schemas

Claim a file's models for one logical database, and route them as a group.

Updated View as Markdown

schema NAME claims a file’s models for one logical database. It is the name a [datasources.*] entry in atoll.toml matches when it decides which engine runs a query.

schema seaport

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

model Leg {
    @id
    id: int
    booking_id: int
    port: string
}

fn legs_for(booking: int): []Leg ! QueryError {
    return FROM Leg WHERE booking_id == booking ORDER BY id?
}

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

Both models belong to seaport. The declaration is compile-time metadata that feeds model registration, DDL generation, migration selection, and datasource routing.

One schema per file

The compiler takes the first schema declaration in a file and applies it to every model in that file. Position does not matter, and a second declaration in the same file is inert:

schema seaport
schema audit

// Both of these belong to `seaport`. `audit` claims nothing.
model Booking {
    @id
    id: int
}

model AuditEntry {
    @id
    id: int
    action: string
}

fn trail(): []AuditEntry ! QueryError {
    return FROM AuditEntry ORDER BY id DESC?
}

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

To place models in different schemas, put them in different files. Keep one schema declaration per file, at the top, where a reader will find it.

The older directory convention — a schema.at file claiming every model in the directory tree below it — is not yet a complete project-level guarantee. Until that lands, a file that declares models and needs an unambiguous association should declare its own schema.

Models without a schema

A schema declaration is optional. Models in a file with none compile and query normally; they simply have no schema to match against, so they take the default route:

model Product {
    @id
    id: int
    sku: string
    price: float
}

fn cheap(ceiling: float): []Product ! QueryError {
    return FROM Product WHERE price <= ceiling ORDER BY price?
}

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

With exactly one configured datasource, that datasource serves them. With zero or several, they fall back to SQLite rendering — fine for a test, a configuration mistake in a deployable application.

Not a value, not a namespace

schema introduces no binding. The name is not importable, not callable, and not addressable from expression code:

schema seaport

model Booking {
    @id
    id: int
}

fn f(): int {
    return seaport.count
}

It is also a top-level declaration, so it cannot appear inside a function:

fn f(): int {
    schema seaport
    return 1
}

And the name is one plain identifier — no dots, no path segments:

schema seaport.bookings

model Booking {
    @id
    id: int
}

Schema versus module

Modules and schemas answer different questions, and a file usually has both:

Concept Controls Declared by
Module source names, imports, visibility the directory (plus module NAME)
Schema which logical database owns a model schema NAME
Datasource engine, connection, dialect, write policy [datasources.*] in atoll.toml
module catalog

schema shop

model Product {
    @id
    id: int
    sku: string
    price: float
}

fn by_sku(wanted: string): { id: int, sku: string, price: float }? ! QueryError {
    return ONE FROM Product WHERE sku == wanted SELECT { id, sku, price }?
}

fn main(): void {
    match by_sku("A-1") {
        Ok(row) => println("price ${row?.price ?? 0.0}")
        Err(e) => println("lookup failed")
    }
}

One module can query models from several schemas, and one schema can be routed to different engines between deployments. Neither fact changes import syntax.

Table naming still has to stay unambiguous within a generated schema even when two modules happen to declare same-named models; use @sql.name to disambiguate.

Joins stay inside a schema

A join reaches across models, so the models it touches should share a schema and therefore a route:

schema seaport

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

    BELONGS TO schedule: Schedule VIA schedule_id
}

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

type ManifestRow = { id: int, vessel: string, passengers: int }

fn manifest(minimum: int): []ManifestRow ! QueryError {
    return FROM Booking
        JOIN Schedule
        WHERE passengers >= minimum
        SELECT { id, vessel: Schedule.vessel, passengers }?
}

fn main(): void {
    match manifest(2) {
        Ok(rows) => {
            mut heads := 0
            for row in rows { heads = heads + row.passengers }
            println("${rows.len()} sailings, ${heads} passengers")
        }
        Err(e) => println("manifest failed")
    }
}

Cross-schema join rejection is not fully enforced today, so a program that compiles is not by itself proof of a clean data boundary. Transaction checking is strict: every query inside one transaction block must resolve to the same datasource route.

Where cross-schema access needs authorization, consistency, or deployment restrictions, put an explicit service or repository function in the way. A schema name is routing metadata, not an access-control mechanism.

Changing a model’s schema

Moving a model between schemas is a data migration, not a source refactor. One edit can change several outputs at once:

Surface Possible change
Query routing a different datasource and engine may be selected
Dialect gates previously accepted SQL may become unsupported
Transactions operations may no longer share one route identity
DDL snapshots tables, foreign keys, and indexes move between snapshots
Migrations the move may render as a destructive drop plus create
Runtime data stored rows do not move because source metadata did

The dialect-gate row is the one that bites during review, because it turns a compiling program into a failing one with no source edit. Move this model from a schema routed to PostgreSQL to one routed to the SQLite default and the array column stops compiling:

schema seaport

model Vessel {
    @id
    id: int
    tags: []string
}

fn f(): []Vessel ! QueryError {
    return FROM Vessel?
}

That reports ATOLL3232. Re-check the whole project after a routing change rather than assuming the source is engine-neutral, and verify both the rendered migration and the live-data movement before deploying a schema rename.

Generated DDL

The compiler builds a desired schema snapshot from the registered models and renders per-dialect CREATE TABLE, foreign-key, and index statements; atoll migrate diff compares that snapshot against an introspected live database. A model with a BELONGS TO ... VIA and an index renders all three kinds of statement together:

schema seaport

model Schedule {
    @id
    id: int
    capacity: int
}

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

    BELONGS TO schedule: Schedule VIA schedule_id

    INDEX (status)
}

fn queued(): []Booking ! QueryError {
    return FROM Booking WHERE status == "queued" ORDER BY id?
}

fn main(): void {
    match queued() {
        Ok(rows) => println("${rows.len()} queued")
        Err(e) => println("read failed")
    }
}
CREATE TABLE schedule (
  id BIGINT NOT NULL,
  capacity BIGINT NOT NULL,
  PRIMARY KEY (id)
);

CREATE TABLE booking (
  id BIGINT NOT NULL,
  status TEXT NOT NULL,
  schedule_id BIGINT NOT NULL,
  PRIMARY KEY (id),
  CONSTRAINT booking_schedule_id_fkey FOREIGN KEY (schedule_id) REFERENCES schedule (id)
);

CREATE INDEX booking_status_idx ON booking (status);

Referenced tables are emitted before the tables that reference them, and constraint and index names are derived from the model and column names — so renaming a field renames its constraint too.

Because temporal and alias types use wire-faithful storage mappings, reverse introspection recovers storage shape rather than every original domain type. Read a generated migration as a database change, not as a lossless round trip of your source.

For the route that a schema name selects — and the difference between schema, route, engine, URL, and host capability — continue to Datasources.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close