Soft delete
Tag a model with soft_delete and deletes become reversible. Rows hide instead of dying.
Soft delete
Tag a model #[umbral(soft_delete)] and the framework stops destroying its rows: delete() becomes an UPDATE ... SET deleted_at = <now> (the current UTC timestamp, bound as a parameter - not a SQL NOW() call, so it's identical on SQLite and Postgres), and every normal query automatically excludes rows where deleted_at is set. You get a trash can instead of a shredder: undo, audits, and "restore this account" flows all stay possible.
The rewrite is idempotent: it only touches rows where deleted_at IS NULL, so re-deleting an already-trashed row never bumps its timestamp.
You declare the timestamp column yourself (the marker doesn't synthesize it):
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, Model)]#[umbral(soft_delete)]pub struct Post { pub id: i64, pub title: String, pub deleted_at: Option<DateTime<Utc>>,}That's the whole setup. From here:
// Soft-deletes: UPDATE post SET deleted_at = NOW() WHERE title = 'a'Post::objects().filter(post::TITLE.eq("a")).delete().await?; // Normal reads never see the row again: WHERE deleted_at IS NULL// is injected on every terminal (fetch / first / get / count / exists).let visible = Post::objects().fetch().await?; // Opt back in:let everything = Post::objects().with_deleted().fetch().await?; // live + trashedlet trash = Post::objects().only_deleted().fetch().await?; // trashed only // Restore: clear the timestamp on the trashed row.// .only_deleted() scopes the UPDATE to trashed rows only, so no live// rows are touched even if you omit the ID filter.let mut patch = serde_json::Map::new();patch.insert("deleted_at".into(), serde_json::Value::Null);Post::objects().only_deleted().filter(post::ID.eq(id)).update_values(patch).await?; // True purge ("empty the trash"): opt-in, two explicit steps.Post::objects() .filter(post::ID.eq(id)) .with_deleted() .hard_delete() .delete() .await?;Models without the marker keep the classic behaviour: delete() is a real DELETE FROM, no filter injection.
Admin and REST
The dynamic path (DynQuerySet) used by the admin and the REST plugin honours soft-delete identically: list endpoints exclude trashed rows by default, and the DELETE endpoint soft-deletes instead of hard-deleting. The same with_deleted() / only_deleted() / hard_delete() toggles are available on DynQuerySet, plus a restore() terminal that clears deleted_at on the matched trashed rows (the inverse of a soft delete()).
The admin ships a full trash UI for any #[umbral(soft_delete)] model, with no per-model config:
- A Trash / Active toggle in the changelist header (with a trashed-row count badge).
?trash=1lists only soft-deleted rows; the default view shows the live set. - A built-in Restore selected bulk action (and per-row restore in the trash view) that calls
DynQuerySet::restore(). - A built-in Delete permanently bulk action behind a confirm dialog - a real
hard_delete(), so the row leaves the table entirely. - The default Delete selected action soft-deletes (moves rows to trash) on a soft-delete model, because it routes through
DynQuerySet::delete().
Restore is gated by the change_<model> permission; Delete-permanently by delete_<model>. See the Admin plugin docs for the trash workflow.
Pinned by crates/umbral-core/tests/soft_delete.rs (feature #72, gaps2 #34), crates/umbral-core/tests/soft_delete_dynamic.rs (gaps2 #35), and plugins/umbral-admin/tests/soft_delete_admin.rs (the admin trash UI); behaviour reference in the Model::SOFT_DELETE docs in crates/umbral-core/src/orm/model.rs.