#![allow(dead_code)]
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use tokio::sync::OnceCell;
use umbral::migrate::registered_models;
use umbral::orm::DynQuerySet;
use umbral_core::db;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(soft_delete, table = "dynsdt_post")]
pub struct DynSdPost {
pub id: i64,
#[umbral(string)]
pub title: String,
pub deleted_at: Option<DateTime<Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "dynsdt_hard")]
pub struct DynHardPost {
pub id: i64,
#[umbral(string)]
pub title: String,
}
static BOOT: OnceCell<()> = OnceCell::const_new();
async fn boot() {
BOOT.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults always load");
let dir = std::env::temp_dir();
let path = dir.join(format!("umbral_soft_delete_dynamic_{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
let url = format!("sqlite://{}?mode=rwc", path.display());
let pool = db::connect_sqlite(&url).await.expect("file-backed sqlite");
umbral::App::builder()
.settings(settings)
.database("default", pool.clone())
.model::<DynSdPost>()
.model::<DynHardPost>()
.build()
.expect("App::build");
sqlx::query(
"CREATE TABLE dynsdt_post (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
deleted_at TEXT
)",
)
.execute(&pool)
.await
.expect("create dynsdt_post");
sqlx::query(
"CREATE TABLE dynsdt_hard (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL
)",
)
.execute(&pool)
.await
.expect("create dynsdt_hard");
})
.await;
}
fn soft_meta() -> umbral::migrate::ModelMeta {
registered_models()
.into_iter()
.find(|m| m.table == "dynsdt_post")
.expect("dynsdt_post registered")
}
fn hard_meta() -> umbral::migrate::ModelMeta {
registered_models()
.into_iter()
.find(|m| m.table == "dynsdt_hard")
.expect("dynsdt_hard registered")
}
async fn insert_sd(title: &str) {
let meta = soft_meta();
let mut body = serde_json::Map::new();
body.insert("title".into(), serde_json::Value::String(title.to_string()));
DynQuerySet::for_meta(&meta)
.insert_json(&body)
.await
.expect("insert_json on soft-delete model");
}
async fn insert_hard(title: &str) {
let meta = hard_meta();
let mut body = serde_json::Map::new();
body.insert("title".into(), serde_json::Value::String(title.to_string()));
DynQuerySet::for_meta(&meta)
.insert_json(&body)
.await
.expect("insert_json on hard-delete model");
}
#[tokio::test]
async fn dyn_default_scope_excludes_trashed_rows() {
boot().await;
let pid = std::process::id();
let live = format!("dyn-live-{pid}-a");
let dead = format!("dyn-dead-{pid}-a");
insert_sd(&live).await;
insert_sd(&dead).await;
let meta = soft_meta();
let deleted = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &dead)
.delete()
.await
.expect("dyn soft-delete");
assert_eq!(deleted, 1, "one row affected");
let rows = DynQuerySet::for_meta(&meta)
.fetch_as_json()
.await
.expect("fetch_as_json");
let titles: Vec<&str> = rows
.iter()
.filter_map(|r| r.get("title").and_then(|v| v.as_str()))
.collect();
assert!(titles.contains(&live.as_str()), "live row must be visible");
assert!(
!titles.contains(&dead.as_str()),
"trashed row must be hidden in default scope"
);
let live_count = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &live)
.count()
.await
.expect("count live");
assert_eq!(live_count, 1, "count of live row must be 1");
let dead_count = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &dead)
.count()
.await
.expect("count dead");
assert_eq!(dead_count, 0, "count of trashed row must be 0 in default scope");
}
#[tokio::test]
async fn dyn_delete_soft_deletes_row_stays_in_db() {
boot().await;
let pid = std::process::id();
let title = format!("dyn-sd-{pid}-b");
insert_sd(&title).await;
let meta = soft_meta();
let affected = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &title)
.delete()
.await
.expect("dyn delete");
assert_eq!(affected, 1, "exactly one row soft-deleted");
let live = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &title)
.count()
.await
.expect("count live");
assert_eq!(live, 0, "soft-deleted row must not appear in default scope");
let all = DynQuerySet::for_meta(&meta)
.with_deleted()
.filter_eq_string("title", &title)
.fetch_as_json()
.await
.expect("fetch with_deleted");
assert_eq!(all.len(), 1, "row must still exist in DB after soft-delete");
let deleted_at = all[0].get("deleted_at").expect("deleted_at column present");
assert!(
!deleted_at.is_null(),
"deleted_at must be set after soft-delete, got: {deleted_at:?}"
);
}
#[tokio::test]
async fn dyn_with_deleted_includes_trashed_rows() {
boot().await;
let pid = std::process::id();
let live = format!("dyn-live-{pid}-c");
let dead = format!("dyn-dead-{pid}-c");
insert_sd(&live).await;
insert_sd(&dead).await;
let meta = soft_meta();
DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &dead)
.delete()
.await
.expect("soft-delete");
let rows = DynQuerySet::for_meta(&meta)
.with_deleted()
.fetch_as_json()
.await
.expect("fetch with_deleted");
let titles: Vec<&str> = rows
.iter()
.filter_map(|r| r.get("title").and_then(|v| v.as_str()))
.collect();
assert!(titles.contains(&live.as_str()), "live row present");
assert!(titles.contains(&dead.as_str()), "trashed row present with with_deleted()");
}
#[tokio::test]
async fn dyn_only_deleted_restricts_to_trash() {
boot().await;
let pid = std::process::id();
let live = format!("dyn-live-{pid}-d");
let dead = format!("dyn-dead-{pid}-d");
insert_sd(&live).await;
insert_sd(&dead).await;
let meta = soft_meta();
DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &dead)
.delete()
.await
.expect("soft-delete");
let rows = DynQuerySet::for_meta(&meta)
.only_deleted()
.fetch_as_json()
.await
.expect("fetch only_deleted");
let titles: Vec<&str> = rows
.iter()
.filter_map(|r| r.get("title").and_then(|v| v.as_str()))
.collect();
assert!(
titles.contains(&dead.as_str()),
"trashed row present in only_deleted()"
);
assert!(
!titles.contains(&live.as_str()),
"live row must NOT appear in only_deleted()"
);
}
#[tokio::test]
async fn dyn_hard_delete_purges_row_from_db() {
boot().await;
let pid = std::process::id();
let title = format!("dyn-purge-{pid}-e");
insert_sd(&title).await;
let meta = soft_meta();
DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &title)
.delete()
.await
.expect("soft-delete");
let affected = DynQuerySet::for_meta(&meta)
.with_deleted()
.hard_delete()
.filter_eq_string("title", &title)
.delete()
.await
.expect("hard_delete");
assert_eq!(affected, 1, "hard_delete must affect one row");
let count = DynQuerySet::for_meta(&meta)
.with_deleted()
.filter_eq_string("title", &title)
.count()
.await
.expect("count after hard_delete");
assert_eq!(count, 0, "row must be gone after hard_delete");
}
#[tokio::test]
async fn dyn_non_soft_model_hard_deletes() {
boot().await;
let pid = std::process::id();
let title = format!("dyn-hard-{pid}-f");
insert_hard(&title).await;
let meta = hard_meta();
assert!(!meta.soft_delete, "hard model must not have soft_delete flag");
let affected = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &title)
.delete()
.await
.expect("hard delete on non-soft model");
assert_eq!(affected, 1, "one row hard-deleted");
let count = DynQuerySet::for_meta(&meta)
.filter_eq_string("title", &title)
.count()
.await
.expect("count after hard delete");
assert_eq!(count, 0, "row must be gone after hard delete on non-soft model");
}