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.
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.
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?;
Today 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.
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
