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.filter.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.
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?;
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.
Terminals
// All matching rows.
let rows: = DBtable
.filter
.get
.await?;
// First row or None.
let first: = DBtable
.filter
.first
.await?;
// Just the count (clears any select/order/limit/offset before
// rendering - count semantics don't care about those).
let n: u64 = DBtable
.filter
.count
.await?;
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.
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.
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, ORDER BY directions, and SQL operators are
interpolated into the SQL string verbatim - 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. That's a safety net, not a license: keep identifiers literal
in your code.
Values on the right-hand side of filter / filter_op 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?;
Aggregate-column gotcha
Untyped aggregates like SELECT COUNT(*) AS n FROM t work through the
builder's .count() helper but may come back silently dropped from
raw DB::select rows on SQLite. The underlying row materialiser walks
sqlx's per-column type info, and a bare aggregate carries none. If you
need raw DB::select with aggregates on SQLite, either wrap the
expression in CAST(… AS BIGINT) to give it a type tag, or use
DB::scalar::<i64> which goes through query_one + try_get and
doesn't depend on the per-column type detection.
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.
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.
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.
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
