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 DB;
let rows = DBtable
.select
.filter
.filter_op
.order_by_desc
.limit
.get
.await?;
for row in rows.iter
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.
DBtable.db_where.get.await?;
// Arbitrary operator. Allowlist: =, <>, <, <=, >, >=, LIKE, NOT LIKE,
// ILIKE, NOT ILIKE, IS, IS NOT.
DBtable.filter_op.get.await?;
DBtable.filter_op.get.await?;
// Multiple filters AND together.
DBtable
.filter
.filter_op
.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.
DBtable.where_in.get.await?;
DBtable.where_not_in.get.await?;
DBtable.where_null.get.await?;
DBtable.where_not_null.get.await?;
// Two columns compared, with no value.
DBtable.where_column.get.await?;
DBtable.where_column_op.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.
DBtable
.where_raw
.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
DBtable
.filter
.where_in
.or_where_not_null
.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 = DBtable
.where_any
.get
.await?;
// WHERE is_admin = ? AND NOT (banned = ? OR suspended = ?)
let staff = DBtable
.filter
.where_none
.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 = DBtable
.where_in
.get
.await?;
// Users who have written a post.
let authors = DBtable
.where_exists
.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":
DBtable.where_binary.get.await?;
DBtable.where_not_binary.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 *.
DBtable.get.await?;
// Restrict columns when you only need some.
DBtable.select.get.await?;
// An alias renames a column in the result; `table.*` selects one
// table's columns, which matters once a query joins another table.
DBtable.select.get.await?;
// Add an expression with select_raw, and group the rows.
DBtable
.select
.select_raw
.group_by
.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 = DBtable
.select_raw
.group_by
.having_op
.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 = DBtable
.left_join
.left_join
.select
.order_by_asc
.get
.await?;
for row in rows.iter
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 = DBtable
.join_with
.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 = DBtable
.select
.select_raw
.group_by;
let rows = DBtable
.left_join_sub
.select
.select_raw
.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
DBtable
.order_by_desc
.order_by_asc
.limit
.offset
.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 Direction;
let recent = DBtable.select.order_by_desc;
let rooms = DBtable
.where_in
.reorder_by
.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 = DBtable.in_random_order.limit.get.await?;
let shuffled = DBtable
.in_random_order_seeded
.limit
.get
.await?;
Terminals
// All matching rows.
let rows: = DBtable
.filter
.get
.await?;
// First row or None.
let first: = DBtable
.filter
.first
.await?;
// Just the count (drops any select list and ordering).
let n: u64 = DBtable
.filter
.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 attrs;
// INSERT, returns the new row's auto-increment id.
let id: i64 = DBtable
.insert
.await?;
// UPDATE, returns rows affected.
let updated: u64 = DBtable
.filter
.update
.await?;
// DELETE, returns rows affected.
let deleted: u64 = DBtable
.filter
.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 ;
let mut changes = new;
changes.insert;
changes.insert;
DBtable.filter.update.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".
DBtable.delete_all.await?;
// Mass update with a WHERE - the _all suffix here matches the typed
// Builder<M> convention for the same operation.
DBtable
.filter_op
.update_all
.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
For nullable columns, use get_optional_*. These distinguish "column
missing" (error - schema mismatch) from "column present, value SQL
NULL" (Ok(None)):
let title: = row.get_optional_string?;
let score: = row.get_optional_int?;
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:
let prefs: UserPrefs = row.get_as?;
let tags: = row.get_as?;
let when: DateTime = row.get_as?;
DynamicRow derefs to Map<String, Value>, so iteration and
key-existence checks work directly:
for in row.iter
if row.contains_key
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.
DBtable.filter.get.await?;
// UNSAFE - never splice user input into a column name.
DBtable
.filter
.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 DB;
use Value;
// SELECT - every row as DynamicRow.
let rows = DBselect.await?;
// SELECT - first row only, mirrors Laravel's DB::selectOne.
let alice = DBselect_one.await?;
// SELECT - first column of first row as a typed scalar.
let total: i64 = DBscalar.await?;
// INSERT - true when at least one row was affected.
DBinsert.await?;
// UPDATE / DELETE - return the rows-affected count.
let updated: u64 = DBupdate.await?;
let deleted: u64 = DBdelete.await?;
// Any prepared statement with bindings.
DBstatement.await?;
// DDL or other no-binding statements that reject placeholder binding.
DBunprepared.await?;
// Generic "rows affected" path - for upserts and operations that
// don't fit the named helpers.
let n: u64 = DBaffecting_statement.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 = DBselect.await?;
let n: i64 = rows.get_int?;
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 Model;
let admins = query
.filter
.filter_op
.order_by_desc
.limit
.get
.await?; // Collection<User> - typed, not DynamicRow
let alice = query.filter.first.await?;
let total = query.filter.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 = DBtable.on.get.await?;
// Equivalent shorthand.
let rows = DBtable_on.get.await?;
// Raw escapes have _on variants too.
let rows = DBselect_on.await?;
let n = DBaffecting_statement_on.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 = DBwith_default_connection.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 -
DBfacade, transactions with savepoints,DB::listenobservability, named connections - Eloquent - typed
#[suprnova::model]structs and the fullBuilder<M>surface - Pagination -
paginate/simple_paginate/cursor_paginateon typed builders - Eloquent Collections - the
Collection<T>returned byget()on both surfaces - Migrations - defining the schema the builders query
