Writes are fallible query expressions and require a writable datasource. They
use the same checked model metadata, prepared parameters, route selection, and
QueryError channel as reads. Values are never interpolated into generated
SQL.
Results
The operation and its terminal determine the success value:
| Form | Success value |
|---|---|
SAVE Model { ... } |
the saved Model |
SAVE Model FROM rows |
[]Model |
REMOVE Model(key) |
bool |
REMOVE Model(keys) |
affected-row int |
INSERT, UPDATE, or DELETE without RETURNING |
affected-row int |
single-key REMOVE ... RETURNING |
an optional projected row |
batch REMOVE ... RETURNING |
a list of projected rows |
other writes with RETURNING |
a list of projected rows |
Every form is Result[success, QueryError]; ? propagates its error. A
database constraint, connection failure, malformed host request, or failure to
decode returned rows stays distinguishable through the
QueryError variants.
Save
SAVE upserts one full model by primary key and returns the saved model:
saved := SAVE Booking {
id: booking_id
reference
passengers
}?All key fields must be present. The compiler generates an engine-specific
conflict clause and returns the full row. A bare field such as reference is
shorthand for reference: reference; field names are checked against the
model.
SAVE Model FROM rows accepts a list of models, performs the same upsert for
each item, and returns the saved models:
saved: []Booking = SAVE Booking FROM bookings?The current batch path executes a prepared single-row statement repeatedly. It supports all-scalar model rows; nullable-column batch binding and some relationship-bearing shapes remain incomplete.
Remove
REMOVE deletes by primary key:
removed: bool = REMOVE Booking(booking_id)?
count: int = REMOVE Booking(ids)?Single removal reports whether a row existed. Batch removal reports affected
rows. RETURNING changes the result to an optional projected row for one key
or a list for many keys.
Composite keys are supplied positionally in declaration order:
removed := REMOVE DailyStat(day, route_id)
RETURNING { total }?For a single key, absence is None, not an error. A malformed key shape is a
compile-time type error.
Insert
created := INSERT INTO Booking
VALUES {
id: booking_id
reference
passengers: 1
}
RETURNING { id, reference }?Single insert rendering and typing ship, but it has less end-to-end runtime
coverage than SAVE and UPSERT. Batch INSERT INTO Model FROM rows without
RETURNING executes and returns an affected-row count. Batch returning,
BATCH SIZE, and several nullable-row paths remain incomplete.
Unlike SAVE, insert does not update an existing row. A duplicate key normally
returns a constraint QueryError. Generated or defaulted columns may be
omitted when the model and database schema permit it.
Update
count := UPDATE Booking
SET passengers = passengers + 1
WHERE id == booking_id?Without RETURNING, update yields an affected-row count. With RETURNING, it
yields a list of projection rows:
changed := UPDATE Booking
SET status = new_status
WHERE customer_id == wanted_customer
RETURNING { id, status }?Assignments may read the old row, as in passengers = passengers + 1, or bind
ordinary Atoll values such as new_status. The compiler separates the two and
binds guest values as statement parameters. An update matching no rows
succeeds with 0 or an empty returned list.
Delete
count := DELETE FROM Booking
WHERE cancelled_at < cutoff?Delete returns affected rows unless RETURNING requests projected deleted
rows.
DELETE FROM is predicate-based and can remove many rows. Prefer REMOVE when
the caller already has a primary key: its single-key bool or optional-row
result makes absence explicit.
Upsert
UPSERT states an explicit conflict target:
saved := UPSERT DailyStat ON day, route_id {
day
route_id
total
}?PostgreSQL/SQLite-style conflict clauses and MySQL duplicate-key syntax are
rendered from the same query plan. Conflict fields must name model columns and
must be present in the values block. Add RETURNING { ... } when the caller
needs database-generated values; the result is a list because a general write
terminal can affect more than one row.
Safety gates
The compiler rejects clauses it cannot safely lower. Unsupported LIMIT,
GROUP BY, joins on writes, conditional SET, and incomplete batch forms do
not silently disappear. Dialect gates also apply: DataFusion is read-only, and
MySQL currently rejects RETURNING. A datasource configured with
readOnly = true rejects every write before execution.
A successful statement is atomic, but several separate statements are not an
atomic unit. Use Transactions when multiple
writes must commit or roll back together. Inspect err.is_constraint() for
data conflicts the caller must correct, and retry only errors for which
err.is_retryable() is true.