Models
Retrieving, saving, timestamps, soft deletes, dirty tracking
Defining models
use rustique::prelude::*;
use chrono::NaiveDateTime;
#[rustique::model(table = "flights", timestamps, soft_deletes)]
#[derive(Debug, Clone, Default)]
pub struct Flight {
pub id: i64,
pub name: String,
pub airline_id: i64,
pub departed_at: Option<NaiveDateTime>,
pub created_at: Option<NaiveDateTime>,
pub updated_at: Option<NaiveDateTime>,
pub deleted_at: Option<NaiveDateTime>,
}#[rustique::model] is an attribute macro, not a derive. Put it above the #[derive(...)]. It:
- adds a hidden
__metafield (whether the row exists, original values for dirty tracking), so always build models with..Default::default(); - adds one
&strconstant per column:Flight::NAME == "name",Flight::AIRLINE_ID == "airline_id"; - implements
rustique::Modeland generates aFlightFillstruct (see Mass assignment).
Every field is a column, named like the field unless #[rustique(column = "...")] renames it, except Rel<...> relation fields (see Relationships). The constant keeps the field’s name and holds the column’s: with #[rustique(column = "email_address")] email: String, User::EMAIL == "email_address".
Model options
| Option | Meaning |
|---|---|
table = "flights" | Table name. Required |
timestamps | Fill created_at / updated_at on insert and update. The fields must exist |
soft_deletes | delete() sets deleted_at; queries hide trashed rows. The field must exist |
incrementing = false | The key is not generated by the database (natural keys) |
uuid / ulid | The String key gets an ordered UUID (v7) / lowercase ULID on insert when empty, like Eloquent’s HasUuids / HasUlids. Implies incrementing = false |
hooks | Use your own impl Hooks for Flight, see events |
observers(Audit, ...) | Register observers, see events |
morph_alias = "App\\Models\\Flight" | Value stored in *_type columns of polymorphic relations. Default: the table name |
schema = "billing" | The schema check reads rustique-schema.billing.txt. Default: the schema of a schema.table name, else rustique-schema.txt |
created_at = "...", updated_at = "...", deleted_at = "..." | Column names for timestamps / soft_deletes, see Timestamps |
global_scopes(Tenant, ...) | Filters every query applies, see Global scopes |
Primary keys
The field named id is the key unless some field has #[rustique(primary_key)].
A single key is generated by the database by default (incrementing): an insert leaves it out and writes the generated value into the field, so a key set by hand is replaced. To insert rows with keys you pick (natural keys, imported data), use incrementing = false, or QueryBuilder::insert.
#[rustique::model(table = "order_lines", incrementing = false)]
#[derive(Debug, Clone, Default)]
pub struct OrderLine {
#[rustique(primary_key)]
pub order_no: i64,
#[rustique(primary_key)]
pub line_no: i64,
pub sku: String,
}
let line = OrderLine::find(&db, (1001, 2)).await?; // composite keys take a tupleLegacy Oracle tables with a sequence:
#[rustique::model(table = "flights")]
#[derive(Debug, Clone, Default)]
pub struct Flight {
#[rustique(primary_key, sequence = "FLIGHTS_SEQ")]
pub id: i64,
}The insert uses FLIGHTS_SEQ.NEXTVAL. A trigger-filled key is read back with RETURNING ... INTO. MySQL ignores sequence.
UUID and ULID keys are generated by the application before the creating hook, unless the key is already set:
#[rustique::model(table = "documents", ulid)] // or `uuid`
#[derive(Debug, Clone, Default)]
pub struct Document {
pub id: String, // `t.ulid("id").primary()`, or `t.uuid(...)`
pub title: String,
}
let doc = Document::create(&db, Document { title: "Q3".into(), ..Default::default() }).await?;
assert_eq!(doc.id.len(), 26);On Oracle the CHAR key columns pad shorter values with blanks, so keys set by hand should have the full length.
Field attributes
| Attribute | Meaning |
|---|---|
#[rustique(primary_key)] | Part of the primary key |
#[rustique(column = "name")] | Column name, when it differs from the field name |
#[rustique(sequence = "SEQ")] | Oracle sequence for the key |
#[rustique(fillable)] | In the Fill struct (mass-assignable); every other column is guarded |
#[rustique(not_null)] | The column is nullable but never holds NULL: the schema check accepts a field that is not an Option |
#[rustique(has_many(Post))] etc. | Relation field, see Relationships |
#[rustique(count)], #[rustique(aggregate)] | Rel<T> field filled by with_count, with_sum, with_exists, …, see Relationships |
There are no accessors and mutators: write plain methods, or a cast newtype that converts on read and write. Eloquent’s $hidden is serde’s #[serde(skip_serializing)] on the field, and $appends a custom Serialize.
Column types: anything implementing FromValue and Into<Value>. Use Option<T> for nullable columns. See Casts.
Retrieving models
let all = Flight::all(&db).await?; // Collection<Flight>
let one = Flight::find(&db, 1).await?; // Option<Flight>
let one = Flight::find_or_fail(&db, 1).await?; // Err(Error::NotFound)
let flights = Flight::query()
.where_(Flight::AIRLINE_ID, "=", 3)
.order_by(Flight::NAME)
.limit(10)
.get(&db).await?;
let first = Flight::query().where_("name", "=", "LH1").first(&db).await?; // Option
let first = Flight::query().where_("name", "=", "LH1").first_or_fail(&db).await?; // Err(NotFound)
let by_key = Flight::query().where_key(1).first(&db).await?;
let some = Flight::find_many(&db, [1, 2, 3]).await?; // missing keys skipped
let only = Flight::query().where_("name", "=", "LH1").sole(&db).await?; // Err(NotFound) or Err(MultipleRecords)
let name: Option<String> = Flight::query().where_key(1).value(&db, "name").await?;Model::query() returns a Query<Flight>. It has every query builder method (where_*, joins, order_by, limit, when, union, locks, …) and returns models. It also has count, exists, max / min / sum / avg, pluck, pluck_keyed, value, sole, where_keys, paginate, simple_paginate, cursor_paginate, chunk, chunk_by_id, cursor, update, increment / decrement, delete, force_delete, and latest() / oldest(), which order by the model’s created_at column.
Need the raw builder? query.into_builder().
A query that selects only some columns, such as Flight::query().select(["id", "name"]), still returns models, as in Eloquent: fields whose columns were not read take their Default (model fields need one anyway). A select list holding * or table.* reads every column, and a missing one is still Error::ColumnNotFound. Saving such a model updates only the fields you changed.
Inserting and updating
// insert
let mut flight = Flight { name: "LH1".into(), airline_id: 3, ..Default::default() };
flight.save(&db).await?; // INSERT, fills flight.id and the timestamps
// or in one call
let flight = Flight::create(&db, Flight { name: "LH1".into(), ..Default::default() }).await?;
// update
let mut flight = Flight::find_or_fail(&db, 1).await?;
flight.name = "LH2".into();
flight.save(&db).await?; // UPDATE flights SET name = ?, updated_at = ? WHERE id = ?save inserts when the model did not come from the database (!flight.exists()), otherwise it updates only changed columns. Nothing changed means no query.
Find or create, increment, refresh
let lh1 = || vec![(Flight::NAME, "LH1".into())];
let flight = Flight::first_or_new(&db, lh1(), vec![("seats", 180.into())]).await?; // not saved
let flight = Flight::first_or_create(&db, lh1(), vec![("seats", 180.into())]).await?; // saved if new
let mut flight = Flight::update_or_create(&db, lh1(), vec![("seats", 200.into())]).await?;
flight.increment(&db, "seats", 10).await?; // UPDATE ... SET seats = seats + ?, updated_at = ?
flight.decrement(&db, "seats", 5).await?; // and the field changes too
flight.touch(&db).await?; // updated_at = now, then save
let current = flight.fresh(&db).await?; // Option<Flight>: the row as it is now
flight.refresh(&db).await?; // reload the fields in place, dropping unsaved changes
let copy = flight.replicate(); // unsaved clone without key and timestamps- The
attributes(first list) are matched with=and set on a new model, then thevalues(second list) are set. These needDefaulton the model. - Racing callers: with a unique index on the matched columns,
first_or_createandupdate_or_createcatch the losing insert’s unique violation and return (or update) the row that won, like Laravel’screateOrFirstfallback.Flight::create_or_first(&db, attributes, values)inserts first and falls back the same way; use it when the row usually does not exist yet. Without a unique index, both callers insert.Error::is_unique_violation()spots the error yourself. increment/decrementadd in SQL, so concurrent increments add up. They save only that column (plusupdated_at) and run theupdating/updatedhooks.freshignores soft deletes and global scopes.refreshleaves relation fields as they are.
Dirty tracking
flight.is_dirty(); // bool
flight.dirty(); // Vec<(&'static str, Value)> of changed columns
flight.get_original("name"); // Option<&Value> as loaded
flight.exists(); // loaded from / saved to the databaseMass updates
let changed: u64 = Flight::query()
.where_("airline_id", "=", 3)
.update(&db, vec![("airline_id", 4.into())])
.await?;With timestamps, updated_at is set too. No hooks run.
Mass assignment
Columns are guarded unless marked #[rustique(fillable)]. The macro generates {Model}Fill with the fillable columns, each wrapped in Option; fill sets the fields that are Some. A new column stays out of mass assignment until you mark it, so is_admin, role or tenant_id cannot be set from a request by accident. The primary key and the timestamp columns cannot be fillable (a compile error); set them on the field itself, never from request input.
#[rustique::model(table = "members")]
#[derive(Debug, Clone, Default, serde::Deserialize)]
pub struct Member {
pub id: i64,
#[rustique(fillable)]
pub name: String,
#[rustique(fillable)]
pub bio: Option<String>,
pub is_admin: bool, // guarded: not in `MemberFill`
}
member.fill(MemberFill { name: Some("Bea".into()), ..Default::default() });
// From request JSON. Unknown and guarded keys ("is_admin", "id") are rejected;
// `null` clears a nullable column ("bio": null sets bio = None).
let input: MemberFill = serde_json::from_str(body)?;
member.fill(input).save(&db).await?;MemberFill derives Deserialize only when the model does. Deserializing Member itself reads every field, is_admin included: use it for trusted data (a cache, your own services), and parse request bodies into MemberFill.
Bulk insert and upsert
let mut flights = vec![Flight { name: "A".into(), ..Default::default() }, Flight { name: "B".into(), ..Default::default() }];
Flight::insert_many(&db, &mut flights).await?; // all or nothing; fills keys, timestamps, exists
Flight::upsert(&db, &flights, &["name"], &["airline_id"]).await?; // unique_by, updateinsert_many runs no hooks. On MySQL it is one multi-row INSERT when innodb_autoinc_lock_mode is 0 or 1 (or when the key is not generated); on the default lock mode 2 it is one INSERT per row inside a transaction. On Oracle every 100 rows go in one PL/SQL block of INSERT ... RETURNING statements: one round trip per block.
Model::upsert fills timestamps, runs no hooks, does not touch the models and does not read keys back.
Deleting
flight.delete(&db).await?; // soft delete if soft_deletes, else DELETE
flight.force_delete(&db).await?; // always DELETE
Flight::query().where_("airline_id", "=", 3).delete(&db).await?; // bulk, honours soft deletes
Flight::query().where_("airline_id", "=", 3).force_delete(&db).await?;Soft deletes
With soft_deletes, every query adds deleted_at IS NULL.
Flight::query().with_trashed().get(&db).await?; // include trashed
Flight::query().only_trashed().get(&db).await?; // only trashed
flight.trashed(); // bool
flight.restore(&db).await?; // clears deleted_at and savesPruning
Implement Prunable to delete obsolete rows, like Eloquent’s Prunable / MassPrunable; call it from your scheduler (there is no model:prune command, since models are compiled into the application):
use rustique::{Prunable, Query};
#[rustique::model(table = "sessions")]
#[derive(Debug, Clone, Default)]
pub struct Session {
pub id: i64,
pub last_seen: chrono::NaiveDateTime,
}
impl Prunable for Session {
fn prunable() -> Query<Self> {
let cutoff = rustique::now() - chrono::Duration::days(30); // in the application time zone
Session::query().where_("last_seen", "<", cutoff)
}
}
let deleted = Session::prune(&db).await?; // loads them in chunks: `pruning` hook, then force_delete hooks
let deleted = Session::mass_prune(&db).await?; // one DELETE, no hooksSoft-deleted rows are pruned too, for real. Override pruning(&mut self, db) to clean up files or other data before each model goes.
Timestamps
timestamps fills created_at and updated_at; soft deletes use deleted_at. Rename them for legacy tables:
#[rustique::model(table = "flights", timestamps, soft_deletes,
created_at = "creation_date", updated_at = "last_update", deleted_at = "removed_on")]
#[derive(Debug, Clone, Default)]
pub struct Flight {
pub id: i64,
pub creation_date: Option<NaiveDateTime>,
pub last_update: Option<NaiveDateTime>,
pub removed_on: Option<NaiveDateTime>,
}
assert_eq!(<Flight as Model>::CREATED_AT, "creation_date");The names are column names, so they combine with #[rustique(column = "...")]. Model::CREATED_AT, UPDATED_AT and DELETED_AT hold them; rustique::CREATED_AT and friends are the defaults. Pivot timestamps have their own names: pivot_timestamps = ["added", "modified"] on the relation (see pivot columns).
Times are stored in UTC unless you set an application time zone, Laravel’s app.timezone, once at startup:
rustique::set_timezone(chrono::FixedOffset::east_opt(3600).unwrap()); // or chrono::Local, or chrono_tz::Europe::Bratislava
let now: chrono::NaiveDateTime = rustique::now(); // wall-clock time in that zoneTimestamps, soft deletes, touch and pivot timestamps then hold wall-clock time in that zone, the way Laravel writes them. DateTime<Utc> fields convert to and from it, NaiveDateTime fields are stored as given, and Oracle TIMESTAMP WITH TIME ZONE columns read as wall-clock time in the zone. Named zones that follow daylight saving time come from the chrono-tz crate. The setting is process-wide; use_current schema defaults stay UTC.
Serde
Relation fields that are not loaded are skipped when serializing, so a model can go straight into a JSON response.