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
- The
#[suprnova::model]attribute - Model module layout
- Finding rows
- Creating and updating
- Deleting and soft deletes
- Query builder - dual API
- Row locking
- Transactions
- Scopes
- Relationships
- Eager loading
- Pagination
- Chunking and lazy iteration
- Collections
- Mass assignment
- Casts
- Accessors and mutators
- Timestamps
- Observers and lifecycle events
- Prunable
- Multi-connection routing
- Replication
- Debugging - dump and dd
- Testing models
- Dropping to SeaORM
- Migrating from
database::Model - DB facade - model-less queries
- Laravel-13 parity - relation-existence + cheap shortcuts
Quick start
One attribute on a struct turns it into a fully-featured Eloquent model:
use ;
use ;
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 withModelNotFoundon 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 | i64 |
PK type - String for UUID, i32 for legacy schemas |
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) |
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 | [] |
Parsed and stored as model metadata (TOUCHES const). The post-save hook that calls .touch() on the listed parents is not yet wired - for now, call parent.touch().await? explicitly from your observer or handler. |
mutators |
list of strings | [] |
Field names whose JSON-fill path routes through a set_<field>(value) mutator method |
Full example
use ;
use Value as Json;
use ;
Function-level macros
Function-level macros work alongside the struct attribute:
#[accessor]on afn name(&self) -> Tmakes it an Eloquent accessor. The model'sto_array()calls it whennameis listed inappends = [...](andto_json()picks it up via theto_array→ string delegation).#[mutator]on afn set_name(&mut self, value: serde_json::Value)makes it an Eloquent mutator. The model's JSON-fill path routes through it whennameis listed inmutators = [...].#[suprnova::scopes(Model)]on animpl Model { ... }block: every method whose signature isfn name(query: Builder<Self>[, args…]) -> Builder<Self>becomes both a chainable.scope_name(args)onBuilder<Self>and aModel::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
GlobalScopetrait, applied throughModel::global_scope::<GS>(). There is no function-level#[global_scope]macro - see Macros for the full pattern. #[prunable]onimpl Prunable for T { ... }registers the pruner via inventory somodel:prunefinds 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 ;
use model;
// 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 ;
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:
- It is the SeaORM-shape row, not your
Poststruct. Cast columns carry theirStoragetype here (e.g.boolbecomes the underlying integer), and the__eager/__pivotruntime slots from your struct are absent. From<post::Model> for PostandFrom<Post> for post::Modelbridge 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 viaQuerySelect::join()for a relation Eloquent'swith(...)doesn't model. - SeaORM-native subqueries through
Entity::find().select_only(). - Plain
ActiveModelmutation 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 crateColumn;
let drafts = query
.db_where
.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 cratepost;
use ;
let db = DBconnection?;
let rows: = find
.filter
.all
.await?;
// Bridge back to the Eloquent shape when the caller wants it.
let posts: = rows.into_iter.map.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
// Suprnova
let user: = find.await?;
let user: User = find_or_fail.await?;
let users: = find_many.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
// Suprnova
let user = first_or_create.await?;
let user = update_or_create.await?;
let user = first_or_new.await?; // returns an unsaved User; caller saves explicitly
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
save().await?.
Creating and updating
Create
// Suprnova
let user = create.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.
Save / update
// Suprnova
user.name = "Alice B".into;
user.save.await?;
user.update.await?;
save() walks every non-PK field, sets them on the ActiveModel via
Set(...), calls SeaORM's update(), and returns the canonical row.
update(attrs) is the same flow but applies a partial attribute
map first (running the Fillable filter and any declared mutators).
Increment / decrement
// Suprnova
user.increment.await?;
user.increment.await?;
user.decrement.await?;
filter.increment.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
// Suprnova
user.refresh.await?;
let copy: User = user.fresh.await?;
let replica: User = user.replicate.await?;
let replica: User = user.replicate_except.await?;
refresh mutates in place; fresh returns a separately-fetched
copy. replicate builds an in-memory clone with the PK reset
(Default::default() for the key type). Caller saves explicitly.
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 ;
use async_trait;
;
// Wire it up once at boot:
.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 T::save - the normal
Saving / Created chain still fires inside save.
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:
Lifecycle
user.delete.await?; // UPDATE: sets deleted_at = NOW()
user.trashed; // -> true
let trashed = with_trashed.find.await?.unwrap;
trashed.restore.await?; // UPDATE: sets deleted_at = NULL
let only_dead = only_trashed.get.await?;
let all_including_dead = with_trashed.get.await?;
user.force_delete.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:
query.filter.filter_in.get.await?;
// Laravel dev:
db_where.where_in.get.await?;
// Same query. Same result. Different muscle memory.
Where shortcuts
// Suprnova - pick either family; both compile, both documented.
// Rust-shape (filter family):
let users = query.filter.get.await?;
let users = query.filter_op.get.await?;
let users = query.filter_like.get.await?;
// Laravel-shape (db_where / where_* family):
let users = db_where.get.await?;
let users = query.db_where_op.get.await?;
let users = query.where_like.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(...) |
|
->whereNot(col, val) |
.filter_not(col, val) |
.where_not(col, val) |
|
->whereIn(col, vals) |
.filter_in(col, vals) |
.where_in(col, vals) |
|
->whereNotIn(col, vals) |
.filter_not_in(col, vals) |
.where_not_in(col, vals) |
|
->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) |
|
->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) |
|
->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(closure) |
.filter_exists(builder) |
.where_exists(builder) |
Subquery |
->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) |
Bound raw predicates use portable ? markers on SQLite, MySQL, and PostgreSQL:
let rows = query
.filter
.filter_raw
.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
let users = query.order_by.get.await?;
let users = query.order_by_desc.get.await?;
let users = latest.get.await?;
let users = oldest.get.await?;
let users = query.in_random_order.get.await?;
Direction::Asc / Direction::Desc is the Suprnova enum
re-exported from SeaORM.
Grouping + having
let rows = query
.group_by
.having_op
.get
.await?;
Limit / offset
let users = query.limit.offset.get.await?;
let users = query.take.skip.get.await?;
Select / add_select / select_raw
let users = query.select.get.await?;
let users = query.select.add_select.get.await?;
let rows = query.select_raw
.group_by
.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: = query.distinct.pluck.await?;
Aggregates
let count = count.await?;
let count = filter.count.await?;
let sum = .await?;
let avg = .await?;
let min = .await?;
let max = .await?;
let exists = filter.exists.await?;
let missing = filter.doesnt_exist.await?;
Aggregates are generic over the return type because SeaORM needs to
know what to coerce the DB scalar to. Type defaults:
count -> i64; sum/avg carry an explicit type parameter.
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.
An incompatible requested Rust type or missing result column is a database
error; it is never converted into a plausible zero or None.
Terminals
let users: = all.await?;
let first: = first.await?;
let user: User = first_or_fail.await?;
let value: = filter.value.await?;
let emails: = .await?;
let keyed: = .await?;
let sql: String = filter.to_sql;
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>).
Unions
let first = filter;
let second = filter;
let users = first.union.get.await?;
let users = first.union_all.get.await?;
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 = query
.filter
.lock_for_update
.first_or_fail
.await?;
// Shared read lock - allows other shared readers, blocks writers.
let inventory = query
.filter
.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.
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(...):
DBtransaction.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.
What's not in v1
NOWAIT/SKIP LOCKED- useful for job-queue claim workflows but they add API surface. Deferred until 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 via a tokio::task_local!
called CURRENT_TX.
use ;
DBtransaction.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::droprolls back).
Reads inside the closure see writes from the same transaction
(via CURRENT_TX lookup at every leaf SQL call). 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:
DBtransaction.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.
DBtransaction.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
DBtransaction.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 CURRENT_TX 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:
DBtransaction_with_attempts.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 MySQLDeadlock found when trying to get lockand 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 whoseDisplaycontains the word will trigger a retry. When raising your own errors from inside atransaction_with_attemptsclosure, avoid "deadlock" in the message - otherwise an unrelated validation error retries up toattemptstimes 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 = DBbegin_transaction.await?;
let mut user = query
.filter
.with_tx
.first_or_fail
.await?;
user.balance = 500;
user.save_with_tx.await?;
if some_condition
tx.commit.await?; // or tx.rollback().await?;
Manual mode does not install CURRENT_TX. 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:
- Builder-level override -
Builder::with_tx(&tx)or anyModel::*_with_tx(&tx, ...)shim. Explicit beats ambient. - Ambient
CURRENT_TX- installed byDB::transaction/DB::transaction_with_attemptsfor the closure's task scope. - Pool fallback -
DB::connection()returns the globalDbConnectionsingleton.
Inside DB::transaction(|tx| ...), calling
Builder::with_tx(&other_tx) explicitly routes that one query
through other_tx - bypassing the ambient CURRENT_TX. 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.
Limitations (v1)
- Relation eager loads -
Builder::with(["posts"])andCollection::load(["posts"])route the eagerIN (...)sub-queries throughDB::connection(), not through the active transaction. Pending writes inside aDB::transactionclosure are not visible to relations loaded via.with(...). For now, scope tx work to directModel::*/Builder::*/DB::table(...)calls; defer relation loads until after the outer write lands (or beforeDB::begin_transactionon the manual path). This is a known seam - the routing helper (ExecutorChoice) is already in place at every SQL leaf; the blocker isEagerLoadDispatch::eager_loadtaking&DatabaseConnection(concrete), which the macro emits for every relation kind. A follow-up sweep will adapt the trait to the dispatch helper. - DDL on Postgres -
DB::statement(...)inside a transaction runs the DDL against the tx connection, which Postgres allows; MySQL implicitly commits and is therefore unsupported inside a Suprnova transaction (this matches Laravel'sDB::transactioncaveat).
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 annotatedimplblock becomes bothModel::name()(a static starter) andBuilder::name()(a chainable method). - Global scopes - implementations of
GlobalScope<M>registered at boot viaScopeRegistry::register::<M, _>(scope). EveryModel::query()call layers them on automatically.
Local scopes
Declare local scopes by giving them the shape
fn(query: Builder<Self>, args...) -> Builder<Self>:
// Use as either a starter or a chainable method:
let active_users = active.get.await?;
let popular_users = query.active.popular.get.await?;
Non-scope methods declared in the same impl block (anything whose
first parameter isn't query: Builder<Self>) pass through unchanged.
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 ;
;
// At boot - typically inside your provider/bootstrap module:
;
// Every read is auto-scoped to the active tenant:
let scoped = 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).
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 = .get.await?;
// Bypass every registered scope. Admin tooling pattern.
let everything = without_global_scopes.get.await?;
Important: the opt-out helpers must be the entry point. Chaining
.without_global_scope::<S>() onto a builder already returned by
Model::query() doesn't undo scopes that have already run -
Model::query() applies scopes eagerly at construction time, so the
mask is set too late. Use the per-model static helpers (above) for
correct semantics.
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::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()andModel::only_trashed()skip soft-delete filtering and also bypass the registry (they build a fresh unscoped builder). Pair with.without_global_scope::<S>()if you need scope-aware reads over trashed rows.
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 shipped today:
| 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 model;
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 = "id". |
related_key = "..." |
BelongsToMany, MorphToMany |
The related-side PK COLUMN name. Default = "id". Required when the related model uses a non-id PK. |
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, i64). |
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. |
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().
let user = find.await?.unwrap;
let profile: = user.profile.first.await?;
let profile = find.await?.unwrap;
let owner: = 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:
let c = find.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.
let u = find.await?.unwrap;
// Every post by this user, default ordering:
let posts: = u.posts.get.await?;
// Filtered + ordered + paged:
let recent = u.posts
.filter
.latest // ORDER BY created_at DESC
.take
.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:
- The pivot row is type-safe. Read
with_pivotcolumns viar.pivot::<P>().<column>, never viar.pivot.get("..."). - The pivot model is reachable from the rest of the framework (factories, scopes, casts, hooks) the same way every model is.
let u = find.await?.unwrap;
let admin = create.await?;
// Attach + sync mutators
u.roles.attach.await?;
u.roles.attach_with.await?;
u.roles.sync.await?;
u.roles.detach.await?;
// Read pivot data through the per-row downcast accessor:
let roles = u.roles.get.await?;
for r in &roles
.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.syncfor idempotency)..attach_with(id, attrs! { ... })- INSERT with extra pivot columns. Stamps timestamps whenwith_timestampsis on..detach(id)- DELETE the pivot row(s) linking parent → id..sync([ids...])- diff-and-apply: attach what's new, detach what's missing, leave the intersection alone. Wrapped in a transaction.
.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).
let c = find.await?.unwrap;
let posts: = 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.
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, i64) for legacy rows whose
<name>_type value doesn't match any registered target.
let c = find.await?.unwrap;
match c.commentable.get.await?
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.
let post = find.await?.unwrap;
let post_comments: = post.comments.get.await?;
let post_cover: = post.cover.first.await?;
let video = find.await?.unwrap;
let video_comments: = 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()).
// Inverse: Tag declares one MorphedByMany per target family.
let post = find.await?.unwrap;
let video = find.await?.unwrap;
let tag = create.await?;
// `attach` / `attach_with` / `detach` / `sync` work the same way as
// BelongsToMany. The `<name>_type` column lands automatically from
// the calling parent's `morph_type`.
post.tags.attach.await?;
video.tags.attach.await?; // independent attachment
post.tags.sync.await?;
// Inverse direction - Tag splits by family:
let posts_with_tag: = tag.posts.get.await?; // typed "post"
let videos_with_tag: = 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:
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.
v1 restrictions
A handful of things the v1 surface holds off on. Each is documented at its declaration site too - collected here for visibility:
- Morph IDs are
i64-only.MorphTo::morph_idis hardcoded toi64, so any model used as aMorphTotarget must declare ani64primary key, and the child table's<name>_idcolumn must also bei64. String / UUID-as-string morph FKs are v2. - No nested eager loading through
MorphTo. The per-family enum erases the child type, so a dotted path likewith(["commentable.user"])can't tail-recurse - the dispatcher returns a typed error. Resolve per-family by matching on the enum and callingwith(["user"])on each variant individually.
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.
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 = with.get.await?;
for u in &users
// Multiple relations:
let users = with.get.await?;
// Nested paths - three queries (users + posts + comments), no N+1:
let users = with.get.await?;
let p1 = users.posts_loaded;
let comments = p1.comments_loaded;
// Deeper nesting works as expected:
let users = with.get.await?;
// Count alongside the parent rows:
let users = with_count.get.await?;
for u in &users
// Aggregates - Sum / Avg / Min / Max over a relation column. The
// ergonomic read is the macro-emitted `<rel>_sum_of(col)` accessor.
let users = with_sum.get.await?;
let sum: f64 = users
.posts_sum_of
.expect;
// 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 = with_sum
.with_avg
.with_min
.get
.await?;
let u = &users;
let sum = u.posts_sum_of.unwrap; // Some(_) - sum of views
let avg = u.posts_avg_of.unwrap; // Some(_) - avg of views
let min = u.posts_min_of.unwrap; // Some(Some(_)) - non-empty group
let max = u.posts_max_of; // 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 = with_where_posts
.get
.await?;
// The returned `Builder<User>` chains with any other base-query
// builder method:
let users = with_where_posts
.filter
.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 = query
.with_where
.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) forwithandwith_count.<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 ifwith_count(["..."])wasn't called.<rel>_sum_of(col)/<rel>_avg_of(col)- returnOption<f64>(Noneif the matchingwith_sum/with_avgwas not called).<rel>_min_of(col)/<rel>_max_of(col)- returnOption<Option<f64>>: outerOptionis "waswith_min/with_maxcalled?", innerOptionis "did SQL return NULL because the group was empty?".
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 = with_sum
.with_avg
.with_min
.with_max
.get
.await?;
let u = &users;
let total_views: f64 = u.posts_sum_of.unwrap;
let avg_views: f64 = u.posts_avg_of.unwrap;
// Min/Max are double-Option because SQL min/max NULLs on empty:
match u.posts_min_of
// Accessor returns `None` when the matching `with_*` was skipped:
assert!; // never called with col="score"
Aggregates and INTEGER columns
SUM over an INTEGER column lands in the cache as f64. The
dispatcher arms try try_get::<Option<f64>> first, then fall back to
try_get::<Option<i64>>().map(|n| n as f64) so SQLite's INTEGER-
preserving COUNT/SUM types don't silently coerce to 0.0. 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 is also not supported in v1 - with(["commentable.user"])
where commentable is a MorphTo returns an error from the
recurse-eager-load dispatcher.
Collection::load / load_missing
When you've already fetched rows and want to eager-load relations after the fact:
use Collection;
let mut users: = all.await?.into;
users.load.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
postscached get the FULL path loaded -postsplus theircomments. - Rows WITH
postsalready cached recurse into the cached posts and loadcommentsonly 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.
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 LengthAwarePaginator;
let page: = query
.filter
.order_by_desc
.paginate
.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 = query.paginate_using.await?;
let comments = query.paginate_using.await?;
JSON shape:
path is omitted from JSON when unset.
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 Paginator;
let page: = query
.order_by_desc
.simple_paginate
.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:
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 CursorPaginator;
let page: = query
.cursor_paginate
.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 (the model's primary key) 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 replaces any existing ORDER BY on the
builder - a stable PK ASC order is required for gt(boundary) to
slice deterministically.
JSON shape:
next_cursor and prev_cursor 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.
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 Context;
async
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 ;
query.chunk.await?;
The closure receives a Collection<M> per batch - slice-shape access
(.iter(), indexing) works directly via Deref.
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
query.chunk_by_id.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.
chunk_by_id requires an i64 primary key. Models with String /
Uuid PKs use chunk with the OFFSET caveat. (Generalising the
cursor shape to non-i64 keys is on the follow-up list.)
chunk_map - chunk + per-chunk map
let totals: = query
.chunk_map
.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
query.each.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 = query.lazy;
while let Some = stream.next.await
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().
Same i64-PK constraint as chunk_by_id.
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(...):
query.chunk.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 Collection;
let nums: = from_vec;
nums.first; // Some(&3)
nums.last; // Some(&9)
nums.len; // 6
nums.is_empty; // false
nums.contains; // true
// Predicate closures receive `&&T` - note the double-deref `**n`:
nums.first_where; // Some(&4)
nums.contains_where; // 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: = nums.clone.map;
let evens: = nums.clone.filter;
let chunks: = nums.clone.chunk; // [[3,1],[4,1],[5,9]]
let unique: = nums.clone.unique;
let sorted: = 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: = query.get.await?;
let emails: = users.;
let by_role: =
users.clone.;
let active: = users.clone.where_eq;
let total: f64 = users.clone.;
let avg: f64 = users.clone.;
let max: = users.clone.;
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: = users.pluck_by;
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
create.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.
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 unguarded;
// Bypass the filter for a one-shot data-migration script:
unguarded.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-only
Casts are declared in #[model(casts = { ... })] - there is no
auto-detection from field types. 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. No magic.
Example
use ;
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) |
object |
AsObject<T> |
T: Serialize + DeserializeOwned |
collection |
AsCollection<T> |
Collection<T> |
json |
AsJson<T> |
T (raw JSON column) |
date, date:format |
AsDate |
chrono::NaiveDate |
datetime, datetime:format |
AsDateTime |
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> |
hashed |
AsHashed |
String (Hash::make on write; never decrypts) |
22 casts total. Most map one-to-one with Laravel; 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-generatedFrom<inner::Model>) panics withcast from_storage failed - corrupt data in database column. Operators see the failure in logs immediately; the model never carries plausible-but-wrong plaintext. - The
AsHashedcast 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):
- Mint a new key:
suprnova key:generate(writes to stdout). - Move the old key to
APP_KEY_PREVIOUSand setAPP_KEYto the new value:APP_KEY_PREVIOUS=<old_key> APP_KEY=<new_key> - 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. - Run a re-encrypt pass. For each model with encrypted casts:
This is idempotent - rows already on the new key just no-op.for chunk in query.chunk.await? - Once logs show no more
APP_KEY_PREVIOUSwarnings (give the batch + any soft-deleted / archived data a generous window), removeAPP_KEY_PREVIOUSfrom 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_PREVIOUSfails boot loudly (same as a malformedAPP_KEY) - a half-rotated secret should never silently degrade. - More than 8 entries in
APP_KEY_PREVIOUSfails 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 = query
.with_casts
.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 Cast;
use FrameworkError;
;
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
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:
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 inappends = [...]becomes a call toself.<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 inmutators = [...]becomes a call toself.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.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/guardedis 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
Defaultand the call returnedOk-fill(attrs!{ age: "abc" })setage = 0and reported success. If you were relying on that coercion, validate or convert before callingfill.
Hidden / visible
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_atis set toUtc::now()onsave()for new rows.updated_atis set toUtc::now()on everysave().
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.
Disabling auto-timestamps
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
The touches = [...] list is parsed and stored on the model as a
TOUCHES const. The post-save hook that would automatically call
self.post().touch().await? after a comment save is not yet wired -
for now, call the parent's .touch() explicitly from an observer
or your handler. The metadata is in place so that switching over
later is a behaviour change, not an API change.
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:
-
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. -
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;
use Attrs;
use EventResult;
use Observer;
use FrameworkError;
;
// <- MUST precede #[async_trait]
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]:
// outer - runs first, sees raw async fns
// inner - rewrites async fn signatures
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:
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"):
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:
// 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:
;
// At runtime:
observe.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) in v1. The macro constructs the observer via
let obs = MyObserver;inside each adapter. Stateful observers (carryingArc<Inner>) need the runtimeModel::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
Userare visible to every test in the same binary. Per-test unique model types (T2Comment,T2Subscription, …) keep cross-test bleed out of the counter assertions. Theeloquent_observers.rsintegration 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;
use ;
use Prunable;
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:
use MassPrunable;
Triggering pruning
Run via the per-project console (which app/cmd/main.rs calls
suprnova::console::dispatch_argv for, after db:seed and the
other built-ins):
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:
-
Database-level FK cascade - declare
ON DELETE CASCADE(orON 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. -
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:
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:
DBregister_named.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 = on
.order_by_desc
.limit
.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:
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
The dispatch chain runs every operation through
ExecutorChoice::resolve_read or resolve_write. The order is:
- Active transaction wins absolutely. Inside
DB::transactionevery 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. - Per-builder
on(name). Set viaModel::on(name)/Builder::on(name). Wins over the model default and the read/write split. Model::on_write_connection(). Forces the primary even when the operation would otherwise route to the replica.- Per-model
#[model(connection = "...")]default. Wins over the read/write split for the model's own queries. - Read/write split. When
__read_replica__is registered, read methods route there; writes route to the primary. - Default.
DB::connection()- the primary, the oneDB::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_namedreturns 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 = find_or_fail.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.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 = find_or_fail.await?;
let invoice: Invoice = order..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 = query
.filter
.dump // → log line, builder continues
.order_by_desc
.get
.await?;
// Logs at tracing::error!, then panics with the SQL in the message.
query.filter.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:
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 aString.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 whatsuprnova migrateproduces.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-testdb.execute_unprepared("CREATE TABLE …").
App-level dogfood pattern
use Migrator;
use User;
use TestDatabase;
use ;
async
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 TestDatabase;
use ;
async
Key patterns
TestDatabase::fresh::<MyMigrator>()for app-level tests with the production schema.TestDatabase::sqlite_memory()for unit-level shape tests.- Use
TestContainer::bind(NOTApp::bind) for any singletons the test mutates - global registry overrides race in parallel runs. TheTestDatabaseconstructor handles the DB binding for you. - Keep model declarations at module scope, not inside test fns.
The macro emits an inner
modwhoseuse 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:
- 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. Fromconversions -From<user::Model> for UserandFrom<User> for user::Modelbridge 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.- The Suprnova-aliased SeaORM types - every SeaORM type a
consumer would touch is re-exported under
suprnova::*. You shouldn't needuse sea_orm::*in app code.
use ;
// Drop to SeaORM mid-query - Eloquent doesn't have a method for
// this, but SeaORM does:
let db = DBconnection?;
let users = find
.filter
.all
.await?;
// Convert to Eloquent shape:
let eloquent: = users.into_iter.map.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 DB;
let rows = DBtable
.filter
.filter_op
.order_by_desc
.limit
.get
.await?;
for row in rows.iter
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?;
let actor_id: i64 = row.get_int?;
let active: bool = row.get_bool?;
let prefs: Prefs = row.get_as?; // any DeserializeOwned
let raw: Value = row.get_value?;
Nullable columns: use get_optional_*. These distinguish "column
missing" (error - schema mismatch) from "column present, value null"
(Ok(None)):
let score: = row.get_optional_int?;
let title: = row.get_optional_string?;
DynamicRow derefs to Map<String, Value>, so iteration and
key-existence checks work naturally:
for in row.iter
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 = DBselect.await?;
// Raw UPDATE / DELETE - return rows-affected.
let updated = DBupdate.await?;
let deleted = DBdelete.await?;
// Raw DDL or no-binding statements.
DBstatement
.await?;
// Generic affecting statement - for INSERT ... ON CONFLICT etc.
let rows = DBaffecting_statement.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.
Aggregate-column gotcha. Untyped aggregates like
SELECT COUNT(*) AS n FROM t work through the builder's .count()
helper but may be silently dropped from raw DB::select rows on
SQLite - the underlying JsonValue::from_query_result walks sqlx's
per-column type info, and a bare aggregate carries none. If you need
the raw select path with aggregates, give the expression a typed
context: either use a CAST(... AS BIGINT) wrapper or read the
column with a typed DB::table(...).count() / .max(...) helper
that uses query_one + try_get under the hood.
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 Model;
// Users who have at least one post.
let users = query.has.get.await?;
// Users who have NO posts.
let empty = query.doesnt_have.get.await?;
// Users with >= 3 posts (Laravel `has("posts", ">=", 3)`).
let prolific = query.has_count.get.await?;
// Inner constraint via closure - restrict the EXISTS subquery body.
let recent = query
.
.get
.await?;
// One-column shortcut - equivalent to `where_has` with a tiny closure.
let with_pub = query
.where_relation
.get
.await?;
// Belongs-to direct join (no EXISTS - FK lives on this table).
let posts = query.where_belongs_to.get.await?;
All variants compose with or_* and *_doesnt_have companions:
has/or_has/has_count/doesnt_have/or_doesnt_havewhere_has/or_where_has/where_doesnt_have/or_where_doesnt_havewhere_relation/where_relation_op/or_where_relationwhere_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 divergence
Laravel's MorphTo inverse (whereMorphedTo, whereHasMorph) walks
multiple target tables because the morph child carries a *_type
discriminator that picks one of N possible parents. Suprnova's
MorphTo lowers to a per-family enum at macro expansion time - the
target type is statically a <Family>Morph { Variant1(...), ... },
not a single SQL table. The existence engine can't render one fixed
EXISTS (SELECT 1 FROM <table>) for that case because there is no
single table.
Recommended migration: do the existence check at the morph-child level instead. Where Laravel writes:
Suprnova writes:
query
.filter
.
.get
.await?;
The narrower-typed form gives full IDE completion on the inner
builder, which the loosely-typed whereHasMorph cannot.
Cheap builder shortcuts
// PK filters.
query.where_key.first.await?; // sugar for filter("id", 7)
query.where_key_not.get.await?; // sugar for filter_op("id", "!=", 7)
// Rust-idiomatic aliases: filter_key / filter_key_not.
// Order by created_at.
query.latest.get.await?; // ORDER BY created_at DESC
query.oldest.get.await?; // ORDER BY created_at ASC
query.latest_by.get.await?; // named column
// Exact-one matching.
let one = query.filter.sole.await?; // errors on 0 or >1
let val: i64 = query.filter.sole_value.await?;
let v: i64 = query.filter.value_or_fail.await?;
// Eager-load opt-outs.
query.with.without.get.await?;
query.with_only.get.await?; // wipes the plan first
// Fully-qualified columns (for joins).
qualify_column; // -> "users.name"
qualify_columns; // -> ["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.
// Mass UPDATE.
let n = query
.filter
.update_all
.await?;
// Mass DELETE.
let n = query
.filter_op
.delete_all
.await?;
// INSERT ... ON CONFLICT (Postgres / SQLite) / ON DUPLICATE KEY UPDATE (MySQL).
let n = query
.upsert
.await?;
// Atomic increment/decrement against a scope.
query
.filter
.increment_each
.await?;
query
.filter
.decrement_each
.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 = destroy.await?;
let removed: u64 = force_destroy.await?;
// Identity comparison by PK.
assert!;
assert!;
*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.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.await?; // not_found if row deleted mid-flight
user.delete_or_fail.await?;
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 ok;
return ok;
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.
// Auto-populated:
let u = create.await?;
// u.id is a fresh UUID v7.
// Caller-supplied IDs still win (matches Laravel's HasUuids behaviour).
let u = create.await?;
Supported strategies:
"uuid"/"uuid_v7"- UUID v7 (timestamp-ordered, recommended; matches Laravel 11+'s defaultStr::uuid7())"uuid_v4"- random UUID (matchesHasVersion4Uuids)"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 = find_or.await?;
// Look up by PK; build an unsaved instance from defaults if not found.
let user = find_or_new.await?;
// user.id == 0 here - the instance is in-memory only.
// Race-safe insert: try create, fall back to fetch on conflict.
let user = create_or_first.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 without_touching;
without_touching.await;
The scope is tokio::task_local-backed, so concurrent requests on
other tasks continue to honour their own scope (or its absence).
Next
- Eloquent Relationships - deep dive on every relation kind, the morph registry, and the polymorphic enum lowering
- Eloquent Collections - full
Collection<T>surface, the generic-vs-model split, andLazyCollection<M>streaming - Eloquent Casts, Accessors & Mutators - the
22 built-in casts plus the
casts!runtime override - Eloquent Serialization -
to_array,to_json, hidden / visible / appends, filtered terminals - Eloquent Factories - randomized model instances for tests and seeders
