Manual contentsEloquent ORMBrowse 126 chapters
Manual 141 min read

Eloquent API

Suprnova's Eloquent layer gives Laravel developers the API they know, implemented as a thin shim over SeaORM. Copy code from the Laravel docs, swap PHP syntax for Rust, add .await?, and it runs.

The whole layer is a struct attribute (#[suprnova::model]), a trait (Model), and a chainable query builder (Builder<M>) - that's it. Behind the scenes the macro generates a SeaORM Entity, Model, ActiveModel, and Column enum, plus every Eloquent trait impl. The SeaORM types stay reachable for the rare case the Eloquent surface doesn't cover (see the SeaORM escape hatches).

Table of contents

Quick start

One attribute on a struct turns it into a fully-featured Eloquent model:

use chrono::{DateTime, Utc};
use suprnova::{model, Model};

#[model(table = "users")]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

Once declared, you can write:

  • User::query() - start a fluent query builder.
  • User::find(id).await? - fetch by primary key.
  • User::find_or_fail(id).await? - same, but errors with ModelNotFound on miss.
  • User::all().await? - every row.
  • User::create(attrs!{ name: "Alice", email: "alice@example.com" }).await? - insert with mass-assignment filtering.
  • User::filter("email", "alice@example.com").first().await? - one row that matches.
  • user.update(attrs!{ name: "Alice B" }).await? - partial update.
  • user.save().await? - persist in-memory changes.
  • user.delete().await? - remove the row.
  • user.refresh().await? / user.fresh().await? / user.replicate().await? - the rest of the Laravel lifecycle.

The user-facing struct (here User) IS the type your handlers and controllers carry. The macro emits a per-model inner module (user::) with the SeaORM Entity, Column, ActiveModel, and Model types for the cases where you want to drop down to SeaORM directly. The struct is also registered in an inventory-backed ModelEntry so admin and tooling code can enumerate every model at boot.

The #[suprnova::model] attribute

The single entry point for declaring a model. Every attribute is optional; the defaults are tuned so a struct with id + created_at + updated_at works as a Suprnova model with zero configuration.

Macro attribute reference

Attribute Type Default Notes
table string snake_case-plural of struct name Override the table name
primary_key string "id" Override the PK column name
key_type type the primary-key field's type Optional. The key type comes from the field primary_key names; a key_type that names another type fails the build
auto_increment bool true Disable for UUID PKs
connection string "default" Multi-connection apps name a non-default connection
fillable list of strings (default = guarded = ["id"]) Mass-assignment allowlist
guarded list of strings ["id"] when neither set Mass-assignment denylist (mutually exclusive with fillable)
defaults(field = value, ...) field expressions none Set the values you read on a new instance and save when a create omits them
casts map of field = CastType {} Per-column casts
hidden list of strings [] Excluded from to_json / to_array
visible list of strings (all) Inclusive variant of hidden (mutually exclusive)
appends list of strings [] Accessors to include in serialization
soft_deletes flag false Enable deleted_at column + tombstone semantics
soft_deletes_column string "deleted_at" Override the soft-delete column name
timestamps flag / bool true when both created_at and updated_at exist Disable auto-managed timestamps
created_at string "created_at" Override the column name
updated_at string "updated_at" Override the column name
touches list of relation names [] BelongsTo or MorphTo relations whose owner row gets its updated_at bumped after this model is created, saved, updated, or deleted
mutators list of strings [] Field names whose JSON-fill path routes through a set_<field>(value) mutator method
route_key string the primary key The column a route parameter without a binding field matches (Laravel's getRouteKeyName()). A name that is no column of the model fails the build. See Route model binding
custom_route_binding flag off The model implements RouteBinding itself; the macro emits none. suprnova::database::resolve_model_route_binding stays callable from your impl

Full example

use chrono::{DateTime, Utc};
use serde_json::Value as Json;
use suprnova::{model, AsBool, AsEncrypted, AsJson};

#[model(
    table = "users",
    fillable = ["name", "email", "preferences"],
    casts = {
        active = AsBool,
        preferences = AsJson<Json>,
        api_token = AsEncrypted,
    },
    hidden = ["password", "remember_token", "api_token"],
    appends = ["full_name"],
    soft_deletes,
    timestamps,
)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub password: String,
    pub remember_token: Option<String>,
    pub api_token: Option<String>,
    pub active: bool,
    pub preferences: Json,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
    pub deleted_at: Option<DateTime<Utc>>,
}

Function-level macros

Function-level macros work alongside the struct attribute:

  • #[accessor] on a fn name(&self) -> T makes it an Eloquent accessor. The model's to_array() calls it when name is listed in appends = [...] (and to_json() picks it up via the to_array → string delegation).
  • #[mutator] on a fn set_name(&mut self, value: serde_json::Value) makes it an Eloquent mutator. The model's JSON-fill path routes through it when name is listed in mutators = [...].
  • #[suprnova::scopes(Model)] on an impl Model { ... } block: every method whose signature is fn name(query: Builder<Self>[, args…]) -> Builder<Self> becomes both a chainable .scope_name(args) on Builder<Self> and a Model::scope_name(args) shortcut. There is no function-level #[scope] form - scopes are declared per impl-block.
  • Global scopes are a runtime registration via the GlobalScope trait, registered with ScopeRegistry::register::<M, _>(scope). There is no function-level #[global_scope] macro - see Macros for the full pattern.
  • #[prunable] on impl Prunable for T { ... } registers the pruner via inventory so model:prune finds it.

Model module layout

#[suprnova::model] keeps your user-facing struct (e.g. Post) at parent scope and emits a sibling pub mod named after the struct in snake_case (post). That inner module is where the SeaORM types live.

For a model declared at app/src/models/posts.rs:

use chrono::{DateTime, Utc};
use suprnova::model;

#[model(table = "posts", fillable = ["title", "body"], timestamps)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub body: String,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

// Convention: re-export the SeaORM types the macro emits inside the
// inner module so call sites can use the unprefixed names. Suprnova's
// own dogfood models all carry this line (see `app/src/models/users.rs`,
// `app/src/models/posts.rs`, etc.).
pub use post::{ActiveModel, Column, Entity};

You now have these items reachable from crate::models::posts:

Path What it is
crate::models::posts::Post Your user-facing struct - the Eloquent model
crate::models::posts::post::Entity SeaORM EntityTrait impl for the posts table
crate::models::posts::post::Column SeaORM Column enum (one variant per column)
crate::models::posts::post::ActiveModel SeaORM ActiveModel for insert/update
crate::models::posts::post::Model SeaORM-shape row (storage-typed columns)
crate::models::posts::{Entity, Column, ActiveModel} The pub use convention above; not auto-emitted

Two things to know about the inner module's Model:

  1. It is the SeaORM-shape row, not your Post struct. Cast columns carry their Storage type here (e.g. bool becomes the underlying integer), and the __eager / __pivot runtime slots from your struct are absent.
  2. From<post::Model> for Post and From<Post> for post::Model bridge the two shapes. See Dropping to SeaORM for the round-trip pattern.

Model is intentionally not part of the conventional parent re-export - the user-facing Post already occupies the Post name at parent scope, and post::Model is a separate type that callers reach through post::Model (or From conversion) when they need the inner shape.

When to reach into the inner module

The Eloquent surface (Model trait + Builder<M>) covers the vast majority of queries. Reach into post::* when you need SeaORM-only features:

  • Raw query construction with SeaORM's EntityTrait::find() chain when Eloquent doesn't expose the helper you want.
  • Custom join logic - building JoinType::* joins explicitly via QuerySelect::join() for a relation Eloquent's with(...) doesn't model.
  • SeaORM-native subqueries through Entity::find().select_only().
  • Plain ActiveModel mutation for the rare case you want to bypass the Eloquent lifecycle (no observers, no auto-timestamps).
// Common case - Column re-exported at parent module level via the
// `pub use post::{...}` convention above.
use crate::models::posts::Column;

let drafts = Post::query()
    .db_where(Column::Status, "draft")
    .get()
    .await?;

// Power-user case - reach into the inner module for the SeaORM Entity
// directly. This is what the parent `pub use` does not surface.
use crate::models::posts::post;
use suprnova::sea_orm::{ColumnTrait, EntityTrait, QueryFilter};

let db = suprnova::DB::connection()?;
let rows: Vec<post::Model> = post::Entity::find()
    .filter(post::Column::Status.eq("published"))
    .all(db.inner())
    .await?;

// Bridge back to the Eloquent shape when the caller wants it.
let posts: Vec<Post> = rows.into_iter().map(Post::from).collect();

If you find yourself reaching into the inner module routinely for the same operation, that's a signal Eloquent is missing a helper - open an issue, or add the helper to the Model / Builder surface.

Finding rows

// Laravel
$user = User::find(1);
$user = User::findOrFail(1);          // throws on missing
$users = User::findMany([1, 2, 3]);
// Suprnova
let user: Option<User> = User::find(1).await?;
let user: User = User::find_or_fail(1).await?;
let users: Vec<User> = User::find_many([1, 2, 3]).await?;

find_or_fail returns FrameworkError::ModelNotFound (HTTP 404 when bubbled to a controller).

first_or_create / update_or_create / first_or_new / first_or

// Laravel
$user = User::firstOrCreate(
    ['email' => 'alice@example.com'],
    ['name' => 'Alice'],
);
$user = User::updateOrCreate(
    ['email' => 'alice@example.com'],
    ['name' => 'Alice Updated'],
);
$user = User::firstOrNew(['email' => 'alice@example.com']);  // unsaved
// Suprnova
let user = User::first_or_create(
    attrs! { email: "alice@example.com" },          // lookup keys
    attrs! { name: "Alice" },                       // extras on create
).await?;

let user = User::update_or_create(
    attrs! { email: "alice@example.com" },
    attrs! { name: "Alice Updated" },
).await?;

let user = User::first_or_new(
    attrs! { email: "alice@example.com" },
).await?;   // returns an unsaved User; caller inserts it explicitly
let user = user.persist().await?;   // `use suprnova::Persistable;`

Lookup keys go in the first map; extra fields applied on the create-path go in the second map. Returning an unsaved model via first_or_new lets the caller mutate it further before inserting it with persist().await?, which fires creating and created and returns the saved model with the key the database assigned. When first_or_new finds a row, the model it returns is loaded, and save updates it.

Why Suprnova diverges

Laravel's save inserts a model that does not exist yet and writes the new key into it. Suprnova's save borrows the model, so it cannot hand the new key back. A model built in the process whose key still holds its reset value - a new model from first_or_new or find_or_new, or a replica - has no row to update, so save refuses it with an error that names persist. persist consumes the model and returns the inserted one, key included.

Creating and updating

Create

// Laravel
$user = User::create([
    'name' => 'Alice',
    'email' => 'alice@example.com',
]);
// Suprnova
let user = User::create(attrs! {
    name: "Alice",
    email: "alice@example.com",
}).await?;

attrs! is a macro that produces an Attrs value (a typed JSON map). Pure JSON also works - User::create(serde_json::json!({"name": "Alice", "email": "..."})). The Fillable filter runs inside create; non-fillable fields are silently dropped, matching Laravel's behaviour.

Declared defaults

Declare defaults(field = value, ...) to give new instances initial values:

use suprnova::{Model, attrs, model};

#[model(
    table = "posts",
    fillable = ["title", "status", "votes"],
    defaults(status = "draft", votes = 0),
)]
pub struct Post {
    pub id: i64,
    pub title: String,
    pub status: String,
    pub votes: i64,
}

let draft = Post::new();
assert_eq!(draft.status, "draft");
let draft = Post::create(attrs! { title: "First" }).await?;
let live = Post::create(attrs! { title: "Second", status: "live" }).await?;

You read declared defaults before saving a new instance. A partial create uses every declared default that your filtered attributes omit, before creation events and the insert. Your supplied attributes win. The same rules apply to create_with_tx and unsaved instances from first_or_new.

You supply values convertible into the declared field types, so "draft" works for a String. You name each column at most once. An unknown column or a value of the wrong type fails the build. Your defaults are trusted model values; your caller's attributes still pass through the fillable filter. A model without the declaration keeps its existing construction and insert behavior.

Save / update

// Laravel
$user->name = 'Alice B';
$user->save();

$user->update(['name' => 'Alice B']);
// Suprnova
user.name = "Alice B".into();
user.save().await?;

user.update(attrs! { name: "Alice B" }).await?;

You call save() after changing a loaded model. It compares your values with the original values kept in memory, without re-reading the row. A clean save issues no UPDATE and fires neither Updating nor Updated; Saving and Saved still fire. A Saving listener can make the model dirty by changing its attributes. A dirty save writes changed columns and updates its timestamp.

You use update(attrs) to apply a partial attribute map, with the Fillable filter and declared mutators, before writing.

Changes after a save

After a save, a model reports which attributes that save changed. While the save's updated and saved observers run, the original values are still the ones loaded before the save; once the save returns, they are the saved ones:

// Laravel
$user->is_admin = true;
$user->save();

$user->wasChanged('is_admin');        // true
$user->wasChanged(['name', 'email']); // false
$user->getChanges();                  // ['is_admin' => 1]
$user->getOriginal('is_admin');       // true
// Suprnova
use suprnova::Model;

user.is_admin = true;
user.save().await?;

user.was_changed("is_admin");             // true
user.was_changed_any(&["name", "email"]); // false
user.was_changed_any(&[]);                // true: the save changed something
user.get_changes();                       // Attrs { "is_admin": 1 }
user.get_original("is_admin")?;           // Some(true): the saved value
user.get_raw_original("is_admin");        // Some(1), as stored
user.get_previous("is_admin");            // Some(0): before the change

An observer reads the value from before the save, which is how an audit trail records both sides of a change:

use async_trait::async_trait;
use suprnova::eloquent::observers::Observer;
use suprnova::{FrameworkError, Model};

pub struct AdminAudit;

#[suprnova::observer(User)]
#[async_trait]
impl Observer<User> for AdminAudit {
    async fn updated(&self, _previous: &User, user: &User) -> Result<(), FrameworkError> {
        if user.was_changed("is_admin") {
            let before = user.get_raw_original("is_admin"); // Some(0)
            tracing::info!(user_id = user.id, ?before, after = user.is_admin, "admin flag changed");
        }
        Ok(())
    }
}

The rules follow Laravel's save:

  • A column counts as changed when the value the database stores after the save differs from the value the model held before it. Writing the same value back is not a change. A column that a Saving listener or the timestamps rewrote is one, so a model with timestamps also reports updated_at. An encrypted column stores a new ciphertext on every save, so it is compared by its decrypted value instead: it counts as changed only when that value changed.
  • get_previous(attribute) returns the stored value before the last change for that attribute. You get None for an unchanged or unknown attribute. A clean or failed save keeps this record.
  • get_changes and get_raw_original return stored values, before casts, as Laravel does. get_original reads the value through the model's casts and returns an error only when the stored value no longer decodes.
  • The record describes the last save that changed something. A later save that changes something replaces it. A save that changes nothing, or a save that fails, leaves it in place.
  • save, update, save_with_tx, and update_with_tx record a save. A model you create reports no changes, and a model you load reports none until you save it. The original is the row as the model last read or saved it, whatever you change in memory. To answer this, every model a query loads keeps a copy of its stored row beside its fields, so a large result takes more memory than the fields alone. A clone keeps the record; replicate builds a new model without one.
  • A model you build in memory and save without reading it first has no loaded values to compare with: every column counts as changed, and get_original returns None until that save returns. Such a model needs a key a row already holds: while its key holds the reset value, as a replica's and a new first_or_new model's do, save refuses it and persist inserts it.

Why Suprnova diverges

A save that changes nothing runs no UPDATE and fires neither updating nor updated, as in Laravel, while saving and saved still fire; the previous save's change record stays in place. refresh replaces the model with a fresh read, so it also clears the record, where Laravel's refresh keeps getChanges.

Increment / decrement

// Laravel
$user->increment('login_count');
$user->increment('login_count', 5);
$user->decrement('credits', 10);
User::where('plan', 'free')->increment('quota_reset_count');
// Suprnova
user.increment("login_count", 1).await?;
user.increment("login_count", 5).await?;
user.decrement("credits", 10).await?;
User::filter("plan", "free").increment("quota_reset_count", 1).await?;

increment / decrement emit UPDATE table SET col = col + N WHERE ... SQL - atomic against concurrent updates, no read-modify-write race. Available both on a fetched model instance (uses the row's PK in the WHERE clause) and as a Builder terminal (uses the chain's WHERE clauses).

Fresh / refresh / replicate

// Laravel
$user->refresh();                          // reload from DB
$user->refreshForUpdate();                 // reload under a row lock
$copy = $user->fresh();                    // fetch + return copy
$replica = $user->replicate();             // unsaved clone with fresh PK
$replica = $user->replicate(['email']);    // skip a field
// Suprnova
user.refresh().await?;
user.refresh_for_update().await?;
let copy: User = user.fresh().await?;
let replica: User = user.replicate().await?;
let replica: User = user.replicate_except(["email"]).await?;

refresh mutates in place; fresh returns a separately-fetched copy. refresh_for_update is refresh under a SELECT ... FOR UPDATE row lock - use it inside a transaction when you need the row's current values and the exclusive lock in one statement. Unlike refresh, refresh_for_update bypasses every registered global scope AND the #[model(soft_deletes)] filter: it reloads a trashed row too, with deleted_at coming back set. The reload is a lookup by primary key under a lock - scoping it the way an ordinary read is scoped would hand admin tooling and cross-tenant callers a false not-found for a row they already hold a reference to. replicate builds an in-memory clone with the PK and the timestamps reset (Default::default() for each type). Insert it with replica.persist().await? (the Persistable trait), which stamps created_at and updated_at the way create does. replica.save() refuses it: the replica has no row yet, and its reset key would name another row.

refresh and refresh_for_update both return an error when the row no longer exists, rather than leaving the model holding stale values. SQLite has no row-level locking, so refresh_for_update reloads without a lock there - see Row locking.

Replicating event

replicate and replicate_except fire the per-model Replicating { source, replica } event after constructing the in-memory clone and BEFORE returning it. The replica field is an Arc<tokio::sync::Mutex<Self>> so listeners can mutate the replica before the caller sees it - useful for prefixing titles with (copy), clearing flags, resetting derived columns, etc.

use suprnova::events::{EventFacade, Listener};
use async_trait::async_trait;

pub struct PrefixTitle;

#[async_trait]
impl Listener<post::events::Replicating> for PrefixTitle {
    async fn handle(&self, e: &post::events::Replicating)
        -> Result<(), FrameworkError>
    {
        let mut replica = e.replica.lock().await;
        replica.title = format!("(copy) {}", replica.title);
        Ok(())
    }
}

// Wire it up once at boot:
EventFacade::listen::<post::events::Replicating, _>(
    std::sync::Arc::new(PrefixTitle)
).await;

Cross-type replication

let replica: UserDraft = user.replicate_into().await?;  // cross-type clone

A Suprnova divergence - Laravel can't do this because PHP doesn't have types. Useful when promoting a draft model into a final one or vice-versa.

replicate_into<T> does NOT fire Replicating (the event carries Arc<Mutex<Self>>, so a listener on the source type couldn't mutate the cross-type replica anyway). Callers wanting per-T setup should run it on the returned T before calling persist - the normal Creating / Saving / Created / Saved chain still fires inside persist.

Deleting and soft deletes

Soft deletes flag

Add soft_deletes to the macro attribute and a deleted_at: Option<DateTime<Utc>> column to the struct:

#[model(table = "users", soft_deletes, timestamps)]
pub struct User {
    pub id: i64,
    pub email: String,
    pub deleted_at: Option<DateTime<Utc>>,
    // ...
}

Lifecycle

user.delete().await?;             // UPDATE: sets deleted_at (and updated_at) = NOW()
user.trashed();                   // -> true
let trashed = User::with_trashed().find(user.id).await?.unwrap();
trashed.restore().await?;         // UPDATE: sets deleted_at = NULL

let only_dead = User::only_trashed().get().await?;
let all_including_dead = User::with_trashed().get().await?;

user.force_delete().await?;       // actual DELETE

delete() sets updated_at along with deleted_at when the model manages timestamps, as Laravel's soft delete does. The mass form follows the row form. delete_all() on a builder soft-deletes every row the query matches with one UPDATE, and it sets updated_at too. force_delete_all() removes the rows for good. Neither fires per-row events. See Mass mutation.

User::query().filter("active", false).delete_all().await?;        // soft delete
User::only_trashed().force_delete_all().await?;                   // actual DELETE

Default scope

When soft_deletes is set, the macro overrides Model::query() so default reads filter out trashed rows automatically. with_trashed() and only_trashed() opt back in. Concretely: User::find(id) skips trashed rows; User::with_trashed().find(id) finds them.

Query builder - dual API

Builder<M> is the chainable query type returned by User::query(), User::filter(...), User::db_where(...), and every other static method that doesn't terminate the chain.

Naming note: dual API

where is a Rust keyword, so the bare-equality where method can't share Laravel's name. Rather than pick a winner, every where-shape method ships under both a Rust-idiomatic name (filter, filter_in, filter_null, …) and a Laravel-shape name (db_where, where_in, where_null, …). They're aliases over one canonical implementation - pick whichever your muscle memory matches.

// Rust dev:
User::query().filter("active", true).filter_in("role", ["admin"]).get().await?;

// Laravel dev:
User::db_where("active", true).where_in("role", ["admin"]).get().await?;

// Same query. Same result. Different muscle memory.

Where shortcuts

// Laravel
$users = User::where('email', $email)->get();
$users = User::where('age', '>=', 18)->get();
$users = User::where('email', 'like', '%@example.com')->get();
// Suprnova - pick either family; both compile, both documented.

// Rust-shape (filter family):
let users = User::query().filter("email", &email).get().await?;
let users = User::query().filter_op("age", ">=", 18).get().await?;
let users = User::query().filter_like("email", "%@example.com").get().await?;

// Laravel-shape (db_where / where_* family):
let users = User::db_where("email", &email).get().await?;
let users = User::query().db_where_op("age", ">=", 18).get().await?;
let users = User::query().where_like("email", "%@example.com").get().await?;

Where variants

Every row has two equivalent Suprnova forms - Rust-shape (filter*) and Laravel-shape (db_where / where_*). Both call the same canonical implementation; both are tagged with #[doc(alias = "...")] so rustdoc search finds either.

Laravel Suprnova (Rust-shape) Suprnova (Laravel-shape) Notes
->where(col, val) .filter(col, val) .db_where(col, val) Equality
->where(col, op, val) .filter_op(col, op, val) .db_where_op(col, op, val) Arbitrary operator
->orWhere(...) .or_filter(...) .or_where(...)
->orWhereKey(id) .or_filter_key(id) .or_where_key(id) PK filter as a disjunct
->orWhereKeyNot(id) .or_filter_key_not(id) .or_where_key_not(id) Negated PK filter as a disjunct
->whereNot(col, val) .filter_not(col, val) .where_not(col, val)
->whereIn(col, vals) .filter_in(col, vals) .where_in(col, vals) A list or a DB::table subquery
->whereNotIn(col, vals) .filter_not_in(col, vals) .where_not_in(col, vals) A list or a subquery
->orWhereIn(col, vals) .or_filter_in(col, vals) .or_where_in(col, vals) A list or a subquery
->orWhereNotIn(col, vals) .or_filter_not_in(col, vals) .or_where_not_in(col, vals) A list or a subquery
->whereAny(cols, op, v) .filter_any(cols, op, v) .where_any(cols, op, v) (c1 op v OR c2 op v ...)
->orWhereAny(cols, op, v) .or_filter_any(cols, op, v) .or_where_any(cols, op, v)
->whereAll(cols, op, v) .filter_all(cols, op, v) .where_all(cols, op, v) (c1 op v AND c2 op v ...)
->orWhereAll(cols, op, v) .or_filter_all(cols, op, v) .or_where_all(cols, op, v)
->whereNone(cols, op, v) .filter_none(cols, op, v) .where_none(cols, op, v) NOT (c1 op v OR c2 op v ...)
->orWhereNone(cols, op, v) .or_filter_none(cols, op, v) .or_where_none(cols, op, v)
->whereBetween(col, [a, b]) .filter_between(col, a..=b) .where_between(col, a..=b) Rust range
->whereNotBetween(col, [a, b]) .filter_not_between(col, a..=b) .where_not_between(col, a..=b)
->whereNull(col) .filter_null(col) .where_null(col)
->whereNotNull(col) .filter_not_null(col) .where_not_null(col)
->orWhereNull(col) .or_filter_null(col) .or_where_null(col)
->orWhereNotNull(col) .or_filter_not_null(col) .or_where_not_null(col)
->whereDate(col, '2026-05-19') .filter_date(col, NaiveDate) .where_date(col, NaiveDate)
->whereMonth(col, 5) .filter_month(col, 5) .where_month(col, 5)
->whereDay(col, 19) .filter_day(col, 19) .where_day(col, 19)
->whereYear(col, 2026) .filter_year(col, 2026) .where_year(col, 2026)
->whereTime(col, '12:30') .filter_time(col, NaiveTime) .where_time(col, NaiveTime)
->whereLike(col, pattern) .filter_like(col, pattern) .where_like(col, pattern)
->whereNotLike(col, pattern) .filter_not_like(col, pattern) .where_not_like(col, pattern)
->whereBinary(col, val) .filter_binary(col, val) .where_binary(col, val) Byte-exact; MySQL and MariaDB only
->orWhereBinary(col, val) .or_filter_binary(col, val) .or_where_binary(col, val)
->whereNotBinary(col, val) .filter_not_binary(col, val) .where_not_binary(col, val)
->orWhereNotBinary(col, val) .or_filter_not_binary(col, val) .or_where_not_binary(col, val)
->whereJsonContains(col, v) .filter_json_contains(col, v) .where_json_contains(col, v) Backend-dispatched
->whereJsonLength(col, op, n) .filter_json_length(col, op, n) .where_json_length(col, op, n)
->whereColumn(a, b) .filter_column(a, b) .where_column(a, b) Column-to-column compare
->whereExists(query) .filter_exists(query) .where_exists(query) A DB::table subquery
->whereNotExists(query) .filter_not_exists(query) .where_not_exists(query)
->whereHas(rel, closure) .filter_has(rel, fn) .where_has(rel, fn) Relation predicate (10B)
->whereDoesntHave(rel) .filter_doesnt_have(rel) .where_doesnt_have(rel) (10B)
->whereRelation(rel, col, op, v) .filter_relation(...) .where_relation(...) (10B)
->whereRaw(sql, bindings) .filter_raw(sql, bindings) .where_raw(sql, bindings)
->orWhereRaw(sql, bindings) .or_filter_raw(sql, bindings) .or_where_raw(sql, bindings)

Every or_* method adds a flat OR condition, so r#where(a).or_where(b).r#where(c) means a OR b AND c. You use r#where(column, value) or db_where(column, value) for equality, and filter_op or or_where_op for an explicit operator. A null equality value compiles to IS NULL. Global scopes keep your complete condition list in a group when it contains an OR.

The grouped helpers keep their comparisons in parentheses: this query never returns a user whose active is false, and the model's soft-delete filter still applies:

// WHERE deleted_at IS NULL AND active = ? AND (is_admin = ? OR is_staff = ?)
let staff = User::query()
    .filter("active", true)
    .where_any(["is_admin", "is_staff"], "=", true)
    .get()
    .await?;

An empty column list adds no condition. where_in and its siblings take another DB::table builder as a subquery, and so do where_exists and where_not_exists, whose subquery can refer to the model's table through where_column:

use suprnova::DB;

let booked = Room::query()
    .where_in("id", DB::table("slots").select(["room_id"]).filter("day", "mon"))
    .get()
    .await?;

let authors = User::query()
    .where_exists(
        DB::table("posts")
            .select_raw("1")
            .where_column("posts.author_id", "users.id"),
    )
    .get()
    .await?;

A subquery's values are bound parameters in their place in the statement. For a declared relation, has and where_has build the EXISTS subquery for you. The full subquery and OR rules are in Query Builder.

The binary family compares raw bytes instead of matching under the column's collation. MySQL and MariaDB emit col = binary ?; Postgres and SQLite have no equivalent operator, so a terminal on those backends returns an error when the statement renders rather than falling back to a collation-dependent =. See Byte-exact comparison.

Bound raw predicates use portable ? markers on SQLite, MySQL, and PostgreSQL:

let rows = User::query()
    .filter("active", true)
    .filter_raw(
        "score >= ? AND role = ?",
        vec![serde_json::json!(80), serde_json::json!("admin")],
    )
    .get()
    .await?;

On PostgreSQL, Suprnova rebases those markers after earlier query bindings, so the example renders $1 for active and $2/$3 for the raw predicate. Use ?? for a literal question-mark operator in a bound raw fragment, such as "payload ?? 'enabled' AND status = ?". Existing $N fragments remain accepted, but portable markers avoid coupling call sites to query position. Mixed marker styles and marker/binding count mismatches are rejected before database I/O. As with every raw expression, the SQL text must be trusted; untrusted values belong only in the bindings vector.

Ordering

$users = User::orderBy('name', 'asc')->get();
$users = User::orderByDesc('created_at')->get();
$users = User::latest()->get();        // shortcut: orderBy(created_at, desc)
$users = User::oldest()->get();        // shortcut: orderBy(created_at, asc)
$users = User::inRandomOrder()->get();
let users = User::query().order_by("name", Direction::Asc).get().await?;
let users = User::query().order_by_desc("created_at").get().await?;
let users = User::latest().get().await?;
let users = User::oldest().get().await?;
let users = User::query().in_random_order().get().await?;

Direction::Asc / Direction::Desc is the Suprnova enum re-exported from SeaORM.

reorder() drops every ordering set so far, and reorder_by(col, dir) drops them and orders by col instead. Orderings a global scope adds when the query runs stay, as in Laravel:

$users = User::latest()->reorder('name')->get();
let users = User::latest().reorder_by("name", Direction::Asc).get().await?;

Ordering by an explicit sequence

in_order_of sorts rows into the order you list. Anything whose value is not in the list sorts after everything that is.

$users = User::inOrderOf('role', ['admin', 'member', 'guest'])->get();
let users = User::query()
    .in_order_of("role", ["admin", "member", "guest"])
    .get()
    .await?;

Suprnova renders this as a bound CASE expression, so the values are parameters and are safe to take from request data:

ORDER BY CASE WHEN role = ? THEN 0 WHEN role = ? THEN 1 WHEN role = ? THEN 2 ELSE 3 END

The column name is an SQL identifier, not a parameter. Hardcode it or pick it from an allowlist, the same as every other column argument. An empty value list adds no ordering at all, so you can build the sequence conditionally without special-casing the empty case.

For a column that uses the AsEnum<E> cast, pass each variant through as_ref(). That is the exact string the cast stores:

let users = User::query()
    .in_order_of("role", [Role::Admin.as_ref(), Role::Member.as_ref()])
    .get()
    .await?;

in_order_of ships on the typed Builder<M> surface. The model-less DB::table(...) builder orders by column and direction only.

Grouping + having

$rows = User::groupBy('role')->having('count(*)', '>', 5)->get();
let rows = User::query()
    .group_by("role")
    .having_op("count(*)", ">", 5)
    .get()
    .await?;

Limit / offset

$users = User::limit(10)->offset(20)->get();
$users = User::take(10)->skip(20)->get();   // aliases
let users = User::query().limit(10).offset(20).get().await?;
let users = User::query().take(10).skip(20).get().await?;

Select / add_select / select_raw

let users = User::query().select(["id", "name", "email"]).get().await?;
let users = User::query().select("name").add_select("email").get().await?;
let rows  = User::query().select_raw("count(*) as total, role")
    .group_by("role")
    .get_raw()
    .await?;

get_raw() returns the raw column-shape result for select_raw cases where the columns don't match the model schema; get() returns Vec<User> and requires the selected columns to fill the model struct.

Distinct

let emails: Vec<String> = User::query().distinct().pluck("email").await?;

Aggregates

let count   = User::count().await?;
let count   = User::filter("active", true).count().await?;
let sum     = User::sum::<f64>("balance").await?;
let visits  = Page::sum::<u64>("views").await?;
let avg     = Order::avg::<f64>("total").await?;
let exact   = Order::avg::<Decimal>("total").await?;
let min     = Order::min::<DateTime<Utc>>("created_at").await?;
let max     = Order::max::<DateTime<Utc>>("created_at").await?;
let exists  = User::filter("email", &email).exists().await?;
let missing = User::filter("email", &email).doesnt_exist().await?;

count returns an i64. sum, avg, min and max take the type to read as a type parameter; for avg it is f64 or rust_decimal::Decimal (the AvgValue trait). Suprnova aliases generated aggregate expressions internally so the same typed result is decoded on PostgreSQL, MySQL, and SQLite. sum and avg return zero for an empty match set, while min and max return None. The same holds when no row comes back at all - an offset skips the aggregate's one row, as skip(10).count() does, or a grouped query has no group: count, sum and avg return zero and min and max return None, as Laravel's count, sum, min and max do. An incompatible requested Rust type or missing result column is a database error; it is never converted into a plausible zero or None.

The database chooses the type of a sum or an average: PostgreSQL answers numeric for the sum of a bigint column and for any average of integers, MySQL answers DECIMAL, and SQLite answers an integer or a real. sum and avg read whichever arrives. An integer type such as i64 or u64 takes a sum exactly, and a sum with a fraction, or outside the type's range, is an error rather than a truncated value. Decimal takes a sum or an average exactly, so the average of a DECIMAL money column keeps every digit, and a value it can't hold is an error. f64 takes the nearest value. SQLite has no exact decimal type: it stores a DECIMAL column as a real, and Decimal reads that real.

Terminals

let users:  Vec<User>          = User::all().await?;
let first:  Option<User>       = User::first().await?;
let user:   User               = User::first_or_fail().await?;
let value:  Option<String>     = User::filter("...").value("email").await?;
let emails: Vec<String>        = User::pluck::<String>("email").await?;
let keyed:  HashMap<i64, String> = User::pluck_keyed::<i64, String>("id", "name").await?;
let ids:    Vec<i64>           = User::query().model_keys().await?;
let sql:    String             = User::filter("...").to_sql();

value, pluck and pluck_keyed skip a NULL: value returns None and pluck leaves the row out. A value that doesn't read as the type you name is an error that names the column, rather than a missing row. These terminals, the aggregates and DB::scalar read u64 and Option<u64> on every database, as a model's u64 field does: on Postgres and SQLite a negative value fails the read. A read by a u64 above i64::MAX answers what the database holds. On Postgres a signed integer column holds only integers, so no row matches and the query gets its answer without sending the value: find returns None, find_many skips it, and in a filter =, >, >= and IN match no row, while !=, <, <= and NOT IN match every row whose column is not NULL. SQLite compares the value's digits, as it does a literal, because an INTEGER column there can hold a REAL above i64::MAX that raw SQL or an older write left. MySQL compares the unsigned number.

The same holds for a column of a narrower integer field such as i64 or i32, which can't hold such a value on any database, and a mass update that writes one to it is refused on Postgres and SQLite before anything is sent. A column the model doesn't know, such as a joined table's column, compares as the number on every database, as with DB::table. A mass write (update_all, upsert) to a field that is neither an integer nor text, such as a decimal, also takes the value as DB::table does: a numeric column on Postgres or MySQL stores it exactly, and SQLite refuses it for a column of INTEGER or NUMERIC affinity, which would store a rounded REAL.

to_sql returns the parameterised SQL the next terminal would emit - useful for debugging or building views. The bindings are accessible via .to_sql_with_bindings() -> (String, Vec<Value>).

model_keys is the key-only terminal: it projects the qualified primary key (users.id) and never hydrates a model, so a "which rows matched?" question costs one column instead of a full row per match. The qualification is what lets it survive a query that joins another table carrying its own id. Any select(...) already on the builder is discarded - the caller asked for keys.

Joins

join, left_join, right_join, and cross_join work as they do on the DB::table builder, along with the closure forms (join_with, left_join_with, right_join_with, cross_join_with) and the subquery joins (join_sub, left_join_sub, join_sub_with, left_join_sub_with). See Query Builder - Joins for the conditions a join takes.

$posts = Post::join('users', 'users.id', '=', 'posts.author_id')
    ->where('users.active', true)
    ->get();
let posts = Post::query()
    .join("users", "users.id", "=", "posts.author_id")
    .filter("users.active", true)
    .get()
    .await?;

Without a select, a model query with a join selects only the model's own columns ("posts".*), so the joined table's id, name, or created_at never lands in the model. Call select to choose the columns yourself; select(["posts.*"]) is the default spelled out. Qualify a column that both tables carry, such as users.active above. On a model that soft-deletes, the soft-delete filter names the model's table once the query joins another, so a joined table with its own deleted_at doesn't make the column ambiguous. The joined table's own soft-delete filter isn't applied, as in Laravel.

count and paginate count the joined rows. You use update or update_all to update matching base rows, and delete or delete_all to delete them. On a soft-delete model, deletion writes its tombstone; force_delete_all removes the rows. Every join and predicate still constrains these writes. increment_each still refuses joins.

You compare two columns with where_column(a, b) for equality or where_column_op(a, op, b) for another allowed operator.

Unions

let first  = User::filter("active", true);
let second = User::filter("role", "admin");
let users  = first.union(second).get().await?;
let users  = first.union_all(second).get().await?;

As in Laravel, an ordering, a limit, or an offset belongs to the whole union when you add it after union, and to the first query alone when you add it before. paginate, simple_paginate, cursor_paginate, first, and count all come after union, so they page, take, and count the rows of the union; a cursor bounds the rows of every arm:

let page = User::filter("active", true)
    .union(User::filter("role", "admin"))
    .order_by_desc("id")      // orders the union
    .paginate(20)             // pages the union; `total` counts its rows
    .await?;

let latest_active = User::filter("active", true)
    .order_by_desc("created_at")
    .limit(10)                // the ten newest active users only
    .union(User::filter("role", "admin"));

The union is wrapped as a subquery for these clauses, so order it by the bare names of its columns (id, not users.id). A UNION keeps one copy of a row both queries return, and total counts it once; union_all keeps both.

Row locking

Two builder methods request a per-row database lock at SELECT time:

// Exclusive write lock - blocks other transactions trying to lock
// or write the same rows until this transaction commits.
let order = Order::query()
    .filter("id", 42)
    .lock_for_update()
    .first_or_fail()
    .await?;

// Shared read lock - allows other shared readers, blocks writers.
let inventory = Inventory::query()
    .filter("sku", sku)
    .shared_lock()
    .first_or_fail()
    .await?;

Per-backend SQL emitted:

Backend lock_for_update() shared_lock()
Postgres FOR UPDATE FOR SHARE
MySQL FOR UPDATE LOCK IN SHARE MODE
SQLite (no SQL, see below) (no SQL, see below)

The lock clause is appended at the very end of the compound statement - after every UNION arm, every ORDER BY, every LIMIT / OFFSET. A union(...) of two builders followed by .lock_for_update() emits exactly one FOR UPDATE at the outer scope, not one per arm.

To reload a model you already hold and take the lock in the same statement, use refresh_for_update:

DB::transaction(|tx| async move {
    let mut order = Order::find_or_fail(42).await?;
    order.refresh_for_update().await?;   // SELECT ... WHERE id = ? FOR UPDATE
    order.status = "processed".into();
    order.save_with_tx(&tx).await?;
    Ok(())
}).await?;

Use inside a transaction

The lock only does useful work inside a transaction - without one, the SQL still emits but the lock releases at statement end. Pair with DB::transaction(...):

DB::transaction(|tx| async move {
    let order = Order::query()
        .filter("id", 42)
        .lock_for_update()
        .first_or_fail()
        .with_tx(&tx)
        .await?;
    // Other transactions trying to lock id=42 block here until commit.
    order.status = "processed".into();
    order.save_with_tx(&tx).await?;
    Ok(())
}).await?;

lock_for_update vs shared_lock

Most "read then write" flows want lock_for_update. A shared lock still lets another shared_lock reader race you to a following UPDATE - only FOR UPDATE is mutually exclusive.

shared_lock is right for consistent snapshot reads where you read a row, derive a decision from it, and don't write back - e.g. an inventory check that doesn't itself decrement stock.

SQLite

SQLite has no row-level locking. It uses file-level transaction locking only (BEGIN IMMEDIATE / BEGIN EXCLUSIVE). The lock methods are kept in the SQLite path so cross-backend code compiles, but they emit no SQL.

The first time per process that lock_for_update / shared_lock runs against a SQLite backend, the framework logs a single warn! on the suprnova::eloquent::lock tracing target. This surfaces the no-op without spamming high-volume code paths.

If you need cross-row contention guarantees on SQLite, wrap the critical section in an explicit BEGIN IMMEDIATE transaction - at the file level that blocks every other writer.

Not provided

  • NOWAIT / SKIP LOCKED - useful for job-queue claim workflows but they add API surface, so they are added only when a real consumer needs them.

Transactions

Suprnova ships three entry points for database transactions plus nested-rollback via savepoints. Two of them - the closure form and the retry-on-deadlock helper - install an ambient context so model operations inside the closure auto-route through the transaction without callers threading a handle through every call site.

Closure form - DB::transaction

The closure form is the common case. The closure receives a &Transaction it can use to checkpoint with savepoint(name); every Model::* / Builder::* operation inside the closure auto-routes through the transaction: the closure's task carries it as the ambient transaction.

use suprnova::{DB, FrameworkError, Model};

DB::transaction(|_tx| {
    Box::pin(async move {
        let mut alice = User::query().filter("name", "alice").first_or_fail().await?;
        alice.balance -= 30;
        alice.save().await?;

        let mut bob = User::query().filter("name", "bob").first_or_fail().await?;
        bob.balance += 30;
        bob.save().await?;
        Ok::<(), FrameworkError>(())
    })
}).await?;
  • Closure returns Ok → commit.
  • Closure returns Err → rollback (original error propagates).
  • Closure panics → rollback (the in-flight transaction is dropped on unwind; SeaORM's DatabaseTransaction::drop rolls back).

Reads inside the closure see writes from the same transaction (every leaf SQL call looks up the ambient transaction). The first DB::transaction call after process start picks the database backend off DB::connection(); subsequent calls reuse the same connection registry.

The signature uses a higher-ranked trait bound + Pin<Box<dyn Future>> so closures can borrow tx across .await points:

DB::transaction(|tx| {
    Box::pin(async move {
        // ... pre-savepoint work ...
        tx.savepoint("inner").await?;
        // ... inner work ...
        if some_condition {
            tx.rollback_to("inner").await?;
        }
        Ok::<(), FrameworkError>(())
    })
}).await?;

The Box::pin(async move { ... }) shape is the cost of letting the future use &tx after an .await - without it, the lifetime of the borrow can't escape the closure body. Mirrors SeaORM's TransactionTrait::transaction signature.

Savepoints - tx.savepoint(name) / tx.rollback_to(name)

Savepoints checkpoint the transaction so you can drop a block of inner work without aborting the outer commit. Works on all three backends - SQLite's SAVEPOINT is fully functional even though SQLite has no row-level locking.

DB::transaction(|tx| {
    Box::pin(async move {
        let mut account = Account::query().filter("id", id).first_or_fail().await?;
        account.balance = 200;
        account.save().await?;     // committed when the outer tx commits

        tx.savepoint("audit_trail").await?;

        let entry = AuditEntry::create(attrs! { actor_id: actor, ... }).await?;
        if audit_validation_failed(&entry) {
            tx.rollback_to("audit_trail").await?;
            // audit_trail row gone; account update still pending commit
        }

        Ok::<(), FrameworkError>(())
    })
}).await?;

The savepoint name is interpolated verbatim into the SQL - use a static identifier, do not splice user input.

Nested DB::transaction is rejected at runtime

DB::transaction(|_outer| Box::pin(async move {
    let inner = DB::transaction(|_inner| Box::pin(async move {
        Ok::<(), FrameworkError>(())
    })).await;
    // inner is Err(FrameworkError::Database(
    //     "nested DB::transaction is not supported; use tx.savepoint(name) for nested rollback"
    // ))
    Ok::<(), FrameworkError>(())
})).await?;

SeaORM's DatabaseConnection::begin() doesn't compose - calling it on a connection that's already holding a transaction starts a brand-new physical transaction that commits / rolls back independently of the outer scope. That's a silent data-integrity footgun, so DB::transaction checks for an ambient transaction up front and returns a database error instead of producing the wrong semantics. Use tx.savepoint(name) for nested behaviour.

Retry-on-deadlock - DB::transaction_with_attempts

Postgres SERIALIZABLE reads and MySQL row-level locks can raise serialization-failure / deadlock errors that resolve by retrying the transaction. transaction_with_attempts runs the closure from scratch each time, up to attempts:

DB::transaction_with_attempts(3, |_tx| {
    Box::pin(async move {
        // SERIALIZABLE-isolated logic that may race a concurrent
        // tx and surface SQLSTATE 40001 / 40P01 on commit.
        let inventory = Inventory::query()
            .filter("sku", sku)
            .lock_for_update()
            .first_or_fail()
            .await?;
        if inventory.units < requested {
            return Err(FrameworkError::bad_request("out of stock"));
        }
        Inventory::query()
            .filter("sku", sku)
            .update(attrs! { units: inventory.units - requested })
            .await?;
        Ok::<(), FrameworkError>(())
    })
}).await?;

Detection is by Display-string substring against the inner error:

  • Postgres SQLSTATE 40001 (serialization_failure)
  • Postgres SQLSTATE 40P01 (deadlock_detected)
  • Case-insensitive "deadlock" substring (covers MySQL Deadlock found when trying to get lock and any user-surfaced deadlock string)

On the final attempt the error propagates unchanged. The closure runs from scratch on every attempt - capture owned state or Arcs rather than &mut references so the retry path is well-defined.

Caveat: because detection includes a case-insensitive "deadlock" substring (needed for MySQL whose driver doesn't surface a SQLSTATE), any inner error whose Display contains the word will trigger a retry. When raising your own errors from inside a transaction_with_attempts closure, avoid "deadlock" in the message - otherwise an unrelated validation error retries up to attempts times before propagating. The Postgres SQLSTATE matches (40001 / 40P01) are the reliable signal; the heuristic is for MySQL only.

Manual form - DB::begin_transaction + *_with_tx shims

When the transaction lifetime doesn't fit a closure (e.g. spans multiple control-flow branches), open a manual Transaction and opt each operation into it explicitly:

let tx = DB::begin_transaction().await?;

let mut user = User::query()
    .filter("name", "alice")
    .with_tx(&tx)
    .first_or_fail()
    .await?;
user.balance = 500;
user.save_with_tx(&tx).await?;

if some_condition {
    let mut other = User::query()
        .filter("name", "bob")
        .with_tx(&tx)
        .first_or_fail()
        .await?;
    other.update_with_tx(&tx, attrs! { balance: 200i64 }).await?;
}

tx.commit().await?;  // or tx.rollback().await?;

Manual mode does not install an ambient transaction. Scope individual operations through the transaction with Builder::with_tx(&tx) or the Model::*_with_tx(&tx, ...) shims:

Trait method Manual variant
Model::create Model::create_with_tx(&tx, attrs)
Model::save Model::save_with_tx(&tx)
Model::update Model::update_with_tx(&tx, attrs)
Model::delete Model::delete_with_tx(&tx)
Model::force_delete Model::force_delete_with_tx(&tx)
Builder::* Builder::with_tx(&tx).*

Holding a Transaction pins one pool connection for the entire lifetime of the handle. On SQLite the pool has a single connection, so any parallel non-transactional read against the same database blocks until the transaction completes - load any pre-flight rows BEFORE DB::begin_transaction() and route every dependent write through the returned tx.

Transaction::commit / Transaction::rollback consume the handle and require Arc::try_unwrap of the inner SeaORM transaction; if any TxHandle clones (from tx.handle() / Builder::with_tx(&tx)) are still alive at commit / rollback time, both fail with a "TxHandle clones still alive" error. The correct fix is to drop your Builder<M> / outstanding handles before calling commit - the framework refuses to race a half-uncommitted write against a parallel writer holding the same tx.

Precedence

Three-way precedence for routing an operation through a connection:

  1. Builder-level override - Builder::with_tx(&tx) or any Model::*_with_tx(&tx, ...) shim. Explicit beats ambient. The eager loads of such a query read through the same transaction, and those of an on(name) query read from that connection, unless the related model declares a connection of its own.
  2. The ambient transaction - installed by DB::transaction / DB::transaction_with_attempts for the closure's task scope. A read that names another connection, through on(name) or a model's declared connection, still runs on that connection: the transaction is pinned to one database, so a read cannot join it there, and rerouting it would change which database the code reads from (audit finding ASTRA-06, 2026-09-13). Writes stay on the transaction, since atomicity must not split across connections.
  3. Pool fallback - DB::connection() returns the global DbConnection singleton.

Inside DB::transaction(|tx| ...), calling Builder::with_tx(&other_tx) explicitly routes that one query through other_tx - bypassing the ambient transaction. That's almost certainly a bug; the override path exists for the manual form, not for overriding the closure's own tx.

with_tx and global scopes

A builder carrying a tx_override still respects global scopes, named scopes, and the eager-load plan - the override only changes the connection routing, not the SQL.

Relations and DDL inside a transaction

  • Relation eager loads - Builder::with(["posts"]) and Collection::load(["posts"]) run their IN (...) sub-queries through the active transaction, so pending writes inside a DB::transaction closure are visible to relations loaded there.
  • DDL - DB::statement(...) inside a transaction runs the DDL against the transaction's connection, which Postgres allows; MySQL and MariaDB commit implicitly on DDL, so DDL is unsupported inside a Suprnova transaction on those engines (the same caveat as Laravel's DB::transaction).

Scopes

Suprnova ships two flavours of scope, mirroring Laravel:

  • Local scopes - extension methods on the builder, declared per model with #[suprnova::scopes(Model)]. Each free function in the annotated impl block becomes both Model::name() (a static starter) and Builder::name() (a chainable method).
  • Global scopes - implementations of GlobalScope<M> registered at boot via ScopeRegistry::register::<M, _>(scope). Every Model::query() call layers them on automatically.

Local scopes

Declare local scopes by giving them the shape fn(query: Builder<Self>, args...) -> Builder<Self>:

#[suprnova::scopes(User)]
impl User {
    pub fn active(query: Builder<Self>) -> Builder<Self> {
        query.filter("active", true)
    }

    pub fn popular(query: Builder<Self>, threshold: i64) -> Builder<Self> {
        query.filter_op("followers_count", ">", threshold)
    }
}

// Use as either a starter or a chainable method:
let active_users  = User::active().get().await?;
let popular_users = User::query().active().popular(500).get().await?;

A method that does not handle a Builder passes through unchanged, so scopes and ordinary methods can share one impl block.

A method that takes or returns a Builder in any other form is a scope written wrong, and the macro refuses it with a compile error that names the accepted shape. This covers a first parameter of &mut Builder<User> or &Builder<Self>, a model name in place of Self (Builder<User>), and a missing Builder<Self> return type. Left alone, such a method would be an ordinary method and no .name() would reach the builder. Put #[not_scope] on a helper that handles a builder and is not a scope:

#[suprnova::scopes(User)]
impl User {
    #[not_scope]
    fn describe(query: &Builder<User>) -> String {
        query.to_sql()
    }
}

Global scopes

Global scopes apply on every Model::query() call. The classic use case is multi-tenancy - every read is scoped to the current tenant without each caller threading the filter through.

use suprnova::eloquent::scopes::{GlobalScope, ScopeRegistry};

pub struct TenantScope;

impl GlobalScope<Article> for TenantScope {
    fn apply(&self, query: Builder<Article>) -> Builder<Article> {
        // Reads the current tenant from a task-local /
        // AtomicI64 / wherever per-request state lives.
        query.filter("tenant_id", current_tenant_id())
    }
}

// At boot - typically inside your provider/bootstrap module:
ScopeRegistry::register::<Article, _>(TenantScope);

// Every read is auto-scoped to the active tenant:
let scoped = Article::query().get().await?;

Multiple scopes per model compose in registration order - first registered runs first, so its filter clauses appear first in the WHERE chain. AND-combined filters don't care about order, but left-to-right matters for any clause whose side-effect order is visible (e.g. ordering, having, raw fragments).

You call apply_scopes() to keep a builder with its registered scopes and soft-delete filter already applied. You inspect the resulting SQL or run the same builder. Repeated calls keep each scope's clauses once. You set without_global_scopes() or with_trashed() before applying scopes when you need those opt-outs.

let query = Article::query().apply_scopes();
let sql = query.to_sql();
let rows = query.get().await?;

Opting out of a global scope

Each model the #[suprnova::model] macro touches gets two static helpers emitted on it:

// Bypass exactly one registered scope by type. Other scopes still apply.
let all_tenants = Article::without_global_scope::<TenantScope>().get().await?;

// Bypass every registered scope. Admin tooling pattern.
let everything = Article::without_global_scopes().get().await?;

The opt-outs also chain on a Builder: Model::query().without_global_scope::<S>() and Model::query().without_global_scopes() return the same rows as the static helpers, wherever you write them in the chain.

The builder applies global scopes when the query runs, not when Model::query() builds it. The statement reads <scopes> AND <your terms>. Your own terms stay together after the scopes: an or_where never widens past a scope. update_all, delete_all, and increment_each resolve scopes the same way, so a mass write reaches only the rows a read returns. A scope that reads per-request state reads it when the query runs.

Where global scopes apply

Path Global scopes apply?
Model::query() Yes - the canonical scoped entry point
Model::without_global_scope::<S>() Yes, minus S
Model::without_global_scopes() No
Model::query().without_global_scope::<S>() Yes, minus S, wherever it is chained
Model::with_trashed() / Model::only_trashed() Yes - only the soft-delete filter is lifted
Route binding (post: Post, RouteParam<Post>) Yes - the bound row is read through Model::query()
Model::find(id) No - PK lookup goes through SeaORM directly
Model::find_many([...]) No - same reason
Model::all() No - same reason

This mirrors Laravel: Eloquent\Model::find doesn't trigger addGlobalScopes. Callers that want scoped PK lookups use Self::query().filter("id", pk).first().await?.

Soft deletes and global scopes coexist

#[suprnova::model(soft_deletes)] installs the deleted_at IS NULL filter via a separate string-tag mechanism, not through the typed scope registry. Both layers compose:

  • Model::query() filters out trashed rows AND runs every registered scope.
  • Model::without_global_scopes() drops registered scopes but preserves the soft-delete filter - admin tooling that wants to read every column-set still excludes trashed rows by default.
  • Model::with_trashed() and Model::only_trashed() lift only the soft-delete filter. Every registered scope still runs, so a tenant scope keeps trashed reads inside the tenant, as Laravel's withTrashed() does.

Relationships

Suprnova ships every Eloquent relation flavour. They're declared in the relations = { ... } block on #[suprnova::model], and the macro emits - per declared relation - a method on the struct, a loaded-accessor (<name>_loaded()), a count-accessor (<name>_count()), and the dispatcher arm the eager loader calls into. This section covers the per-kind shape and option table; the deep dive on join-key resolution, the morph registry, pivot rows, and the polymorphic enum lowering lives in Eloquent Relationships. The relation kinds:

Kind One/many Across families Backed by
HasOne<R> one no IN query on <parent>_id
BelongsTo<R> one no IN query on FK on this row
HasMany<R> many no same as HasOne, returns Vec<R>
BelongsToMany<R, P> many no pivot table P, INNER JOIN + pivot::<P>()
HasOneThrough<B, R> one no two-query JOIN parent → B → R
HasManyThrough<B, R> many no same as above, returns Vec<R>
MorphOne<R> one yes IN + <name>_type = "<self>" filter
MorphMany<R> many yes same as MorphOne, returns Vec<R>
MorphTo one yes (children → many families) per-family enum emitted at the declaration site
MorphToMany<R, P> many yes polymorphic m2m pivot P
MorphedByMany<R, P> many yes (inverse) same pivot, scanned the other way

relations = { ... } syntax

Every relation declaration carries the same outer shape: the relation name, the kind, the related type (and pivot/intermediate types where applicable), and a { ... } block of options.

use suprnova::model;

#[model(
    table = "users",
    relations = {
        // HasMany<R>
        posts: HasMany<crate::models::Post> {
            fk = "author_id",         // override default `user_id`
        },
        // BelongsToMany<R, Pivot>
        roles: BelongsToMany<crate::models::Role, crate::models::RoleUser> {
            with_pivot = ["assigned_at"],
            with_timestamps,
        },
    },
)]
pub struct User {
    pub id: i64,
    pub name: String,
    pub email: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

Common options:

Option Relation kinds Purpose
fk = "..." every kind with a child FK Column on the CHILD pointing at the parent. Default = <snake(parent_struct)>_id.
lk = "..." one/many kinds Column on the PARENT used as the join key. Default = the parent model's primary key.
related_key = "..." BelongsToMany, MorphToMany The related-side COLUMN the pivot's related key holds. Default = the related model's primary key.
with_pivot = ["...", ...] BelongsToMany, MorphToMany Extra columns on the pivot to surface in the join.
with_timestamps BelongsToMany, MorphToMany Stamp created_at / updated_at on attach/sync.
with_default = || { ... } BelongsTo Closure producing a default when the FK is null OR the parent is missing.
first_key, second_key, second_local_key HasOneThrough, HasManyThrough JOIN key overrides - see the Through section below.
name = "..." every morph kind Morph family name (e.g. "commentable", "taggable"). Drives the <name>_id / <name>_type columns on the child/pivot.
targets = [T1, T2, ...] MorphTo The list of concrete morph targets. The macro emits a <Name>Morph enum at the declaration site with one variant per target plus Unknown(String, serde_json::Value).
target_morph_type = "..." MorphedByMany The morph-type string identifying the target family on the pivot.
pivot_table, pivot_foreign_key, pivot_related_key BelongsToMany, MorphToMany Pivot-side column / table overrides when the defaults don't fit.

Why Suprnova diverges: default foreign key names

A default foreign key column always ends in _id. Laravel ends it in the parent model's primary key name instead (getForeignKey() is the snake-cased class name, _, and getKeyName()), so the two agree for the usual id key and differ for any other. BelongsTo differs in the name as well: Laravel takes it from the relation, Suprnova from the target model.

Relation Suprnova default Laravel default
HasOne, HasMany on a parent keyed by uuid user_id user_uuid
BelongsTo declared as author: BelongsTo<User> user_id (the target model) author_id (the relation name)
BelongsToMany pivot keys <snake(model)>_id <snake(model)>_<primary key>
Through first_key / second_key <snake(model)>_id <snake(model)>_<primary key>

Most schemas name the column <model>_id whatever the parent's key is called, as Laravel's own $table->foreignUuid('user_id') does, so the default matches the column you would write. The join column on the other side already follows the model: lk, related_key and the owner key default to the model's primary key. When you mirror a Laravel schema whose columns follow Laravel's rule, name them with fk = "...", pivot_foreign_key, pivot_related_key, first_key or second_key.

HasOne<R> and BelongsTo<R>

One-to-one in both directions. HasOne lives on the parent side and calls R::query().filter(<fk>, <self.id>).first(). BelongsTo lives on the child side and reads the FK off self, then calls R::query().filter(<owner_key>, <fk_value>).first().

#[model(table = "users", relations = {
    profile: HasOne<crate::models::Profile>,
})]
pub struct User { /* ... */ }

#[model(table = "profiles", relations = {
    user: BelongsTo<crate::models::User>,
})]
pub struct Profile {
    pub id: i64,
    pub user_id: i64,
    pub bio: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

let user = User::find(1).await?.unwrap();
let profile: Option<Profile> = user.profile().first().await?;

let profile = Profile::find(42).await?.unwrap();
let owner: Option<User> = profile.user().first().await?;

BelongsTo supports with_default = || R { ... }, which fires either when the FK is null OR when the parent row is missing. The default closure runs per call (and per eager-loaded row) - perfect for an empty stand-in when a deleted user still has comments:

#[model(table = "comments", relations = {
    author: BelongsTo<crate::models::User> {
        with_default = || User {
            name: "[deleted]".into(),
            ..Default::default()
        },
    },
})]
pub struct Comment { /* ... */ }

let c = Comment::find(99).await?.unwrap();
// Always Some - the default fires when the user row is missing.
let author = c.author().first().await?.unwrap();

HasMany<R>

One-to-many on the parent side. Returns a fluent builder; chain filter / order / latest / take / get / count and terminate.

#[model(table = "users", relations = {
    posts: HasMany<crate::models::Post> {
        fk = "author_id",
    },
})]
pub struct User { /* ... */ }

let u = User::find(1).await?.unwrap();

// Every post by this user, default ordering:
let posts: Vec<Post> = u.posts().get().await?;

// Filtered + ordered + paged:
let recent = u.posts()
    .filter("published", true)
    .latest()                          // ORDER BY created_at DESC
    .take(10)
    .get()
    .await?;

// COUNT alone - no row fetching:
let total: i64 = u.posts().count().await?;

Available terminal methods: .first(), .get(), .count(). Available chainable filters: .filter / .db_where, .filter_in / .where_in, .order_by, .latest, .oldest, .limit, .take.

BelongsToMany<R, P> - first-class Pivot

Many-to-many through a #[suprnova::model]-declared pivot. The pivot is a first-class model with its own row identity - not a tuple, not a hidden hash map. Two key benefits over Laravel's anonymous-pivot shape:

  1. The pivot row is type-safe. Read with_pivot columns via r.pivot::<P>().<column>, never via r.pivot.get("...").
  2. The pivot model is reachable from the rest of the framework (factories, scopes, casts, hooks) the same way every model is.
#[model(table = "role_user", fillable = ["user_id", "role_id", "assigned_at"])]
pub struct RoleUser {
    pub id: i64,
    pub user_id: i64,
    pub role_id: i64,
    pub assigned_at: Option<chrono::DateTime<chrono::Utc>>,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

#[model(table = "users", relations = {
    roles: BelongsToMany<crate::models::Role, RoleUser> {
        with_pivot = ["assigned_at"],
        with_timestamps,
    },
})]
pub struct User { /* ... */ }

let u = User::find(1).await?.unwrap();
let admin = Role::create(attrs! { name: "admin" }).await?;

// Attach + sync mutators
u.roles().attach(admin.id).await?;
u.roles().attach_with(admin.id, attrs! { assigned_at: chrono::Utc::now() }).await?;
u.roles().sync([role_a.id, role_b.id, role_c.id]).await?;
u.roles().sync_without_detaching([role_d.id]).await?;
u.roles().detach(admin.id).await?;

// Read pivot data through the per-row downcast accessor:
let roles = u.roles().get().await?;
for r in &roles {
    let p: &RoleUser = r.pivot::<RoleUser>();
    println!("user {} got role {} at {:?}", p.user_id, p.role_id, p.assigned_at);
}
  • .attach(id) - INSERT a single pivot row. Errors on duplicate unless your pivot allows it (the framework doesn't dedupe at the Rust layer; use .sync for idempotency).
  • .attach_with(id, attrs! { ... }) - INSERT with extra pivot columns. Stamps timestamps when with_timestamps is on.
  • .detach(id) - DELETE the pivot row(s) linking parent → id.
  • .sync([ids...]) - diff-and-apply: attach what's new, detach what's missing, update supplied pivot columns on existing rows. You receive SyncChanges with attached, detached and updated id lists.
  • .sync_without_detaching([ids...]) - attach what's new and keep every existing row. You can supply (id, attrs! { ... }) records to update existing pivot columns. Unchanged rows keep their timestamps, and the returned SyncChanges.detached is empty.

You pass ids alone or (id, Attrs) records to either sync method. Only rows whose supplied columns change appear under updated. Pivot filters constrain the current set, updates and detachments; rows outside that set remain unchanged. All changes run in one transaction. You call .touch_parent() to update the parent's timestamp when sync changes the relation; the touch commits or rolls back with the pivot writes.

Each id and extra column binds by the type its column has: the pivot model's field, or else the key the column holds. On Postgres and SQLite, a u64 above i64::MAX written to the pivot fails with an error that names the column, and nothing is sent. detach of such an id deletes nothing on Postgres, and on SQLite it compares the id's digits, as it does a literal. On MySQL an unsigned pivot column holds the whole u64 range, and sync and .get() read its ids back.

.get() returns Vec<R> with the pivot stamped on each row's internal __pivot field. The .pivot::<P>() accessor downcasts the Arc<dyn Any> to the pivot type you declared. Calling it with the wrong type panics - match the type to the declared pivot.

HasOneThrough<B, R> and HasManyThrough<B, R>

Reach a final target R through an intermediate B. Useful when the relation traverses two tables but you don't need to expose the intermediate (A → B → R).

#[model(table = "countries", relations = {
    posts: HasManyThrough<crate::models::User, crate::models::Post>,
})]
pub struct Country {
    pub id: i64,
    pub name: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

let c = Country::find(1).await?.unwrap();
let posts: Vec<Post> = c.posts().get().await?;

The dispatcher infers JOIN keys from struct names. Overrides:

Option Default Description
first_key <snake(parent_struct)>_id Column on intermediate B pointing at parent A.
second_key <snake(intermediate_struct)>_id Column on final R pointing at intermediate B.
second_local_key "id" Column on intermediate B matched by second_key. Required when B uses a non-id PK.

The parent's primary-key column is read from the model's primary_key declaration (defaulting to "id") - there is no local_key override on HasManyThrough / HasOneThrough; change the parent's PK via the #[suprnova::model] attribute if you need a non-id parent key.

#[model(table = "countries", relations = {
    posts: HasManyThrough<crate::models::User, crate::models::Post> {
        first_key = "country_id",
        second_key = "author_id",
    },
})]
pub struct Country { /* ... */ }

MorphTo with targets = [...] and per-family enum

Polymorphic relations point a child row at one of several parent families. The child carries a (<name>_id, <name>_type) pair; the *_type column holds the morph-type string each parent declares.

MorphTo lives on the child. Its declaration lists every parent family it can point at via targets = [...]. The macro emits a per-family enum named <RelationName>Morph (matching the relation name's PascalCase form, suffixed with Morph) with one variant per target type plus Unknown(String, serde_json::Value) for legacy rows whose <name>_type value doesn't match any registered target. The second field is the value of <name>_id as JSON: a number for an integer key, a string for a String, UUID or ULID key, null for a null column.

#[model(table = "posts", morph_type = "post")]
pub struct Post { /* ... */ }

#[model(table = "videos", morph_type = "video")]
pub struct Video { /* ... */ }

#[model(table = "comments", relations = {
    commentable: MorphTo {
        name = "commentable",
        targets = [
            crate::models::Post,
            crate::models::Video,
        ],
    },
})]
pub struct Comment {
    pub id: i64,
    pub commentable_id: i64,
    pub commentable_type: String,
    pub body: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    pub updated_at: chrono::DateTime<chrono::Utc>,
}

let c = Comment::find(1).await?.unwrap();
match c.commentable().get().await? {
    CommentableMorph::Post(post)   => println!("comment on post {}", post.title),
    CommentableMorph::Video(video) => println!("comment on video {}", video.url),
    // Legacy / dangling rows - `<name>_type` doesn't match any target,
    // OR the morph_type matched but the row at `<name>_id` is gone.
    CommentableMorph::Unknown(ty, id) => {
        eprintln!("comment {} points at unknown {ty}#{id}", c.id);
    }
}

The morph_type = "..." attribute on each target struct is what the loader writes into the child's <name>_type column on insert and filters by on read. Without morph_type, the framework derives the type-string from to_snake(struct_name).

MorphTo dispatch - how the per-family enum picks the right variant - consults the runtime morph registry (the inventory populated by every #[suprnova::model(morph_type = "...")] declaration). For each declared target, the fetch helper looks up the target's TypeId, reads the registered morph_type string, and compares it against the stored <name>_type value on the child row. First match wins, in declaration order. Targets without an explicit morph_type attribute fall back to to_snake(target_type_name) - the same default the parent-side MorphMany / MorphOne uses to stamp the type-string at write time, so the two sides stay aligned. This means custom morph_type values (e.g. morph_type = "blog_post" on a struct named Post, or any non-conventional string) dispatch correctly without changes to the declaration site.

MorphOne<R> and MorphMany<R> - parent side

The inverse direction of MorphTo: a parent type declares the polymorphic one-or-many it owns. MorphOne returns Option<R> from .first(); MorphMany returns Vec<R> from .get(). Both filter the child's (<name>_id, <name>_type) pair by self.id and the parent's morph_type.

#[model(table = "posts", morph_type = "post", relations = {
    comments: MorphMany<crate::models::Comment> {
        name = "commentable",
    },
    cover: MorphOne<crate::models::Image> {
        name = "imageable",
    },
})]
pub struct Post { /* ... */ }

#[model(table = "videos", morph_type = "video", relations = {
    comments: MorphMany<crate::models::Comment> {
        name = "commentable",
    },
})]
pub struct Video { /* ... */ }

let post = Post::find(1).await?.unwrap();
let post_comments: Vec<Comment> = post.comments().get().await?;
let post_cover:    Option<Image> = post.cover().first().await?;

let video = Video::find(1).await?.unwrap();
let video_comments: Vec<Comment> = video.comments().get().await?;
// post.comments() returns only `commentable_type = "post"` rows;
// video.comments() returns only `commentable_type = "video"`.

The same chainable surface as HasMany / HasOne: .filter / .db_where, .order_by / .latest / .oldest, .limit / .take, .first / .get / .count.

MorphToMany<R, P> and MorphedByMany<R, P>

Polymorphic many-to-many. The shared pivot P carries the FK pair PLUS a <name>_type discriminator column. One end declares MorphToMany (e.g. Post.tags(), Video.tags()), the other end declares one MorphedByMany per target family (e.g. Tag.posts(), Tag.videos()).

#[model(table = "taggables", fillable = ["tag_id", "taggable_id", "taggable_type"])]
pub struct Taggable {
    pub id: i64,
    pub tag_id: i64,
    pub taggable_id: i64,
    pub taggable_type: String,
}

#[model(table = "posts", morph_type = "post", relations = {
    tags: MorphToMany<crate::models::Tag, Taggable> {
        name = "taggable",
    },
})]
pub struct Post { /* ... */ }

#[model(table = "videos", morph_type = "video", relations = {
    tags: MorphToMany<crate::models::Tag, Taggable> {
        name = "taggable",
    },
})]
pub struct Video { /* ... */ }

// Inverse: Tag declares one MorphedByMany per target family.
#[model(table = "tags", relations = {
    posts: MorphedByMany<crate::models::Post, Taggable> {
        name = "taggable",
        target_morph_type = "post",
    },
    videos: MorphedByMany<crate::models::Video, Taggable> {
        name = "taggable",
        target_morph_type = "video",
    },
})]
pub struct Tag { /* ... */ }

let post  = Post::find(1).await?.unwrap();
let video = Video::find(1).await?.unwrap();
let tag   = Tag::create(attrs! { name: "rust" }).await?;

// `attach` / `attach_with` / `detach` / `sync` / `sync_without_detaching`
// work the same way as BelongsToMany. The `<name>_type` column lands automatically from
// the calling parent's `morph_type`.
post.tags().attach(tag.id).await?;
video.tags().attach(tag.id).await?;          // independent attachment
post.tags().sync([tag_a.id, tag_b.id]).await?;

// Inverse direction - Tag splits by family:
let posts_with_tag:  Vec<Post>  = tag.posts().get().await?;   // typed "post"
let videos_with_tag: Vec<Video> = tag.videos().get().await?;  // typed "video"

MorphedByMany's target_morph_type is required because the macro at Tag's declaration site can't introspect the target's morph_type = "..." attribute (it lives in a separate #[suprnova::model] invocation). Setting it explicitly keeps each MorphedByMany arm honest about which family it scans.

Escape hatch: hand-written relation methods

The relations declared in relations = { ... } are the only ones the eager-load dispatcher (and with, with_count, etc.) knows about. If a relation is too unusual for the macro shape - for example a query that aggregates across two pivots, or a typed view of a denormalised cache table - you can omit it from relations = { ... } and write a plain inherent impl:

impl User {
    /// Posts this user authored OR is tagged in. Crosses two relations
    /// and is therefore not expressible as a single `relations = { ... }`
    /// declaration - written by hand.
    pub async fn posts_touched(&self) -> Result<Vec<Post>, FrameworkError> {
        let authored: Vec<Post> = self.posts().get().await?;
        let tagged:   Vec<Post> = /* ...custom query... */;
        // ...merge + dedupe...
        Ok(/* ... */)
    }
}

Such methods lose eager-load support - User::with(["posts_touched"]) will error because the dispatcher has no arm for posts_touched. The in-macro declarations remain the path the framework knows how to eager-load, count, aggregate, and predicate-filter.

Polymorphic keys and nested loads

  • Morph IDs follow the target's primary key. A MorphTo target can declare an i64, String, UUID or ULID primary key, and the child table's <name>_id column takes the same type. All targets of one MorphTo relation share one key type. A relation that mixes key types, or a child whose <name>_id field has another type than the key, is a compile error.
  • The parent side is not checked. MorphMany and MorphOne are not compared with the child's <name>_id field. Keep that field at the key type of every parent that owns it.
  • MorphTo::morph_id is a serde_json::Value. So is the id in the Unknown variant. Call as_i64() or as_str() to read it as a number or a string.
  • Lazy and eager reads treat global scopes differently. The lazy comment.commentable().get() finds the target by its key and applies no global scope of the target. The eager with(["commentable"]) runs the query of the target and applies its global scopes, so a target that a scope hides comes back as Unknown.
  • Nested eager loading through MorphTo. A dotted path like with(["commentable.user"]) loads user on every morph target, one query per target type. The loader groups the targets by type and loads the rest of the path once for each type that is present.
// Comments on posts and on videos; both models declare `user`.
// 1 query for comments + 1 for posts + 1 for videos
// + 2 for the users (one per target type) = 5 queries,
// however many comments there are.
let comments = Comment::query()
    .with(["commentable.user"])
    .get()
    .await?;

Every target of the relation must declare the next relation of the path. A target that does not is an error that names the type and the relation. The check covers all targets, so it fails also when no row of that type is loaded, and when no row is loaded at all.

Eager loading

Eager loading avoids N+1 queries. Instead of posts.len() queries to fetch every user's posts, Suprnova issues ONE query per top-level relation regardless of how many parent rows are loaded.

To find the reads that should be eager loads, see Preventing lazy loading.

The full surface - flat list, nested paths, count, aggregates, and predicate-filtered eager loads - is reached through the #[suprnova::model]-emitted helpers on each model:

// Single relation:
let users = User::with(["posts"]).get().await?;
for u in &users {
    for p in u.posts_loaded() { /* ... */ }
}

// Multiple relations:
let users = User::with(["posts", "profile"]).get().await?;

// Nested paths - three queries (users + posts + comments), no N+1:
let users = User::with(["posts.comments"]).get().await?;
let p1 = users[0].posts_loaded()[0];
let comments = p1.comments_loaded();

// Deeper nesting works as expected:
let users = User::with(["posts.comments.author"]).get().await?;

// Count alongside the parent rows:
let users = User::with_count(["posts"]).get().await?;
for u in &users {
    println!("{} has {} posts", u.name, u.posts_count());
}

// Aggregates - Sum / Avg / Min / Max over a relation column. The
// ergonomic read is the macro-emitted `<rel>_sum_of(col)` accessor.
let users = User::with_sum(("posts", "views")).get().await?;
let sum: f64 = users[0]
    .posts_sum_of("views")
    .expect("with_sum populated the cache");

// Multiple aggregates on the same relation compose - the cache key
// is the wide `<rel>_<kind>_<col>` form, so distinct kinds and
// distinct columns don't collide:
let users = User::with_sum(("posts", "views"))
    .with_avg(("posts", "views"))
    .with_min(("posts", "id"))
    .get()
    .await?;
let u = &users[0];
let sum = u.posts_sum_of("views").unwrap();   // Some(_) - sum of views
let avg = u.posts_avg_of("views").unwrap();   // Some(_) - avg of views
let min = u.posts_min_of("id").unwrap();      // Some(Some(_)) - non-empty group
let max = u.posts_max_of("id");               // None - with_max was not called

// Filter the eager-loaded children. The macro emits a typed
// `with_where_<rel>(closure)` static helper per relation so the closure
// parameter type is inferred - no need to spell out `Builder<Post>`:
let users = User::with_where_posts(|q| q.filter("published", true))
    .get()
    .await?;
// The returned `Builder<User>` chains with any other base-query
// builder method:
let users = User::with_where_posts(|q| q.filter("published", true))
    .filter("active", true)
    .get()
    .await?;
// The generic form is still available - useful when the relation name
// is computed at runtime - but you'll need to name the target type on
// the closure:
let users = User::query()
    .with_where(("posts", |q: Builder<Post>| q.filter("published", true)))
    .get()
    .await?;
// Each u.posts_loaded() contains only published posts.

Cache layout

The per-row __eager cache cells are keyed by:

  • <rel> (relation NAME alone) for with and with_count. The rows and the count are kept in separate cells, so with(["posts"]) and with_count(["posts"]) on one query keep both, and a count alone does not count as loaded rows for load_missing.
  • <rel>_<kind>_<col> (e.g. posts_sum_views) for the four aggregate kinds - with_sum / with_avg / with_min / with_max. This wide key lets multiple aggregates on the same relation coexist on the same row without overwriting each other.
Method Cache key Cache cell type Empty-group value
with(["posts"]) posts Vec<Post> Vec::new()
with(["profile"]) profile Option<Profile> None
with_count(["posts"]) posts u64 0
with_sum(("posts","views")) posts_sum_views f64 0.0
with_avg(("posts","views")) posts_avg_views f64 0.0
with_min(("posts","id")) posts_min_id Option<f64> None
with_max(("posts","id")) posts_max_id Option<f64> None

The macro emits matching accessors on each model:

  • <rel>_loaded() - for collection relations: &[Post] (panics if the relation wasn't eager-loaded). For single-value relations: Option<&Profile>.
  • <rel>_count() - u64. Panics if with_count(["..."]) wasn't called.
  • <rel>_sum_of(col) / <rel>_avg_of(col) - return Option<f64> (None if the matching with_sum / with_avg was not called).
  • <rel>_min_of(col) / <rel>_max_of(col) - return Option<Option<f64>>: outer Option is "was with_min / with_max called?", inner Option is "is there a numeric minimum?". It is None when the group was empty or the minimum is not a number, such as a date.
  • <rel>_min_as::<T>(col) / <rel>_max_as::<T>(col) - return Option<T>: the minimum or maximum read as T, whatever the column's type, as Laravel's withMax('posts', 'created_at') attribute holds it. A date or a time reads from its ISO 8601 text, so T can be the chrono type of the column. A numeric / DECIMAL minimum on Postgres or MySQL reads exactly as rust_decimal::Decimal, as the nearest value as f64, and as its decimal text as String. None when the call was not made, the group was empty, or the value doesn't read as T.

The accessors are the ergonomic surface - read through them rather than reaching into __eager.get_aggregate::<T>(...) directly. They build the same cache key under the hood via eloquent::relations::aggregate_cache_key.

Composing aggregates on the same relation

The wide cache key means you can stack as many with_* calls on the same relation in one query as you want - no collisions:

let users = User::with_sum(("posts", "views"))
    .with_avg(("posts", "views"))
    .with_min(("posts", "id"))
    .with_max(("posts", "id"))
    .get()
    .await?;

let u = &users[0];
let total_views: f64 = u.posts_sum_of("views").unwrap();
let avg_views:   f64 = u.posts_avg_of("views").unwrap();

// Min/Max are double-Option because SQL min/max NULLs on empty:
match u.posts_min_of("id") {
    None              => panic!("with_min not called"),
    Some(None)        => println!("no posts yet"),
    Some(Some(min))   => println!("smallest post id: {min}"),
}

// Accessor returns `None` when the matching `with_*` was skipped:
assert!(u.posts_avg_of("score").is_none()); // never called with col="score"

// The latest post's date, as Laravel's withMax('posts', 'created_at'):
let users = User::with_max(("posts", "created_at")).get().await?;
let latest: Option<DateTime<Utc>> = users[0].posts_max_as("created_at");

Aggregates and INTEGER columns

SUM over an INTEGER column lands in the cache as f64. The database chooses the type of an aggregate: an integer of the column's width, a real, or numeric / DECIMAL (Postgres and MySQL sum and average integers that way). The dispatcher reads whichever arrives, and a value that is not a number, such as the maximum of a date column, is kept for <rel>_max_as rather than failing the query. Read via the macro-emitted accessors regardless of the source column type.

with_where predicate routing

User::with_where_posts(|q| q.filter("published", true)) applies a closure to the inner Builder<Post> BEFORE the filter_in(<fk>, parent_ids) IN-query is issued, so only matching child rows reach the cache. The macro emits one typed with_where_<rel> static helper per declared relation, so the closure's parameter type is inferred from the method signature.

The generic with_where(("posts", |q: Builder<Post>| q.filter("published", true))) is still available - useful when the relation name is computed at runtime, or when you already hold a Builder<User> and want to attach a predicate. It requires naming the target type on the closure because the predicate goes through a Box<dyn Any> and Rust can't infer the type from the relation name alone. (Rust's orphan rules forbid the macro from adding a typed method directly on Builder<User>, so the typed shorthand is offered only on the model - User::with_where_<rel> - not as a builder-chain method.)

For the polymorphic kinds, the predicate runs against the related-table query - not the pivot scan.

with_where is supported on every relation kind EXCEPT MorphTo. MorphTo's per-family enum erases the child type, so no single Builder<R> covers all variants. Nested eager loading through MorphTo does work: with(["commentable.user"]) groups the loaded targets by type and loads user once per type.

Collection::load / load_missing

When you've already fetched rows and want to eager-load relations after the fact:

use suprnova::Collection;

let mut users: Collection<User> = User::all().await?.into();
users.load(["posts.comments"]).await?;

load_missing is per-row: each row in the collection is partitioned independently. Rows that already have the named relation cached stay untouched; rows that don't get the relation loaded. Mirrors Laravel's $collection->loadMissing(...) semantics.

For nested paths the partition repeats at every level. Given load_missing(["posts.comments"]):

  • Rows without posts cached get the FULL path loaded - posts plus their comments.
  • Rows WITH posts already cached recurse into the cached posts and load comments only on the posts that don't already have comments cached.

The same per-row partition repeats at every further segment of a longer dotted path ("posts.comments.author" etc.) - at each step only the rows missing that segment get the bulk-load.

load / load_missing on one model

When you hold one model, call the same methods on it. They are methods of the Model trait, so they work on any model and through an M: Model bound:

use suprnova::Model;

let mut post = Post::find_or_fail(id).await?;
post.load(["comments"]).await?;
post.load_missing(["comments.user", "tags"]).await?;

The relations land in the model's own relation cache. Read them with the <relation>_loaded() accessors, which do not query again.

Both methods take the same relation names as the collection methods, including dotted names, and run the same loader, so each relation costs one query. Inside a DB::transaction closure the reads go through the transaction.

load loads a relation again when the model already has it. load_missing runs no query for a relation the model has. It checks a dotted name at every level: with comments loaded, load_missing(["comments.user"]) loads only the users that the cached comments lack.

Preventing lazy loading

A relation read on one model runs one query. A template that reads post.author() for each of 50 posts runs 51 queries. Eager loading (with, load, load_missing) reads the relation for every row with one query. prevent_lazy_loading(true) makes the 51-query loop fail where it is written, instead of under load in production.

Turn it on in the bootstrap of the application, outside production. Config::is_production() reads APP_ENV (see Configuration):

use suprnova::{Config, prevent_lazy_loading};

prevent_lazy_loading(!Config::is_production());

The switch is process-wide and off by default. With it off, every read behaves as if this section did not exist.

What is refused

With the switch on, a relation read is refused when both hold:

  • The model came out of a query that returned more than one row: a get, all or find_many, a page of a paginator, a batch of chunk, lazy or cursor, a row of each over a query that matches more than one row, the rows of a HasManyThrough relation, or the related rows of an eager load that fetched more than one.
  • The relation is not in the model's cache, because it was not loaded with with, load or load_missing.

The reads are get() and first() of every relation kind. A refused read runs no query and returns an error that names the model and the relation and says to use with or load. The error holds no key and no value of the row.

These reads are not refused:

  • A model from find, first, a get that returned one row, or create. Its relations load lazily, as in Laravel.
  • A model you build in the process, and a replica from replicate.
  • A relation that is loaded.
  • count() of a relation. It loads no model. Its eager form is with_count.
  • attach, detach and sync, which write.

The last batch of a chunk walk that holds one row is not marked, in the same way that a get of one row is not.

prevent_lazy_loading(true);

let posts = Post::query().get().await?;   // several rows
for post in &posts {
    // Err: lazy loading of the relation `author` of `Post` is prevented
    let author = post.author().first().await?;
}

let posts = Post::with(["author"]).get().await?;
for post in &posts {
    let author = post.author_loaded();    // no query
}

Logging instead of failing

handle_lazy_loading_violation registers one handler for the process. With a handler, a violation calls it and the read goes on and runs its query. A staging system can log the reads and keep serving:

use suprnova::{LazyLoadingViolation, handle_lazy_loading_violation, prevent_lazy_loading};

prevent_lazy_loading(true);
handle_lazy_loading_violation(|violation: &LazyLoadingViolation| {
    tracing::warn!(
        model = violation.model,
        relation = violation.relation,
        "lazy loading of a relation",
    );
})?;

LazyLoadingViolation has two fields: model, the struct name of the model (such as "Post"), and relation, the relation name as relations = { ... } declares it (such as "author"). Both are &'static str. The handler runs with no framework lock held, so a handler that panics does not break the next read.

clear_lazy_loading_violation_handler() removes the handler, so a violation is an error again. preventing_lazy_loading() returns whether the switch is on.

Why Suprnova diverges

The rule is Laravel's preventLazyLoading. Two behaviours differ:

  • refresh() replaces the row with a fresh find, so the row loses its mark. Laravel keeps it.
  • cursor() and lazy() hydrate in batches, so their rows are marked. Laravel's cursor() hydrates one row at a time and marks nothing.

Pagination

Three paginator types compose on top of Builder<M>:

Method Returns Queries per page Use when
paginate(per_page) LengthAwarePaginator<M> 2 (COUNT + LIMIT) UI needs total page count
simple_paginate(per_page) Paginator<M> 1 (LIMIT + 1) Large tables; "Next" button only
cursor_paginate(per_page) CursorPaginator<M> 1 (LIMIT + 1) Infinite scroll; deep pagination

All three implement Serialize with the Laravel-standard JSON shape, so they ship directly to Inertia / JSON consumers without reshaping.

Length-aware

use suprnova::LengthAwarePaginator;

let page: LengthAwarePaginator<User> = User::query()
    .filter("active", true)
    .order_by_desc("created_at")
    .paginate(20)
    .await?;

// page.data: Vec<User>
// page.total: u64 - total row count across all pages
// page.last_page: u64 - 1-based last page index
// page.current_page: u64
// page.per_page: u64
// page.from / page.to: Option<u64> - 1-based window bounds
// page.path: Option<String> - optional base URL for link generation

Page-param parsing reads ?page=N from the active request via Context::query_param. To paginate multiple lists on the same page with their own query keys, use paginate_using:

let posts = Post::query().paginate_using("posts_page", 10).await?;
let comments = Comment::query().paginate_using("comments_page", 25).await?;

JSON shape (with with_path("/api/users"), page 1 of 3):

{
  "current_page": 1,
  "data": [...],
  "first_page_url": "/api/users?page=1",
  "from": 1,
  "last_page": 3,
  "last_page_url": "/api/users?page=3",
  "links": [...],
  "next_page_url": "/api/users?page=2",
  "path": "/api/users",
  "per_page": 10,
  "prev_page_url": null,
  "to": 10,
  "total": 25
}

path is omitted from JSON when unset. The fields are Laravel's, and links is the row of page links. See Pagination for the links entries and the page URLs.

Simple paginate (no count)

paginate always runs two queries - a COUNT(*) plus the page fetch. On large tables the count alone can dominate request time. simple_paginate skips the count entirely; instead it fetches per_page + 1 rows and reports whether a next page exists via the has_more flag:

use suprnova::Paginator;

let page: Paginator<User> = User::query()
    .order_by_desc("id")
    .simple_paginate(20)
    .await?;

// page.has_more: bool - was there an extra row past per_page?
// page.current_page, page.per_page, page.data, page.path: as above.

JSON shape (with with_path("/api/users"), page 1):

{
  "current_page": 1,
  "current_page_url": "/api/users?page=1",
  "data": [...],
  "first_page_url": "/api/users?page=1",
  "from": 1,
  "has_more": true,
  "next_page_url": "/api/users?page=2",
  "path": "/api/users",
  "per_page": 10,
  "prev_page_url": null,
  "to": 10
}

Cursor paginate (keyset)

Cursor paginate is the choice for infinite scroll, deep pagination, or anywhere a stable row order with cheap O(1)-per-page seeking is worth more than a numeric page UI. Bidirectional - it reads the ?cursor=<opaque> query parameter, walks forward or backward by the cursor's direction, and emits both next_cursor and prev_cursor as the page's neighbours exist (matching Laravel's cursorPaginate()).

use suprnova::CursorPaginator;

let page: CursorPaginator<User> = User::query()
    .cursor_paginate(20)
    .await?;

// page.data: Vec<User>
// page.per_page: u64
// page.next_cursor: Option<String> - opaque cursor for the next page (None on the last)
// page.prev_cursor: Option<String> - opaque cursor for the previous page (None on the first)
// page.path: Option<String>

Cursors are encrypted and authenticated via CursorPaginator::encode_value - they encode the keyset boundary (each ordered column's value, the model's primary key among them) plus a direction tag, AES-256-GCM-sealed with the framework's APP_KEY. Tampering produces a 400 ParamParse error; the cursor is opaque to the client and unforgeable without the key.

The next request passes the cursor through ?cursor=<opaque>:

GET /api/users?cursor=eyJ0IjoiQmlnSW50IiwidiI6MTAwLCJkIjoibmV4dCJ9...

Cursor pagination keeps the builder's ORDER BY, as Laravel's cursorPaginate does: latest() pages newest first, and a descending order walks downward. The primary key, ascending, breaks ties when no order names it, and a builder with no order walks the primary key ascending. An order the cursor cannot carry (order_by_raw, in_random_order) returns a 400 error. See Pagination.

JSON shape:

{
  "data": [...],
  "path": "/api/users",
  "per_page": 10,
  "next_cursor": "...",
  "next_page_url": "/api/users?cursor=...",
  "prev_cursor": null,
  "prev_page_url": null
}

next_cursor, next_page_url, prev_cursor, and prev_page_url are always present as JSON keys (emitted as null when absent) so client schemas can rely on the field's presence; path is omitted when unset. A page URL is the path with the cursor in the cursor key.

Errors

Condition Variant HTTP
per_page == 0 FrameworkError::ParamError { param_name: "per_page" } 400
Invalid cursor (bad base64, JSON, or HMAC fails) FrameworkError::Internal from Crypt::decrypt_string 500
Underlying DB failure FrameworkError::Database 500

Cursor authentication failure surfaces as Internal (not ParamParse) so a tampered cursor doesn't leak protocol-level information to the client; the response body still carries a human-readable reason.

Reading query params outside a real request

Tests, console commands, and background workers don't run inside a hyper request - so Context::query_param("page") returns None and paginate falls back to page 1. Tests that need to exercise a specific page can install a per-thread override:

use suprnova::context::Context;

#[tokio::test]
async fn paginate_page_2() {
    Context::test_clear_query();
    Context::test_set_query("page", "2");

    let page = User::query().paginate(10).await.unwrap();
    assert_eq!(page.current_page, 2);

    Context::test_clear_query();
}

test_set_query / test_clear_query are gated behind the testing feature (default-enabled in framework/Cargo.toml) so release builds never see this surface.

Chunking and lazy iteration

Seven streaming entry points on Builder<M> let you process large result sets in bounded memory. Pick by trade-off:

Method Pagination Concurrent-safe? Returns
chunk(n, async |batch| { ... }) OFFSET No Result<(), _>
chunk_by_id(n, async |batch| { ... }) PK cursor Yes Result<(), _>
chunk_map(n, async |batch| { ... }) OFFSET No Collection<U>
each(async |row| { ... }) OFFSET, size 1 No Result<(), _>
lazy() PK cursor, batch 1000 Yes LazyCollection<M>
lazy_by_id(batch_size) PK cursor, custom batch Yes LazyCollection<M>
cursor() Alias for lazy() Yes LazyCollection<M>

chunk - OFFSET-paginated batches

use suprnova::{Collection, Model};

User::query().chunk(100, |batch: Collection<User>| async move {
    for user in &batch {
        send_welcome_email(user).await?;
    }
    Ok(())
}).await?;

The closure receives a Collection<M> per batch - slice-shape access (.iter(), indexing) works directly via Deref.

chunk, chunk_map, and each keep the query's own OFFSET and LIMIT, as Laravel's chunk does: the offset skips rows once, at the start of the walk, and the limit caps the rows the whole walk visits.

chunk is OFFSET-paginated and not safe under concurrent inserts: rows inserted before the next batch's offset get skipped; rows deleted before the offset get processed twice (whatever shifted into their slot). Use chunk_by_id for production-grade bulk processing against tables under write load.

chunk_by_id - PK-cursor batches, concurrent-safe

User::query().chunk_by_id(500, |batch| async move {
    for user in &batch {
        reindex_user(user).await?;
    }
    Ok(())
}).await?;

Each batch filters on WHERE id > last_id ORDER BY id ASC LIMIT n, so rows inserted mid-iteration with PKs above the cursor land in a later batch (or are picked up by a subsequent run) - they never cause an original row to skip or duplicate.

The walk sets its own order. An ORDER BY already on the query is dropped, because any other order would make the cursor skip some rows and repeat others. An OFFSET on the query skips that many rows once, before the first batch; every later batch starts at the cursor. A LIMIT on the query caps the rows the whole walk visits, as in Laravel's chunkById: .limit(10).chunk_by_id(3, ..) hands over 3, 3, 3 and 1 rows.

The cursor is the value of the primary key, in the order of the key. These keys work:

  • An integer key (i64, i32, and the other integer types).
  • A text key: String, and the keys of unique_id = "uuid", "uuid_v4" and "ulid", which the model stores as text.

For a key that grows with the creation time of its row (auto-increment, UUID v7, ULID), a row inserted mid-run lands after the cursor, and a later batch finds it. A random key (UUID v4) has no such order: a row inserted mid-run with a key below the cursor is not seen by that walk, and the next run picks it up. Every row that existed when the walk started is seen once.

chunk_by_id refuses a key that cannot carry the cursor with FrameworkError::internal. The error names the model and the column and never the value of the key. Two cases are refused before the first query:

  • A composite key.
  • A key column that is neither an integer nor text, such as a native uuid::Uuid column, a timestamp or a decimal.

A third case is found while the walk runs. A row whose key is null, or is not of the kind of its column, ends the walk with the same error. The check runs on each batch before the closure sees it, so the closure never processes a batch that holds such a row.

A u64 key walks its whole range. On MySQL, whose unsigned keys reach u64::MAX, that includes keys above i64::MAX; on Postgres and SQLite no key passes i64::MAX.

For a key that chunk_by_id refuses, use chunk(). It paginates by OFFSET, so it needs no cursor, and it has the concurrency limits above.

chunk_map - chunk + per-chunk map

let totals: Collection<i64> = Order::query()
    .chunk_map(1000, |batch| async move {
        let sum: i64 = batch.iter().map(|o| o.amount).sum();
        Ok(Collection::from_vec(vec![sum]))
    })
    .await?;

Maps each batch through f, concatenates the mapped output, and returns a single Collection<U>. Memory-bounded only when U is strictly smaller than M - pick this when you're producing summaries (per-batch totals, ids, aggregates) rather than transformed rows.

each - one row at a time, OFFSET

User::query().each(|user| async move {
    send_welcome_email(&user).await?;
    Ok(())
}).await?;

Sugar for chunk(1, ...) - one query per row. For large datasets, switch to lazy() which batches internally (default 1000 rows per fetch) while still surfacing one row at a time to the consumer.

lazy / lazy_by_id / cursor - streams

let mut stream = User::query().lazy();
while let Some(row) = stream.next().await {
    let user = row?;
    println!("{}", user.email);
}

lazy() returns a LazyCollection<M> - a Send stream wrapper that yields Result<M, FrameworkError> per row. Backpressure works naturally: a slow consumer parks at the await point and the next batch only fetches when the in-memory buffer drains.

lazy() batches via PK cursor with a default size of 1000 rows. Override the batch size with lazy_by_id(500). cursor() is the Laravel name and is a zero-cost alias for lazy().

lazy(), lazy_by_id() and cursor() use the same keyset cursor as chunk_by_id, so the same keys work, the same keys are refused, and an ORDER BY or OFFSET on the query is treated the same way. The error is the first item of the stream. Refusal by column type happens before the first query. A row with a null or mismatched key is refused when its batch arrives, before the stream yields any row of that batch.

Eager loads inside chunks

All seven entry points reject .with(...) up front with a loud FrameworkError::internal. The Builder's cross-batch clone drops the type-erased eager-load plan (its boxed-dyn Any predicate isn't clonable without tightening the public API), so honouring the plan would be silently inconsistent across batches. Re-apply .with(...) inside the per-chunk closure when needed - each batch's Collection<M> composes with load(...) / load_missing(...):

User::query().chunk(100, |batch| async move {
    let mut batch = batch;
    batch.load("posts").await?;
    for u in &batch {
        let posts = u.posts_loaded();
        // ...
    }
    Ok(())
}).await?;

Collections

Collection<T> is Suprnova's Laravel-shape collection - the return type of Builder::get (where T is the model), of Model::all, of pluck / chunk_map, and of every other terminal that yields more than one row. It dereferences to &[T] so existing Vec call sites keep working without changes; the Laravel surface is composed on top. This section is the everyday surface; the full method index, the generic-vs-model split, the LazyCollection<M> streaming wrapper, and the borrow-vs-consume rules are in Eloquent Collections.

Generic surface

Available on every Collection<T> regardless of T:

use suprnova::Collection;

let nums: Collection<i32> = Collection::from_vec(vec![3, 1, 4, 1, 5, 9]);

nums.first();              // Some(&3)
nums.last();               // Some(&9)
nums.len();                // 6
nums.is_empty();           // false
nums.contains(&4);         // true
// Predicate closures receive `&&T` - note the double-deref `**n`:
nums.first_where(|n| **n > 3);    // Some(&4)
nums.contains_where(|n| **n > 8); // true
// For a count, run the predicate inline: `nums.iter().filter(|n| **n > 2).count()` - 4

Transformations consume self and return a new Collection:

let doubled: Collection<i32> = nums.clone().map(|n| n * 2);
let evens:   Collection<i32> = nums.clone().filter(|n| n % 2 == 0);
let chunks:  Vec<Collection<i32>> = nums.clone().chunk(2); // [[3,1],[4,1],[5,9]]
let unique:  Collection<i32> = nums.clone().unique();
let sorted:  Collection<i32> = nums.clone().sort();

Model-aware methods on Collection<M>

When T is a model, additional string-keyed methods route through the macro-emitted field_value(name) accessor:

let users: Collection<User> = User::query().get().await?;

let emails: Collection<String> = users.pluck::<String>("email");
let by_role: HashMap<String, Vec<User>> =
    users.clone().group_by::<String>("role");
let active: Collection<User> = users.clone().where_eq("active", true);

let total: f64 = users.clone().sum::<f64>("balance");
let avg:   f64 = users.clone().avg::<f64>("balance");
let max:   Option<i64> = users.clone().max::<i64>("login_count");

The closure-based pluck_by is the typed alternative - useful when the field name would otherwise require a string lookup the type system can't check:

let names: Collection<String> = users.pluck_by(|u| u.name.clone());

Per-row field_value(name) returns Option<serde_json::Value> - None when the column name doesn't match any declared field. Custom casts that fail to serialise also surface as None. The string-keyed methods silently skip those rows; the closure form short-circuits in the closure body so the caller can decide.

Streaming via LazyCollection

For datasets too large to materialise, Builder::lazy() / lazy_by_id(n) / cursor() return a LazyCollection<M> - a Stream wrapper that fetches rows in PK-cursor batches. See Chunking and lazy iteration.

Eager loading on a collection

Collection::load(["posts"]) / load_missing(["posts"]) execute the same eager-load dispatch a Builder::with(...) chain emits, but against an existing collection. load_missing is per-row: each row in the collection is partitioned into "needs load" / "already loaded" buckets and only the missing ones get the bulk-load. See Eager loading.

Mass assignment

Fillable allowlist

#[model(
    table = "users",
    fillable = ["name", "email"],
)]
pub struct User { /* ... */ }

User::create(attrs! {
    name: "Alice",
    email: "alice@example.com",
    admin: true,    // silently dropped at runtime - not in fillable
}).await?;

Guarded denylist

guarded is the inverse - every field is fillable EXCEPT the guarded ones. Mutually exclusive with fillable; using both at once is a compile-time error from the macro.

#[model(
    table = "posts",
    guarded = ["id", "user_id"],   // everything else is fillable
)]
pub struct Post { /* ... */ }

Default policy

When neither fillable nor guarded is set, the default policy is guarded = ["id"] (or whatever primary_key = "..." resolves to) - every field is fillable except the primary key. This matches Laravel's "all fields fillable except the PK" default.

unguarded(closure) escape hatch

unguarded(closure) turns off the filter for a block:

use suprnova::eloquent::unguarded;

// Bypass the filter for a one-shot data-migration script:
unguarded(|| async {
    User::create(attrs! {
        name: "Bootstrap",
        email: "boot@example.com",
        admin: true,    // assignable inside the closure
    }).await
}).await?;

Implementation: a tokio::task_local! boolean the Fillable::apply filter checks before running. Task-local means concurrent requests aren't affected by another task's unguarded scope.

Casts

Casts run at the boundary between storage (column value) and runtime (model field). Each cast type implements the Cast trait. Built-in casts cover Laravel's full set; users register custom casts via the trait. This section is the quick-reference index; the full per-cast contract - primitive, temporal, structured, enum, encrypted, hashed, plus the casts! runtime override macro - lives in Eloquent Casts, Accessors & Mutators.

Explicit, with two defaults

Casts are declared in #[model(casts = { ... })]. A prefs: Json field doesn't implicitly become AsJson; you write casts = { prefs = AsJson }. Rationale: you should be able to read the model and know exactly what runs at storage boundaries.

Two field types get a cast without one, because they don't work without it. A field's own cast wins over both:

  • A DateTime<Utc> or Option<DateTime<Utc>> field gets AsDateTime or AsOptionalDateTime, which store text. datetime_cast in the package's Cargo.toml swaps them for the native casts; see Native date-time casts.
  • A u64 or Option<u64> field, the key included, gets AsU64 or AsOptionalU64. SeaORM reads a u64 on MySQL only; the cast reads it on Postgres and SQLite too, where it holds 0 to i64::MAX. For more information, see Laravel tables on MySQL.

Example

use suprnova::{model, AsArray, AsBool, AsCollection, AsDate, AsDateTime,
    AsEncrypted, AsEnum, AsObject, AsTimestamp};

#[model(
    table = "users",
    casts = {
        active        = AsBool,
        preferences   = AsArray<String>,
        options       = AsObject<UserOptions>,
        profile       = AsCollection<ProfileField>,
        birthday      = AsDate,
        last_seen_at  = AsDateTime,
        role          = AsEnum<UserRole>,
        api_token     = AsEncrypted,
    },
)]
pub struct User { /* ... */ }

Full Laravel cast list and Suprnova mapping

Laravel cast Suprnova cast Runtime type
bool, boolean AsBool bool
int, integer AsInt<I> I: PrimInt
float, double, real AsFloat f64
decimal:N AsDecimal<N> rust_decimal::Decimal
string AsString String
array AsArray<T> Vec<T> (JSON-encoded)
array (nullable column) AsOptionalArray<T> Option<Vec<T>> (None is SQL NULL)
object AsObject<T> T: Serialize + DeserializeOwned
object (nullable column) AsOptionalObject<T> Option<T>
collection AsCollection<T> Collection<T>
collection (nullable column) AsOptionalCollection<T> Option<Collection<T>>
json AsJson<T> T (raw JSON column)
json (nullable column) AsOptionalJson<T> Option<T>
date, date:format AsDate chrono::NaiveDate
datetime, datetime:format AsDateTime chrono::DateTime<Utc>
datetime (nullable column) AsOptionalDateTime Option<chrono::DateTime<Utc>>
datetime (native column with a zone) AsNativeDateTime chrono::DateTime<Utc>
datetime (nullable native column with a zone) AsOptionalNativeDateTime Option<chrono::DateTime<Utc>>
datetime (native column without a zone) AsNaiveDateTime chrono::DateTime<Utc> (stored as the UTC wall clock)
datetime (nullable native column without a zone) AsOptionalNaiveDateTime Option<chrono::DateTime<Utc>>
immutable_date AsImmutableDate chrono::NaiveDate
immutable_datetime AsImmutableDateTime chrono::DateTime<Utc>
timestamp AsTimestamp i64 (unix epoch)
encrypted AsEncrypted String (encrypted via Crypt)
encrypted:array AsEncryptedArray<T> Vec<T> (JSON + encrypted)
encrypted:object AsEncryptedObject<T> T (JSON + encrypted)
encrypted:collection AsEncryptedCollection<T> Collection<T>
EnumClass::class AsEnum<E> E: EnumString + AsRefStr
AsArrayObject::class AsArrayObject<T> IndexMap<String, T>
AsArrayObject::class (nullable column) AsOptionalArrayObject<T> Option<IndexMap<String, T>>
hashed AsHashed String (Hash::make on write; never decrypts)

31 casts total. Most map one-to-one with Laravel. A Laravel cast passes null through on any column; in Rust the field type says whether a value can be missing, so the five JSON casts and three of the date-time casts have AsOptional* forms for nullable columns. The AsOptionalDateTime (used by soft_deletes) is auto-injected by the macro when the soft-delete column is Option<DateTime<Utc>>.

Encrypted cast failure modes

The four AsEncrypted* casts route every encrypt/decrypt through the Crypt facade (keyed by APP_KEY). When decryption fails - wrong key, truncated ciphertext, tampered bytes, AEAD tag mismatch - the cast surfaces a clear FrameworkError::Internal from Cast::from_storage. There is no silent fallback to garbage:

  • Loading a row through Model::find / Model::query() propagates the decrypt error and (per the macro-generated From<inner::Model>) panics with cast from_storage failed - corrupt data in database column. Operators see the failure in logs immediately; the model never carries plausible-but-wrong plaintext.
  • The AsHashed cast is one-way; it never decrypts so this failure mode does not apply.

This matches Laravel's encrypted cast: a wrong APP_KEY against an existing encrypted column is a hard error, never a quiet null/empty string.

Rotating APP_KEY

Suprnova supports zero-downtime key rotation via a key ring: the current APP_KEY encrypts; an optional APP_KEY_PREVIOUS env var (comma-separated, oldest-to-newest) supplies decrypt fallbacks for data written under older keys. Encryption always uses the current key - previous keys participate only on decrypt.

Each decrypt that falls through to a previous key emits a tracing::warn! line containing the previous-key index. The log payload deliberately excludes plaintext and ciphertext; just the fact-of-rotation plus an actionable re-encrypt hint.

Rotation procedure (zero-downtime, safe for production):

  1. Mint a new key: suprnova key:generate (writes to stdout).
  2. Move the old key to APP_KEY_PREVIOUS and set APP_KEY to the new value:
    APP_KEY_PREVIOUS=<old_key>
    APP_KEY=<new_key>
    
  3. Deploy. New writes use the new key; existing rows continue to decrypt via the previous-key fallback. Warnings in logs identify columns that still depend on APP_KEY_PREVIOUS.
  4. Run a re-encrypt pass. You explicitly update each encrypted attribute:
    User::query().chunk(500, |batch| async move {
        for user in batch {
            let token = user.api_token.clone();
            user.update(attrs! { api_token: token }).await?;
        }
        Ok::<(), suprnova::FrameworkError>(())
    }).await?;
    
    You use an explicit update because a clean save writes no columns. The cast encrypts the supplied attribute under the current key.
  5. Once logs show no more APP_KEY_PREVIOUS warnings (give the batch + any soft-deleted / archived data a generous window), remove APP_KEY_PREVIOUS from the environment and redeploy.

Multi-step rotation. If you rotate again before completing the previous pass, append: APP_KEY_PREVIOUS=<oldest>,<previous>. The ring tries every previous key in order. The list is capped at 8 entries - a realistic chain is 1-3 (one in-flight rotation, maybe one stalled prior roll) and a longer list is almost always a config- templating accident; exceeding the cap fails boot with an actionable diagnostic rather than silently dropping a key the operator may still depend on.

Constraints.

  • A malformed entry in APP_KEY_PREVIOUS fails boot loudly (same as a malformed APP_KEY) - a half-rotated secret should never silently degrade.
  • More than 8 entries in APP_KEY_PREVIOUS fails boot loudly - see [suprnova::crypto::MAX_PREVIOUS_KEYS].
  • Empty entries in the list (e.g. trailing commas from templated config) are tolerated as "no key in this slot" - not an error.
  • The wire format is unchanged from the pre-rotation single-key layout: no key identifier is embedded in the ciphertext. The ring trial-decrypts each key in order until one succeeds.

Runtime cast override - with_casts

let users = User::query()
    .with_casts(suprnova::casts! { birthdate = AsDateTime })
    .get()
    .await?;

with_casts overrides the model's declared casts for the duration of a single query - useful when a raw column comes back from a join / view / select_raw and needs a different type coercion than the model's default.

Custom casts

Custom casts implement Cast:

use suprnova::eloquent::casts::Cast;
use suprnova::FrameworkError;

pub struct AsAesGcmJson<T>(std::marker::PhantomData<T>);

impl<T: serde::Serialize + serde::de::DeserializeOwned + Send + Sync> Cast
    for AsAesGcmJson<T>
{
    type Runtime = T;
    type Storage = String;
    fn to_storage(value: &T) -> Result<String, FrameworkError> { /* ... */ }
    fn from_storage(stored: &String) -> Result<T, FrameworkError> { /* ... */ }
}

#[model(casts = { secret = AsAesGcmJson<SecretBundle> })]
pub struct Vault { /* ... */ }

The Cast trait is shipped alongside the primitive casts. Custom casts can use either String storage (when JSON-encoding) or any of the SeaORM-supported scalar types (i64, f64, bool, Vec<u8>).

Accessors and mutators

Accessors

#[model(
    table = "users",
    appends = ["full_name"],
)]
pub struct User {
    pub id: i64,
    pub first_name: String,
    pub last_name: String,
    // ...
}

impl User {
    #[accessor]
    pub fn full_name(&self) -> String {
        format!("{} {}", self.first_name, self.last_name)
    }
}

When user.to_array() runs (or user.to_json(), which delegates to it), the full_name accessor is called and its return value is inserted into the JSON output. Calling user.full_name() from Rust is just a regular method call.

Mutators

Mutators run before storage:

#[model(
    table = "users",
    fillable = ["first_name", "last_name", "password"],
    mutators = ["password"],
)]
pub struct User { /* ... */ }

impl User {
    #[mutator]
    pub fn set_password(
        &mut self,
        value: serde_json::Value,
    ) -> Result<(), suprnova::FrameworkError> {
        let raw: String = serde_json::from_value(value).map_err(|e| {
            suprnova::FrameworkError::validation("password", format!("{e}"))
        })?;
        self.password = hash::make(&raw);
        Ok(())
    }
}

Calling user.password = "secret".into() directly assigns the raw value without running the mutator. To run the mutator path, call user.set_password(json!("secret")) or use the JSON path (user.fill(attrs!{password: "secret"})), which routes through the mutator automatically because "password" is listed in mutators = [...].

How routing works

  • Serialization (to_array → Value, to_json → String) runs accessors. Every field name listed in appends = [...] becomes a call to self.<name>(); the return value is inserted into the JSON output. to_json() is a thin wrapper: serde_json::to_string(&self.to_array()).
  • Fill-style writes (fill, create, update) route through mutators. Every field name listed in mutators = [...] becomes a call to self.set_<field>(value) instead of direct assignment.

The function-level #[accessor] and #[mutator] macros emit registry entries the macro's serialization / fill paths walk.

Malformed values are errors, not defaults

A value that cannot decode into its field's type fails the write and names the field:

let err = user.fill(attrs! { age: "not a number" }).unwrap_err();
// ValidationError { field: "age", message: "could not decode the
// supplied value: invalid type: string \"not a number\", expected i32" }

The model is left untouched - a rejected fill applies nothing.

Two nearby cases behave differently, on purpose:

  • An unknown column is still skipped silently, matching Laravel's $model->fill(). Not knowing about a column is not the same as being handed a broken value for one you do know.
  • A column excluded by fillable / guarded is dropped by the mass-assignment filter before decoding, so a malformed value for a field the caller may not set is also silent. Erroring there would tell an unauthorised caller which columns exist.

Numeric widening is not a type error: a JSON integer decodes into an f64 field normally.

Before v0.8.0 a malformed value was silently replaced by the field's Default and the call returned Ok - fill(attrs!{ age: "abc" }) set age = 0 and reported success. If you were relying on that coercion, validate or convert before calling fill.

Hidden / visible

#[model(
    table = "users",
    hidden = ["password", "remember_token"],
)]
pub struct User { /* ... */ }

hidden = [...] is a denylist - every column except the listed ones serialises. visible = [...] is the inclusive form - only the listed ones serialise. Mutually exclusive at compile time.

Timestamps

When both created_at and updated_at columns exist, the macro auto-detects them and enables timestamp tracking:

  • created_at is set to Utc::now() on save() for new rows.
  • updated_at is set to Utc::now() when a dirty save() writes. A clean save leaves the timestamp unchanged.

The auto-detect is conservative: if the struct has only one of the two columns, the macro errors out so a typo (craeted_at) doesn't silently disable timestamps. Set timestamps = false to opt out entirely.

A struct with neither column gets no timestamps by default. If you ask for them with timestamps = true (or the bare timestamps flag), the build fails and the error names both columns, as #[suprnova::document] does, so a model never silently writes none.

Disabling auto-timestamps

#[model(table = "audit_logs", timestamps = false)]
pub struct AuditLog {
    pub id: i64,
    pub event: String,
    pub created_at: chrono::DateTime<chrono::Utc>,
    // No updated_at field - but timestamps = false silences the
    // macro's `only one column found` error too.
}

touch() - bump updated_at without other changes

user.touch().await?;

touch() issues UPDATE table SET updated_at = ? WHERE pk = ? - atomic, no read-modify-write. The macro emits a Touchable impl on every timestamped model.

Parent touching

#[model(
    table = "comments",
    touches = ["post"],
    relations = {
        post: BelongsTo<Post> { fk = "post_id" },
    },
)]
pub struct Comment {
    pub id: i64,
    pub post_id: i64,
    // ...
}

After a comment is created, saved, updated, or deleted, its post's updated_at is bumped - one UPDATE posts SET updated_at = ? WHERE id = ?, no SELECT. That is what a cache key hanging off post.updated_at needs to stay honest when only a child changed.

Every name in touches must be a BelongsTo or a MorphTo relation declared in the same relations = { ... } block. A name that doesn't resolve, or one that resolves to a different relation kind, is a compile error rather than a surprise on the first save.

A MorphTo name touches the owner that the row's <name>_type and <name>_id columns point at:

#[model(
    table = "comments",
    touches = ["commentable"],
    relations = {
        commentable: MorphTo { targets = [Post, Video] },
    },
)]
pub struct Comment {
    pub id: i64,
    pub commentable_id: i64,
    pub commentable_type: String,
    // ...
}

The write finds the owner after the pre-write listeners (Creating, Saving, Updating, Deleting) have run and before the statement, from the values the statement writes. A listener that rewrites <name>_type or <name>_id therefore decides which owner is touched. A <name>_type that names none of the targets makes the write fail: the write returns Err, no row is written, no post-write event is dispatched and no owner is touched.

An owner whose model has timestamps = false is skipped: no error, no write, and the child's save still returns Ok. Same for an owner reached through a NULL foreign key or a NULL <name>_id, and for a soft-deleted owner.

The touch runs on the same executor as the write that triggered it, so inside a DB::transaction closure it joins that transaction and a rollback reverts it.

Why Suprnova diverges

Laravel's touchOwners loads each parent model and recurses, so a comment save also bumps the post's own owners and fires each parent's saved event. Suprnova resolves the parent through the relation registry and writes the column directly - one statement per touched relation, no hydration. The cascade is therefore one level deep and fires no parent events. That is the trade for a save that doesn't issue a SELECT per touched relation. Reach for an observer when you need the grandparent bump or the event.

restore() on a soft-deleted child does not touch its owners. Laravel's restore goes through save; Suprnova's is a direct UPDATE deleted_at = NULL.

Format

Always ISO 8601 with UTC. No Model::$timestampsFormat override (per the divergence-from-Eloquent table - frontend interop comes first; locale formatting belongs in the i18n layer).

Observers and lifecycle events

Every model goes through a fixed 16-event lifecycle as it moves through create / save / update / delete / restore / replicate / Builder query paths. Listeners can hook each event to log, audit, side-effect, validate, or cancel the in-flight operation.

The 16 lifecycle events

Events split into two groups by cancellability:

Cancellable (5) - fire BEFORE the database write. A listener returning EventResult::cancel("reason") aborts the operation with FrameworkError::bad_request(reason).

Event When Payload
Saving Before both create and save Arc<Mutex<Attrs>> + is_creating: bool
Creating Before create Arc<Mutex<Attrs>>
Updating Before save / update on existing row Pre-update model snapshot + Arc<Mutex<Attrs>>
Deleting Before delete (soft or hard) Model + is_force: bool (force-delete on soft-delete)
Restoring Before restore on soft-delete model Model

Non-cancellable (11) - fire AFTER the operation. Listener errors propagate but cannot stop a write that already landed.

Event When Payload
Retrieving Once per Builder query, before the DB call None
Retrieved Once per row returned by a Builder query Model
Created After successful create Model
Updated After successful save / update Previous + current snapshots
Saved After both create and save Model
Deleted After successful delete Model + is_force: bool
Trashed After soft-delete (NOT force-delete) Model
Restored After successful restore Model
Replicating During replicate / replicate_except, before return (NOT replicate_into - per-source-type) Source + Arc<Mutex<replica>> (mutable)
ForceDeleting Before force_delete on soft-delete model Model
ForceDeleted After successful force_delete Model

The cancellable / non-cancellable split mirrors Laravel's creating vs created hook pair. Saving fires for both insert and update - override that one when the behaviour is identical across both paths and discriminate via is_creating.

Replicating is the one non-cancellable hook that hands a mutable reference (the replica is Arc<Mutex<M>>). Use it to clear timestamps, regenerate UUIDs, reset auto-increments, etc. before the clone is returned to the caller.

Observers vs raw listeners

Two ways to hook lifecycle events:

  1. Raw listeners - call EventFacade::listen::<Created, _>(Arc::new(MyListener)) for each event you want, one impl per event. This is the underlying mechanism; observers ride on top of it.

  2. Observers - bundle all 16 hooks under one trait. The macro sees which methods the user overrode and registers exactly those. This is the recommended path for any non-trivial set of hooks.

use async_trait::async_trait;
use suprnova::eloquent::attrs::Attrs;
use suprnova::eloquent::events::EventResult;
use suprnova::eloquent::observers::Observer;
use suprnova::FrameworkError;

pub struct AuditObserver;

#[suprnova::observer(User)]   // <- MUST precede #[async_trait]
#[async_trait]
impl Observer<User> for AuditObserver {
    async fn creating(&self, attrs: &mut Attrs) -> EventResult {
        if attrs.get("email").is_none() {
            return EventResult::cancel("email is required");
        }
        EventResult::ok()
    }

    async fn created(&self, user: &User) -> Result<(), FrameworkError> {
        tracing::info!(user.id = user.id, "user created");
        Ok(())
    }
}

Every trait method has a default no-op, so the impl block contains only the events you care about. The macro identifies overrides by name match against the closed 16-method set; methods you don't override register no listeners.

Required attribute ordering

#[suprnova::observer(M)] MUST appear ABOVE #[async_trait]:

#[suprnova::observer(User)]   // outer - runs first, sees raw async fns
#[async_trait]                // inner - rewrites async fn signatures
impl Observer<User> for AuditObserver { /* ... */ }

Attribute macros expand outside-in. async_trait rewrites every async fn into a desugared Pin<Box<dyn Future>> poll-fn shape; if #[async_trait] ran first, the observer macro's name-match against the 16 trait method names would find nothing and silently emit zero listeners.

Four registration paths

Path When to use
#[suprnova::observer(M)] (inventory) Static observer known at compile time. Auto-installs on boot.
#[model(observers = [Foo, Bar])] Documentation + compile-time validation that the listed types resolve. Does NOT itself register.
Model::observe(MyObs).await Runtime registration. Hand-driven; useful when registration depends on config.
EventFacade::listen::<events::Created, _>(...) Lowest level - one event at a time. Use when an observer feels heavy.

The observers = [...] attribute on #[model] is a documentation marker. It compiles to a const _: fn() = || { let _ = ::std::any::type_name::<T>; ... }; block that proves each listed type resolves to a real Rust item; typos surface at the model declaration site. Actual install is via the inventory pathway - the #[observer(M)] attribute on Foo is what enrolls Foo for auto-install.

Bootstrap

Call bootstrap_observers() once at startup to drain the inventory and install every #[observer(M)]-registered observer:

suprnova::eloquent::observers::bootstrap_observers().await?;

The drain is idempotent for the inventory pathway - each observer's install closure is gated by a per-type AtomicBool (T2b's macro emission), so calling bootstrap_observers() twice does not double-register.

The runtime Model::observe(MyObs) shim is NOT gated. Calling it twice registers two listener sets, matching Laravel's manual Model::observe(MyObs::class) semantics. If a hand-driven observer also has #[observer], the inventory adapter fires in addition to the manually-installed ones.

Cancelling from an observer

The five cancellable hooks return EventResult. To abort the operation, return EventResult::cancel("reason"):

#[suprnova::observer(Subscription)]
#[async_trait]
impl Observer<Subscription> for PolicyObserver {
    async fn creating(&self, attrs: &mut Attrs) -> EventResult {
        if let Some(plan) = attrs.get("plan") {
            if plan == "blocked" {
                return EventResult::cancel("plan is blocked");
            }
        }
        EventResult::ok()
    }
}

The cancel reason surfaces as FrameworkError::bad_request(reason) from Subscription::create. The row never lands in the database - cancel is a true abort, not a "delete after the fact".

Multiple observers may register cancellable hooks on the same model; any one of them returning Cancel stops the operation. Order is the inventory enrolment order (link order in practice).

Multiple observers on one model

Multiple Observer<M> impls all fire for the same event - EventFacade dispatch fans out to every registered listener rather than picking one:

#[suprnova::observer(Comment)]
#[async_trait]
impl Observer<Comment> for AuditObserver { /* ... */ }

#[suprnova::observer(Comment)]
#[async_trait]
impl Observer<Comment> for NotifyObserver { /* ... */ }

// Comment::create(...) fires AuditObserver::created AND NotifyObserver::created.

This matches Laravel's fan-out semantics and is the load-bearing property behind the "decompose hooks by concern" pattern: an AuditObserver only knows about audit, a NotifyObserver only knows about notifications, and the model declaration doesn't care how many observers attach.

Manual Model::observe()

Every #[suprnova::model] struct gets a per-model observe<O>() shim. Call it at boot for dynamic registration:

#[derive(Clone)]
struct MyObs;

#[async_trait]
impl Observer<User> for MyObs { /* ... */ }

// At runtime:
User::observe(MyObs).await;

The shim's O: Clone + 'static bound is what lets the framework hand a fresh observer clone to each of the 16 internal adapter listeners. All 16 listener adapters install on every call - the trait defaults make non-overridden methods cheap no-ops.

Constraints

  • The macro version requires the impl block use plain method names matching the trait's 16 hooks. Renamed methods, #[allow]-suppressed defaults, and #[cfg]-gated bodies fall outside the name-match and don't register listeners.

  • Observer structs the macro inspects must be zero-sized (no fields). The macro constructs the observer via let obs = MyObserver; inside each adapter. Stateful observers (carrying Arc<Inner>) need the runtime Model::observe() path, which takes the observer by value and clones it into each adapter.

  • Test isolation: use unique model types per scenario. The process-global EventDispatcher means listeners installed for User are visible to every test in the same binary. Per-test unique model types (T2Comment, T2Subscription, …) keep cross-test bleed out of the counter assertions. The eloquent_observers.rs integration tests exercise this pattern.

Prunable

Laravel ships a Prunable trait that lets a model declare a scope of rows to delete on a schedule. Suprnova mirrors that with two traits and a console command.

Declaring a pruner

use async_trait::async_trait;
use chrono::{Duration, Utc};
use suprnova::eloquent::Prunable;

#[suprnova::prunable]
#[async_trait]
impl Prunable for ExpiredSession {
    fn prunable() -> suprnova::Builder<Self> {
        Self::query().filter_op(
            "expires_at",
            "<",
            (Utc::now() - Duration::days(30)).to_rfc3339(),
        )
    }
}

MassPrunable - bulk-delete variant

For high-volume tables (audit logs, request logs, expired cache entries) MassPrunable skips per-row events and runs a single DELETE WHERE … statement. It runs where the prunable() query routes, the same place the --pretend count reads: the model's declared connection, or the query's own on(name) or with_tx:

use suprnova::eloquent::MassPrunable;

#[suprnova::prunable]
#[async_trait]
impl MassPrunable for AuditLog {
    fn prunable() -> suprnova::Builder<Self> {
        Self::query().filter_op(
            "created_at",
            "<",
            (Utc::now() - Duration::days(365)).to_rfc3339(),
        )
    }
}

Triggering pruning

Run it with the suprnova CLI. The CLI runs your project's console binary, which owns the command:

suprnova model:prune                          # prune every registered type
suprnova model:prune --model=ExpiredSession   # filter to one model
suprnova model:prune --pretend                # dry run; deletes nothing

cargo run --bin console -- model:prune runs the same command without the CLI.

--model matches the last segment of the type name. When no registered pruner has that name, the command prints model:prune: no pruner registered for `<name>` on the error stream, deletes nothing and exits with code 0.

Programmatically the runners are at suprnova::eloquent::{prune_all, prune_all_dry, prune_one}.

Pruning hook

Prunable::pruning(&self) fires before each row delete so the user can run side-effects (cleaning up associated files, fanning out events, etc.). The default impl is empty. MassPrunable skips this hook by definition - bulk deletes don't enumerate rows.

Cascade behavior

Pruning does NOT auto-cascade to related rows. A Prunable or MassPrunable impl on User deletes user rows; their posts, role_user pivot entries, polymorphic comments, etc. are LEFT ORPHANED with FK columns pointing at the now-deleted user.

This matches Laravel's contract: relation cleanup is the user's job. Two clean ways to handle it:

  1. Database-level FK cascade - declare ON DELETE CASCADE (or ON DELETE SET NULL) in the foreign-key constraint when you write the migration. The DB engine handles cascade for free, with no per-row Rust code.

  2. Per-row hook - implement Prunable::pruning(&self) to delete children before the parent row is dropped. The hook fires inside the same logical operation as the parent delete, so consistent ordering is guaranteed:

    #[async_trait]
    impl Prunable for User {
        fn prunable() -> Builder<Self> {
            Self::query().filter_op("deleted_at", "<", thirty_days_ago())
        }
    
        async fn pruning(&self) -> Result<(), FrameworkError> {
            // Delete posts.
            Post::query().filter("user_id", self.id).get().await?
                .into_iter()
                .map(|p| p.delete());
            // Detach role pivots.
            self.roles().sync(Vec::<i64>::new()).await?;
            Ok(())
        }
    }
    

MassPrunable is set-based - pruning() does not fire. Use plain Prunable whenever you need cascade. The framework will not silently issue a per-row DELETE when you opt into MassPrunable; the trade-off is documented loudly.

Registry mechanism

Pruner registration uses the same inventory pattern as observers, commands, and supervisors. The #[suprnova::prunable] attribute on the impl Prunable for T { ... } block auto-registers via inventory::submit! at compile time. No central config file; adding a new prunable type is one attribute.

Multi-connection routing

Production apps regularly need more than one database connection - the canonical case is a read replica for analytics + the primary for writes, but the surface generalises to any named connection (reporting DB, archive DB, per-tenant shard).

Registering a connection

Call DB::register_named(name, config) at boot for every non-default connection your app talks to:

DB::register_named(
    "reporting",
    DatabaseConfig {
        url: env::var("REPORTING_DATABASE_URL")?,
        max_connections: Some(20),
        ..Default::default()
    },
).await?;

Two names are reserved: __primary__ short-circuits the registry to DB::connection(), and __read_replica__ opts the connection into automatic read-write split routing - see below.

Per-query opt-in: Model::on(name)

Model::on("reporting") returns a Builder<M> pre-set to route through the named connection:

let totals = Order::on("reporting")
    .order_by_desc("total")
    .limit(100)
    .get()
    .await?;

on(...) is request-scoped - it only affects the chained builder. The next plain Order::query() call resolves through the default.

Per-model default: #[model(connection = "...")]

When a model always lives on one connection, declare the default on the attribute:

#[model(table = "events", connection = "events_db")]
pub struct Event { /* ... */ }

Every Event::query() / Event::create() / Event::find() call routes through events_db without needing the per-query .on(...) override. An explicit .on(...) on a builder still wins.

Read-write split

Registering a connection under the reserved name __read_replica__ opts every model into automatic routing: read methods (first / get / find / count / paginate / chunk / the closure-driven walkers) flow through the replica; writes (save / create / update / delete / force_delete / replicate / attach / detach / sync / increment / decrement) flow through the primary.

Model::on_write_connection() opts a single builder OUT of the replica - useful when read-your-writes consistency matters (e.g. immediately after a save, before replication catches up).

Routing precedence

Every read and every write resolves its connection in this order:

  1. Active transaction wins absolutely. Inside DB::transaction every read AND every write uses the tx connection. on(name) is IGNORED inside a transaction - the tx is bound to a specific physical connection. SeaORM can't begin a transaction on one connection and run statements against another.
  2. Per-builder on(name). Set via Model::on(name) / Builder::on(name). Wins over the model default and the read/write split.
  3. Model::on_write_connection(). Forces the primary even when the operation would otherwise route to the replica.
  4. Per-model #[model(connection = "...")] default. Wins over the read/write split for the model's own queries.
  5. Read/write split. When __read_replica__ is registered, read methods route there; writes route to the primary.
  6. Default. DB::connection() - the primary, the one DB::init() set up.

Caveats

  • Active transactions IGNORE on(name) (see §1 above). If you need a write on a different connection mid-tx, you can't - the tx is bound to one connection.
  • The reserved names __primary__ and __read_replica__ cannot be used as user connection names. DB::register_named returns an error on collision.
  • Replica lag is YOUR problem. Suprnova does not retry-on-read or fall back to primary when the replica is stale; if you need read-your-writes after a save, use Model::on_write_connection() explicitly.

Replication

Model::replicate() returns an unsaved copy of the model with the primary key reset to its default. Useful for "duplicate this record" UX where the user wants to start from an existing row.

let template: User = User::find_or_fail(42).await?;
let mut copy = template.replicate().await?;  // id reset to default
copy.email = "fresh@example.com".into();
copy.save().await?;  // INSERT, not UPDATE

replicate is async in Suprnova (diverges from Laravel) because it fires the Replicating event - Saving / Created / etc. listeners can mutate the replica before it's returned. See Replicating event for the listener mutation contract.

replicate_except

Drop named fields from the replica:

let copy = order.replicate_except(["payment_token", "stripe_id"]).await?;

Listed fields fall back to the model's Default impl - Strings become "", Options become None, etc. Use this for sensitive columns the replicated row shouldn't carry forward.

Cross-type replicate_into::<T>

The Suprnova divergence - Laravel can't because PHP has no types. replicate_into::<T>() bridges to a sibling type via serde_json:

let order: Order = Order::find_or_fail(42).await?;
let invoice: Invoice = order.replicate_into::<Invoice>().await?;
invoice.save().await?;

Fields with matching names + serde-compatible types carry over; fields that don't match on either side silently drop. T must implement Default so unfilled fields have a value. Cross-type replication does NOT fire Replicating (the event carries a &mut Self - there's no way to address T through it). If you need event-driven mutation, replicate same-type first and then materialise T from the result.

Debugging - dump and dd

Two interactive debugging aids on every Builder<M>:

// Logs SQL + bindings via tracing::info!, returns self.
let users = User::query()
    .filter("active", true)
    .dump()                       // → log line, builder continues
    .order_by_desc("created_at")
    .get()
    .await?;

// Logs at tracing::error!, then panics with the SQL in the message.
User::query().filter("id", 1).dd();  // - !

dump is chainable; dd returns ! (never returns - the panic is the contract). Both mirror Laravel's Builder::dump() / Builder::dd() exactly.

Both helpers fall back to the SQLite dialect when no live DB connection is bound (matches the to_sql_with_bindings fallback) so they stay useful at REPL or in a test without TestDatabase.

The panic message uses the literal prefix eloquent dd: so tests can assert against it:

#[test]
#[should_panic(expected = "eloquent dd")]
fn dd_panics_with_sql_in_message() {
    User::query().filter("id", 1).dd();
}

Never commit dd() to a production code path. It's an interactive debugging aid; the panic on the way out is the entire point. dump() is safer (just logs) but spamming it in hot paths will fill your logs - strip it before pushing.

If you want the SQL without the side effects, reach for the non-logging helpers:

  • Builder::to_sql() - returns the rendered SQL as a String.
  • Builder::to_sql_with_bindings() - returns (String, Vec<SeaValue>).
  • Builder::to_sql_for(backend) - render for an explicit dialect (cross-backend debugging).

Testing models

Tests instantiate a real database via TestDatabase, which registers the connection in the per-test container so anything calling DB::connection() inside the SUT resolves to the test DB.

Two entry points

  • TestDatabase::fresh::<MyMigrator>().await - runs every migration the production migrator runs. Use this for app-level dogfood tests where you want the test schema to exactly match what suprnova migrate produces.
  • TestDatabase::sqlite_memory().await - opens an in-memory SQLite database WITHOUT applying any migrations. Use this for framework-level unit tests where you want precise column-shape control via per-test db.execute_unprepared("CREATE TABLE …").

App-level dogfood pattern

use app::migrations::Migrator;
use app::models::users::User;
use suprnova::testing::TestDatabase;
use suprnova::{attrs, Model};

#[tokio::test]
async fn user_lifecycle() {
    let _db = TestDatabase::fresh::<Migrator>().await.unwrap();

    let alice = User::create(attrs! {
        name: "Alice",
        email: "alice@example.com",
        password: "hashed",
    }).await.unwrap();

    assert!(alice.id > 0);

    alice.delete().await.unwrap();
    assert!(User::find(alice.id).await.unwrap().is_none(),
        "default scope hides soft-deleted rows");
}

The _db binding holds the TestDatabase for the whole test - dropping it tears the container down and releases the in-memory SQLite connection. Don't shadow it to _ or the connection disappears before the SUT runs.

Framework-level shape pattern

use suprnova::testing::TestDatabase;
use suprnova::{attrs, model, Model};

#[model(table = "t_users", timestamps = false)]
pub struct TUser { pub id: i64, pub name: String }

#[tokio::test]
async fn shape_test() {
    let db = TestDatabase::sqlite_memory().await.unwrap();
    db.execute_unprepared(
        "CREATE TABLE t_users (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)"
    ).await.unwrap();

    let u = TUser::create(attrs! { name: "Alice" }).await.unwrap();
    assert_eq!(u.name, "Alice");
}

Key patterns

  • TestDatabase::fresh::<MyMigrator>() for app-level tests with the production schema. TestDatabase::sqlite_memory() for unit-level shape tests.
  • Use TestContainer::bind (NOT App::bind) for any singletons the test mutates - global registry overrides race in parallel runs. The TestDatabase constructor handles the DB binding for you.
  • Keep model declarations at module scope, not inside test fns. The macro emits an inner mod whose use super::*; only sees the file's top-level imports - declaring a model inside a test function breaks SeaORM type resolution.

Dropping to SeaORM

Three escape hatches keep SeaORM reachable from inside the Eloquent layer:

  1. The inner module - user::Entity, user::Column, user::ActiveModel, user::Model. The macro emits these for every model; they're SeaORM types you can use directly. See Model module layout for the full layout and when to reach in.
  2. From conversions - From<user::Model> for User and From<User> for user::Model bridge between SeaORM-shape rows (storage-typed columns) and Eloquent-shape rows (runtime-typed columns). Useful when you want to issue a SeaORM query and convert the result to the Eloquent shape, or vice-versa.
  3. The Suprnova-aliased SeaORM types - every SeaORM type a consumer would touch is re-exported under suprnova::*. You shouldn't need use sea_orm::* in app code.
use suprnova::sea_orm::{ColumnTrait, EntityTrait};

// Drop to SeaORM mid-query - Eloquent doesn't have a method for
// this, but SeaORM does:
let db = suprnova::DB::connection()?;
let users = user::Entity::find()
    .filter(user::Column::Email.like("%@example.com"))
    .all(db.inner())
    .await?;

// Convert to Eloquent shape:
let eloquent: Vec<User> = users.into_iter().map(User::from).collect();

Three escape hatches and the From bridge means the Eloquent layer never blocks you from reaching the underlying ORM.

Migrating from database::Model

Older code may carry impl suprnova::database::Model for Entity {} on a hand-rolled SeaORM entity. The trait was renamed to EntityExt to make room for the new Model trait - which sits on the user-facing struct, not on the SeaORM entity.

The recommended migration path is to switch the type to #[suprnova::model], which gives you the full Eloquent surface plus the renamed EntityExt traits as a bonus. For the rare case where you want to keep the old SeaORM-Entity-extension shape, the EntityExt / EntityExtMut trait names are still available under suprnova::database::*. They behave exactly like the old database::Model did.

DB facade - model-less queries

Some tables don't belong on a #[suprnova::model] struct: short-lived audit logs, ad-hoc reporting joins, dashboard aggregates. For those, reach for the DB facade. Two surfaces sit under it:

DB::table(name) - chainable query builder

DbTableBuilder mirrors the where / order / limit shape of Builder<M> but returns rows as DynamicRow (a typed-accessor newtype over serde_json::Map<String, Value>):

use suprnova::DB;

let rows = DB::table("audit_log")
    .filter("actor_id", 42)
    .filter_op("created_at", ">=", "2026-01-01")
    .order_by_desc("id")
    .limit(50)
    .get()
    .await?;

for row in rows.iter() {
    let event: String = row.get_string("event")?;
    let actor_id: i64 = row.get_int("actor_id")?;
    println!("{actor_id}: {event}");
}

The full surface:

Method Returns Purpose
.select(["id", "event"]) DbTableBuilder Restrict columns (default *)
.filter(col, val) DbTableBuilder WHERE col = ?
.filter_op(col, op, val) DbTableBuilder WHERE col <op> ?
.order_by_asc(col) / _desc(col) DbTableBuilder Ordering
.limit(n) / .offset(n) DbTableBuilder Window
.get() Collection<DynamicRow> All matching rows
.first() Option<DynamicRow> First row or None
.count() u64 SELECT COUNT(*) ...
.insert(attrs) i64 New row's id
.update(attrs) u64 Rows affected
.delete() u64 Rows affected

Identifier trust boundary. Table names, column names, SQL operators, and ORDER BY directions are interpolated into the SQL string verbatim - they are NOT bound as parameters. Pass only trusted, compile-time literals to these arguments. Values (the right-hand side of filter / filter_op) ARE bound and safe to pass through from request data.

Empty WHERE on update / delete operates on every row. DB::table("audit_log").delete().await? truncates the table by design - add a filter if you don't mean that.

Insert backend split. RETURNING id is used on Postgres and SQLite; MySQL runs the INSERT then issues SELECT LAST_INSERT_ID() as id to recover the auto-increment.

DynamicRow - typed accessors over JSON map

DynamicRow wraps a serde_json::Map<String, Value> and exposes typed getters. Each returns Result<T, FrameworkError> with a clear error message on missing key or type mismatch:

let event: String     = row.get_string("event")?;
let actor_id: i64     = row.get_int("actor_id")?;
let active: bool      = row.get_bool("active")?;
let prefs: Prefs      = row.get_as("prefs")?;  // any DeserializeOwned
let raw: serde_json::Value = row.get_value("meta")?;

Nullable columns: use get_optional_*. These distinguish "column missing" (error - schema mismatch) from "column present, value null" (Ok(None)):

let score: Option<i64>      = row.get_optional_int("score")?;
let title: Option<String>   = row.get_optional_string("title")?;

DynamicRow derefs to Map<String, Value>, so iteration and key-existence checks work naturally:

for (key, value) in row.iter() {
    println!("{key} = {value}");
}

Raw-SQL escapes

When the builder isn't enough - window functions, recursive CTEs, backend-specific DDL - drop to a raw string. Placeholders match the active backend ($1, $2, ... for Postgres, ? for MySQL + SQLite):

// Raw SELECT, materialised as DynamicRow.
let rows = DB::select(
    "SELECT u.name, COUNT(p.id) as post_count
     FROM users u LEFT JOIN posts p ON p.user_id = u.id
     GROUP BY u.id
     HAVING post_count > ?",
    vec![5i64.into()],
).await?;

// Raw UPDATE / DELETE - return rows-affected.
let updated = DB::update(
    "UPDATE users SET verified_at = NOW() WHERE id = ANY($1)",
    vec![ids.into()],
).await?;

let deleted = DB::delete(
    "DELETE FROM stale_sessions WHERE expires_at < ?",
    vec![now.into()],
).await?;

// Raw DDL or no-binding statements.
DB::statement("CREATE INDEX CONCURRENTLY idx_users_email ON users(email)")
    .await?;

// Generic affecting statement - for INSERT ... ON CONFLICT etc.
let rows = DB::affecting_statement(
    "INSERT INTO counters (k, n) VALUES ($1, 1) ON CONFLICT (k) DO UPDATE SET n = counters.n + 1",
    vec!["page_views".into()],
).await?;

Use these escape hatches sparingly - the typed builder catches more errors at compile time and reads cleaner in business logic. But when you need them, they're here.

A computed column such as SELECT COUNT(*) AS n FROM t comes back in raw DB::select rows on every backend. SQLite declares no type for it, so Suprnova reads the value by its runtime type; see Computed columns on SQLite.

Relation-existence + cheap shortcuts

Suprnova mirrors Laravel's relation-existence query family. Every method here pairs the Laravel-shape name with an idiomatic Rust alias (Suprnova's standing dual-API convention).

Relation-existence filters (has / where_has / where_belongs_to)

The correlated EXISTS (...) family constrains the parent query by the existence (or absence, or count) of related rows, without joining the relation into the outer SELECT.

use suprnova::Model;

// Users who have at least one post.
let users = User::query().has("posts").get().await?;

// Users who have NO posts.
let empty = User::query().doesnt_have("posts").get().await?;

// Users with >= 3 posts (Laravel `has("posts", ">=", 3)`).
let prolific = User::query().has_count("posts", ">=", 3).get().await?;

// Inner constraint via closure - restrict the EXISTS subquery body.
let recent = User::query()
    .where_has::<Post, _>("posts", |q| q.filter_op("created_at", ">=", "2026-01-01"))
    .get()
    .await?;

// One-column shortcut - equivalent to `where_has` with a tiny closure.
let with_pub = User::query()
    .where_relation("posts", "published", true)
    .get()
    .await?;

// Belongs-to direct join (no EXISTS - FK lives on this table).
let posts = Post::query().where_belongs_to("author", author.id).get().await?;

All variants compose with or_* and *_doesnt_have companions:

  • has / or_has / has_count / doesnt_have / or_doesnt_have
  • where_has / or_where_has / where_doesnt_have / or_where_doesnt_have
  • where_relation / where_relation_op / or_where_relation
  • where_belongs_to

The engine reads relation metadata from the macro-generated RelationEntry inventory: join columns, pivot tables, morph discriminators all flow through automatically. Three subquery shapes are rendered:

  • Has - EXISTS (SELECT 1 FROM child WHERE child.fk = parent.pk)
  • Pivot - EXISTS (SELECT 1 FROM pivot INNER JOIN target ON ... WHERE pivot.parent_fk = parent.pk)
  • Morph - has/pivot shape plus AND target.<morph>_type = '<value>'

Unknown relation names render the safe-fail form (EXISTS (SELECT 1 WHERE 1 = 0)), which evaluates to FALSE and returns zero rows. A typo never leaks a full-table scan.

MorphTo existence

You use has_morph or doesnt_have_morph with your MorphTo relation's name and registered morph names. You also use a model's registered alias or Rust type name. You pass "*" to query every owner type the relation declares in its targets list, an owner without a morph_type included. The wildcard absence form includes rows whose type column is null. Explicit type lists match only those types, including their aliases.

let comments = Comment::query()
    .where_has_morph("commentable", ["post"], |q: Builder<()>, kind| {
        q.filter("published", kind == "post")
    })
    .get()
    .await?;
let missing = Comment::query()
    .doesnt_have_morph("commentable", "*")
    .get()
    .await?;

You use where_has_morph or where_doesnt_have_morph to constrain each type's existence query. Your closure receives a builder and the canonical morph name once per selected type. The target's global scopes and soft-delete filter apply. An unknown morph type or a relation that is not MorphTo returns an error when you compile or execute the query. An empty type list matches no rows for existence, and every row for absence.

Cheap builder shortcuts

// PK filters.
User::query().where_key(7).first().await?;        // sugar for filter("id", 7)
User::query().where_key_not(7).get().await?;      // sugar for filter_op("id", "!=", 7)
User::query().filter("name", n).or_where_key(7).get().await?;      // ... OR id = 7
User::query().filter("name", n).or_where_key_not(7).get().await?;  // ... OR id != 7
// Rust-idiomatic aliases: filter_key / filter_key_not /
// or_filter_key / or_filter_key_not.

// Order by created_at.
Post::query().latest().get().await?;              // ORDER BY created_at DESC
Post::query().oldest().get().await?;              // ORDER BY created_at ASC
Post::query().latest_by("published_at").get().await?;  // named column

// Exact-one matching.
let one = User::query().filter("email", e).sole().await?;          // errors on 0 or >1
let val: i64 = User::query().filter("id", 1).sole_value("views").await?;
let v: i64 = User::query().filter("name", "x").value_or_fail("views").await?;

// Eager-load opt-outs.
User::query().with(["posts","tags"]).without(["tags"]).get().await?;
User::query().with_only(["posts"]).get().await?;   // wipes the plan first

// Fully-qualified columns (for joins).
Builder::<User>::qualify_column("name");           // -> "users.name"
Builder::<User>::qualify_columns(["name", "id"]);  // -> ["users.name", "users.id"]

Mass mutation - update_all / delete_all / upsert / *_each

These hit the database directly with a single statement and do NOT fire per-row model events. Use them when scope-narrowing is sufficient and you don't need lifecycle hooks; for per-row hooks iterate with .get() and call .update() / .delete() per row. delete_all always targets the model's static M::TABLE; runtime table names are not accepted as executable SQL. On a soft_deletes model it soft-deletes: one UPDATE sets the deleted-at column across the scope, as Laravel's builder delete() does. force_delete_all removes the rows for good. Explicit null attributes are emitted as SQL NULL, so nullable bigint, integer, boolean, timestamp, and other non-text columns retain their database type on PostgreSQL. Every non-null attribute remains parameter-bound. Upsert rows must have the same column set; a missing or extra key is rejected rather than being interpreted as null.

// Mass UPDATE.
let n = User::query()
    .filter("active", false)
    .update_all(attrs! { archived_at: Utc::now() })
    .await?;

// Mass DELETE.
let n = Session::query()
    .filter_op("expires_at", "<", cutoff)
    .delete_all()
    .await?;

// INSERT ... ON CONFLICT (Postgres / SQLite) / ON DUPLICATE KEY UPDATE (MySQL).
let n = Counter::query()
    .upsert(
        vec![attrs! { key: "page_views", n: 1 }, attrs! { key: "signups", n: 1 }],
        vec!["key"],                  // conflict target
        Some(vec!["n"]),              // update columns; None = every non-unique column
    )
    .await?;

// Atomic increment/decrement against a scope.
User::query()
    .filter("id", 7)
    .increment_each(vec![("views", 1), ("likes", 1)])
    .await?;

User::query()
    .filter("id", 7)
    .decrement_each(vec![("balance", 100)])
    .await?;

Static Model helpers

// Mass-destroy by PK set. Per-row events fire (each row goes through
// .delete() so soft-delete tombstone semantics + Deleting/Deleted
// dispatch are honoured).
let removed: u64 = User::destroy(vec![1i64, 2, 3]).await?;
let removed: u64 = User::force_destroy(vec![1i64, 2, 3]).await?;

// Identity comparison by PK.
assert!(alice.is(&also_alice));
assert!(alice.is_not(&bob));

You use force_destroy(keys) to remove matching rows physically, including rows you already soft-deleted. You receive the number removed. Duplicate or missing keys add nothing to the count, and an empty list returns zero. Each removed row fires its force-delete lifecycle events.

*Quietly variants - suppress lifecycle events

Sugar over seed::without_events. The five static lifecycle events (Saving/Creating/Updating/Deleting/Restoring) and the non-cancellable after-events both short-circuit inside the scope.

user.save_quietly().await?;            // no Saving / Updated / Saved
user.update_quietly(attrs).await?;
user.delete_quietly().await?;
user.force_delete_quietly().await?;

*_or_fail variants

Explicit error on the not-found case. Useful in invariant-checking code paths where a missing row is a bug.

let user = user.update_or_fail(attrs).await?;   // not_found if row deleted mid-flight
user.delete_or_fail().await?;

On a model declared with soft_deletes, delete_or_fail, delete_quietly, destroy, and a delete called through the Model trait all tombstone the row, as delete() does. delete_or_fail answers not-found for a row that is already trashed.

Filtered serialisation - to_array_except / to_array_only

Suprnova's Rust-native replacement for Laravel's per-instance makeHidden / makeVisible. The Eloquent struct doesn't carry a runtime attribute bag, so the column list is supplied at the call site:

return Json::ok(user.to_array_except(&["password_hash", "remember_token"]));
return Json::ok(user.to_array_only(&["id", "name", "email"]));

Divergence note. Laravel's per-instance makeHidden mutates state that propagates when the model is nested inside a parent's toArray() call. Suprnova's filter is terminal - it produces a serde_json::Value and doesn't affect future serialisations of self. For declarative-and-permanent visibility control, use the #[model(hidden = [...])] / #[model(visible = [...])] attributes.

UUID / ULID primary keys - #[model(unique_id = "...")]

Suprnova's analogue of Laravel's HasUuids / HasUlids / HasVersion4Uuids trait family. Set the attribute, type the PK as String, and the macro auto-populates the ID before INSERT. The key type comes from the String field.

#[model(
    table = "users",
    primary_key = "id",
    auto_increment = false,
    unique_id = "uuid",      // or "uuid_v4", "ulid"
)]
pub struct User {
    pub id: String,
    pub email: String,
}

// Auto-populated:
let u = User::create(attrs! { email: "a@b.com" }).await?;
// u.id is a fresh UUID v7.

// Caller-supplied IDs still win (matches Laravel's HasUuids behaviour).
let u = User::create(attrs! { id: "...", email: "..." }).await?;

Supported strategies:

  • "uuid" / "uuid_v7" - UUID v7 (timestamp-ordered, recommended; matches Laravel 11+'s default Str::uuid7())
  • "uuid_v4" - random UUID (matches HasVersion4Uuids)
  • "ulid" - lowercase 26-char Crockford-base32 ULID

The macro emits an impl HasUniqueId for YourStruct block exposing UNIQUE_ID_KIND and a new_unique_id() hook you can override on the type for a custom generator (e.g. prefixed IDs like usr_<uuid>).

find_or / find_or_new / create_or_first

Round out the FirstOrCreate trait surface.

// Look up by PK; run fallback if not found.
let user = User::find_or(id, || async {
    User::create(attrs! { id, name: "guest" }).await
}).await?;

// Look up by PK; build an unsaved instance from defaults if not found.
let user = User::find_or_new(id, attrs! { name: "draft" }).await?;
// user.id == 0 here - the instance is in-memory only; insert it with
// `user.persist().await?`.

// Race-safe insert: try create, fall back to fetch on conflict.
let user = User::create_or_first(
    attrs! { email: "race@x.com" },
    attrs! { name: "race winner" },
).await?;

without_touching scope

The Suprnova analogue of Laravel's Model::withoutTouching. Inside the scope, every model.touch().await call short-circuits - useful when running data migrations or batch jobs that mutate timestamps through other paths.

use suprnova::eloquent::without_touching;

without_touching(async {
    // .touch() calls here are no-ops.
    for post in posts {
        post.touch().await?;
    }
}).await;

The scope is tokio::task_local-backed, so concurrent requests on other tasks continue to honour their own scope (or its absence). without_touching also suppresses the parent-touch cascade - a child saved inside the scope leaves every owner named in its touches list alone.

without_touching_on::<Post, _, _>(fut) is the per-type form - Laravel's Model::withoutTouchingOn([Post::class], $cb). Inside it, post.touch() and any cascade that would bump a Post go quiet, while owners of every other type keep bumping:

use suprnova::eloquent::without_touching_on;

without_touching_on::<Post, _, _>(async {
    // Comment saves in here leave their Post owners alone; a Video
    // owner on the same comment still bumps.
    comment.save().await
}).await?;

The per-type form also holds for a MorphTo owner: when the owner the row points at is a Post, the touch is skipped.

Scopes nest, and both are tokio::task_local-backed.

Next