Suprnova's database layer wraps SeaORM with a Laravel-shaped DB facade:
raw query escapes, a model-less query builder, transactions with
savepoints and retry-on-deadlock, connection registry for read replicas
and shards, and a full observability surface that mirrors Laravel 13's
DB::listen / QueryExecuted / query log API.
The Eloquent ORM (use suprnova::eloquent::*) builds on top of this
layer and lives in eloquent.md. When you want a typed
model, go there; when you want a raw query against an unmodeled table
or want to observe every query the framework runs, this is the page.
Configuration
use ;
// In bootstrap.rs
register;
DBinit.await.expect;
DatabaseConfig::from_env reads DATABASE_URL and (optionally) the
pool tunables DB_MAX_CONNECTIONS, DB_MIN_CONNECTIONS,
DB_CONNECT_TIMEOUT, DB_LOGGING. When DATABASE_URL is unset the
config falls back to sqlite://./database.db - convenient for
zero-setup development; production boots refuse the fallback via
validate_for_environment so you can't accidentally ship a SQLite
file in APP_ENV=production.
URL → driver detection:
postgres://user:pass@host/db → DatabaseType::Postgres
postgresql://user:pass@host/db → DatabaseType::Postgres
mysql://user:pass@host/db → DatabaseType::Mysql
sqlite://./file.db → DatabaseType::Sqlite
sqlite::memory: → DatabaseType::Sqlite
Raw queries
The DB facade ships the full Laravel 13 raw escape surface. Every
helper goes through the same instrumented executor - every call fires
QueryExecuted (see Observability).
Bindings are sea_orm::Value - one of the few sea_orm types the
framework intentionally does NOT re-mask, because every value that hits
the wire goes through it. Value::from(...) works for every primitive
the database understands.
use DB;
use Value;
// SELECT - all rows as DynamicRow.
let users = DBselect.await?;
// SELECT - first row only.
let alice = DBselect_one.await?;
// SELECT - first column of first row as a typed value.
let count: i64 = DBscalar.await?;
// INSERT - returns bool (true when at least one row was affected).
DBinsert.await?;
// UPDATE / DELETE - return the rows-affected count.
let updated = DBupdate.await?;
let deleted = DBdelete.await?;
// Any prepared statement with bindings.
DBstatement.await?;
// DDL with no bindings - `unprepared` mirrors Laravel's
// `DB::unprepared` for statements (CREATE INDEX, ALTER TABLE, VACUUM)
// that reject placeholder binding.
DBunprepared.await?;
// affecting_statement is the explicit form used by update/delete
// internally - drop to it directly for ops that don't fit either name
// (e.g. INSERT...ON CONFLICT DO UPDATE).
let affected = DBaffecting_statement.await?;
Placeholder syntax
? for SQLite + MySQL. $1, $2, ... for Postgres. The active
backend is auto-detected from DatabaseConfig::url.
DynamicRow
Untyped rows materialise as DynamicRow - a serde_json::Map newtype
with typed accessors:
for row in users
get_* errors when the column is absent OR null. get_optional_*
errors only when absent and returns Ok(None) for SQL NULL. The full
accessor list is get_int / get_string / get_bool / get_float /
get_value / get_as<T> plus get_optional_string /
get_optional_int; for nullable types without a dedicated
get_optional_* reach for get_value + a serde_json::Value match,
or get_as::<Option<T>>.
Model-less query builder - DB::table
For ad-hoc queries against tables you haven't bothered to model with
#[suprnova::model], DB::table(...) returns a chainable builder
shaped like the Eloquent Builder<M> but materialising rows as
DynamicRow:
use ;
let rows = DBtable
.select
.filter
.filter_op
.order_by_desc
.limit
.get
.await?;
let first = DBtable
.filter
.first
.await?;
let count = DBtable
.filter
.count
.await?;
let id = DBtable
.insert
.await?;
let updated = DBtable
.filter
.update
.await?;
let deleted = DBtable
.filter
.delete
.await?;
Trust boundary on identifiers
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 literal:
// Safe - the column name is a constant.
DBtable.filter.get.await?;
// UNSAFE - never splice user input into a column name.
DBtable.filter.get.await?;
Values (the right-hand side of filter / filter_op) ARE bound as
parameters and safe for user input.
The framework enforces a strict allowlist on identifiers
([A-Za-z_][A-Za-z0-9_]* with one optional schema. prefix) and
operators (=, <>, <, <=, >, >=, LIKE, NOT LIKE,
ILIKE, NOT ILIKE, IS, IS NOT). Violations error at the I/O
boundary before the SQL string is rendered.
Transactions
Three entry points, each with the QueryExecuted /
TransactionBeginning / TransactionCommitted /
TransactionRolledBack observation hooks wired in.
Closure form
use DB;
DBtransaction.await?;
Commit on Ok(_). Rollback + propagate the error on Err(_).
Operations inside the closure automatically pick up the active
transaction via a tokio::task_local - you do NOT have to thread a
&tx handle through every model call. Nested DB::transaction
returns a database error; use tx.savepoint(...) for nested-rollback
behaviour.
For typed aggregate or custom SQL that must execute on the same pinned connection, use the transaction handle directly:
use ;
DBtransaction.await?;
query_all emits normal QueryExecuted observations and returns typed
SeaORM QueryResult rows. Use bound Statement::from_sql_and_values for
dynamic values; do not interpolate untrusted input.
Retry on deadlock
DBtransaction_with_attempts.await?;
Manual form
use ;
let tx = DBbegin_transaction.await?;
// Per-model: the `*_with_tx` shims pin one CRUD op to the manual tx.
create_with_tx.await?;
create_with_tx.await?;
// Per-query: `Builder::with_tx(&tx)` pins a builder chain.
let stale = query
.filter
.with_tx
.get
.await?;
if some_condition else
Manual mode does NOT install the task-local - every operation that
should run inside the transaction has to opt in, either via
Builder::with_tx(&tx) on a chained query or one of the
Model::*_with_tx shims (create_with_tx, save_with_tx,
delete_with_tx, etc.). Operations that forget to opt in run against
the global pool and are NOT part of the transaction.
Holding a Transaction handle pins one pool connection for its
lifetime; pre-load any rows you need to read BEFORE the
begin_transaction() call, especially on SQLite (single shared
connection).
Savepoints
DBtransaction.await?;
All three first-class backends support SAVEPOINT / ROLLBACK TO SAVEPOINT - SQLite included.
Observability
Laravel 13's DB::listen / QueryExecuted / query log surface, ported
to Rust through Suprnova's event dispatcher.
DB::listen - direct callback
use ;
// In bootstrap.rs (or a service provider).
DBlisten?;
Listeners run synchronously inside the executor helper. A slow
listener slows the query - keep direct callbacks light. For anything
that can fail, prefer the EventFacade path below; it runs through
dispatch_best_effort and tolerates errors.
EventFacade dispatch path
QueryExecuted is a real suprnova::Event - listen through the
dispatcher to get queued, fakeable, fail-tolerant delivery:
use ;
use Arc;
;
// In bootstrap.rs.
.await;
Listeners on this path:
- Run through
dispatch_best_effort- a failing listener does NOT fail the query. - Are short-circuited when they themselves issue a query (re-entrancy guard).
- Can use
Event::fake()in tests to assert dispatch without actually running listeners.
In-memory query log
DBenable_query_log?;
query.filter.get.await?;
query.count.await?;
let log = DBget_query_log?;
for query in &log
DBflush_query_log?; // drop entries, keep enabled
DBdisable_query_log?; // stop capturing
let still_capturing = DBlogging;
The log is unbounded - every captured query grows it until the
process exits, flush_query_log() runs, or disable_query_log() is
called. Use it for development, not as a long-running production
profiler.
Transaction lifecycle events
TransactionBeginning, TransactionCommitted, and
TransactionRolledBack are real suprnova::Event types - listen for
them through EventFacade::listen to drive auditing, distributed
locks, or compensation logic.
.await;
.await;
All three transaction entry points
(DB::transaction / DB::transaction_with_attempts /
DB::begin_transaction + Transaction::commit/rollback) fire the
events. A leaked manual Transaction handle that gets dropped without
explicit commit/rollback emits no event - SeaORM's Drop impl is
synchronous and can't reach the async dispatcher.
QueryExecuted payload
to_raw_sql() substitutes the captured bindings into the SQL for
display:
let query = /* captured from a listener */;
println!;
// SELECT * FROM users WHERE id = 42 AND active = true
The substitution is debug-format (not SQL-safe escaping) and is intended for log output only. Never feed the result back into a query.
Coverage scope
Today, QueryExecuted fires for every query that goes through the
instrumented ExecutorChoice helpers:
- Every raw helper on
DB(select/select_one/scalar/insert/update/delete/statement/affecting_statement/unprepared). - Every terminal method on
DbTableBuilder(the model-less builder). DB::transaction/DB::begin_transactionBEGIN / COMMIT / ROLLBACK fire transaction events.DbConnection::connectfiresConnectionEstablished.
The Eloquent ORM (Builder<M>::get / first / count, model CRUD)
matches the ExecutorChoice Tx / Pool arms directly today rather
than calling through the instrumented helpers - adopting the helpers
(and therefore the observation hook) lands in the Eloquent module.
Connection metadata
let name = DBdatabase_name?; // "myapp" for postgres://.../myapp
let driver = DBdriver_name?; // "postgres" | "mysql" | "sqlite"
let title = DBdriver_title?; // "Postgres" | "MySQL" | "SQLite"
let version = DBserver_version.await?; // "15.5" | "8.0.36" | "3.42.0"
server_version issues a backend-specific introspection query
(SELECT VERSION() for Postgres + MySQL, SELECT sqlite_version()
for SQLite). Cache the result if you call it often - every call is a
round trip.
Named connections
For read replicas, sharded shards, or per-model warehouse pools:
// In bootstrap.rs
DBregister_named.await?;
DBregister_named.await?;
// Per-query routing:
let rows = query.on.get.await?;
let warehouse_rows = DBtable.on.get.await?;
let raw = DBselect_on.await?;
The __read_replica__ name is well-known: when registered, every
read-shape terminal method auto-routes through it. Writes ignore the
replica and target the primary. Use Builder::on_write_connection
(per query) or #[model(connection = "...")] (per model default) to
opt back to the primary for specific operations.
Reserved names:
__primary__- the default pool. Cannot be registered (it's the return value ofDB::connection()).__read_replica__- well-known read replica. ANY connection registered under this name takes over read routing.
See eloquent.md → Multi-connection routing for the
full precedence chain (builder tx override → ambient tx → builder
on(name) → model default → __read_replica__ → primary).
Testing
TestDatabase builds an in-memory SQLite database, registers it in
the test container so DB::connection() resolves to it, and runs your
migrations:
use TestDatabase;
use crateMigrator;
async
// `test_database!()` is the macro shortcut.
let db = test_database!;
For tests that build their own ad-hoc schema:
let db = sqlite_memory.await.unwrap;
db.execute_unprepared.await.unwrap;
When a TestDatabase is dropped, the test container is cleared and
the connection registry is wiped - no cross-test leakage. Tests that
mutate process-wide state (the registry, the listener registry, the
query log) should be annotated #[serial_test::serial] so they don't
collide.
Next
- Eloquent - the typed
#[suprnova::model]ORM that sits on top of this layer - Migrations -
Migrator,make:migration, and thedb:syncworkflow - Database Testing -
TestDatabase, fixture loading, and serial-test annotations - Events - the dispatcher behind
QueryExecuted/TransactionCommittedlisteners - Configuration - registering
DatabaseConfigalongside the rest of your typed config
Surface index
| Surface | Laravel analogue |
|---|---|
DB::init / DB::init_with / DB::connection / DB::is_connected / DB::get |
DB::connection() |
DB::table(name) → DbTableBuilder |
DB::table($name) |
DB::select / select_one / scalar / insert / update / delete / statement / affecting_statement / unprepared |
DB::select / selectOne / scalar / insert / update / delete / statement / affectingStatement / unprepared |
DB::transaction / transaction_with_attempts / begin_transaction |
DB::transaction($cb, $attempts) / DB::beginTransaction |
Transaction::commit / rollback / savepoint / rollback_to |
DB::commit / rollBack / savepoint helpers |
DB::listen(callback) |
DB::listen |
DB::enable_query_log / disable_query_log / get_query_log / flush_query_log / logging |
DB::enableQueryLog / disableQueryLog / getQueryLog / flushQueryLog / logging |
DB::database_name / driver_name / driver_title / server_version |
getDatabaseName / getDriverName / getDriverTitle / getServerVersion |
DB::register_named / named / select_on / table_on / statement_on / affecting_statement_on |
multi-connection DB::connection($name) |
QueryExecuted / TransactionBeginning / TransactionCommitted / TransactionRolledBack / ConnectionEstablished / DatabaseBusy |
Illuminate\Database\Events\* |
DatabaseConfig::builder() / from_env / validate_for_environment |
config/database.php |
TestDatabase::fresh::<M> / sqlite_memory / execute_unprepared / fetch_one / fetch_all |
RefreshDatabase testing trait |
