1use 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 #[error("invalid primary key: {0}")]
31 InvalidPk(String),
32 #[error("database error: {0}")]
33 Database(String),
34}
35
36#[derive(Debug, Clone)]
38pub struct BulkActionResult {
39 pub affected: u64,
41 pub action: String,
43 pub table: String,
45}
46
47#[derive(Debug, Clone)]
50pub struct PkSet {
51 model: &'static ModelSchema,
52 keys: Vec<SqlValue>,
53}
54
55impl PkSet {
56 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 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 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 #[must_use]
107 pub fn model(&self) -> &'static ModelSchema {
108 self.model
109 }
110
111 #[must_use]
113 pub fn keys(&self) -> &[SqlValue] {
114 &self.keys
115 }
116
117 #[must_use]
119 pub fn is_empty(&self) -> bool {
120 self.keys.is_empty()
121 }
122
123 fn filter(&self) -> WhereExpr {
125 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 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#[async_trait]
169pub trait BulkAction: Send + Sync + 'static {
170 fn name(&self) -> &str;
172
173 fn label(&self) -> &str;
175
176 async fn run(&self, pks: &PkSet, pool: &Pool) -> Result<BulkActionResult, BulkActionError>;
178}
179
180pub type BoxedAction = Arc<dyn BulkAction>;
182
183pub 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 #[must_use]
197 pub fn new() -> Self {
198 Self {
199 actions: HashMap::new(),
200 }
201 }
202
203 #[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 #[must_use]
212 pub fn get(&self, name: &str) -> Option<&BoxedAction> {
213 self.actions.get(name)
214 }
215
216 #[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 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
243fn 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
254async 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
293pub 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
317pub struct BulkSoftDeleteAction {
321 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
342pub 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 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 #[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 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 #[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}