Writes are query expressions like reads: checked against the model, rendered
into a prepared statement, and typed Result[T, QueryError]. No value a write
carries is ever spliced into SQL text.
schema shop
model Order {
@id
id: int
customer_id: int
total: float
status: string
}
fn record(new_id: int, customer: int, amount: float): Order ! QueryError {
return SAVE Order {
id: new_id,
customer_id: customer,
total: amount,
status: "new",
}?
}
fn main(): void {
match record(1, 42, 19.5) {
Ok(order) => println("saved order ${order.id} (${order.status})")
Err(e) => println("write failed")
}
}SAVE returns the saved model, so record hands back an Order. Other verbs
return other things, and picking the right one is mostly about picking the
success value you want to reason about.
Which verb, and what it gives back
| Form | Success value |
|---|---|
SAVE Model { ... } |
the saved Model |
SAVE Model FROM rows |
[]Model |
UPSERT Model ON keys { ... } |
affected-row int |
UPDATE / DELETE FROM without RETURNING |
affected-row int |
REMOVE Model(key) |
bool — whether a row existed |
REMOVE Model(keys) |
affected-row int |
INSERT INTO Model FROM rows |
affected-row int |
any write ... RETURNING { ... } |
a list of projected rows |
single-key REMOVE ... RETURNING { ... } |
one optional projected row |
These are not interchangeable spellings. Swapping SAVE for a plain insert
turns a duplicate key from an error into an update. Swapping a single-key
REMOVE for a predicate DELETE FROM changes the result from “did that row
exist” into “how many rows went away”, and widens what can be deleted.
Save
SAVE upserts one complete model by primary key, and returns the stored row:
schema shop
model Order {
@id
id: int
customer_id: int
total: float
status: string
}
fn store(new_id: int, customer: int, amount: float): Order ! QueryError {
return SAVE Order {
id: new_id,
customer_id: customer,
total: amount,
status: "new",
}?
}
fn main(): void {
match store(1, 42, 19.5) {
Ok(saved) => println("stored order ${saved.id} with status ${saved.status}")
Err(e) => println("write failed")
}
}The value block is a record literal, so fields are comma-separated and a bare
name is shorthand for name: name:
schema shop
model Order {
@id
id: int
customer_id: int
total: float
status: string
}
fn store(id: int, customer_id: int, total: float, status: string): Order ! QueryError {
return SAVE Order { id, customer_id, total, status }?
}
fn main(): void {
match store(1, 42, 19.5, "new") {
Ok(saved) => println("stored ${saved.id}")
Err(e) => println("write failed")
}
}The batch form takes a list of models and upserts each one, returning the saved rows:
schema shop
model Order {
@id
id: int
customer_id: int
total: float
}
fn store_all(orders: []Order): []Order ! QueryError {
return SAVE Order FROM orders?
}
fn main(): void {
mut batch: []Order
batch.add(Order { id: 1, customer_id: 42, total: 19.5 })
batch.add(Order { id: 2, customer_id: 42, total: 3.25 })
match store_all(batch) {
Ok(saved) => println("stored ${saved.len()} orders")
Err(e) => println("write failed")
}
}The batch path executes a prepared single-row statement repeatedly. It handles all-scalar model rows; nullable-column batch binding and some relationship-bearing shapes are still incomplete.
Nullable columns
A value block may omit a nullable column, or bind it from an Option-typed
local. Both write SQL NULL when there is no value:
schema shop
model Order {
@id
id: int
total: float
note: string?
}
// The column simply is not mentioned.
fn without_note(new_id: int, amount: float): Order ! QueryError {
return SAVE Order { id: new_id, total: amount }?
}
// The column is bound from a `string?` — `None` and `Some` both work.
fn with_note(new_id: int, amount: float, note: string?): Order ! QueryError {
return SAVE Order { id: new_id, total: amount, note }?
}
fn main(): void {
match without_note(1, 19.5) { Ok(o) => println("${o.id}") Err(e) => println("failed") }
match with_note(2, 3.25, None) { Ok(o) => println("${o.id}") Err(e) => println("failed") }
match with_note(3, 8.0, "gift wrap") { Ok(o) => println("${o.id}") Err(e) => println("failed") }
}What does not work is spelling the literal None in the value block. That
shape has no plan, so the whole statement fails to lower:
schema shop
model Order {
@id
id: int
total: float
note: string?
}
fn broken(new_id: int): Order ! QueryError {
return SAVE Order { id: new_id, total: 1.0, note: None }?
}That is ATOLL3244. Omit the field, or route it through an Option local as
with_note does.
Update
UPDATE changes every row matching its predicate and reports how many it
touched:
schema shop
model Order {
@id
id: int
customer_id: int
status: string
}
fn cancel_all(customer: int): int ! QueryError {
return UPDATE Order
SET status = "cancelled"
WHERE customer_id == customer?
}
fn main(): void {
match cancel_all(42) {
Ok(n) => println("cancelled ${n} orders")
Err(e) => println("update failed")
}
}One SET clause per column; repeat the keyword to change several:
schema shop
model Order {
@id
id: int
total: float
status: string
note: string
}
fn settle(wanted: int, amount: float, note: string): int ! QueryError {
return UPDATE Order
SET status = "settled"
SET total = amount
SET note = note
WHERE id == wanted?
}
fn main(): void {
match settle(1, 19.5, "paid by card") {
Ok(n) => println("settled ${n} orders")
Err(e) => println("update failed")
}
}The assigned value must be a bare local, a parameter, or a literal — nothing
else. Reading the old row in the assignment is not supported, so the familiar
SET count = count + 1 does not lower:
schema shop
model Order {
@id
id: int
retries: int
}
fn broken(wanted: int): int ! QueryError {
return UPDATE Order
SET retries = retries + 1
WHERE id == wanted?
}That reports ATOLL3244. The same restriction bites Atoll-side arithmetic:
SET total = amount * 2.0 is rejected even though amount is a parameter,
because the right-hand side is an expression rather than a bare name. Compute
the value into a local first:
schema shop
model Order {
@id
id: int
total: float
}
fn double_price(wanted: int, amount: float): int ! QueryError {
doubled := amount * 2.0
return UPDATE Order
SET total = doubled
WHERE id == wanted?
}
fn main(): void {
match double_price(1, 19.5) {
Ok(n) => println("repriced ${n} orders")
Err(e) => println("update failed")
}
}For a genuine read-modify-write, read the row, compute in Atoll, and write it back with a predicate that pins the version you read — a compare-and-set, which is safer than a blind increment anyway.
RETURNING swaps the affected-row count for a list of projected rows:
schema shop
model Order {
@id
id: int
customer_id: int
status: string
}
fn cancel_and_report(
customer: int,
): []{ id: int, status: string } ! QueryError {
return UPDATE Order
SET status = "cancelled"
WHERE customer_id == customer
RETURNING { id, status }?
}
fn main(): void {
match cancel_and_report(42) {
Ok(rows) => {
for row in rows {
println("order ${row.id} is now ${row.status}")
}
}
Err(e) => println("update failed")
}
}The affected count is a signal
An update that matches nothing is a success with count 0, not a
QueryError — the database ran the statement and found no row. Put the state
you expect into the predicate and treat 0 as the conflict:
schema shop
model Order {
@id
id: int
status: string
}
error OrderError { Conflict { id: int } }
fn advance(wanted: int, expected: string, next: string): void ! OrderError {
n := (
UPDATE Order
SET status = next
WHERE id == wanted && status == expected
) catch {
_ => error Conflict { id: wanted }
}
if n == 0 {
error Conflict { id: wanted }
}
}
fn main(): void {
match advance(1, "open", "settled") {
Ok(_) => println("advanced")
Err(Conflict { id }) => println("order ${id} was not in the expected state")
}
}The WHERE status == expected clause makes the prior state part of the write,
so two concurrent callers cannot both advance the same order.
A clause expression must be a plain local, a parameter, or a literal. A field
access such as order.id is not lowered — bind it to a local first:
schema shop
model Order {
@id
id: int
status: string
}
fn settle(order: Order): int ! QueryError {
wanted := order.id
return UPDATE Order
SET status = "settled"
WHERE id == wanted && status == "open"?
}
fn main(): void {
order := Order { id: 1, status: "open" }
match settle(order) {
Ok(n) => println("settled ${n} rows")
Err(e) => println("update failed")
}
}Delete and remove
DELETE FROM is predicate-based and can remove many rows:
schema shop
model Order {
@id
id: int
total: float
status: string
}
fn purge_empty(): int ! QueryError {
return DELETE FROM Order WHERE total == 0.0?
}
fn purge_all(): int ! QueryError {
return DELETE FROM Order?
}
fn main(): void {
match purge_empty() {
Ok(n) => println("removed ${n} zero-value orders")
Err(e) => println("delete failed")
}
match purge_all() {
Ok(n) => println("removed everything: ${n} rows")
Err(e) => println("delete failed")
}
}REMOVE deletes by primary key instead, and its result makes absence explicit:
schema shop
model Order {
@id
id: int
total: float
}
fn drop_one(wanted: int): bool ! QueryError {
return REMOVE Order(wanted)?
}
fn drop_many(ids: []int): int ! QueryError {
return REMOVE Order(ids)?
}
fn main(): void {
match drop_one(1) {
Ok(existed) => println("row 1 existed: ${existed}")
Err(e) => println("remove failed")
}
mut ids: []int
ids.add(2)
ids.add(3)
match drop_many(ids) {
Ok(n) => println("removed ${n} rows")
Err(e) => println("remove failed")
}
}drop_one returns false when no such row existed — absence is a value, not
an error. With RETURNING, the single-key form yields an optional row and the
batch form a list:
schema shop
model Order {
@id
id: int
total: float
}
fn drop_and_report(wanted: int): { id: int, total: float }? ! QueryError {
return REMOVE Order(wanted) RETURNING { id, total }?
}
fn drop_batch_and_report(ids: []int): float ! QueryError {
mut refunded := 0.0
for r in REMOVE Order(ids) RETURNING { id, total }? {
refunded = refunded + r.total
}
return refunded
}
fn main(): void {
match drop_and_report(1) {
Ok(Some(r)) => println("removed ${r.id} worth ${r.total}")
Ok(None) => println("nothing to remove")
Err(e) => println("remove failed")
}
mut ids: []int
ids.add(2)
ids.add(3)
match drop_batch_and_report(ids) {
Ok(total) => println("refunded ${total}")
Err(e) => println("remove failed")
}
}Composite keys are supplied positionally, in declaration order:
schema stats
model DailyStat {
@id
day: int
@id
route_id: int
total: int
}
fn drop_stat(day: int, route_id: int): { total: int }? ! QueryError {
return REMOVE DailyStat(day, route_id) RETURNING { total }?
}
fn main(): void {
match drop_stat(20260729, 7) {
Ok(Some(row)) => println("dropped a stat of ${row.total}")
Ok(None) => println("no such stat")
Err(e) => println("remove failed")
}
}Upsert
UPSERT names its conflict target explicitly, which is what you want when the
uniqueness that matters is not the primary key:
schema stats
model DailyStat {
@id
day: int
@id
route_id: int
total: int
}
fn record(day: int, route_id: int, total: int): int ! QueryError {
return UPSERT DailyStat ON day, route_id {
day,
route_id,
total,
}?
}
fn main(): void {
match record(20260729, 7, 148) {
Ok(n) => println("upsert touched ${n} rows")
Err(e) => println("upsert failed")
}
}The conflict columns must name model columns and must also appear in the value
block. The compiler renders the PostgreSQL/SQLite ON CONFLICT ... DO UPDATE
form and the MySQL ON DUPLICATE KEY UPDATE form from the same plan.
RETURNING gives a list, because a general write terminal can affect more than
one row:
schema stats
model DailyStat {
@id
day: int
@id
route_id: int
total: int
}
fn record(day: int, route_id: int, total: int): []{ total: int } ! QueryError {
return UPSERT DailyStat ON day, route_id { day, route_id, total }
RETURNING { total }?
}
fn main(): void {
match record(20260729, 7, 148) {
Ok(rows) => {
for row in rows {
println("stat is now ${row.total}")
}
}
Err(e) => println("upsert failed")
}
}Batch insert
INSERT INTO Model FROM rows inserts a list and returns the affected-row
count:
schema shop
model Order {
@id
id: int
customer_id: int
total: float
}
fn import_orders(orders: []Order): int ! QueryError {
return INSERT INTO Order FROM orders?
}
fn main(): void {
mut batch: []Order
batch.add(Order { id: 1, customer_id: 42, total: 19.5 })
batch.add(Order { id: 2, customer_id: 42, total: 3.25 })
match import_orders(batch) {
Ok(n) => println("inserted ${n} rows")
Err(e) => println("insert failed")
}
}This FROM form is the only insert shape that lowers. Two neighbouring
spellings parse and type-check but have no plan, so they are compile errors —
not runtime gaps.
The single-row INSERT INTO Model VALUES { ... } is one of them:
schema shop
model Order {
@id
id: int
total: float
}
fn broken(new_id: int): Order ! QueryError {
return INSERT INTO Order VALUES { id: new_id, total: 1.0 }?
}So is BATCH SIZE, even on the batch form that otherwise compiles:
schema shop
model Order {
@id
id: int
total: float
}
fn broken(orders: []Order): int ! QueryError {
return INSERT INTO Order FROM orders BATCH SIZE 100?
}Both report ATOLL3244. Drop the BATCH SIZE clause; and for a single row,
reach for SAVE when an existing row should be replaced, or UPSERT when you
want to state the conflict key yourself.
Handling failure
Every write is Result[T, QueryError]. ? propagates it; catch converts it
into your own error type. QueryError classifies itself, so a caller can tell
a data conflict from a transient one:
schema shop
model Order {
@id
id: int
customer_id: int
total: float
status: string
}
error PlaceError { Duplicate, Unavailable }
fn place(new_id: int, customer: int, amount: float): Order ! PlaceError {
return SAVE Order {
id: new_id,
customer_id: customer,
total: amount,
status: "new",
} catch {
e if e.is_constraint() => error Duplicate
_ => error Unavailable
}
}
fn main(): void {
match place(1, 42, 19.5) {
Ok(order) => println("placed ${order.id}")
Err(Duplicate) => println("that order id is taken")
Err(Unavailable) => println("store unavailable")
}
}is_constraint() means the caller supplied data the database refused.
is_retryable() means the failure looked transient — a deadlock, a lock
timeout, a lost connection. is_conflict() and is_not_found() cover the
other coarse classes.
Retryability says the failure may be transient. It does not say the operation is safe to run twice:
| Write shape | Repeating it |
|---|---|
| set a column to a fixed value | idempotent |
SAVE/UPSERT the same complete row |
idempotent, if no trigger says otherwise |
| append a log row, or increment a counter | duplicates the effect |
| insert with a generated key | creates a second logical row |
REMOVE by stable key |
second attempt reports absence |
When several statements establish one invariant, retry from the transaction boundary rather than replaying the last statement. See Transactions.
Safety gates
The compiler refuses to emit a statement it cannot lower rather than dropping
the clause it does not understand. LIMIT on a write, GROUP BY on a write,
joins on a write, conditional SET clauses, and the incomplete batch forms all
diagnose at compile time. Engine gates apply too: a DataFusion route is
read-only, MySQL rejects RETURNING, and a datasource configured with
readOnly = true rejects every write before it executes.
A single statement is atomic on its own. Several statements are not, unless you
put them in a transaction block.
A worked example
schema shop
model Order {
@id
id: int
customer_id: int
total: float
status: string
}
error CheckoutError {
Duplicate { id: int }
NotOpen { id: int }
Unavailable
}
/// Create an order, then settle it — with the previous state pinned into the
/// update so a concurrent settle cannot double-count.
fn checkout(new_id: int, customer: int, amount: float): Order ! CheckoutError {
created := SAVE Order {
id: new_id,
customer_id: customer,
total: amount,
status: "open",
} catch {
e if e.is_constraint() => error Duplicate { id: new_id }
_ => error Unavailable
}
wanted := created.id
settled := (
UPDATE Order
SET status = "settled"
WHERE id == wanted && status == "open"
RETURNING { id, total, status }
) catch {
_ => error Unavailable
}
if settled.len() == 0 {
error NotOpen { id: wanted }
}
return created
}
fn main(): void {
match checkout(1, 42, 19.5) {
Ok(order) => println("checked out ${order.id}")
Err(Duplicate { id }) => println("order ${id} already exists")
Err(NotOpen { id }) => println("order ${id} was not open")
Err(Unavailable) => println("store unavailable")
}
}