๐ Visit the Official Website & Documentation Hub ๐
Built on top of sqlx and procedural macros, Rullst ORM brings the delightful, fluent syntax of Active Record frameworks directly to the high-performance Rust ecosystem.
| Security Audit | Status | Description |
|---|---|---|
| OpenSSF Scorecard | Current public supply-chain practice score; not a security certification | |
| Release Provenance | Provenance attestations for release artifacts; no SLSA level is claimed here | |
| Codecov | Blocking 90% target for the measured framework-library scope; repository aggregate remains separately visible | |
| Matrix DB Tests | Live PostgreSQL, MySQL, MariaDB, MongoDB, SurrealDB and libSQL contracts, plus in-process DuckDB tests | |
| OpenSSF | Open source security standards | |
| Property tests | Scheduled/manual bounded invariant evidence | |
| Miri research matrix | Manual bounded evidence; the selected compatible scopes are strict, while native database FFI remains outside Miri | |
| Kani research harnesses | Manual, bounded formal evidence; not whole-ORM proof | |
| CodeQL SAST | Advanced semantic code analysis | |
| Cargo Deny | Banning unmaintained/vulnerable crates | |
| Cargo Audit | Continuous scanning for crate vulnerabilities | |
| Cargo SemVer | Strict SemVer API breakage checks | |
| Cargo Machete | Detecting unused and bloated dependencies | |
| On-demand fuzzing | Manual time-bounded targets; no continuous OSS-Fuzz claim | |
| Mutation Testing | Mutation testing for test suite robustness | |
| Continuous Benchmarks | Continuous performance regression testing & live dashboard | |
| Unsafe Policy | Audits unsafe usage within the workflow's declared scope | |
| Panic Policy | Graceful error handling across the framework |
๐ Why Rullst ORM?
In traditional Rust database handling, you have to write raw SQL queries, manage connection pools manually, and bind variables repetitively. Rullst ORM abstracts the heavy lifting behind a single #[derive(Orm)] macro, generating hundreds of safe, chainable query methods at compile time.
Key Features:
- Generated CRUD: Derive insert, update, delete, and find helpers from model metadata.
- Typed Fluent Query Builder: Generated primitive filters such as
.where_id(i32)and.where_name(impl Into<String>)reject mismatched value types at compile time; string-column methods validate identifiers at runtime, while explicitly named raw methods remain caller-owned SQL escape hatches. - Eager Loading: Batch supported
has_many,belongs_to,morph_many,morph_one, and typedmorph_totargets. Inverse polymorphic fields use an explicit target per relation and a persisted<morph_name>_idplus<morph_name>_typediscriminator. A batch whose related rows exceed the global query cap fails with aValidationerror instead of silently returning partial relations. - Fail-Closed Tenant Scopes: Models declaring
tenant_columnrequirewith_tenant, inject the tenant predicate (typed as the tenant field, so a mistyped context fails closed) into generated queries, protect instance mutations, and reserve explicitunscoped()for reviewed global paths. Authentication and permission to use that escape hatch remain the application's responsibility. - Actor-Bound Audit Revisions: Opt-in auditable models require a validated
user/service/system
AuditContext, derive tenant metadata fromwith_tenant, and persist recursively redacted bounded changes in the model transaction. Eligible v2 update revisions can be restored with stale-state, tenant, model, identity, and sensitive-field guards; the compensating update records its actor, reason, and source revision. Bulk per-row history and durable external export remain explicit application/outbox work. See the audit revision guide. - Bounded Post-Commit Effects:
after_commitand the generated observercommittedcallback run only afterOrm::transactionor a direct generated save/delete/restore/force_delete commits. Rollback discards them, and post-commit failures use a distinct error that says the database is already durable. These callbacks remain process-local; caller-owned raw SQLx transactions cannot expose their eventual commit decision to generated hooks. - Durable Transactional Outbox: opt-in
Outboxevents commit atomically with domain state and use stream-scoped idempotency keys, bounded leases, exact claim tokens, retries and dead-letter state. PostgreSQL, MySQL, MariaDB and SQLite share the contract. Delivery is at least once, so the external consumer must also be idempotent. - Data Governance & Privacy Helpers: At-rest encryption, recursive audit masking, and data-erasure primitives; legal compliance remains application-specific.
#[orm(encrypted)]and#[orm(masked)]values appear as"***"in generatedto_json(), audit rows and committed events (including Redisorm:events:*), are omitted from Scout documents, and remain encrypted insave_to_redishashes.SecretStringfields get the same treatment, andSecretStringitself serializes as an encryptedRULLST:v2envelope (never plaintext), which the query cache stores and decrypts on a hit. - Scout Search Providers:
scout-httpadds bounded Meilisearch, Elasticsearch and Algolia update/delete/search adapters with deterministic offline fallbacks. Generated projections run after commit; guaranteed crash recovery requires explicit outbox composition. Meilisearch has a live pinned container contract, while Elastic/Algolia have protocol fixtures rather than hosted-provider certification. - Typed pgvector Search:
pgvectorre-exports SQLx-compatibleVectorand the L2/cosine/inner-product helpers bind vector and distance values. A digest-pinned PostgreSQL + pgvector lifecycle covers typed insert/read and parameterized nearest-neighbor filtering/ordering; RAG orchestration and production index tuning remain application concerns. - Bounded Qdrant Search:
qdrantexposes validated dense-cosine collection, single-point upsert/delete and bounded nearest-neighbor query operations with deterministic fallback, authenticated protocol fixtures and a digest-pinned live lifecycle. Named/sparse vectors, arbitrary filters and ANN tuning remain explicit provider/application work. - Native Redis Structures:
redisadds a separately namespaced Hash, Set and Sorted Set datastore with bounded inputs/reads, TLS-required remote endpoints, redacted ACL credentials and a deterministic fallback. A pinned lifecycle covers increment, membership, ranking, exact deletion and namespace isolation; Lists, Streams and cluster/failover remain outside the contract. - Structured ORM Telemetry: Generated/raw queries and streams emit
rullst.orm.queryspans without SQL, bindings or model values; managed transactions record bounded commit/rollback outcomes and every Rullst-owned pool reports checkout timing. The application's tracing/OpenTelemetry subscriber controls export, sampling and retention. - Reproducible Comparative Benchmark: A lockfile-pinned Criterion harness gives Rullst, Diesel and SeaORM one typed SQLite connection, an equivalent indexed schema/seed/policy and the same five operations. It records evidence for that exact runner; the initial smoke did not support the historical โnegligible overhead versus Dieselโ claim.
- Database-First Introspection: The official framework CLI (
cargo rullst generate:models) connects to legacy databases and generates your#[derive(Orm)]Rust structs automatically. - Declarative Migration Preview:
make:migration:autooffers a bounded SQLite AST/schema diff; review generated SQL before applying it. - Atomic Cascading Soft Deletes: Mark generated has-one/has-many relationships for cascade; implicit deletes open a transaction when needed, while explicit or task-scoped transactions are reused without nesting.
- Typed Partial Updates:
.update_partial()changes only explicitly selected columns and preserves model policy/tenant checks. - Native Relational Enums:
#[derive(Enum)]owns one closed label mapping for SQLx, Serde and ORM values.Blueprint::native_enumemits a named, drift-checked PostgreSQL type withstrict-postgres, inline MySQL/MariaDBENUM, or a SQLiteTEXT CHECKconstraint. - Stable Keyset Chunking:
.chunk_by_id()traverses ascending generatedi32IDs without offset drift when already processed rows are deleted, and propagates callback errors..chunk()remains available for offset-based compatibility. - Transaction-Aware Redis Query Cache:
.remember(seconds)uses a versioned SHA-256 key bound to the application namespace, active tenant, generated SQL and typed bindings. Generated reads bypass cache inside every ORM transaction so Redis cannot replace the transaction's database view. Generated model saves/deletes/restores/force-deletes invalidate keys for the active tenant and table only after commit through a per-table key index, never a keyspaceSCAN, so write latency does not grow with unrelated keys in a shared Redis database; cluster/failover evidence remains outside the current contract. - Model Policies (Authorization):
#[orm(policy = "MyPolicy")]checks generated instance mutations. Policy-protected models rejectdelete_all()because bulk SQL cannot invoke per-row authorization; load the intended rows and call their instancedelete()methods insideOrm::transactioninstead. - Development lazy-loading diagnostics: An opt-in development policy can fail loudly when a guarded lazy load would hide an N+1 query.
- Explicit Capability Boundaries: Unsupported replication paths fail closed instead of reporting simulated success.
- Optional Polyglot Persistence: Feature-gated MongoDB document CRUD, DuckDB OLAP queries, Turso/libSQL edge SQL, and SurrealDB document/read-only graph operations live behind explicit capability APIs instead of pretending to be one universal Active Record interface. See the Polyglot guide.
- Authenticated Portable Document Recovery: MongoDB, SurrealDB and the deterministic store expose stable identifier-preserving inventory. A bounded AES-256-GCM snapshot performs two equal source scans, binds application and collection scope, resumes only into an empty or exactly matching subset and verifies the final inventory. It is an application-operated recovery primitive, not an online cross-store transaction or managed backup service.
- Bounded Turso Primary Profile:
#[derive(Orm)]with#[orm(backend = "turso")]exposes typed CRUD/query methods throughTursoOrm, reversible checked migrations, and a persistent offline or remote libSQL store. The CLI currently guarantees this primary path for the blank/API starter; SQLx-specific relations, hooks, schema auto-diff and the other blueprints are not claimed as parity.
Query and transaction boundaries
select, pluck_string, and pluck_i32 accept validated column identifiers,
including table.column. Expressions and aliases belong in explicit raw SQL
APIs. Qualified names retain skipped/encrypted-field restrictions; supported
encrypted string projections use the original field name when decrypting.
where_in and or_where_in with an empty vector emit a false predicate, so an
empty selected-ID list cannot become an unrestricted read or delete. An empty
where_not_in remains true. Unfiltered delete_all on models without policies
is still deliberately a bulk operation. Its statement renders only the WHERE
and soft-delete predicates, so an explicit limit(), offset(), order_by(),
a join, group_by() or a CTE makes it fail with Validation instead of
deleting every matching row; select the IDs first and delete them with
where_in("id", ids). Model-wide, tenant, and soft-delete
scopes constrain every user OR branch, and keyset traversal applies its cursor
to the entire original filter. Nested generated subqueries propagate validation
errors, including a missing tenant context, into their containing query.
Typed subqueries passed to where_exists, or_where_exists, with_cte and
with_recursive are embedded with portable ? markers; on PostgreSQL the
final statement is numbered once, in textual order, so nested scopes, CTEs and
joins keep every tenant and caller binding at its own $n position.
with_raw, with_recursive_raw and select_raw take no bindings, and
bind() appends WHERE values after the scope, JOIN and WHERE bindings. On a
tenant or model-wide scoped query, or once JOIN/WHERE values exist, these
fragments therefore fail with Validation when they contain a bind marker,
instead of letting the marker take the tenant binding; use a typed with_cte
or where_raw(..., bindings) there.
Only PostgreSQL statements are renumbered: delete_all(), the child UPDATE
issued by cascade_soft_delete, and instance restore()/force_delete()
receive $n there and keep ? markers on MySQL/MariaDB and SQLite.
The renumbering reads quoted text with PostgreSQL's default
standard_conforming_strings = on rules: a backslash escapes only inside
E'...' strings, so a raw ESCAPE '\' does not hide later markers.
Generated Model::search() uses those same model-wide and tenant scopes for
both its SQL fallback and external Scout result IDs. The SQL fallback never
matches #[orm(hidden)], #[orm(encrypted)], #[orm(masked)] or
SecretString columns, treats % and _ in the query literally, and rejects
queries over 1,024 bytes or with control characters. Missing tenant context
fails before contacting Scout, and an empty provider result remains an empty
match even when a database contains an explicitly inserted ID of zero. Search
index access controls still belong to the application/operator. Providers
return at most 1,000 IDs for the whole shared index, before tenant, model-wide
and soft-delete scopes apply. For a tenant-scoped model an answer of that size
is treated as truncated and search() uses the SQL fallback instead, so
another tenant's hits cannot hide a tenant's matches (the fallback's substring
semantics then apply); other models keep the provider answer.
Generated builders start with a global row cap (Orm::set_max_query_limit,
1,000 by default; 0 disables it). limit() clamps to that cap and
unsafe_unlimited() removes it for one explicit query. paginate(page, per_page) clamps per_page to the same cap, because the value often comes
from request input; PaginationResult::per_page and last_page report the
effective page size.
Eager loading runs one related-model query for all parents of a batch and
never assigns relations from a result truncated by that cap: when the related
rows exceed it, get() fails with a Validation error naming the relation.
Load fewer parents per query, raise the cap, or choose explicitly with
with_<relation>_constrained(...): an explicit smaller limit(n) there applies
to the whole batch, and unsafe_unlimited() loads every related row.
Parents that share a related row or group all receive it: every child of one
belongs_to parent, parents whose non-unique local_key matches the same
has_many/has_one rows, and duplicated parent rows. The shared value is
cloned for all but the last such parent, so a related model without Clone
loads normally until a row must be shared, and then get() fails with a
Validation error instead of leaving a parent without its relation.
Prefer Orm::transaction with ordinary model/query methods when combining
eager relationships or after_fetch hooks with transactional reads. Fetches
release the managed transaction lock before invoking hooks and loading related
records, and scalar projections use that same transaction. Caller-owned raw
SQLx transactions still support plain get_with_tx reads, but eager-loading or
after_fetch configurations fail with an actionable error because their
secondary queries require the managed executor context.
A nested Orm::transaction joins the active transaction through a savepoint
instead of opening a second pooled transaction. An error inside it rolls back
only the nested work; a success commits (or rolls back) with the outer
transaction, and its after_commit callbacks run after the outer commit. A
helper that wraps Outbox::enqueue or model saves in its own
Orm::transaction is therefore atomic with its caller. The returned future is
Send, so it can also run in a spawned task. Do not hold the shared handle's
lock across a nested call.
Sibling nested transactions started concurrently on one task (for example with
tokio::join!) take turns on the shared connection: each opens its savepoint
only after the previous sibling's savepoint was released or rolled back, so a
failure rolls back only that sibling's work. Plain model statements issued by
a sibling future while another sibling's savepoint is open still run inside
that savepoint; wrap each concurrent branch in its own Orm::transaction when
they must be isolated. A savepoint that cannot be settled (for example a nested
future cancelled while another operation holds the connection) makes the
enclosing transaction fail closed: it rolls back and returns an error instead
of committing, and the pool closes any connection returned while still inside
a transaction instead of reusing it.
A transaction-backed stream retains exclusive access to the transaction until
it is consumed or dropped. Consume/drop it before starting another operation
on that transaction. Transactional streams reject after_fetch hooks to avoid
reentrant queries while retaining that access; use get() inside
Orm::transaction for those models. Streaming does not eagerly load relations.
force_delete() and restore() run in a savepoint like delete().
force_delete() runs the delete hooks and observers, writes a force_deleted
audit entry on auditable models (so it needs an AuditContext there) and
registers the same post-commit cache, Redis, committed and Scout removal
effects; it does not cascade. restore() re-reads the row, calls the updated
and saved observers, writes a restored audit entry and registers the update
effects of save(), including a Scout re-index. Their can_force_delete and
can_restore policies run before the transaction, as before.
Full save/delete policies and their lifecycle callbacks run while their
executor is borrowed.
Ordinary ORM calls made recursively from those callbacks on the same task fail
with a typed Validation error instead of waiting on their own transaction.
Perform database authorization and any required row locking explicitly within
the managed transaction before invoking the mutation; the policy can then
validate trusted context and in-memory model values. Defer only post-commit
effects to after_commit/committed, which run outside that callback scope.
Failure in an after_save hook still rolls back the generated mutation. Inside
an existing transaction (save_with_tx, or save() within Orm::transaction)
the save runs in a savepoint, so catching that error and committing the outer
transaction does not persist the failed write.
Independently spawned tasks and separately retained raw connections do not
inherit this executor contract and must not be used to bypass atomicity.
Raw SQL, unscoped QueryBuilder::new() construction, direct editing of builder
SQL fields, and custom SubqueryBuilder
implementations remain trusted application code. Custom subqueries should
expose their validation failure through validation_error(); the trait's
compatibility default does not validate arbitrary custom SQL. These controls
do not authenticate users or decide who may invoke unscoped().
๐ ๏ธ Quick Start
Installation
Add the library to your Cargo.toml:
Install the exact stable train with:
Zero-to-Hero Example
use ;
// 1. Just add the Orm macro to your struct!
async
Selecting an isolated SQLx driver
From 12.1, a standalone ORM dependency can omit unrelated SQLx drivers:
= { = "12.1.0", = false, = ["strict-postgres"] }
strict-mysql and strict-sqlite select their respective backends. The default
drivers-all feature preserves the existing three-driver convenience profile.
Orm::driver() reads the SQL dialect from the DSN scheme, ignoring case:
postgres:// and postgresql:// are PostgreSQL, and mysql:// and
mariadb:// are MySQL/MariaDB. IPv6 literal hosts use brackets
(postgres://app@[2001:db8::10]:5432/app); Orm::init rejects any other
bracketed text, such as an unedited [your-database-id] template placeholder.
If an application previously disabled defaults without choosing a backend,
enable drivers-all explicitly or select a strict backend when upgrading.
Cargo features combine across dependencies. A dependency enabling ORM defaults,
Studio, a SQLite queue or Turso's offline transport can restore other drivers;
the example above proves a standalone ORM boundary, not a complete facade or
Studio application. Check the final normal/build graph with cargo tree.
Derived enums also emit codecs only for the ORM's selected drivers; the
application does not need matching Cargo feature names. The CI consumer compiles
generated CRUD, transactions and enum encoding/decoding and rejects unrelated
SQLx driver packages separately from the workspace test matrix.
Native database enums
Select a strict primary feature in generated applications. PostgreSQL native
enums specifically require strict-postgres, because SQLx's dynamic Any
driver cannot decode custom PostgreSQL types:
= { = "12.1.0", = ["strict-postgres"] }
Then derive one label contract and use it in schema code:
use ;
use ;
# async
The derive accepts 1โ64 unique labels of at most 63 bytes using ASCII letters,
digits, spaces, underscores or hyphens. An existing PostgreSQL type must have
the exact same ordered labels or schema creation fails. MySQL/MariaDB store the
labels in the table's inline ENUM; SQLite enforces them through TEXT CHECK.
Adding, removing or reordering labels is an explicit reviewed migration. Drop
every dependent table before calling Schema::drop_native_enum::<T>() on
PostgreSQL; the method is a validated no-op on the other backends. The enum
type creation, its label check and drop_native_enum use the active
Orm::transaction or test sandbox like the table DDL, so they roll back with it
and the type can be dropped right after its tables in the same transaction.
Builder filters on a model field whose type derives Enum (or
Option<...> of it) work on every backend: under the strict-postgres
runtime the comparison, IN and BETWEEN markers of that column become
CAST(? AS "<type_name>"), because a text parameter has no operator against a
named enum type. This covers where_eq, where_in, the generated
where_<column> helpers and their or_/not_ variants; where_like and raw
SQL are unchanged. SQLx Any cannot decode named enum types, so on PostgreSQL
through Any such fields stay text columns and keep plain ? markers.
table.timestamps() adds nullable created_at/updated_at TEXT columns
that default to the current timestamp. MySQL/MariaDB reject a literal default
on TEXT, BLOB, JSON and GEOMETRY columns, so on that driver the
builder emits DEFAULT (CURRENT_TIMESTAMP) and wraps other non-NULL
defaults on those types in parentheses (MySQL 8.0.13+, MariaDB 10.2.1+).
SQLite and PostgreSQL DDL is unchanged.
Optional Redis query cache
Enable the redis feature and give each application sharing a Redis database a
stable namespace. Generated Redis methods and effects follow this ORM feature;
the application does not need a redis feature of its own:
use Orm;
init_redis_with_namespace.await?;
let users = query.where_like
.remember
.get
.await?;
Generated save_to_redis/get_from_redis/increment_redis_field hashes are
keyed by that namespace, the table and, for tenant models, an opaque digest of
the active tenant: they require with_tenant(...) and never read or overwrite
another tenant's hash. For a model without a tenant scope, a hash stored by
12.1 under orm:<table>:<id> is still read while the namespaced hash is
missing and moves to the namespaced key on its next save_to_redis or
increment_redis_field. Tenant models never read that shared key; migrate
their hashes explicitly as described in the 12.2 behaviour notes of the ORM
crate guide.
An explicitly remembered query outside a transaction requires Redis initialization. Connection/command failures and corrupt cache entries fall back to the database, while missing configuration fails closed. Explicit and task-scoped transactions always bypass the cache. Generated model saves and deletes invalidate that table's generated cache keys after commit, using the index each cache write maintains for its table (at most 10,000 live keys per write; members of entries that already expired are pruned and not counted, and entries cached by earlier versions are not read again and expire by TTL). Raw SQL, bulk builders and writes outside generated model methods cannot be inferred, so keep a defensive TTL and do not cache authorization or other reads whose freshness requires a stronger distributed consistency contract.
Register process-local effects against the managed commit boundary:
use ;
transaction.await?;
For delivery that must survive a process crash, use the explicit outbox in the same managed transaction and dispatch it from a retrying worker:
use ;
use json;
transaction.await?;
if let Some = claim_next.await?
# Ok::
Outbox::install() is only an explicit setup/test helper. Register
OutboxMigration through the application's normal migration runner in
production. Streams and event keys are case-sensitive on every backend; on
MySQL/MariaDB, run Outbox::install() once (for example from a new migration)
to convert a table created by an earlier version, whose key columns used the
server's case-insensitive default collation. Generated observers are not silently persisted, and an ACK lost
after the external effect can cause redelivery; see the
transactional outbox tutorial.
๐ Documentation
We recently launched a brand-new Interactive Documentation Hub!
๐ Explore the Full Documentation in the Rullst Book
๐ก๏ธ Security
Rullst ORM uses SQLx prepared-statement bindings for values accepted by its query builders. Structural identifiers are restricted to a bounded ASCII identifier grammar before interpolation. Raw SQL and application authorization remain the caller's responsibility; these controls reduce injection risk but are not an absolute safety guarantee.
๐ License
This project is licensed under the MIT License.