rullst-orm 12.1.2

Fast, un-opinionated, database-agnostic ORM and query builder for Rust
Documentation

๐Ÿš€ 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 typed morph_to targets. Inverse polymorphic fields use an explicit target per relation and a persisted <morph_name>_id plus <morph_name>_type discriminator.
  • Fail-Closed Tenant Scopes: Models declaring tenant_column require with_tenant, inject the tenant predicate into generated queries, protect instance mutations, and reserve explicit unscoped() 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 from with_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_commit and the generated observer committed callback run only after Orm::transaction or a direct generated save/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 Outbox events 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.
  • Scout Search Providers: scout-http adds 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: pgvector re-exports SQLx-compatible Vector and 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: qdrant exposes 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: redis adds 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.query spans 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:auto offers 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_enum emits a named, drift-checked PostgreSQL type with strict-postgres, inline MySQL/MariaDB ENUM, or a SQLite TEXT CHECK constraint.
  • Stable Keyset Chunking: .chunk_by_id() traverses ascending generated i32 IDs 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 invalidate keys for the active tenant and table only after commit, using a bounded non-blocking scan; cluster/failover evidence remains outside the current contract.
  • Model Policies (Authorization): #[orm(policy = "MyPolicy")] checks generated instance mutations. Policy-protected models reject delete_all() because bulk SQL cannot invoke per-row authorization; load the intended rows and call their instance delete() methods inside Orm::transaction instead.
  • 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 through TursoOrm, 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. 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. Generated Model::search() uses those same model-wide and tenant scopes for both its SQL fallback and external Scout result IDs. 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.

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 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.

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. 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:

cargo add rullst-orm@12.1.0
cargo add tokio -F full

Zero-to-Hero Example

use rullst_orm::{Orm, FromRow};

// 1. Just add the Orm macro to your struct!
#[derive(Clone, FromRow, Orm)]
pub struct User {
    pub id: i32, // ID = 0 means it hasn't been saved yet
    pub name: String,
    pub email: String,
    #[orm(hidden)] // Excluded from the ORM's generated to_json() projection
    pub password: String,
}

#[tokio::main]
async fn main() -> Result<(), rullst_orm::Error> {
    // 2. Initialize the connection pool (SQLite, Postgres, MySQL/MariaDB)
    Orm::init("sqlite::memory:").await?;

    // 3. Create a new user through the generated Active Record API
    let mut user = User {
        id: 0,
        name: "Alice".to_string(),
        email: "alice@example.com".to_string(),
        password: "secret_password".to_string(),
    };
    
    user.save().await?; // Runs INSERT and hydrates the generated ID.

    // 4. Fluent Queries
    let active_users = User::query()
        .where_like("email", "%@example.com")
        .order_by_desc("id")
        .limit(10)
        .get()
        .await?;

    println!("Found {} user(s)", active_users.len());

    Ok(())
}

Selecting an isolated SQLx driver

From 12.1, a standalone ORM dependency can omit unrelated SQLx drivers:

rullst-orm = { version = "12.1.0", default-features = false, features = ["strict-postgres"] }

strict-mysql and strict-sqlite select their respective backends. The default drivers-all feature preserves the existing three-driver convenience profile. 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:

rullst-orm = { version = "12.1.0", features = ["strict-postgres"] }

Then derive one label contract and use it in schema code:

use rullst_orm::schema::{Blueprint, Schema};
use rullst_orm::{Enum, Orm};

#[derive(Enum, Debug, Clone, Copy, PartialEq, Eq)]
#[rullst_enum(type_name = "account_status", rename_all = "snake_case")]
enum AccountStatus {
    AwaitingReview,
    Active,
}

# async fn create_schema() -> Result<(), rullst_orm::Error> {
Orm::init("postgres://user:password@localhost/application").await?;
Schema::create("accounts", |table: &mut Blueprint| {
    table.id();
    table.native_enum::<AccountStatus>("status").not_null();
}).await?;
# Ok(())
# }

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.

Optional Redis query cache

Enable the redis feature and give each application sharing a Redis database a stable namespace:

use rullst_orm::Orm;

Orm::init_redis_with_namespace(
    "redis://127.0.0.1:6379",
    "academy-production",
).await?;

let users = User::query().where_like("email", "%@example.com")
    .remember(30)
    .get()
    .await?;

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. 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 rullst_orm::{Error, Orm, after_commit};

Orm::transaction(|_| Box::pin(async move {
    // Executor-aware ORM operations participate in this transaction.
    after_commit(|| async {
        // Publish a best-effort projection after commit.
        Ok::<(), Error>(())
    }).await?;
    Ok::<(), Error>(())
})).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 rullst_orm::{Error, Orm, Outbox};
use serde_json::json;

Orm::transaction(|_| Box::pin(async move {
    // Persist domain state through the task-scoped transaction here.
    Outbox::enqueue(
        "tenant-42",
        "invoice:123:issued:v1",
        "invoice.issued",
        &json!({ "invoice_id": 123 }),
    ).await?;
    Ok::<(), Error>(())
})).await?;

if let Some(event) = Outbox::claim_next("tenant-42", "mail-worker-1", 30, 8).await? {
    // Deliver using (event.stream, event.event_key) as the consumer's
    // idempotency boundary, then acknowledge the exact lease token.
    Outbox::acknowledge(event.id, event.claim_key).await?;
}
# Ok::<(), Error>(())

Outbox::install() is only an explicit setup/test helper. Register OutboxMigration through the application's normal migration runner in production. 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.