use serde::{Deserialize, Serialize};
use sqlx::sqlite::SqlitePoolOptions;
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "club", soft_delete)]
pub struct Club {
pub id: i64,
pub name: String,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "team", soft_delete)]
pub struct Team {
pub id: i64,
#[umbral(on_delete = "cascade")]
pub club: umbral::orm::ForeignKey<Club>,
pub name: String,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "player", soft_delete)]
pub struct Player {
pub id: i64,
#[umbral(on_delete = "cascade")]
pub team: umbral::orm::ForeignKey<Team>,
pub name: String,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
#[umbral(table = "invoice", soft_delete)]
pub struct Invoice {
pub id: i64,
#[umbral(on_delete = "set_null")]
pub club: Option<umbral::orm::ForeignKey<Club>>,
pub amount: i64,
pub deleted_at: Option<chrono::DateTime<chrono::Utc>>,
}
async fn boot() {
static ONCE: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new();
ONCE.get_or_init(|| async {
let settings = umbral::Settings::from_env().expect("figment defaults");
let pool = SqlitePoolOptions::new()
.connect("sqlite::memory:")
.await
.expect("pool");
umbral::App::builder()
.settings(settings)
.database("default", pool)
.model::<Club>()
.model::<Team>()
.model::<Player>()
.model::<Invoice>()
.build()
.expect("App::build");
umbral_core::migrate::create_tables_for_tests()
.await
.expect("create the test schema");
})
.await;
}
fn lock() -> &'static tokio::sync::Mutex<()> {
static L: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
&L
}
async fn seed(club_name: &str) -> i64 {
let pool = umbral::db::pool();
let club = sqlx::query("INSERT INTO club (name) VALUES (?) RETURNING id")
.bind(club_name)
.fetch_one(&pool)
.await
.expect("club");
let club_id: i64 = sqlx::Row::get(&club, 0);
let team = sqlx::query("INSERT INTO team (club, name) VALUES (?, 'first xi') RETURNING id")
.bind(club_id)
.fetch_one(&pool)
.await
.expect("team");
let team_id: i64 = sqlx::Row::get(&team, 0);
sqlx::query("INSERT INTO player (team, name) VALUES (?, 'ada')")
.bind(team_id)
.execute(&pool)
.await
.expect("player");
sqlx::query("INSERT INTO invoice (club, amount) VALUES (?, 100)")
.bind(club_id)
.execute(&pool)
.await
.expect("invoice");
club_id
}
async fn live(table: &str) -> i64 {
let pool = umbral::db::pool();
let row = sqlx::query(&format!(
"SELECT COUNT(*) FROM {table} WHERE deleted_at IS NULL"
))
.fetch_one(&pool)
.await
.expect("count");
sqlx::Row::get(&row, 0)
}
#[tokio::test]
async fn soft_deleting_a_parent_cascades_to_children_and_grandchildren() {
let _g = lock().lock().await;
boot().await;
let club_id = seed("cascade-club").await;
let (t0, p0) = (live("team").await, live("player").await);
assert!(t0 >= 1 && p0 >= 1, "seeded rows are live to start");
Club::objects()
.filter(club::ID.eq(club_id))
.delete()
.await
.expect("soft delete club");
assert_eq!(
live("team").await,
t0 - 1,
"the club's team must be soft-deleted with it (cascade child)",
);
assert_eq!(
live("player").await,
p0 - 1,
"the team's player must be soft-deleted too — the cascade recurses",
);
}
#[tokio::test]
async fn a_non_cascade_child_is_left_alone() {
let _g = lock().lock().await;
boot().await;
let club_id = seed("invoice-club").await;
let before = live("invoice").await;
Club::objects()
.filter(club::ID.eq(club_id))
.delete()
.await
.expect("soft delete");
assert_eq!(
live("invoice").await,
before,
"`on_delete = set_null` does not cascade — the invoice must survive",
);
}
#[tokio::test]
async fn restoring_the_parent_restores_the_cascaded_descendants() {
let _g = lock().lock().await;
boot().await;
let club_id = seed("restore-club").await;
let (t0, p0) = (live("team").await, live("player").await);
Club::objects()
.filter(club::ID.eq(club_id))
.delete()
.await
.expect("soft delete");
assert_eq!(live("team").await, t0 - 1, "cascaded down");
umbral::orm::DynQuerySet::for_meta(&umbral::migrate::ModelMeta::for_::<Club>())
.filter_in_i64("id", &[club_id])
.restore()
.await
.expect("restore club");
assert_eq!(live("team").await, t0, "the team comes back with its club");
assert_eq!(live("player").await, p0, "and so does the player");
}
#[tokio::test]
async fn restore_does_not_resurrect_an_independently_deleted_child() {
let _g = lock().lock().await;
boot().await;
let club_id = seed("independent-club").await;
let pool = umbral::db::pool();
let row = sqlx::query("INSERT INTO team (club, name) VALUES (?, 'reserves') RETURNING id")
.bind(club_id)
.fetch_one(&pool)
.await
.expect("team2");
let team2: i64 = sqlx::Row::get(&row, 0);
Team::objects()
.filter(team::ID.eq(team2))
.delete()
.await
.expect("delete reserves on its own");
let after_independent = live("team").await;
Club::objects()
.filter(club::ID.eq(club_id))
.delete()
.await
.expect("soft delete club");
assert_eq!(
live("team").await,
after_independent - 1,
"cascade took the live one"
);
umbral::orm::DynQuerySet::for_meta(&umbral::migrate::ModelMeta::for_::<Club>())
.filter_in_i64("id", &[club_id])
.restore()
.await
.expect("restore");
assert_eq!(
live("team").await,
after_independent,
"restore brings back ONLY what the cascade took — the independently \
deleted 'reserves' team must stay deleted",
);
}