Skip to main content

rustango/
bulk_actions.rs

1//! Pluggable bulk actions for the auto-admin — the "Action" dropdown
2//! above a list view.
3//!
4//! A [`BulkAction`](crate::bulk_actions::BulkAction) has a codename, a
5//! label, and a `run` that acts on a [`PkSet`] of selected primary keys.
6//! The admin's "Action" dropdown is built from a
7//! [`BulkActionRegistry`](crate::bulk_actions::BulkActionRegistry) mounted
8//! on the [`crate::server::Builder`].
9//!
10//! The built-in actions write through the ORM, so they work on PG, MySQL
11//! and SQLite, and audited models get one audit row per changed row.
12
13use std::collections::HashMap;
14use std::sync::Arc;
15
16use async_trait::async_trait;
17
18use crate::core::{
19    Assignment, FieldSchema, FieldType, Filter, ModelSchema, Op, SqlValue, WhereExpr,
20};
21use crate::sql::Pool;
22
23#[derive(Debug, thiserror::Error)]
24pub enum BulkActionError {
25    #[error("unknown action: {0}")]
26    UnknownAction(String),
27    #[error("invalid table or column identifier: {0}")]
28    InvalidIdent(String),
29    /// A key that does not fit the model's primary key type.
30    #[error("invalid primary key: {0}")]
31    InvalidPk(String),
32    #[error("database error: {0}")]
33    Database(String),
34}
35
36/// Outcome of a bulk action invocation.
37#[derive(Debug, Clone)]
38pub struct BulkActionResult {
39    /// Number of database rows affected.
40    pub affected: u64,
41    /// Action name that ran.
42    pub action: String,
43    /// Table the action targeted.
44    pub table: String,
45}
46
47/// Selected primary keys, each typed from the model's PK field, so a key
48/// can never be bound against a PK column of another type (#1817).
49#[derive(Debug, Clone)]
50pub struct PkSet {
51    model: &'static ModelSchema,
52    keys: Vec<SqlValue>,
53}
54
55impl PkSet {
56    /// Most keys one set holds: one `IN` list stays under every
57    /// backend's bind cap (SQLite: 32766).
58    pub const MAX_KEYS: usize = 10_000;
59
60    fn capped(
61        model: &'static ModelSchema,
62        keys: impl Iterator<Item = Result<SqlValue, BulkActionError>>,
63    ) -> Result<Self, BulkActionError> {
64        let keys: Vec<SqlValue> = keys.take(Self::MAX_KEYS + 1).collect::<Result<_, _>>()?;
65        if keys.len() > Self::MAX_KEYS {
66            return Err(BulkActionError::InvalidPk(format!(
67                "more than {} keys in one action",
68                Self::MAX_KEYS
69            )));
70        }
71        Ok(Self { model, keys })
72    }
73
74    /// Parse raw keys (form or URL values) with the PK field's type.
75    ///
76    /// # Errors
77    /// [`BulkActionError::InvalidPk`] when the model has no PK, a key
78    /// does not parse as its type, or past [`Self::MAX_KEYS`] keys.
79    pub fn parse<S: AsRef<str>>(
80        model: &'static ModelSchema,
81        raw: impl IntoIterator<Item = S>,
82    ) -> Result<Self, BulkActionError> {
83        let pk = pk_field(model)?;
84        let keys = raw.into_iter().map(|r| {
85            crate::forms::parse_pk_string(pk, r.as_ref())
86                .map_err(|e| BulkActionError::InvalidPk(e.to_string()))
87        });
88        Self::capped(model, keys)
89    }
90
91    /// Typed keys. Integers are narrowed to the PK's width; any other
92    /// type mismatch is refused.
93    ///
94    /// # Errors
95    /// [`BulkActionError::InvalidPk`] when the model has no PK, a key
96    /// does not fit its type, or past [`Self::MAX_KEYS`] keys.
97    pub fn new<V: Into<SqlValue>>(
98        model: &'static ModelSchema,
99        keys: impl IntoIterator<Item = V>,
100    ) -> Result<Self, BulkActionError> {
101        let pk = pk_field(model)?;
102        Self::capped(model, keys.into_iter().map(|k| coerce_key(pk, k.into())))
103    }
104
105    /// The model the keys belong to.
106    #[must_use]
107    pub fn model(&self) -> &'static ModelSchema {
108        self.model
109    }
110
111    /// The typed keys.
112    #[must_use]
113    pub fn keys(&self) -> &[SqlValue] {
114        &self.keys
115    }
116
117    /// `true` when no key is selected.
118    #[must_use]
119    pub fn is_empty(&self) -> bool {
120        self.keys.is_empty()
121    }
122
123    /// `<pk column> IN (keys)`.
124    fn filter(&self) -> WhereExpr {
125        // `pk_field` passed at construction, so the PK is there.
126        let column = self.model.primary_key().map_or("id", |pk| pk.column);
127        WhereExpr::Predicate(Filter::new(
128            column,
129            Op::In,
130            SqlValue::List(self.keys.clone()),
131        ))
132    }
133
134    /// The model to write through: the audited one on this table, if any (#1794).
135    fn target(&self) -> &'static ModelSchema {
136        first_audited(inventory::iter::<crate::core::ModelEntry>, self.model.table)
137            .unwrap_or(self.model)
138    }
139}
140
141fn pk_field(model: &'static ModelSchema) -> Result<&'static FieldSchema, BulkActionError> {
142    model
143        .primary_key()
144        .ok_or_else(|| BulkActionError::InvalidPk(format!("`{}` has no primary key", model.table)))
145}
146
147fn coerce_key(pk: &FieldSchema, v: SqlValue) -> Result<SqlValue, BulkActionError> {
148    let int = match v {
149        SqlValue::I16(n) => Some(i64::from(n)),
150        SqlValue::I32(n) => Some(i64::from(n)),
151        SqlValue::I64(n) => Some(n),
152        _ => None,
153    };
154    let typed = match pk.ty {
155        FieldType::I64 => int.map(SqlValue::I64),
156        FieldType::I32 => int.and_then(|n| i32::try_from(n).ok()).map(SqlValue::I32),
157        FieldType::I16 => int.and_then(|n| i16::try_from(n).ok()).map(SqlValue::I16),
158        FieldType::String if matches!(v, SqlValue::String(_)) => Some(v.clone()),
159        FieldType::Uuid if matches!(v, SqlValue::Uuid(_)) => Some(v.clone()),
160        _ => None,
161    };
162    typed.ok_or_else(|| {
163        BulkActionError::InvalidPk(format!("`{}` is {}, got {v:?}", pk.name, pk.ty.as_str()))
164    })
165}
166
167/// Pluggable bulk action.
168#[async_trait]
169pub trait BulkAction: Send + Sync + 'static {
170    /// Action codename used in URLs / select dropdowns (e.g. `"delete_selected"`).
171    fn name(&self) -> &str;
172
173    /// Human-readable label rendered in the UI dropdown.
174    fn label(&self) -> &str;
175
176    /// Apply this action to the rows whose primary keys are in `pks`.
177    async fn run(&self, pks: &PkSet, pool: &Pool) -> Result<BulkActionResult, BulkActionError>;
178}
179
180/// `Arc<dyn BulkAction>` alias.
181pub type BoxedAction = Arc<dyn BulkAction>;
182
183/// Action registry — keyed by `name()`.
184pub struct BulkActionRegistry {
185    actions: HashMap<String, BoxedAction>,
186}
187
188impl Default for BulkActionRegistry {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl BulkActionRegistry {
195    /// New empty registry.
196    #[must_use]
197    pub fn new() -> Self {
198        Self {
199            actions: HashMap::new(),
200        }
201    }
202
203    /// Register an action. Builder-style — chain multiple `register` calls.
204    #[must_use]
205    pub fn register(mut self, action: BoxedAction) -> Self {
206        self.actions.insert(action.name().to_owned(), action);
207        self
208    }
209
210    /// Look up a registered action by name.
211    #[must_use]
212    pub fn get(&self, name: &str) -> Option<&BoxedAction> {
213        self.actions.get(name)
214    }
215
216    /// All registered (name, label) pairs — for rendering dropdowns.
217    #[must_use]
218    pub fn list(&self) -> Vec<(String, String)> {
219        let mut out: Vec<(String, String)> = self
220            .actions
221            .values()
222            .map(|a| (a.name().to_owned(), a.label().to_owned()))
223            .collect();
224        out.sort_by(|a, b| a.0.cmp(&b.0));
225        out
226    }
227
228    /// Run a registered action by name. Returns [`BulkActionError::UnknownAction`]
229    /// if `name` is not registered.
230    pub async fn run(
231        &self,
232        name: &str,
233        pks: &PkSet,
234        pool: &Pool,
235    ) -> Result<BulkActionResult, BulkActionError> {
236        let action = self
237            .get(name)
238            .ok_or_else(|| BulkActionError::UnknownAction(name.to_owned()))?;
239        action.run(pks, pool).await
240    }
241}
242
243/// Both conditions in one `find`, so an unaudited proxy on the table cannot win.
244fn first_audited<'a>(
245    entries: impl IntoIterator<Item = &'a crate::core::ModelEntry>,
246    table: &str,
247) -> Option<&'static ModelSchema> {
248    entries
249        .into_iter()
250        .find(|e| e.schema.table == table && e.audited_delete().is_some())
251        .map(|e| e.schema)
252}
253
254/// `SET column = value WHERE pk IN (…) AND column IS [NOT] NULL`, audited as `op`.
255async fn set_column(
256    pool: &Pool,
257    pks: &PkSet,
258    column: &'static str,
259    value: SqlValue,
260    op: crate::audit::AuditOp,
261    only_null: bool,
262) -> Result<u64, BulkActionError> {
263    let model = pks.target();
264    let field = model
265        .field_by_column(column)
266        .ok_or_else(|| BulkActionError::InvalidIdent(format!("{}.{column}", model.table)))?;
267    if pks.is_empty() {
268        return Ok(0);
269    }
270    let mut filter = pks.filter();
271    filter.push_and(WhereExpr::Predicate(Filter::new(
272        field.column,
273        Op::IsNull,
274        only_null,
275    )));
276    let set = vec![Assignment::new(field.column, value)];
277    let q = crate::core::UpdateQuery::new(model, set, filter);
278    crate::audit::update_as(pool, &q, op).await.map_err(db_err)
279}
280
281fn done(action: &dyn BulkAction, pks: &PkSet, affected: u64) -> BulkActionResult {
282    BulkActionResult {
283        affected,
284        action: action.name().to_owned(),
285        table: pks.model().table.to_owned(),
286    }
287}
288
289fn db_err(e: crate::sql::ExecError) -> BulkActionError {
290    BulkActionError::Database(e.to_string())
291}
292
293// ------------------------------------------------------------------ Built-in actions
294
295/// Hard-delete every selected row.
296pub struct BulkDeleteAction;
297
298#[async_trait]
299impl BulkAction for BulkDeleteAction {
300    fn name(&self) -> &str {
301        "delete_selected"
302    }
303    fn label(&self) -> &str {
304        "Delete selected"
305    }
306
307    async fn run(&self, pks: &PkSet, pool: &Pool) -> Result<BulkActionResult, BulkActionError> {
308        if pks.is_empty() {
309            return Ok(done(self, pks, 0));
310        }
311        let q = crate::core::DeleteQuery::new(pks.target(), pks.filter());
312        let affected = crate::audit::delete(pool, &q).await.map_err(db_err)?;
313        Ok(done(self, pks, affected))
314    }
315}
316
317/// Soft-delete: set the named column (typically `deleted_at`) to the
318/// current UTC time for every selected row. Only updates rows where
319/// the column is currently NULL.
320pub struct BulkSoftDeleteAction {
321    /// SQL column name to set (e.g. `"deleted_at"`).
322    pub column: &'static str,
323}
324
325#[async_trait]
326impl BulkAction for BulkSoftDeleteAction {
327    fn name(&self) -> &str {
328        "soft_delete_selected"
329    }
330    fn label(&self) -> &str {
331        "Soft-delete selected"
332    }
333
334    async fn run(&self, pks: &PkSet, pool: &Pool) -> Result<BulkActionResult, BulkActionError> {
335        let now = SqlValue::DateTime(chrono::Utc::now());
336        let op = crate::audit::AuditOp::SoftDelete;
337        let affected = set_column(pool, pks, self.column, now, op, true).await?;
338        Ok(done(self, pks, affected))
339    }
340}
341
342/// Restore: set a soft-delete column back to NULL for every selected
343/// deleted row.
344pub struct BulkRestoreAction {
345    pub column: &'static str,
346}
347
348#[async_trait]
349impl BulkAction for BulkRestoreAction {
350    fn name(&self) -> &str {
351        "restore_selected"
352    }
353    fn label(&self) -> &str {
354        "Restore selected"
355    }
356
357    async fn run(&self, pks: &PkSet, pool: &Pool) -> Result<BulkActionResult, BulkActionError> {
358        // Only deleted rows, so an active one writes no audit row.
359        let op = crate::audit::AuditOp::Restore;
360        let affected = set_column(pool, pks, self.column, SqlValue::Null, op, false).await?;
361        Ok(done(self, pks, affected))
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368
369    struct Dummy {
370        name: &'static str,
371        label: &'static str,
372    }
373
374    #[async_trait]
375    impl BulkAction for Dummy {
376        fn name(&self) -> &str {
377            self.name
378        }
379        fn label(&self) -> &str {
380            self.label
381        }
382        async fn run(
383            &self,
384            pks: &PkSet,
385            _pool: &Pool,
386        ) -> Result<BulkActionResult, BulkActionError> {
387            Ok(done(self, pks, 0))
388        }
389    }
390
391    #[test]
392    fn registry_starts_empty() {
393        let r = BulkActionRegistry::new();
394        assert!(r.list().is_empty());
395    }
396
397    #[test]
398    fn register_adds_action() {
399        let r = BulkActionRegistry::new()
400            .register(Arc::new(Dummy {
401                name: "a",
402                label: "A",
403            }))
404            .register(Arc::new(Dummy {
405                name: "b",
406                label: "B",
407            }));
408        let list = r.list();
409        assert_eq!(list.len(), 2);
410        assert_eq!(list[0], ("a".to_owned(), "A".to_owned()));
411        assert_eq!(list[1], ("b".to_owned(), "B".to_owned()));
412    }
413
414    #[test]
415    fn get_returns_registered_action() {
416        let r = BulkActionRegistry::new().register(Arc::new(Dummy {
417            name: "x",
418            label: "X",
419        }));
420        assert!(r.get("x").is_some());
421        assert!(r.get("nope").is_none());
422    }
423
424    #[test]
425    fn list_is_alphabetically_sorted() {
426        let r = BulkActionRegistry::new()
427            .register(Arc::new(Dummy {
428                name: "zebra",
429                label: "Z",
430            }))
431            .register(Arc::new(Dummy {
432                name: "apple",
433                label: "A",
434            }))
435            .register(Arc::new(Dummy {
436                name: "mango",
437                label: "M",
438            }));
439        let list = r.list();
440        assert_eq!(list[0].0, "apple");
441        assert_eq!(list[1].0, "mango");
442        assert_eq!(list[2].0, "zebra");
443    }
444
445    #[test]
446    fn re_registering_same_name_replaces() {
447        let r = BulkActionRegistry::new()
448            .register(Arc::new(Dummy {
449                name: "k",
450                label: "old",
451            }))
452            .register(Arc::new(Dummy {
453                name: "k",
454                label: "new",
455            }));
456        let list = r.list();
457        assert_eq!(list.len(), 1);
458        assert_eq!(list[0].1, "new");
459    }
460
461    const fn pk(ty: FieldType) -> FieldSchema {
462        let mut f = FieldSchema::new("code", "code", ty);
463        f.primary_key = true;
464        f
465    }
466    static TEXT_FIELDS: [FieldSchema; 1] = [pk(FieldType::String)];
467    static TEXT_PK: ModelSchema = {
468        let mut s = ModelSchema::new("Text", "text_pk");
469        s.fields = &TEXT_FIELDS;
470        s
471    };
472    static I32_FIELDS: [FieldSchema; 1] = [pk(FieldType::I32)];
473    static I32_PK: ModelSchema = {
474        let mut s = ModelSchema::new("Small", "small_pk");
475        s.fields = &I32_FIELDS;
476        s
477    };
478
479    /// An integer key cannot reach a text PK column (#1817).
480    #[test]
481    fn pk_set_refuses_keys_of_another_type() {
482        assert!(matches!(
483            PkSet::new(&TEXT_PK, [1_i64]),
484            Err(BulkActionError::InvalidPk(_))
485        ));
486        let keys = PkSet::new(&TEXT_PK, ["a".to_owned()]).unwrap();
487        assert_eq!(keys.keys(), [SqlValue::String("a".into())]);
488        assert!(PkSet::parse(&I32_PK, ["x"]).is_err());
489        assert!(PkSet::new(&I32_PK, [i64::MAX]).is_err());
490        let keys = PkSet::new(&I32_PK, [7_i64]).unwrap();
491        assert_eq!(keys.keys(), [SqlValue::I32(7)]);
492    }
493
494    #[test]
495    fn pk_set_filters_on_the_schema_pk_column() {
496        let keys = PkSet::parse(&TEXT_PK, ["a"]).unwrap();
497        match keys.filter() {
498            WhereExpr::Predicate(f) => assert_eq!(f.column, "code"),
499            other => panic!("{other:?}"),
500        }
501    }
502
503    #[test]
504    fn pk_set_needs_a_primary_key() {
505        static NO_PK: ModelSchema = ModelSchema::new("NoPk", "no_pk");
506        assert!(PkSet::parse(&NO_PK, ["1"]).is_err());
507    }
508
509    #[cfg(feature = "sqlite")]
510    #[tokio::test]
511    async fn unknown_action_returns_error() {
512        // Lazy pool: the lookup fails before any SQL runs.
513        let sq = crate::sql::sqlx::SqlitePool::connect_lazy("sqlite::memory:").unwrap();
514        let pool: Pool = sq.into();
515        let r = BulkActionRegistry::new();
516        let keys = PkSet::parse(&TEXT_PK, ["a"]).unwrap();
517        let err = r.run("nonexistent", &keys, &pool).await.unwrap_err();
518        assert!(matches!(err, BulkActionError::UnknownAction(_)));
519    }
520
521    fn fake_delete<'a>(
522        _: &'a Pool,
523        _: &'a crate::core::DeleteQuery,
524    ) -> std::pin::Pin<
525        Box<dyn std::future::Future<Output = Result<u64, crate::sql::ExecError>> + Send + 'a>,
526    > {
527        Box::pin(async { Ok(0) })
528    }
529
530    /// An unaudited proxy listed first on the same table must not win.
531    #[test]
532    fn first_audited_skips_an_unaudited_model_on_the_table() {
533        static PROXY: ModelSchema = ModelSchema::new("Proxy", "shared");
534        static REAL: ModelSchema = ModelSchema::new("Real", "shared");
535        let entries = [
536            crate::core::ModelEntry::new(&PROXY, "t"),
537            crate::core::ModelEntry::new(&REAL, "t").with_audited(|| None, || Some(fake_delete)),
538        ];
539        let got = first_audited(&entries, "shared").map(|m| m.name);
540        assert_eq!(got, Some("Real"));
541        assert!(first_audited(&entries[..1], "shared").is_none());
542    }
543
544    #[test]
545    fn builtin_action_names() {
546        assert_eq!(BulkDeleteAction.name(), "delete_selected");
547        assert_eq!(
548            BulkSoftDeleteAction {
549                column: "deleted_at"
550            }
551            .name(),
552            "soft_delete_selected"
553        );
554        assert_eq!(
555            BulkRestoreAction {
556                column: "deleted_at"
557            }
558            .name(),
559            "restore_selected"
560        );
561    }
562}