Manual contentsDatabaseBrowse 126 chapters
Manual 29 min read

Query Builder

When you want to query a table without modelling it as a typed #[suprnova::model] struct, reach for DB::table(name). It returns a chainable builder shaped like the typed Eloquent Builder<M>, but materialises rows as DynamicRow - a serde_json::Map newtype with typed accessors. This is the chapter for audit logs, ad-hoc reports, dashboard aggregates, and any table you haven't bothered to model. For the typed equivalent, see Eloquent. For raw DB::select inside transactions or with DB::listen observation, see Database.

use suprnova::DB;

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

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

When to use which surface

Three query surfaces overlap; pick the right one for the table.

Table is… Use Returns
Modeled with #[suprnova::model] Model::query() → Builder<M> typed M values
Unmodeled but you want a chainable WHERE/ORDER/LIMIT shape DB::table(name) → DbTableBuilder DynamicRow
Anything the builders can't express - CTEs, window functions, backend DDL DB::select / DB::statement / DB::affecting_statement DynamicRow / bool / u64

DbTableBuilder exists for the middle case. You get the WHERE / ORDER / LIMIT chain without committing to a #[suprnova::model] struct and without dropping all the way to raw SQL strings.

The chainable surface

DB::table(name) returns a DbTableBuilder. Build it up, then call a terminal method to execute.

Filtering

// Equality.
DB::table("users").db_where("email", "alice@example.com").get().await?;

// Arbitrary operator. Allowlist: =, <>, <, <=, >, >=, LIKE, NOT LIKE,
// ILIKE, NOT ILIKE, IS, IS NOT.
DB::table("orders").filter_op("total", ">=", 100i64).get().await?;
DB::table("posts").filter_op("title", "LIKE", "%rust%").get().await?;

// Multiple filters AND together.
DB::table("audit_log")
    .filter("actor_id", 42i64)
    .filter_op("event", "<>", "noop")
    .get()
    .await?;

filter and filter_op both accept any Into<SeaValue> for the right-hand side, which covers i64, String, &str, bool, f64, Option<T>, chrono::*, uuid::Uuid, and serde_json::Value - every column type the backend understands.

db_where(column, value) and filter(column, value) use =. r#where(column, value) is the same comparison under Laravel's name: where is a Rust keyword, so you write it as the raw identifier r#where. You pass an operator with filter_op; or_where and or_where_op add their comparisons with OR. A null value in an equality comparison becomes IS NULL, with no null binding.

A u64 above i64::MAX compares as the number it is, whatever the column's type, in a filter and in a raw fragment's bindings alike, so the count is the database's own count for the stored rows. Postgres has no unsigned integers, so it receives the value as a numeric: a bigint column matches no row, and a numeric or double precision column compares exactly. SQLite receives its digits and applies the column's affinity, as it does to a literal. MySQL receives an unsigned integer. Compare a text column with the digits as a string: Postgres refuses to compare text with a number.

The rest of the WHERE vocabulary uses the Laravel names:

// Lists, and NULL checks.
DB::table("users").where_in("role", ["admin", "editor"]).get().await?;
DB::table("users").where_not_in("id", [1i64, 2]).get().await?;
DB::table("users").where_null("deleted_at").get().await?;
DB::table("users").where_not_null("verified_at").get().await?;

// Two columns compared, with no value.
DB::table("orders").where_column("shipped_at", "paid_at").get().await?;
DB::table("orders").where_column_op("shipped_at", ">", "paid_at").get().await?;

// A raw fragment. Write each value as `?` and pass it in the bindings;
// Postgres gets `$N` markers numbered for their place in the statement.
DB::table("orders")
    .where_raw("total * ? > budget", vec![1.2.into()])
    .get()
    .await?;

An empty where_in list matches no row, and an empty where_not_in list excludes none.

You pass inclusive bounds with where_between(column, low..=high) or where_not_between(column, low..=high). The or_where_between and or_where_not_between forms keep the same flat OR precedence. You can use a plain column, DB::raw("score + 1"), or a scalar DB::table subquery as the column. Plain names still pass identifier validation; your subquery's values stay bound.

OR conditions

Each or_* method adds a flat OR condition. You get SQL precedence: where(a).or_where(b).where(c) means a OR b AND c, and AND binds before OR. You use a grouped helper when comparisons must stay together:

// WHERE active = ? AND role IN (?, ?) OR invited_by IS NOT NULL
DB::table("users")
    .filter("active", true)
    .where_in("role", ["admin", "editor"])
    .or_where_not_null("invited_by")
    .get()
    .await?;

The or_ family is or_where, or_where_op, or_where_in, or_where_not_in, or_where_null, or_where_not_null, or_where_raw, or_where_between, or_where_not_between, and the three grouped helpers below.

One comparison across several columns

where_any compares several columns with one operator and value, and matches when any of them does. where_all matches when every column does, and where_none when none does:

// WHERE (code LIKE ? OR description LIKE ?)
let found = DB::table("products")
    .where_any(["code", "description"], "like", format!("%{search}%"))
    .get()
    .await?;

// WHERE is_admin = ? AND NOT (banned = ? OR suspended = ?)
let staff = DB::table("users")
    .filter("is_admin", true)
    .where_none(["banned", "suspended"], "=", true)
    .get()
    .await?;

The comparisons sit in parentheses, so an OR inside never reaches the conditions around it: filter("a", 1).where_any(["b", "c"], "=", 2) returns only rows whose a is 1. or_where_any, or_where_all, and or_where_none add the parenthesized group with a flat OR. An empty column list adds no condition.

Subqueries

where_in, where_not_in, and their or_ forms also take another DB::table builder, which runs as a subquery. where_exists and where_not_exists take one too, and the subquery can refer to the outer table through where_column:

// Rooms with a slot on Monday.
let booked = DB::table("rooms")
    .where_in("id", DB::table("slots").select(["room_id"]).filter("day", "mon"))
    .get()
    .await?;

// Users who have written a post.
let authors = DB::table("users")
    .where_exists(
        DB::table("posts")
            .select_raw("1")
            .where_column("posts.author_id", "users.id"),
    )
    .get()
    .await?;

A subquery's values stay bound parameters, in the position they take in the statement. On Postgres the $N numbers continue through the subquery.

Byte-exact comparison

where_binary compares the raw bytes of a column instead of matching under its collation, so "Alice" does not match "alice" or "ALICE":

DB::table("users").where_binary("email", submitted).get().await?;
DB::table("users").where_not_binary("email", submitted).get().await?;

This is a MySQL and MariaDB feature - it emits their binary operator modifier, email = binary ?. Postgres and SQLite have no equivalent, so on those backends every terminal returns an error when the statement renders, before any query runs. Suprnova refuses rather than falling back to a plain =, because a fallback would compare under the column's collation and return rows you asked to exclude.

If you need case-sensitive matching on Postgres or SQLite, set a case-sensitive collation on the column, or use DB::select with a backend-specific expression.

Selecting columns

// Default is SELECT *.
DB::table("users").get().await?;

// Restrict columns when you only need some.
DB::table("users").select(["id", "email"]).get().await?;

// An alias renames a column in the result; `table.*` selects one
// table's columns, which matters once a query joins another table.
DB::table("users").select(["users.*", "email as login"]).get().await?;

// Add an expression with select_raw, and group the rows.
DB::table("orders")
    .select(["status_id"])
    .select_raw("COUNT(*) AS total")
    .group_by("status_id")
    .get()
    .await?;

select replaces the list each time you call it. select_raw adds an expression to the end of the list, so it combines with select. The expression is written into the query as given, so never build it from request data.

You use DB::raw(expression) when SQL must evaluate an expression in group_by, having or having_op. You write trusted SQL there; the expression is never a bound value:

let rows = DB::table("orders")
    .select_raw("status_id, SUM(total) AS total")
    .group_by(DB::raw("status_id + 0"))
    .having_op(DB::raw("SUM(total)"), ">", 100i64)
    .get().await?;

You keep application values in bindings with select_raw_with_bindings(sql, Vec<sea_orm::Value>), where_raw(sql, bindings) and order_by_raw(sql, bindings). You write portable ? markers; Postgres receives correctly numbered $N markers. On a model query, you use order_by_raw_with_bindings for a bound ordering and keep order_by_raw(sql) for SQL without bindings.

Joins

join, left_join, and right_join take the table and one ON condition between two columns. To join a table under an alias, write "table as alias". cross_join takes the table; cross_join_with also takes a closure whose conditions become its ON clause. This query lists every post with its category and author:

let rows = DB::table("posts")
    .left_join("categories", "categories.id", "=", "posts.category_id")
    .left_join("users as authors", "authors.id", "=", "posts.author_id")
    .select([
        "posts.title",
        "categories.name as category_name",
        "authors.name as author_name",
    ])
    .order_by_asc("posts.title")
    .get()
    .await?;

for row in rows.iter() {
    let author: Option<String> = row.get_optional_string("author_name")?;
}

The rows come back as DynamicRow. Columns that the joined tables share, such as id or name, overwrite one another in the row, so alias them in select.

For more than one condition, join_with, left_join_with, and right_join_with, and cross_join_with pass a JoinClause to a closure. on and or_on compare two columns. db_where, db_where_op, or_where, and or_where_op compare a column with a value, as Laravel's where and orWhere do in a join closure. filter, filter_op, or_filter, and or_filter_op do the same:

// INNER JOIN users ON users.id = posts.author_id
//   AND posts.views > ? OR users.role = ?
let rows = DB::table("posts")
    .join_with("users", |join| {
        join.on("users.id", "=", "posts.author_id")
            .db_where_op("posts.views", ">", 100)
            .or_where("users.role", "editor")
    })
    .get()
    .await?;

A join condition's value is a bound parameter, like every other value. You get the same flat AND and OR precedence in an ON clause as in a WHERE clause. A join other than cross_join needs at least one condition: the query fails with an error before it runs otherwise.

To join a subquery, pass another DB::table builder and an alias to join_sub or left_join_sub. join_sub_with and left_join_sub_with take a closure instead of one condition. This query counts each status's orders, with zero for a status that has none:

let totals = DB::table("orders")
    .select(["status_id"])
    .select_raw("COUNT(*) AS total")
    .group_by("status_id");

let rows = DB::table("statuses")
    .left_join_sub(totals, "order_totals", "order_totals.status_id", "=", "statuses.id")
    .select(["statuses.name"])
    .select_raw("COALESCE(order_totals.total, 0) AS total")
    .get()
    .await?;

The subquery's values bind ahead of the values of the conditions and WHERE clauses after it, so the order you call the methods in doesn't matter.

get and count record a read of every table the query names, including joined tables and the tables a subquery names, so a cached page that ran the query is invalidated when any of them changes. A select_raw, where_raw, or or_where_raw fragment can read a table the builder doesn't name, so a query that carries one anywhere, subqueries included, keeps the page out of the cache: the response is still served, but it is never stored. A select_raw that is a bare number, such as the select_raw("1") in an EXISTS subquery, reads nothing and doesn't count. See Render cache.

Ordering and windowing

DB::table("posts")
    .order_by_desc("created_at")
    .order_by_asc("title")
    .limit(20)
    .offset(40)
    .get()
    .await?;

order_by_desc and order_by_asc chain in insertion order; the generated SQL preserves it.

reorder() drops every ordering set so far, and reorder_by(col, Direction::Asc) drops them and orders by col instead. Use it on a base query before you reuse it as a subquery:

use suprnova::Direction;

let recent = DB::table("slots").select(["room_id"]).order_by_desc("starts_at");
let rooms = DB::table("rooms")
    .where_in("id", recent.reorder())
    .reorder_by("name", Direction::Asc)
    .get()
    .await?;

You call oldest() to order a table by created_at ascending, or oldest_by(column) to choose another column, expression or scalar subquery. On a model query, oldest() and latest() use your model's declared creation timestamp, including a renamed created_at option. You override it with oldest_by(column) or latest_by(column).

You call in_random_order() on either builder to order rows randomly. MySQL orders by RAND(), and Postgres and SQLite order by RANDOM().

When you need the same order again, for example to page through one shuffled list, you call in_random_order_seeded(seed) instead. On MySQL, you get RAND(seed). On Postgres, you set the connection seed in the same statement before ordering by random(). Repeating the seed on the same rows and connection repeats the order on those engines. On SQLite, RANDOM() cannot be seeded. Both builders order by an expression derived from the seed and each row's rowid, then by the rowid itself, so the same seed repeats the order on the same rows, whatever type your primary key has. A model query carries the rowid through a union: each query of the union adds it to its selected columns, so the union and its pages repeat the order too, and UNION removes only copies of the same row. Every table a migration creates has a rowid. A view or a WITHOUT ROWID table has none, and a seeded order on it returns the engine's error. You still receive each matching row once.

let sample = DB::table("posts").in_random_order().limit(5).get().await?;
let shuffled = DB::table("posts")
    .in_random_order_seeded(42)
    .limit(20)
    .get()
    .await?;

Terminals

// All matching rows.
let rows: Collection<DynamicRow> = DB::table("audit_log")
    .filter("actor_id", 42i64)
    .get()
    .await?;

// First row or None.
let first: Option<DynamicRow> = DB::table("audit_log")
    .filter("event", "user.deleted")
    .first()
    .await?;

// Just the count (drops any select list and ordering).
let n: u64 = DB::table("audit_log")
    .filter("actor_id", 42i64)
    .count()
    .await?;

You call first_or_fail() when a missing row must produce a 404. The default message names the table or model. You supply your own message with first_or_fail_with(message) on either builder; database failures still return their original error.

You read the largest value with max::<i64>(column).await?, choosing the Rust type your column uses. You receive None for an empty set or SQL NULL, and an error if the value cannot decode into your chosen type. You can pass a plain column, DB::raw(expression) or a scalar subquery.

You inspect a table query's SQL with to_sql_for(backend). You receive Result<(String, Vec<sea_orm::Value>), FrameworkError> and no query runs.

On a query with group_by, count() returns the number of groups.

A limit or an offset stays on the count, as in Laravel, where it bounds the one row the count returns rather than the rows it counts. limit(10).count() counts every match, and offset(1).count() returns 0, because the offset skips the count's only row.

get() returns Collection<DynamicRow> - the same collection wrapper typed models use, with the same .iter(), .len(), .into_vec() surface. See Eloquent Collections.

Inserts, updates, deletes

use suprnova::attrs;

// INSERT, returns the new row's auto-increment id.
let id: i64 = DB::table("audit_log")
    .insert(attrs! { event: "user.created", actor_id: 42 })
    .await?;

// UPDATE, returns rows affected.
let updated: u64 = DB::table("audit_log")
    .filter("id", id)
    .update(attrs! { event: "user.created.v2" })
    .await?;

// DELETE, returns rows affected.
let deleted: u64 = DB::table("audit_log")
    .filter("actor_id", 42i64)
    .delete()
    .await?;

The attrs! macro builds the column-to-value map at the call site. Keys are SQL identifiers (validated) and values are bound as parameters. An explicit null is emitted as SQL NULL because the JSON attribute map no longer carries its original Rust type; all non-null values remain parameter-bound. The same rule applies to typed Eloquent mass writes and many-to-many pivot extras.

You mix raw assignments and bound values with UpdateAttrs on either builder. You keep attrs! for updates containing only data:

use suprnova::{DB, UpdateAttrs};

let mut changes = UpdateAttrs::new();
changes.insert("views", DB::raw("views + 1"));
changes.insert("status", "published");
DB::table("posts").filter("id", 42i64).update(changes).await?;

You can also pass an array for one kind of assignment: update([("views", DB::raw("views + 1"))]). You validate assignment column names as usual. Raw expressions contain trusted SQL and reach the database unbound; you never build them from request data.

A u64 above i64::MAX is written as the number it is, and no column stores a rounded value of it. A numeric or text column stores it exactly on every database, and a floating-point column stores the nearest float, as it does for any number. An integer column refuses it: MySQL's unsigned columns store it, but a signed integer column refuses it on every database. SQLite would store it in an INTEGER or NUMERIC column as a rounded REAL, so there insert and update read the column's affinity first and refuse the write before it is sent. Every refusal is a database error: a client gets the generic 500 response and the log gets the detail.

update_all and delete_all aliases

update and delete are the Laravel-faithful names. The Builder<M>-style aliases - update_all and delete_all - call the same implementation. Prefer the _all form when the table-wide intent is the point of the call site; it makes a missing filter visible to reviewers:

// Same behaviour as DB::table("rate_limits").delete().await? but the
// _all suffix tells reviewers "yes, I meant to truncate the table".
DB::table("rate_limits").delete_all().await?;

// Mass update with a WHERE - the _all suffix here matches the typed
// Builder<M> convention for the same operation.
DB::table("sessions")
    .filter_op("expires_at", "<", chrono::Utc::now())
    .update_all(attrs! { status: "expired" })
    .await?;

Empty WHERE on update or delete operates on every row

DB::table("x").delete().await? removes every row in the table. That is supported by design - sometimes you really do want to truncate - but it's rarely correct. Always look at a delete() / delete_all() call and check whether there's a filter in front of it. The same is true of update / update_all.

update and delete keep every join and predicate when selecting rows to change. You write columns on the builder's base table; joined tables only constrain the match. Both methods return the affected base-row count.

Insert backend split

RETURNING id is used on Postgres and SQLite. MySQL doesn't support RETURNING, so the builder runs the INSERT and reads the driver's per-connection last_insert_id() from the result. The model-less builder assumes a standard id auto-increment primary key. UUID, composite, renamed, or non-integer primary keys aren't supported on this surface - use the typed Eloquent Model interface instead, which consults the model definition for primary-key shape.

DynamicRow - typed accessors over a JSON map

Every row returned by DB::table or DB::select materialises as DynamicRow, a serde_json::Map<String, Value> newtype with typed accessors. Each getter returns Result<T, FrameworkError> with a clear error message on missing key or type mismatch.

for row in rows.iter() {
    let id: i64                 = row.get_int("id")?;
    let event: String           = row.get_string("event")?;
    let active: bool            = row.get_bool("active")?;
    let weight: f64             = row.get_float("weight")?;
    let payload: serde_json::Value = row.get_value("payload")?;
}

For nullable columns, use get_optional_*. These distinguish "column missing" (error - schema mismatch) from "column present, value SQL NULL" (Ok(None)):

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

The optional family covers String and i64. For other nullable types, use get_value and match on serde_json::Value::Null yourself, or read the column through get_as::<Option<T>> (any T: DeserializeOwned).

To deserialise a column into any struct or container type, use get_as. The full serde_json deserialisation surface is available:

#[derive(serde::Deserialize)]
struct UserPrefs {
    theme: String,
    notifications: bool,
}

let prefs: UserPrefs    = row.get_as("prefs")?;
let tags: Vec<String>   = row.get_as("tags")?;
let when: chrono::DateTime<chrono::Utc> = row.get_as("created_at")?;

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

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

if row.contains_key("deleted_at") { /* … */ }

Identifier trust boundary

Table names, column names, aliases, ORDER BY directions, and SQL operators are written into the SQL string - they are NOT bound as parameters (SQL doesn't allow placeholder-bound identifiers). Treat every impl Into<String> argument as a trusted, compile-time literal.

// Safe - the column name is a constant; the value is bound.
DB::table("users").filter("email", request.email()).get().await?;

// UNSAFE - never splice user input into a column name.
DB::table("users")
    .filter(request.user_supplied_column(), value)
    .get()
    .await?;

The framework enforces a strict allowlist at the I/O boundary - identifiers must match [A-Za-z_][A-Za-z0-9_]* with one optional schema. prefix, and operators must come from a fixed list. Violations fail closed with a FrameworkError::Database before any SQL is rendered. The builder then quotes every identifier for the backend: backticks on MySQL and MariaDB, double quotes on Postgres and SQLite. That's a safety net, not a license: keep identifiers literal in your code. select_raw and where_raw fragments are written as given and never checked.

Values on the right-hand side of filter / filter_op, in where_in lists, in join conditions, and in raw-fragment bindings are always bound as parameters and safe to splice through from request data.

Raw queries

When the builder can't express what you need - recursive CTEs, window functions, backend-specific DDL, INSERT … ON CONFLICT DO UPDATE - drop to a raw string. Placeholders match the active backend ($1, $2, … for Postgres, ? for MySQL and SQLite); the framework auto-detects from DatabaseConfig::url.

use suprnova::DB;
use sea_orm::Value;

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

// SELECT - first row only, mirrors Laravel's DB::selectOne.
let alice = DB::select_one(
    "SELECT * FROM users WHERE email = ?",
    vec![Value::from("alice@example.com")],
).await?;

// SELECT - first column of first row as a typed scalar.
let total: i64 = DB::scalar(
    "SELECT COUNT(*) FROM users WHERE active = ?",
    vec![Value::from(true)],
).await?;

// INSERT - true when at least one row was affected.
DB::insert(
    "INSERT INTO users (name, active) VALUES (?, ?)",
    vec![Value::from("bob"), Value::from(true)],
).await?;

// UPDATE / DELETE - return the rows-affected count.
let updated: u64 = DB::update(
    "UPDATE users SET active = ? WHERE id = ?",
    vec![Value::from(false), Value::from(1i64)],
).await?;

let deleted: u64 = DB::delete(
    "DELETE FROM users WHERE active = ?",
    vec![Value::from(false)],
).await?;

// Any prepared statement with bindings.
DB::statement(
    "UPDATE users SET votes = votes + ? WHERE id = ?",
    vec![Value::from(1i64), Value::from(42i64)],
).await?;

// DDL or other no-binding statements that reject placeholder binding.
DB::unprepared("CREATE INDEX idx_users_name ON users(name)").await?;

// Generic "rows affected" path - for upserts and operations that
// don't fit the named helpers.
let n: u64 = DB::affecting_statement(
    "INSERT INTO counters (k, n) VALUES ($1, 1)
     ON CONFLICT (k) DO UPDATE SET n = counters.n + 1",
    vec![Value::from("page_views")],
).await?;

Computed columns on SQLite

SQLite declares no type for a computed column: COUNT(*) AS n, COALESCE(t.total, 0) AS total, or any other select_raw expression. Rows from DB::table and DB::select still carry the column. Suprnova reads each such value by its runtime type, so an integer comes back as a JSON integer, a real as a JSON number, text as a string, and NULL as null:

let rows = DB::select(
    "SELECT actor_id, COUNT(*) AS n FROM audit_log GROUP BY actor_id",
    vec![],
).await?;
let n: i64 = rows[0].get_int("n")?;

Bridge to typed Eloquent

When the table is worth a #[suprnova::model] struct, the chainable shape carries over. Model::query() returns Builder<M>, which ships the same filter / filter_op / order_by_* / limit / offset / get / first / count surface - plus a much wider WHERE vocabulary (filter_in, filter_between, filter_null, filter_has, filter_raw, …) and Laravel-shape aliases (db_where, where_in, where_between, where_null, where_has, where_raw, …).

use suprnova::Model;

let admins = User::query()
    .filter("role", "admin")
    .filter_op("created_at", ">=", since)
    .order_by_desc("created_at")
    .limit(20)
    .get()
    .await?;     // Collection<User> - typed, not DynamicRow

let alice = User::query().filter("email", &email).first().await?;
let total = User::query().filter("active", true).count().await?;
// Note: Builder<M>::count returns i64 (matches Laravel's Eloquent),
// whereas DbTableBuilder::count returns u64. Both surfaces give you a
// non-negative SQL COUNT - they only differ in their wire type.

The full Builder<M> surface - every WHERE shape, aggregates, relations, eager loading, scopes, paginators, chunk iteration - is in Eloquent. The chainable shape you learned above is the same shape; the differences are typing and reach. The joins, the grouped where_any family, the or_ helpers, subqueries, and reorder work the same way on both builders, and a model query takes a DB::table builder as its subquery.

Routing to a named connection

DB::table and the raw helpers default to the primary connection. To target a read replica, shard, or warehouse pool, pin the call:

// Builder pinned to a named connection.
let rows = DB::table("audit_log").on("warehouse").get().await?;

// Equivalent shorthand.
let rows = DB::table_on("warehouse", "audit_log").get().await?;

// Raw escapes have _on variants too.
let rows = DB::select_on("warehouse", "SELECT …", vec![]).await?;
let n    = DB::affecting_statement_on(
    "warehouse",
    "UPDATE …",
    vec![],
).await?;

When __read_replica__ is registered, every read-shape terminal auto-routes through it; writes (insert / update / delete / update_all / delete_all) always target the primary. Inside a DB::transaction closure the active transaction's connection wins absolutely - on(name) is silently ignored to preserve atomicity. See Database - Named connections for the full precedence chain.

A default for one task

Use DB::with_default_connection(name, future) when several queries need one named pool without repeating .on(name):

let rows = DB::with_default_connection("reporting", async {
    assert_eq!(DB::default_connection(), "reporting");
    DB::table("audit_log").get().await
}).await?;

You route default DB calls, direct DB::connection() access, and model queries to that pool. Explicit query or model connections keep their precedence. You start new transactions on that pool; an already active transaction stays on its original pool. Your scoped default takes precedence over an automatic read replica.

You keep each concurrent task's default isolated. Nested scopes restore your outer default on return, error, panic or cancellation. A task you spawn starts with its own default. Outside a scope, DB::default_connection() returns PRIMARY_CONNECTION_NAME ("__primary__"). An unknown connection fails before your future runs.

Stored keys for chunk cursors

When you call a model query's chunk_by_id(size, closure), you advance from the key as the database stores it. A model cast can change the key's value in the model you receive, but it does not change the next chunk's cursor. You see every matching stored row once, in stored-key order. You receive an error for a zero chunk size or a failing closure.

Why Suprnova diverges

Laravel's DB::table(...) is its model-less query builder; under the hood it returns a stdClass per row (a PHP object whose properties are the columns). Suprnova returns DynamicRow instead - a serde_json::Map newtype with typed accessors. The accessor shape catches missing-column and wrong-type errors at the boundary instead of panicking deep in user code with a property-access exception.

The dual update/update_all and delete/delete_all names exist because the typed Eloquent Builder<M> surface uses the _all suffix to make table-wide intent explicit at the call site. Rather than pick a side, the model-less builder ships both - update and delete match Laravel's DB::table($t)->update(...) and ->delete() letter for letter; update_all and delete_all match the convention M users will already have in their muscle memory.

where_binary returns an error on Postgres and SQLite where Laravel throws a RuntimeException from the base grammar. The reason is the same and only the mechanism differs: public-surface code in Suprnova returns Result rather than panicking, so the refusal arrives as an Err from the terminal instead of an exception from the grammar.

Laravel's join and whereExists also take a closure that builds the subquery, an Eloquent builder, or a raw expression. Here a subquery is always a DB::table builder, and a join's closure only adds conditions. The column-list helpers take the operator every time - where_any(cols, "=", value) - because Rust has no optional arguments. You use where_column_op(a, op, b) for a column comparison with an operator, because Rust cannot overload where_column(a, b) by arity.

Next

  • Database - DB facade, transactions with savepoints, DB::listen observability, named connections
  • Eloquent - typed #[suprnova::model] structs and the full Builder<M> surface
  • Pagination - paginate / simple_paginate / cursor_paginate on typed builders
  • Eloquent Collections - the Collection<T> returned by get() on both surfaces
  • Migrations - defining the schema the builders query