v0.3 GitHub

Models

Retrieving, saving, timestamps, soft deletes, dirty tracking

Defining models

rust
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 __meta field (whether the row exists, original values for dirty tracking), so always build models with ..Default::default();
  • adds one &str constant per column: Flight::NAME == "name", Flight::AIRLINE_ID == "airline_id";
  • implements rustique::Model and generates a FlightFill struct (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

OptionMeaning
table = "flights"Table name. Required
timestampsFill created_at / updated_at on insert and update. The fields must exist
soft_deletesdelete() sets deleted_at; queries hide trashed rows. The field must exist
incrementing = falseThe key is not generated by the database (natural keys)
uuid / ulidThe String key gets an ordered UUID (v7) / lowercase ULID on insert when empty, like Eloquent’s HasUuids / HasUlids. Implies incrementing = false
hooksUse 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.

rust
#[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 tuple

Legacy Oracle tables with a sequence:

rust
#[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:

rust
#[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

AttributeMeaning
#[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

rust
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

rust
// 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

rust
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 the values (second list) are set. These need Default on the model.
  • Racing callers: with a unique index on the matched columns, first_or_create and update_or_create catch the losing insert’s unique violation and return (or update) the row that won, like Laravel’s createOrFirst fallback. 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 / decrement add in SQL, so concurrent increments add up. They save only that column (plus updated_at) and run the updating / updated hooks.
  • fresh ignores soft deletes and global scopes. refresh leaves relation fields as they are.

Dirty tracking

rust
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 database

Mass updates

rust
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.

rust
#[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

rust
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, update

insert_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

rust
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.

rust
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 saves

Pruning

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):

rust
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 hooks

Soft-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:

rust
#[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:

rust
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 zone

Timestamps, 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.