use std::collections::HashSet;
use sea_query::{Alias, Expr, PostgresQueryBuilder, Query, SelectStatement, SqliteQueryBuilder};
use sea_query_binder::SqlxBinder;
use crate::db::router::schema_qualified_table;
pub(crate) enum CascadeConn<'a> {
Sqlite(&'a mut sqlx::SqliteConnection),
Pg(&'a mut sqlx::PgConnection),
}
impl<'a> CascadeConn<'a> {
pub(crate) fn from_tx(tx: &'a mut crate::db::Transaction) -> Self {
if tx.backend_name() == "sqlite" {
CascadeConn::Sqlite(tx.as_sqlite_mut().expect("sqlite backend_name"))
} else {
CascadeConn::Pg(tx.as_pg_mut().expect("postgres backend_name"))
}
}
}
use crate::migrate::ModelMeta;
use crate::orm::FkAction;
const MAX_CASCADE_DEPTH: usize = 16;
fn cascade_children(parent_table: &str) -> Vec<(ModelMeta, String)> {
let mut out = Vec::new();
for meta in crate::migrate::registered_models_opt().unwrap_or_default() {
for col in &meta.fields {
if col.fk_target.as_deref() == Some(parent_table)
&& matches!(col.on_delete, FkAction::Cascade)
{
out.push((meta.clone(), col.name.clone()));
}
}
}
out
}
fn selector(meta: &ModelMeta, conds: Vec<sea_query::SimpleExpr>) -> Option<SelectStatement> {
let pk = meta.pk_column()?;
let mut sel = Query::select();
sel.column(Alias::new(&pk.name))
.from(schema_qualified_table(&meta.table));
for c in conds {
sel.and_where(c);
}
Some(sel)
}
async fn exec(
conn: &mut CascadeConn<'_>,
stmt: &sea_query::UpdateStatement,
) -> Result<u64, sqlx::Error> {
match conn {
CascadeConn::Sqlite(c) => {
let (sql, values) = stmt.build_sqlx(SqliteQueryBuilder);
Ok(sqlx::query_with(&sql, values)
.execute(&mut **c)
.await?
.rows_affected())
}
CascadeConn::Pg(c) => {
let (sql, values) = stmt.build_sqlx(PostgresQueryBuilder);
Ok(sqlx::query_with(&sql, values)
.execute(&mut **c)
.await?
.rows_affected())
}
}
}
pub(crate) async fn cascade_soft_delete(
conn: &mut CascadeConn<'_>,
parent: &ModelMeta,
parent_sel: SelectStatement,
at: chrono::DateTime<chrono::Utc>,
) -> Result<u64, sqlx::Error> {
let mut seen = HashSet::from([parent.table.clone()]);
cascade_delete_level(conn, parent, parent_sel, at, &mut seen, 0).await
}
async fn cascade_delete_level(
conn: &mut CascadeConn<'_>,
parent: &ModelMeta,
parent_sel: SelectStatement,
at: chrono::DateTime<chrono::Utc>,
seen: &mut HashSet<String>,
depth: usize,
) -> Result<u64, sqlx::Error> {
if depth >= MAX_CASCADE_DEPTH {
tracing::warn!(
table = %parent.table,
"soft-delete cascade hit the depth limit ({MAX_CASCADE_DEPTH}); \
deeper descendants were not cascaded — is there an FK cycle?",
);
return Ok(0);
}
let mut total = 0u64;
for (child, fk) in cascade_children(&parent.table) {
if !child.soft_delete || !seen.insert(child.table.clone()) {
continue;
}
let mut stmt = Query::update();
stmt.table(schema_qualified_table(&child.table))
.value(
Alias::new("deleted_at"),
sea_query::Value::ChronoDateTimeUtc(Some(Box::new(at))),
)
.and_where(Expr::col(Alias::new("deleted_at")).is_null())
.and_where(Expr::col(Alias::new(fk.as_str())).in_subquery(parent_sel.clone()));
total += exec(conn, &stmt).await?;
if let Some(child_sel) = selector(
&child,
vec![
Expr::col(Alias::new("deleted_at")).eq(at),
Expr::col(Alias::new(fk.as_str())).in_subquery(parent_sel.clone()),
],
) {
total += Box::pin(cascade_delete_level(
conn,
&child,
child_sel,
at,
seen,
depth + 1,
))
.await?;
}
}
Ok(total)
}
pub(crate) async fn cascade_restore(
conn: &mut CascadeConn<'_>,
parent: &ModelMeta,
parent_sel: SelectStatement,
at: chrono::DateTime<chrono::Utc>,
) -> Result<u64, sqlx::Error> {
let mut seen = HashSet::from([parent.table.clone()]);
cascade_restore_level(conn, parent, parent_sel, at, &mut seen, 0).await
}
async fn cascade_restore_level(
conn: &mut CascadeConn<'_>,
parent: &ModelMeta,
parent_sel: SelectStatement,
at: chrono::DateTime<chrono::Utc>,
seen: &mut HashSet<String>,
depth: usize,
) -> Result<u64, sqlx::Error> {
if depth >= MAX_CASCADE_DEPTH {
return Ok(0);
}
let mut total = 0u64;
for (child, fk) in cascade_children(&parent.table) {
if !child.soft_delete || !seen.insert(child.table.clone()) {
continue;
}
let Some(child_sel) = selector(
&child,
vec![
Expr::col(Alias::new("deleted_at")).eq(at),
Expr::col(Alias::new(fk.as_str())).in_subquery(parent_sel.clone()),
],
) else {
continue;
};
total += Box::pin(cascade_restore_level(
conn,
&child,
child_sel.clone(),
at,
seen,
depth + 1,
))
.await?;
let mut stmt = Query::update();
stmt.table(schema_qualified_table(&child.table))
.value(
Alias::new("deleted_at"),
sea_query::Value::ChronoDateTimeUtc(None),
)
.and_where(Expr::col(Alias::new("deleted_at")).eq(at))
.and_where(Expr::col(Alias::new(fk.as_str())).in_subquery(parent_sel.clone()));
total += exec(conn, &stmt).await?;
}
Ok(total)
}
pub(crate) async fn deleted_at_values(
conn: &mut CascadeConn<'_>,
meta: &ModelMeta,
conds: &[sea_query::Condition],
) -> Result<Vec<chrono::DateTime<chrono::Utc>>, sqlx::Error> {
use sqlx::Row as _;
let mut sel = Query::select();
sel.distinct()
.column(Alias::new("deleted_at"))
.from(schema_qualified_table(&meta.table));
for c in conds {
sel.cond_where(c.clone());
}
sel.and_where(Expr::col(Alias::new("deleted_at")).is_not_null());
match conn {
CascadeConn::Sqlite(c) => {
let (sql, values) = sel.build_sqlx(SqliteQueryBuilder);
let rows = sqlx::query_with(&sql, values).fetch_all(&mut **c).await?;
Ok(rows
.iter()
.filter_map(|r| r.try_get::<chrono::DateTime<chrono::Utc>, _>(0).ok())
.collect())
}
CascadeConn::Pg(c) => {
let (sql, values) = sel.build_sqlx(PostgresQueryBuilder);
let rows = sqlx::query_with(&sql, values).fetch_all(&mut **c).await?;
Ok(rows
.iter()
.filter_map(|r| r.try_get::<chrono::DateTime<chrono::Utc>, _>(0).ok())
.collect())
}
}
}
pub(crate) fn selector_at(
meta: &ModelMeta,
conds: &[sea_query::Condition],
at: chrono::DateTime<chrono::Utc>,
) -> Option<SelectStatement> {
let pk = meta.pk_column()?;
let mut sel = Query::select();
sel.column(Alias::new(&pk.name))
.from(schema_qualified_table(&meta.table));
for c in conds {
sel.cond_where(c.clone());
}
sel.and_where(Expr::col(Alias::new("deleted_at")).eq(at));
Some(sel)
}
pub fn check_cascade_targets() -> Vec<String> {
let Some(models) = crate::migrate::registered_models_opt() else {
return Vec::new();
};
let mut problems = Vec::new();
for parent in models.iter().filter(|m| m.soft_delete) {
for (child, fk) in cascade_children(&parent.table) {
if !child.soft_delete {
problems.push(format!(
"`{child}.{fk}` declares `on_delete = \"cascade\"` to `{parent}`, but \
`{parent}` is `#[umbral(soft_delete)]` and `{child}` is not. A soft delete \
is an UPDATE, so the database never cascades, and `{child}` rows would be \
left behind pointing at a deleted `{parent}`. Fix by marking `{child}` \
`#[umbral(soft_delete)]` too (so the cascade can follow), or by changing \
`{child}.{fk}` to `on_delete = \"set_null\"` / `\"restrict\"` if the child \
is meant to outlive its parent.",
child = child.table,
parent = parent.table,
fk = fk,
));
}
}
}
problems
}