Tutorial 03: Active Record CRUD Operations 🗄️
rullst-orm derives a typed query builder and persistence methods from a Rust
struct. The SQLx-backed model uses an i32 primary key named id; a zero value
means that save() inserts, while a non-zero value means that it updates.
This tutorial continues in the shop project of
Tutorial 02, whose cargo rullst make:model Product --migration wrote src/models/product.rs and
src/migrations/m<timestamp>_create_products.rs.
Step 1: Define an Active Record model
Give the generated migration the columns the model will use. In its up():
Schema::create("products", |table| {
table.id();
table.string("name").not_null();
table.integer("price_cents").not_null();
table.timestamps();
}).await
Then add the matching fields to src/models/product.rs:
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
pub struct Product {
pub id: i32,
pub name: String,
pub price_cents: i32,
// Remembers the loaded values, so `save()` writes only the columns this
// record changed. Build new records with `snapshot: RowSnapshot::default()`.
#[sqlx(skip)]
pub snapshot: RowSnapshot,
}
}
Apply the migration with cargo rullst db:migrate (or restart
cargo rullst dev) before using the model. The model leaves out
created_at/updated_at, so inserts keep their database defaults. If
cargo rullst dev already applied the migration while it had only an id,
run cargo rullst db:rollback (it reverts the last batch of migrations,
including this one) and then cargo rullst db:migrate;
Tutorial 05 explains both commands.
Step 2: Perform CRUD operations
Call these methods from an async handler or seeder, for example in a
controller of Tutorial 02.
Create
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn create_product() -> Result<(), rullst::orm::Error> {
let mut product = Product {
id: 0,
name: "Starter kit".to_string(),
price_cents: 2_500,
snapshot: RowSnapshot::default(),
};
product.save().await?;
// `product.id` now contains the inserted primary key.
Ok(())
}
}
Read and filter
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn read_products() -> Result<(), rullst::orm::Error> {
let product = Product::find(1).await?; // Result<Option<Product>, rullst::orm::Error>
let kits = Product::query()
.where_like("name", "%kit%")
.get()
.await?;
let _ = (product, kits);
Ok(())
}
}
Builder values are bound as query parameters. Column names are validated as identifiers, but they should still be application-owned constants rather than untrusted request input.
Update
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn update_product() -> Result<(), rullst::orm::Error> {
if let Some(mut product) = Product::find(1).await? {
product.price_cents = 2_000;
// The snapshot makes this an UPDATE of `price_cents` only.
product.save().await?;
}
Ok(())
}
}
Delete
#![allow(unused)]
fn main() {
use rullst::db::{Orm, RowSnapshot};
#[derive(Debug, Clone, Orm)]
#[orm(table = "products")]
struct Product { id: i32, name: String, price_cents: i32, #[sqlx(skip)] snapshot: RowSnapshot }
async fn delete_product() -> Result<(), rullst::orm::Error> {
if let Some(product) = Product::find(1).await? {
product.delete().await?;
}
Ok(())
}
}
Without a RowSnapshot field, save() on a loaded record writes every
column, so the last writer wins; see the
ORM guide for dirty
tracking and update_partial().
Soft deletes
A deleted_at: Option<String> field opts the model into the default soft-delete
contract. For a different sentinel, configure it explicitly and ensure the
migration uses the same representation:
#![allow(unused)]
fn main() {
use rullst_orm::Orm;
#[derive(Debug, Clone, Orm)]
#[orm(
table = "users",
soft_delete(field = "is_deleted", value = "0", delval = "1")
)]
pub struct SoftUser {
pub id: i32,
pub name: String,
pub is_deleted: i32,
}
}
query() applies the model’s configured scopes. unscoped() is an explicit
administrative escape hatch and should not be used directly from request data.
restore() clears the soft-delete marker and force_delete() removes the row
permanently. Both check the tenant and the policy (can_restore /
can_force_delete) first and then run in a savepoint of the task-scoped
Orm::transaction, or in their own transaction, like delete():
force_delete()runs thebefore_delete/after_deletehooks and thedeleting/deletedobservers, writes aforce_deletedaudit entry on auditable models, and after commit invalidates the query cache, publishes the Redisdeletedevent, callscommitted(Deleted)and removes the Scout document. It does not cascade tocascade_soft_deleterelations.restore()re-reads the restored row, calls theupdatedandsavedobservers with it, writes arestoredaudit entry, and after commit invalidates the cache, publishes the Redisupdated/savedevents, callscommitted(Updated)and re-indexes the Scout document. The save hooks and thesaving/updatingobservers are not called, because restore writes only the soft-delete column. Restoring a missing or live row is a no-op, and deleting an already trashed row again fails without changing its deletion time.
Key takeaways
save,find,all,query, anddeleteare generated for SQLx-backedOrmmodels.- Creation uses a normal Rust struct, not a JSON map.
- CRUD errors are returned as
rullst::orm::Error(therullst_orm::Errortype); a missing row isOk(None). Saving or deleting a handle whose row was deleted meanwhile returnsRecordNotFoundinstead of silently succeeding. - Use a caller-owned transaction and the generated
*_with_txmethods when a business operation must commit multiple writes atomically.