Skip to main content

cratestack_sqlx/query/write/
delete_many.rs

1//! Bulk DELETE-by-predicate: one statement tombstones (soft-delete)
2//! or removes (hard-delete) every row matching the filter AND the
3//! delete policy.
4//!
5//! Same shape as `update_many` — refuses to run without ≥1 filter so
6//! callers can't accidentally truncate a table at the typed-builder
7//! level.
8
9use cratestack_core::{BatchSummary, CoolContext, CoolError};
10
11use crate::audit::dispatch_audit_sink;
12use crate::{FilterExpr, ModelDescriptor, SqlxRuntime, cool_error_from_sqlx, sqlx};
13
14use super::delete_many_exec::run_delete_many_in_tx;
15
16#[derive(Debug, Clone)]
17pub struct DeleteMany<'a, M: 'static, PK: 'static> {
18    pub(crate) runtime: &'a SqlxRuntime,
19    pub(crate) descriptor: &'static ModelDescriptor<M, PK>,
20    pub(crate) filters: Vec<FilterExpr>,
21}
22
23impl<'a, M: 'static, PK: 'static> DeleteMany<'a, M, PK> {
24    pub fn where_(mut self, filter: crate::Filter) -> Self {
25        self.filters.push(FilterExpr::from(filter));
26        self
27    }
28
29    pub fn where_expr(mut self, filter: FilterExpr) -> Self {
30        self.filters.push(filter);
31        self
32    }
33
34    pub fn where_any(mut self, filters: impl IntoIterator<Item = FilterExpr>) -> Self {
35        self.filters.push(FilterExpr::any(filters));
36        self
37    }
38
39    /// Conditionally append a filter; `None` is a no-op.
40    pub fn where_optional<F>(mut self, filter: Option<F>) -> Self
41    where
42        F: Into<FilterExpr>,
43    {
44        if let Some(filter) = filter {
45            self.filters.push(filter.into());
46        }
47        self
48    }
49
50    /// Approximate SQL preview. The runtime path interpolates filter
51    /// predicates and the delete policy clause; this returns the rough
52    /// shape for migration tooling and the schema studio.
53    pub fn preview_sql(&self) -> String {
54        let mut sql = match self.descriptor.soft_delete_column {
55            Some(col) => {
56                let mut s = format!("UPDATE {} SET {col} = NOW()", self.descriptor.table_name);
57                if let Some(version_col) = self.descriptor.version_column {
58                    s.push_str(&format!(", {version_col} = {version_col} + 1"));
59                }
60                s.push_str(&format!(" WHERE {col} IS NULL AND "));
61                s
62            }
63            None => format!("DELETE FROM {} WHERE ", self.descriptor.table_name),
64        };
65        sql.push_str("<filters> AND <delete_policy> RETURNING ");
66        sql.push_str(&self.descriptor.select_projection());
67        sql
68    }
69
70    pub async fn run(self, ctx: &CoolContext) -> Result<BatchSummary, CoolError>
71    where
72        for<'r> M: Send + Unpin + sqlx::FromRow<'r, sqlx::postgres::PgRow> + serde::Serialize,
73    {
74        let runtime = self.runtime;
75        let descriptor = self.descriptor;
76        let mut tx = runtime.pool().begin().await.map_err(cool_error_from_sqlx)?;
77        let (summary, emits_event, audit_events) =
78            run_delete_many_in_tx(&mut tx, runtime, descriptor, &self.filters, ctx).await?;
79        tx.commit().await.map_err(cool_error_from_sqlx)?;
80        if emits_event {
81            let _ = runtime.drain_event_outbox().await;
82        }
83        dispatch_audit_sink(runtime, &audit_events).await;
84        Ok(summary)
85    }
86
87    /// No `AuditSink` fan-out happens here for the same reason the
88    /// event outbox isn't drained here — see `create.rs`'s
89    /// `run_in_tx` doc comment.
90    pub async fn run_in_tx<'tx>(
91        self,
92        tx: &mut sqlx::Transaction<'tx, sqlx::Postgres>,
93        ctx: &CoolContext,
94    ) -> Result<BatchSummary, CoolError>
95    where
96        for<'r> M: Send + Unpin + sqlx::FromRow<'r, sqlx::postgres::PgRow> + serde::Serialize,
97    {
98        let (summary, ..) =
99            run_delete_many_in_tx(tx, self.runtime, self.descriptor, &self.filters, ctx).await?;
100        Ok(summary)
101    }
102}