use crate::core::{Assignment, DeleteQuery, Filter, ModelSchema, Op, SqlValue, UpdateQuery, WhereExpr};
use crate::sql::sqlx::PgPool;
use crate::sql::{delete as sql_delete, update as sql_update, ExecError};
#[must_use]
pub fn active_filter(model: &'static ModelSchema) -> Option<WhereExpr> {
let col = model.soft_delete_column?;
Some(WhereExpr::Predicate(Filter {
column: col,
op: Op::IsNull,
value: SqlValue::Bool(true),
}))
}
#[must_use]
pub fn trashed_filter(model: &'static ModelSchema) -> Option<WhereExpr> {
let col = model.soft_delete_column?;
Some(WhereExpr::Predicate(Filter {
column: col,
op: Op::IsNull,
value: SqlValue::Bool(false),
}))
}
#[must_use]
pub fn compose_with_active(
model: &'static ModelSchema,
existing: WhereExpr,
) -> WhereExpr {
let Some(active) = active_filter(model) else {
return existing;
};
if existing.is_empty() {
return active;
}
WhereExpr::And(vec![existing, active])
}
#[must_use]
pub fn compose_with_trashed(
model: &'static ModelSchema,
existing: WhereExpr,
) -> WhereExpr {
let Some(trashed) = trashed_filter(model) else {
return existing;
};
if existing.is_empty() {
return trashed;
}
WhereExpr::And(vec![existing, trashed])
}
#[derive(Debug, thiserror::Error)]
pub enum SoftDeleteError {
#[error("model `{0}` is not soft-delete-enabled (missing #[rustango(soft_delete)])")]
NotSoftDeleteEnabled(&'static str),
#[error(transparent)]
Exec(#[from] ExecError),
}
pub async fn soft_delete(
pool: &PgPool,
model: &'static ModelSchema,
pk_column: &'static str,
pk_value: SqlValue,
) -> Result<u64, SoftDeleteError> {
let col = model
.soft_delete_column
.ok_or(SoftDeleteError::NotSoftDeleteEnabled(model.name))?;
let n = sql_update(
pool,
&UpdateQuery {
model,
set: vec![Assignment {
column: col,
value: SqlValue::from(chrono::Utc::now()),
}],
where_clause: WhereExpr::Predicate(Filter {
column: pk_column,
op: Op::Eq,
value: pk_value,
}),
},
)
.await?;
Ok(n)
}
pub async fn restore(
pool: &PgPool,
model: &'static ModelSchema,
pk_column: &'static str,
pk_value: SqlValue,
) -> Result<u64, SoftDeleteError> {
let col = model
.soft_delete_column
.ok_or(SoftDeleteError::NotSoftDeleteEnabled(model.name))?;
let n = sql_update(
pool,
&UpdateQuery {
model,
set: vec![Assignment {
column: col,
value: SqlValue::Null,
}],
where_clause: WhereExpr::Predicate(Filter {
column: pk_column,
op: Op::Eq,
value: pk_value,
}),
},
)
.await?;
Ok(n)
}
pub async fn purge(
pool: &PgPool,
model: &'static ModelSchema,
pk_column: &'static str,
pk_value: SqlValue,
) -> Result<u64, ExecError> {
sql_delete(
pool,
&DeleteQuery {
model,
where_clause: WhereExpr::Predicate(Filter {
column: pk_column,
op: Op::Eq,
value: pk_value,
}),
},
)
.await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::{FieldSchema, FieldType};
static FIELDS_WITH_SD: &[FieldSchema] = &[
FieldSchema {
name: "id",
column: "id",
ty: FieldType::I64,
nullable: false,
primary_key: true,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: true,
unique: false,
},
FieldSchema {
name: "title",
column: "title",
ty: FieldType::String,
nullable: false,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
},
FieldSchema {
name: "deleted_at",
column: "deleted_at",
ty: FieldType::DateTime,
nullable: true,
primary_key: false,
relation: None,
max_length: None,
min: None,
max: None,
default: None,
auto: false,
unique: false,
},
];
static MODEL_WITH_SD: ModelSchema = ModelSchema {
name: "Post",
table: "posts",
fields: FIELDS_WITH_SD,
display: None,
app_label: None,
admin: None,
soft_delete_column: Some("deleted_at"),
audit_track: None,
permissions: false,
indexes: &[],
check_constraints: &[],
m2m: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
};
static MODEL_WITHOUT_SD: ModelSchema = ModelSchema {
name: "Tag",
table: "tags",
fields: FIELDS_WITH_SD, display: None,
app_label: None,
admin: None,
soft_delete_column: None,
audit_track: None,
permissions: false,
indexes: &[],
check_constraints: &[],
m2m: &[],
composite_relations: &[],
generic_relations: &[],
scope: crate::core::ModelScope::Tenant,
};
#[test]
fn active_filter_returns_is_null_predicate() {
let f = active_filter(&MODEL_WITH_SD).unwrap();
match f {
WhereExpr::Predicate(Filter { column, op, value }) => {
assert_eq!(column, "deleted_at");
assert_eq!(op, Op::IsNull);
assert_eq!(value, SqlValue::Bool(true));
}
other => panic!("expected predicate, got {other:?}"),
}
}
#[test]
fn trashed_filter_returns_is_not_null_predicate() {
let f = trashed_filter(&MODEL_WITH_SD).unwrap();
match f {
WhereExpr::Predicate(Filter { column, op, value }) => {
assert_eq!(column, "deleted_at");
assert_eq!(op, Op::IsNull);
assert_eq!(value, SqlValue::Bool(false));
}
other => panic!("expected predicate, got {other:?}"),
}
}
#[test]
fn no_filter_when_model_lacks_soft_delete_column() {
assert!(active_filter(&MODEL_WITHOUT_SD).is_none());
assert!(trashed_filter(&MODEL_WITHOUT_SD).is_none());
}
#[test]
fn compose_with_active_returns_input_when_no_sd_column() {
let existing = WhereExpr::Predicate(Filter {
column: "title",
op: Op::Eq,
value: SqlValue::String("hi".into()),
});
let composed = compose_with_active(&MODEL_WITHOUT_SD, existing.clone());
assert_eq!(composed, existing);
}
#[test]
fn compose_with_active_returns_filter_when_existing_is_empty() {
let composed = compose_with_active(&MODEL_WITH_SD, WhereExpr::And(vec![]));
assert!(matches!(composed, WhereExpr::Predicate(Filter { op: Op::IsNull, .. })));
}
#[test]
fn compose_with_active_ands_when_existing_nonempty() {
let existing = WhereExpr::Predicate(Filter {
column: "title",
op: Op::Eq,
value: SqlValue::String("hi".into()),
});
let composed = compose_with_active(&MODEL_WITH_SD, existing);
match composed {
WhereExpr::And(items) => {
assert_eq!(items.len(), 2);
assert!(matches!(&items[0], WhereExpr::Predicate(f) if f.column == "title"));
assert!(matches!(&items[1], WhereExpr::Predicate(f) if f.column == "deleted_at"));
}
other => panic!("expected And, got {other:?}"),
}
}
#[test]
fn compose_with_trashed_mirrors_active_for_consistency() {
let composed = compose_with_trashed(&MODEL_WITH_SD, WhereExpr::And(vec![]));
match composed {
WhereExpr::Predicate(Filter { op, value, .. }) => {
assert_eq!(op, Op::IsNull);
assert_eq!(value, SqlValue::Bool(false));
}
other => panic!("expected trashed predicate, got {other:?}"),
}
}
#[tokio::test]
async fn soft_delete_on_unsupported_model_returns_clear_error() {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://localhost:1/none")
.unwrap();
let err = soft_delete(&pool, &MODEL_WITHOUT_SD, "id", SqlValue::I64(1))
.await
.unwrap_err();
assert!(matches!(err, SoftDeleteError::NotSoftDeleteEnabled("Tag")));
}
#[tokio::test]
async fn restore_on_unsupported_model_returns_clear_error() {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect_lazy("postgres://localhost:1/none")
.unwrap();
let err = restore(&pool, &MODEL_WITHOUT_SD, "id", SqlValue::I64(1))
.await
.unwrap_err();
assert!(matches!(err, SoftDeleteError::NotSoftDeleteEnabled("Tag")));
}
}