Queries

Every #[model] gets a typed query API while your project builds. You never write queries by hand: you walk your model's keys and indexes, field by field, and the types only offer what that path supports. Import margaret::framework::active_record::model::Model to use it, and pass a &Database or a &Transaction wherever a query runs. Database introduces it with the box office's concerts.

Walking keys and indexes

Model::query() starts a query. Its fields are exactly the leading fields of the model's primary key, unique constraints, and indexes. After .eq(value) on a field, the next fields of the same index become available.

You writeYou get
.field.eq(value) reaching a primary key or a unique constraintone row
.field.eq(value) along an indexthe next fields of that index
.field.above(v), .at_least(v), .at_most(v), .below(v)a range
.ascending() or .descending()a scan, in the order of the rest of the index

A scan needs an order that's unique, so end your index with the primary key, like [starts_at, id]. Ranges work on numbers, strings, bytes, UUIDs, decimals, and times.

One row

MethodAnswers with
find(executor)Lookup::Found(model) or Lookup::Missing
load::(executor)Lookup of a shape, related rows included
when(|row| condition)the same row, only if the condition holds
update(executor, |columns| columns.a.to(x).and(columns.b.to(y)))Change::Changed(model) or Change::Unmatched
delete(executor)Removal::Removed(model) or Removal::Missing
exists::()a condition for create(...).when(...) and insert().when(...)
find_key_shared(&transaction)Lookup, and the row can't be deleted until the transaction ends

Conditions compare any column with eq, above, at_least, at_most, below, check an Option column with is_null(), and combine with and and negated.

Many rows

MethodAnswers with
a range or an index prefix, then delete(executor)the number of deleted rows
a range or an index prefix, then update(executor, |columns| ...)the number of updated rows
a scan, then limit::(), then fetch(executor)a Page of models
a scan, then limit::(), then load::(executor)a Page of shapes
a Page's nextNextPage::Continues(cursor) or NextPage::Exhausted
resume(cursor)the next page of the same scan
a scan, then stream::(executor)a stream of models, read BATCH at a time

The limit is part of the type, so every read has a bound.

Writing

MethodAnswers with
Model::create(Draft { ... }).run(executor)the created model, with its database defaults filled in
Model::create(...).when(condition).run(executor)Creation::Created(model) or Creation::Refused
model.insert().run(executor)nothing, or an error
model.insert().or_ignore(executor)Insertion::Inserted or Insertion::AlreadyPresent
model.insert().when(condition).run(executor)GuardedInsertion::Inserted or GuardedInsertion::Refused
model.insert().or_update(|columns| columns.a.excluded()).run(executor)Upsertion::Written or Upsertion::Kept
model.save(executor)Saving::Saved or Saving::Missing
model.delete(executor)Deletion::Deleted or Deletion::Missing

A Draft is the model without the fields the database fills in. or_ignore and or_update look at the primary key. In or_update, excluded() takes the value you tried to insert, and greatest() keeps the larger of the two. or_update(...).when(...) updates only rows that pass a condition.

Relations and shapes

  • A field of type Key points at another model's primary key. Build one with Key::of(&model) or Key::new(primary_key).

  • #[has_many(name, model, key)] and #[has_one(name, model, key)] declare the other direction.

  • An #[eager_load(model = ...)] shape loads a model together with its relations: a related row for a key, an Option for has_one, and Children for has_many, which always takes a limit and tells you whether it's Complete or Truncated. Shapes can nest.

  • Models and shapes can be bound from a route parameter by their primary key.

Transactions

let mut connection = database.connection().await?;
let transaction = connection.transaction(Isolation::ReadCommitted).await?;

Pass &transaction instead of the database, then commit() or rollback() it. A transaction you drop without committing is rolled back. Isolation also has RepeatableRead and Serializable.

Errors

Queries fail with an ActiveRecordError. The ones worth matching on:

  • UniqueViolation when a unique constraint refuses a row,

  • ForeignKeyViolation when a key points nowhere, or a row is still referenced,

  • CheckViolation when a minimum or byte_length refuses a value,

  • NumericValueOutOfRange when a decimal doesn't fit its precision,

  • SerializationFailure and Deadlock when concurrent transactions collide,

  • UnknownVariant when an enum column holds a value your enum doesn't have.

What doesn't compile

  • Filtering by a field that doesn't lead an index: there's no such field on the query.

  • Skipping a field of an index, or narrowing further after a range.

  • find along a path that isn't unique.

  • Fetching a scan without limit, or a limit of zero.

  • Deleting or updating a whole table.

  • Ranges on booleans, enums, keys, and JSON.

  • Resuming a cursor of a different scan or direction.

  • Updating a primary key, assigning a column twice, or an update that assigns nothing.

  • Reaching a related row through a key, like concert.venue.name. Load a shape instead.