Skip to main content

laterite_admin/
persist.rs

1//! The persister seam: a form's write is a handler, resolved at boot from a
2//! string key on the descriptor.
3//!
4//! Every form gets a [`DefaultPersister`] (the descriptor-driven single-statement
5//! insert/update) unless it names a registered [`Persister`], letting an entity
6//! that needs a custom, atomic, multi-statement write (a tree's ancestor closure,
7//! say) plug in without a hand-written CRUD handler. A persister is a registered
8//! domain write service, the same category as a picker source.
9
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use laterite_core::query::{bind_values, build as to_sql, insert_returning_id_on, text_cast};
14use laterite_core::strata::async_trait;
15use laterite_core::validation::ErrorBag;
16use laterite_core::AnyRowExt;
17use laterite_core::{t, Actor, AttrValue, Db, ModelListener, Op, Record, SaveCx, SavedCx, Text};
18use sea_query::{Alias, Expr, Query, SimpleExpr};
19
20use crate::form::FormConfig;
21use crate::sql::valid_ident;
22
23/// Why a persist failed.
24#[derive(Debug)]
25pub enum SaveError {
26    /// A persist-time domain check (for example "re-parenting is not supported"):
27    /// the form re-renders 422 with these per-field messages.
28    Invalid(ErrorBag),
29    /// The write itself failed: logged, and the form re-renders the generic
30    /// banner.
31    Failed(String),
32}
33
34/// Why a delete failed.
35#[derive(Debug)]
36pub enum DeleteError {
37    /// A listener or the persister refused it. Unlike a save, a delete has no
38    /// fields to hang messages on, so this is one sentence for the operator,
39    /// such as "three records still reference this one".
40    Refused(Text),
41    /// The delete itself failed: logged, and the operator sees a generic
42    /// message.
43    Failed(String),
44}
45
46/// Replaces the default insert/update for one form. An implementor owns its own
47/// transaction when the write is multi-statement. `rec` holds the validated
48/// submission plus anything the listeners added; `rec.to_text_map()` gives the
49/// all-text shape if that suits an implementation better.
50#[async_trait]
51pub trait Persister: Send + Sync + 'static {
52    /// Persists a new record, returning its id.
53    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError>;
54    /// Persists an edit to the record with primary key `id`.
55    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError>;
56
57    /// The row as it stands, read in the same transaction, so listeners can see
58    /// what an update changes. Returning `None` (the default) means this
59    /// persister supplies no snapshot and change-based listeners sit out.
60    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError> {
61        let _ = (cx, id);
62        Ok(None)
63    }
64
65    /// Puts the record under another parent, on the pipeline's transaction:
66    /// what a tree list's "Move to" calls. The default writes the parent
67    /// column; a persister keeping more than that column in step (a path, a
68    /// closure table) overrides it.
69    async fn move_to(&self, cx: &mut SaveCx<'_>, mv: &MoveTo<'_>) -> Result<(), SaveError> {
70        if !(valid_ident(mv.entity) && valid_ident(mv.id_field) && valid_ident(mv.parent_key)) {
71            return Err(SaveError::Failed("invalid identifier".to_string()));
72        }
73        let backend = cx.backend();
74        let (sql, values) = to_sql(
75            backend,
76            Query::update()
77                .table(Alias::new(mv.entity))
78                .value(
79                    Alias::new(mv.parent_key),
80                    match mv.parent.and_then(|p| p.parse::<i64>().ok()) {
81                        Some(parent) => SimpleExpr::from(parent),
82                        None => SimpleExpr::Keyword(sea_query::Keyword::Null),
83                    },
84                )
85                .and_where(
86                    Expr::col(Alias::new(mv.id_field))
87                        .cast_as(Alias::new(text_cast(backend)))
88                        .eq(mv.id.to_string()),
89                )
90                .to_owned(),
91        );
92        bind_values(sqlx::query(&sql), values)
93            .execute(cx.conn())
94            .await
95            .map_err(|e| SaveError::Failed(e.to_string()))?;
96        Ok(())
97    }
98
99    /// Removes the record with primary key `id`, on the pipeline's transaction.
100    ///
101    /// The default refuses. A persister that writes through more than one table
102    /// has to say how those rows come apart, and guessing at that is worse than
103    /// declining: an implementor opts in by overriding this.
104    async fn delete(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), DeleteError> {
105        let _ = (cx, id, rec);
106        Err(DeleteError::Refused(t!(
107            "Deleting this kind of record is not supported."
108        )))
109    }
110}
111
112/// A move under another parent, as [`Persister::move_to`] receives it.
113#[derive(Debug)]
114#[non_exhaustive]
115pub struct MoveTo<'a> {
116    pub entity: &'a str,
117    pub id_field: &'a str,
118    /// The record moving.
119    pub id: &'a str,
120    /// The column holding the parent id.
121    pub parent_key: &'a str,
122    /// The new parent's id, or `None` for the top level.
123    pub parent: Option<&'a str>,
124}
125
126/// Binds one attribute for the storage layer.
127///
128/// Absent, null, and empty text all render the SQL `NULL` keyword, which every
129/// backend accepts for a column of any type; a bound null parameter carries a
130/// text type that Postgres refuses for a bigint column. Booleans bind as
131/// integers (the one representation every backend indexes), and timestamps
132/// and JSON bind as text.
133fn bind(value: Option<&AttrValue>) -> SimpleExpr {
134    match value {
135        None | Some(AttrValue::Null) => SimpleExpr::Keyword(sea_query::Keyword::Null),
136        Some(AttrValue::Int(i)) => (*i).into(),
137        Some(AttrValue::Float(f)) => (*f).into(),
138        Some(AttrValue::Bool(b)) => i32::from(*b).into(),
139        Some(AttrValue::Text(s)) if s.is_empty() => SimpleExpr::Keyword(sea_query::Keyword::Null),
140        Some(other) => other.to_text().into(),
141    }
142}
143
144/// Runs the listeners around a persister write: each `before_save` may change
145/// the record or refuse the write, then the persister writes, then each
146/// `after_save` runs. `after_save` is not atomic with the write.
147///
148/// `id` is the row to update, and is ignored on a create.
149pub(crate) struct SaveRequest<'a> {
150    pub db: &'a Db,
151    pub listeners: &'a [Arc<dyn ModelListener>],
152    pub persister: &'a dyn Persister,
153    /// Who is performing the write; reaches listeners and the persister.
154    pub actor: &'a Actor,
155    pub op: Op,
156    /// The row to update; `None` on a create.
157    pub id: Option<&'a str>,
158}
159
160pub(crate) async fn save(req: SaveRequest<'_>, rec: &mut Record) -> Result<(), SaveError> {
161    let SaveRequest {
162        db,
163        listeners,
164        persister,
165        actor,
166        op,
167        id,
168    } = req;
169    let mut tx = db
170        .pool
171        .begin()
172        .await
173        .map_err(|e| SaveError::Failed(e.to_string()))?;
174
175    let outcome = save_in(
176        &mut tx,
177        db.backend,
178        listeners,
179        persister,
180        actor.clone(),
181        rec,
182        op,
183        id,
184    )
185    .await;
186    match outcome {
187        Ok(()) => tx
188            .commit()
189            .await
190            .map_err(|e| SaveError::Failed(e.to_string()))?,
191        Err(e) => {
192            // A veto or a failed write rolls the whole save back, snapshot read
193            // included.
194            let _ = tx.rollback().await;
195            return Err(e);
196        }
197    }
198
199    // After the commit, so a listener here cannot roll the write back.
200    let saved = SavedCx::new(db, actor);
201    for listener in listeners {
202        listener.after_save(&saved, rec, op).await;
203    }
204    Ok(())
205}
206
207/// What one delete needs: where to write, who is doing it, and which record.
208pub(crate) struct DeleteRequest<'a> {
209    pub db: &'a Db,
210    pub listeners: &'a [Arc<dyn ModelListener>],
211    pub persister: &'a dyn Persister,
212    pub actor: &'a Actor,
213    pub entity: &'a str,
214    pub id: &'a str,
215}
216
217/// Removes one record through the same pipeline a save runs through: the
218/// transaction is owned here, the listeners run inside it and may refuse, and
219/// the after-stage runs once it has committed.
220///
221/// Returns the record as it was, so the caller can report what went.
222pub(crate) async fn delete(req: DeleteRequest<'_>) -> Result<Record, DeleteError> {
223    let DeleteRequest {
224        db,
225        listeners,
226        persister,
227        actor,
228        entity,
229        id,
230    } = req;
231
232    let mut tx = db
233        .pool
234        .begin()
235        .await
236        .map_err(|e| DeleteError::Failed(e.to_string()))?;
237
238    let outcome = delete_in(
239        &mut tx,
240        db.backend,
241        listeners,
242        persister,
243        actor.clone(),
244        entity,
245        id,
246    )
247    .await;
248    let rec = match outcome {
249        Ok(rec) => {
250            tx.commit()
251                .await
252                .map_err(|e| DeleteError::Failed(e.to_string()))?;
253            rec
254        }
255        Err(e) => {
256            // A veto or a failed delete rolls the whole thing back, the read
257            // included.
258            let _ = tx.rollback().await;
259            return Err(e);
260        }
261    };
262
263    // After the commit: the row is gone, so nothing here can undo it.
264    let saved = SavedCx::new(db, actor);
265    for listener in listeners {
266        listener.after_delete(&saved, &rec).await;
267    }
268    Ok(rec)
269}
270
271/// Everything a delete does inside the transaction: read the row, offer it to
272/// the listeners, then remove it.
273async fn delete_in(
274    tx: &mut sqlx::Transaction<'_, sqlx::Any>,
275    backend: laterite_core::migration::DbBackend,
276    listeners: &[Arc<dyn ModelListener>],
277    persister: &dyn Persister,
278    actor: Actor,
279    entity: &str,
280    id: &str,
281) -> Result<Record, DeleteError> {
282    let mut cx = SaveCx::new(backend, tx, actor);
283
284    // The row as it stands, so a listener can decide on its contents and the
285    // after-stage can report what was removed. A persister that supplies no
286    // snapshot leaves an identity-only record, the same as on an update.
287    let loaded = persister.load(&mut cx, id).await.map_err(|e| match e {
288        SaveError::Invalid(_) => DeleteError::Failed("snapshot read refused".to_string()),
289        SaveError::Failed(m) => DeleteError::Failed(m),
290    })?;
291    let mut rec = loaded.unwrap_or_else(|| Record::new(entity));
292    if rec.id().is_none() {
293        if let Ok(numeric) = id.parse::<i64>() {
294            rec.set_id(numeric);
295        }
296    }
297
298    for listener in listeners {
299        listener
300            .before_delete(&mut cx, &rec)
301            .await
302            .map_err(DeleteError::Refused)?;
303    }
304
305    persister.delete(&mut cx, id, &rec).await?;
306    Ok(rec)
307}
308
309/// Everything a save does inside the transaction: read the pre-write row, run
310/// the listeners, then write.
311#[allow(clippy::too_many_arguments)]
312async fn save_in(
313    tx: &mut sqlx::Transaction<'_, sqlx::Any>,
314    backend: laterite_core::migration::DbBackend,
315    listeners: &[Arc<dyn ModelListener>],
316    persister: &dyn Persister,
317    actor: Actor,
318    rec: &mut Record,
319    op: Op,
320    id: Option<&str>,
321) -> Result<(), SaveError> {
322    let mut cx = SaveCx::new(backend, tx, actor);
323
324    if op == Op::Update {
325        if let Some(id) = id {
326            if let Some(previous) = persister.load(&mut cx, id).await? {
327                rec.set_original(previous);
328            }
329        }
330    }
331
332    for listener in listeners {
333        listener
334            .before_save(&mut cx, rec, op)
335            .await
336            .map_err(SaveError::Invalid)?;
337    }
338
339    match op {
340        Op::Create => {
341            let new_id = persister.create(&mut cx, rec).await?;
342            rec.set_id(new_id);
343        }
344        Op::Update => {
345            let id = id.ok_or_else(|| SaveError::Failed("update without an id".to_string()))?;
346            persister.update(&mut cx, id, rec).await?;
347        }
348        // `Op` is non-exhaustive; a stage added later needs its arm here.
349        _ => return Err(SaveError::Failed(format!("unsupported operation {op:?}"))),
350    }
351    Ok(())
352}
353
354/// A registered persister: its dotted `vendor.name` key and the handler.
355pub struct PersisterReg {
356    pub name: String,
357    pub persister: Arc<dyn Persister>,
358}
359
360impl PersisterReg {
361    pub fn new(name: impl Into<String>, persister: Arc<dyn Persister>) -> Self {
362        Self {
363            name: name.into(),
364            persister,
365        }
366    }
367}
368
369/// The persister registry, keyed by name.
370pub type PersisterRegistry = HashMap<String, Arc<dyn Persister>>;
371
372/// The built-in write: a descriptor-driven single-statement insert/update over
373/// the form's own columns, what every form gets unless it names a custom one.
374/// Trusts validated identifiers (the form guards them before calling).
375pub(crate) struct DefaultPersister {
376    entity: String,
377    id_field: String,
378    columns: Vec<String>,
379}
380
381impl DefaultPersister {
382    /// The columns to write: the descriptor's own fields, plus any attribute a
383    /// listener added, minus the database-assigned id. Widening this way means a
384    /// listener-injected `created_at` is written rather than silently dropped;
385    /// the form admits only descriptor fields, so nothing a request submitted can
386    /// reach a column it did not declare.
387    fn write_columns(&self, rec: &Record) -> Vec<String> {
388        let mut cols = self.columns.clone();
389        for (key, _) in rec.iter() {
390            if key != self.id_field && !cols.iter().any(|c| c == key) {
391                cols.push(key.to_string());
392            }
393        }
394        cols.retain(|c| c != &self.id_field && crate::sql::valid_ident(c));
395        cols
396    }
397
398    /// A persister over a list's own table, for a resource whose writes are not
399    /// a generic form (a bespoke editor, or a read screen that still deletes).
400    /// The list's columns become the snapshot a listener sees.
401    pub(crate) fn from_list(config: &crate::list::ListConfig) -> Self {
402        Self {
403            entity: config.entity.clone(),
404            id_field: config.id_field.clone(),
405            columns: config.columns.iter().map(|c| c.field.clone()).collect(),
406        }
407    }
408
409    pub(crate) fn from_config(config: &FormConfig) -> Self {
410        Self {
411            entity: config.entity.clone(),
412            id_field: config.id_field.clone(),
413            columns: config.fields.iter().map(|f| f.name.clone()).collect(),
414        }
415    }
416}
417
418#[async_trait]
419impl Persister for DefaultPersister {
420    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError> {
421        // The PK is database-assigned, so the insert never lists it. Scope the
422        // builder so it drops before the await, keeping the future `Send`.
423        let stmt = {
424            let cols = self.write_columns(rec);
425            let vals: Vec<SimpleExpr> = cols.iter().map(|c| bind(rec.get(c))).collect();
426            Query::insert()
427                .into_table(Alias::new(&self.entity))
428                .columns(cols.iter().map(Alias::new))
429                .values_panic(vals)
430                .to_owned()
431        };
432        let backend = cx.backend();
433        insert_returning_id_on(backend, cx.conn(), stmt, Alias::new(&self.id_field))
434            .await
435            .map_err(|e| SaveError::Failed(e.to_string()))
436    }
437
438    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError> {
439        let backend = cx.backend();
440        // Only what the record carries: an attribute a field type left out (a
441        // blank password, a sealed value's mask) keeps the stored value.
442        let columns: Vec<String> = rec
443            .iter()
444            .map(|(key, _)| key.to_string())
445            .filter(|c| c != &self.id_field && crate::sql::valid_ident(c))
446            .collect();
447        if columns.is_empty() {
448            return Ok(());
449        }
450        let (sql, values) = {
451            let mut update = Query::update();
452            update.table(Alias::new(&self.entity));
453            for column in &columns {
454                update.value(Alias::new(column), bind(rec.get(column)));
455            }
456            update.and_where(
457                Expr::col(Alias::new(&self.id_field))
458                    .cast_as(Alias::new(text_cast(backend)))
459                    .eq(id),
460            );
461            to_sql(backend, update)
462        };
463        bind_values(sqlx::query(&sql), values)
464            .execute(cx.conn())
465            .await
466            .map(|_| ())
467            .map_err(|e| SaveError::Failed(e.to_string()))
468    }
469
470    /// Removes the row. One table, one statement, on the pipeline's transaction,
471    /// so a listener's veto rolls it back with everything else.
472    async fn delete(
473        &self,
474        cx: &mut SaveCx<'_>,
475        id: &str,
476        _rec: &Record,
477    ) -> Result<(), DeleteError> {
478        let backend = cx.backend();
479        let (sql, values) = {
480            let mut del = Query::delete();
481            del.from_table(Alias::new(&self.entity));
482            del.and_where(
483                Expr::col(Alias::new(&self.id_field))
484                    .cast_as(Alias::new(text_cast(backend)))
485                    .eq(id),
486            );
487            to_sql(backend, del)
488        };
489        bind_values(sqlx::query(&sql), values)
490            .execute(cx.conn())
491            .await
492            .map(|_| ())
493            .map_err(|e| DeleteError::Failed(e.to_string()))
494    }
495
496    /// Reads the row's declared columns in the transaction, so a listener sees
497    /// what an update changes. Values come back as text, the shape `sqlx::Any`
498    /// gives for a mixed row.
499    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError> {
500        let backend = cx.backend();
501        let (sql, values) = {
502            let mut select = Query::select();
503            select.from(Alias::new(&self.entity));
504            for column in &self.columns {
505                select.expr_as(
506                    Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(backend))),
507                    Alias::new(column),
508                );
509            }
510            select.and_where(
511                Expr::col(Alias::new(&self.id_field))
512                    .cast_as(Alias::new(text_cast(backend)))
513                    .eq(id),
514            );
515            to_sql(backend, select)
516        };
517        let row = bind_values(sqlx::query(&sql), values)
518            .fetch_optional(cx.conn())
519            .await
520            .map_err(|e| SaveError::Failed(e.to_string()))?;
521        let Some(row) = row else {
522            return Ok(None);
523        };
524        let mut previous = Record::new(&self.entity);
525        for column in &self.columns {
526            match row.get_text_opt(column) {
527                Ok(Some(v)) => {
528                    previous.set(column.clone(), v);
529                }
530                Ok(None) => {
531                    previous.set(column.clone(), AttrValue::Null);
532                }
533                Err(e) => return Err(SaveError::Failed(e.to_string())),
534            }
535        }
536        Ok(Some(previous))
537    }
538}
539
540#[cfg(test)]
541mod pipeline_tests {
542    use super::*;
543    use laterite_core::testing::connect_test;
544    use laterite_core::{ListenerTarget, ModelListenerReg, Text};
545    use std::sync::Mutex;
546
547    /// Records what reached the persister, so a test can assert on the write.
548    #[derive(Default)]
549    struct SpyPersister {
550        seen: Mutex<Vec<(String, HashMap<String, String>)>>,
551    }
552
553    #[async_trait]
554    impl Persister for SpyPersister {
555        async fn create(&self, _cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError> {
556            self.seen
557                .lock()
558                .unwrap()
559                .push(("create".into(), rec.to_text_map()));
560            Ok(42)
561        }
562        async fn update(
563            &self,
564            _cx: &mut SaveCx<'_>,
565            id: &str,
566            rec: &Record,
567        ) -> Result<(), SaveError> {
568            self.seen
569                .lock()
570                .unwrap()
571                .push((format!("update:{id}"), rec.to_text_map()));
572            Ok(())
573        }
574    }
575
576    /// Records deletes, and can refuse them.
577    #[derive(Default)]
578    struct DeleteSpy {
579        deleted: Mutex<Vec<String>>,
580        refuse: Option<&'static str>,
581    }
582
583    #[async_trait]
584    impl Persister for DeleteSpy {
585        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
586            Ok(1)
587        }
588        async fn update(
589            &self,
590            _cx: &mut SaveCx<'_>,
591            _id: &str,
592            _rec: &Record,
593        ) -> Result<(), SaveError> {
594            Ok(())
595        }
596        async fn load(&self, _cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError> {
597            let mut rec = Record::new("samples");
598            rec.set("code", format!("code-{id}"));
599            if let Ok(n) = id.parse::<i64>() {
600                rec.set_id(n);
601            }
602            Ok(Some(rec))
603        }
604        async fn delete(
605            &self,
606            _cx: &mut SaveCx<'_>,
607            id: &str,
608            _rec: &Record,
609        ) -> Result<(), DeleteError> {
610            if let Some(why) = self.refuse {
611                return Err(DeleteError::Refused(Text::new(why)));
612            }
613            self.deleted.lock().unwrap().push(id.to_string());
614            Ok(())
615        }
616    }
617
618    /// Refuses every delete, the way a listener guarding its references would.
619    struct Guard;
620
621    #[async_trait]
622    impl ModelListener for Guard {
623        async fn before_delete(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<(), Text> {
624            Err(Text::new("still referenced"))
625        }
626    }
627
628    /// Notes what the after-stage saw, which is the record as it was.
629    #[derive(Default)]
630    struct Mourner {
631        seen: Arc<Mutex<Vec<String>>>,
632    }
633
634    #[async_trait]
635    impl ModelListener for Mourner {
636        async fn after_delete(&self, _cx: &SavedCx<'_>, rec: &Record) {
637            self.seen
638                .lock()
639                .unwrap()
640                .push(rec.text("code").unwrap_or_default().to_string());
641        }
642    }
643
644    #[tokio::test]
645    async fn a_delete_runs_the_listeners_around_the_persister() {
646        let (db, _guard) = connect_test(&[]).await;
647        let persister = DeleteSpy::default();
648        let seen = Arc::new(Mutex::new(Vec::new()));
649        let listeners: Vec<Arc<dyn ModelListener>> = vec![Arc::new(Mourner { seen: seen.clone() })];
650
651        let rec = delete(DeleteRequest {
652            db: &db,
653            listeners: &listeners,
654            persister: &persister,
655            actor: &actor(),
656            entity: "samples",
657            id: "7",
658        })
659        .await
660        .expect("deleted");
661
662        assert_eq!(persister.deleted.lock().unwrap().as_slice(), ["7"]);
663        // The after-stage sees the row as it was, which is the point of reading
664        // it before the delete.
665        assert_eq!(seen.lock().unwrap().as_slice(), ["code-7"]);
666        assert_eq!(rec.text("code"), Some("code-7"));
667        assert_eq!(rec.id(), Some(7));
668    }
669
670    /// A listener's refusal stops the delete: the persister is never reached and
671    /// the after-stage never runs.
672    #[tokio::test]
673    async fn a_listener_can_refuse_a_delete() {
674        let (db, _guard) = connect_test(&[]).await;
675        let persister = DeleteSpy::default();
676        let seen = Arc::new(Mutex::new(Vec::new()));
677        let listeners: Vec<Arc<dyn ModelListener>> =
678            vec![Arc::new(Guard), Arc::new(Mourner { seen: seen.clone() })];
679
680        let err = delete(DeleteRequest {
681            db: &db,
682            listeners: &listeners,
683            persister: &persister,
684            actor: &actor(),
685            entity: "samples",
686            id: "7",
687        })
688        .await
689        .expect_err("refused");
690
691        match err {
692            DeleteError::Refused(message) => assert_eq!(message.source(), "still referenced"),
693            other => panic!("expected a refusal, got {other:?}"),
694        }
695        assert!(
696            persister.deleted.lock().unwrap().is_empty(),
697            "nothing removed"
698        );
699        assert!(
700            seen.lock().unwrap().is_empty(),
701            "no after-stage on a refusal"
702        );
703    }
704
705    /// A persister with no delete of its own says so rather than reporting
706    /// success, since a silent no-op would look like the record went.
707    #[tokio::test]
708    async fn a_persister_without_delete_refuses() {
709        let (db, _guard) = connect_test(&[]).await;
710        let persister = SpyPersister::default();
711        let err = delete(DeleteRequest {
712            db: &db,
713            listeners: &[],
714            persister: &persister,
715            actor: &actor(),
716            entity: "samples",
717            id: "1",
718        })
719        .await
720        .expect_err("unsupported");
721        assert!(matches!(err, DeleteError::Refused(_)));
722    }
723
724    /// Sets an attribute before the write, and appends its name to a shared log
725    /// after it.
726    struct Stamp {
727        key: &'static str,
728        log: Arc<Mutex<Vec<&'static str>>>,
729    }
730
731    #[async_trait]
732    impl ModelListener for Stamp {
733        async fn before_save(
734            &self,
735            _cx: &mut SaveCx<'_>,
736            rec: &mut Record,
737            _op: Op,
738        ) -> Result<(), ErrorBag> {
739            rec.set(self.key, "set");
740            Ok(())
741        }
742        async fn after_save(&self, _cx: &SavedCx<'_>, _rec: &Record, _op: Op) {
743            self.log.lock().unwrap().push(self.key);
744        }
745    }
746
747    /// Refuses every write.
748    struct Veto;
749
750    #[async_trait]
751    impl ModelListener for Veto {
752        async fn before_save(
753            &self,
754            _cx: &mut SaveCx<'_>,
755            _rec: &mut Record,
756            _op: Op,
757        ) -> Result<(), ErrorBag> {
758            let mut bag = ErrorBag::default();
759            // A plain Text, not `t!`: a test fixture must not enter the catalog.
760            bag.add("name", Text::new("Not allowed"));
761            Err(bag)
762        }
763    }
764
765    fn actor() -> Actor {
766        Actor::system("test")
767    }
768
769    fn listeners(regs: &[ModelListenerReg], entity: &str) -> Vec<Arc<dyn ModelListener>> {
770        regs.iter()
771            .filter(|r| r.matches(entity))
772            .map(|r| r.listener.clone())
773            .collect()
774    }
775
776    #[tokio::test]
777    async fn a_listener_mutation_reaches_the_persister_and_the_id_comes_back() {
778        let (db, _guard) = connect_test(&[]).await;
779        let spy = SpyPersister::default();
780        let log = Arc::new(Mutex::new(Vec::new()));
781        let regs = vec![ModelListenerReg::all(Arc::new(Stamp {
782            key: "created_at",
783            log: log.clone(),
784        }))];
785        let mut rec = Record::new("samples");
786        rec.set("name", "Chair");
787
788        save(
789            SaveRequest {
790                db: &db,
791                listeners: &listeners(&regs, "samples"),
792                persister: &spy,
793                actor: &actor(),
794                op: Op::Create,
795                id: None,
796            },
797            &mut rec,
798        )
799        .await
800        .unwrap();
801
802        let seen = spy.seen.lock().unwrap();
803        assert_eq!(seen[0].0, "create");
804        assert_eq!(seen[0].1.get("created_at").map(String::as_str), Some("set"));
805        assert_eq!(rec.id(), Some(42), "the create id lands on the record");
806        assert_eq!(*log.lock().unwrap(), ["created_at"]);
807    }
808
809    #[tokio::test]
810    async fn a_veto_stops_the_write_and_returns_field_messages() {
811        let (db, _guard) = connect_test(&[]).await;
812        let spy = SpyPersister::default();
813        let regs = vec![ModelListenerReg::all(Arc::new(Veto))];
814        let mut rec = Record::new("samples");
815
816        let err = save(
817            SaveRequest {
818                db: &db,
819                listeners: &listeners(&regs, "samples"),
820                persister: &spy,
821                actor: &actor(),
822                op: Op::Create,
823                id: None,
824            },
825            &mut rec,
826        )
827        .await
828        .expect_err("the veto refuses the write");
829
830        match err {
831            SaveError::Invalid(bag) => assert_eq!(bag.messages("name").len(), 1),
832            SaveError::Failed(e) => panic!("expected a field error, got {e}"),
833        }
834        assert!(
835            spy.seen.lock().unwrap().is_empty(),
836            "nothing reached the persister"
837        );
838    }
839
840    #[tokio::test]
841    async fn listeners_run_in_registration_order() {
842        let (db, _guard) = connect_test(&[]).await;
843        let log = Arc::new(Mutex::new(Vec::new()));
844        let regs = vec![
845            ModelListenerReg::all(Arc::new(Stamp {
846                key: "first",
847                log: log.clone(),
848            })),
849            ModelListenerReg::all(Arc::new(Stamp {
850                key: "second",
851                log: log.clone(),
852            })),
853        ];
854        let mut rec = Record::new("samples");
855
856        save(
857            SaveRequest {
858                db: &db,
859                listeners: &listeners(&regs, "samples"),
860                persister: &SpyPersister::default(),
861                actor: &actor(),
862                op: Op::Create,
863                id: None,
864            },
865            &mut rec,
866        )
867        .await
868        .unwrap();
869
870        assert_eq!(*log.lock().unwrap(), ["first", "second"]);
871    }
872
873    #[tokio::test]
874    async fn a_target_selects_which_listeners_run() {
875        let (db, _guard) = connect_test(&[]).await;
876        let log = Arc::new(Mutex::new(Vec::new()));
877        let regs = vec![
878            ModelListenerReg::all(Arc::new(Stamp {
879                key: "everywhere",
880                log: log.clone(),
881            })),
882            ModelListenerReg::for_entity(
883                "orders",
884                Arc::new(Stamp {
885                    key: "orders_only",
886                    log: log.clone(),
887                }),
888            ),
889        ];
890        let mut rec = Record::new("samples");
891
892        save(
893            SaveRequest {
894                db: &db,
895                listeners: &listeners(&regs, "samples"),
896                persister: &SpyPersister::default(),
897                actor: &actor(),
898                op: Op::Create,
899                id: None,
900            },
901            &mut rec,
902        )
903        .await
904        .unwrap();
905
906        assert_eq!(
907            *log.lock().unwrap(),
908            ["everywhere"],
909            "another entity's listener stays out"
910        );
911        assert!(matches!(regs[1].target, ListenerTarget::Entity(ref e) if e == "orders"));
912    }
913
914    #[tokio::test]
915    async fn a_listener_injected_attribute_is_written_not_dropped() {
916        let (db, _guard) = connect_test(&[]).await;
917        let spy = SpyPersister::default();
918        let log = Arc::new(Mutex::new(Vec::new()));
919        let regs = vec![ModelListenerReg::all(Arc::new(Stamp {
920            key: "created_at",
921            log,
922        }))];
923        let mut rec = Record::new("samples");
924        rec.set("name", "Chair");
925
926        save(
927            SaveRequest {
928                db: &db,
929                listeners: &listeners(&regs, "samples"),
930                persister: &spy,
931                actor: &actor(),
932                op: Op::Create,
933                id: None,
934            },
935            &mut rec,
936        )
937        .await
938        .unwrap();
939
940        let default = DefaultPersister {
941            entity: "samples".into(),
942            id_field: "id".into(),
943            columns: vec!["name".into()],
944        };
945        // The descriptor knows only `name`; the listener's key widens the write.
946        assert_eq!(default.write_columns(&rec), ["name", "created_at"]);
947    }
948
949    #[test]
950    fn the_id_column_is_never_written() {
951        let default = DefaultPersister {
952            entity: "samples".into(),
953            id_field: "id".into(),
954            columns: vec!["name".into()],
955        };
956        let mut rec = Record::with_id("samples", 3);
957        rec.set("name", "Chair").set("id", 3i64);
958        assert_eq!(default.write_columns(&rec), ["name"]);
959    }
960
961    #[test]
962    fn binding_maps_each_kind_to_its_storage_form() {
963        // Absent, null and empty text all render the NULL keyword, so an
964        // optional column of any type stores rather than failing on "".
965        let null = SimpleExpr::Keyword(sea_query::Keyword::Null);
966        assert_eq!(bind(None), null);
967        assert_eq!(bind(Some(&AttrValue::Null)), null);
968        assert_eq!(bind(Some(&AttrValue::Text(String::new()))), null);
969        assert_eq!(bind(Some(&AttrValue::Int(7))), 7i64.into());
970        // Booleans store as integers, the representation every backend indexes.
971        assert_eq!(bind(Some(&AttrValue::Bool(true))), 1i32.into());
972        assert_eq!(
973            bind(Some(&AttrValue::Text("Chair".into()))),
974            Some("Chair".to_string()).into()
975        );
976    }
977
978    /// Supplies a pre-write snapshot, as the built-in persister does.
979    struct SnapshotPersister;
980
981    #[async_trait]
982    impl Persister for SnapshotPersister {
983        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
984            Ok(1)
985        }
986        async fn update(
987            &self,
988            _cx: &mut SaveCx<'_>,
989            _id: &str,
990            _rec: &Record,
991        ) -> Result<(), SaveError> {
992            Ok(())
993        }
994        async fn load(&self, _cx: &mut SaveCx<'_>, _id: &str) -> Result<Option<Record>, SaveError> {
995            let mut previous = Record::new("samples");
996            previous.set("name", "Chair");
997            Ok(Some(previous))
998        }
999    }
1000
1001    /// Reads the snapshot in `before_save` and records what it saw.
1002    struct WatchChanges {
1003        seen: Arc<Mutex<Vec<(bool, bool)>>>,
1004    }
1005
1006    #[async_trait]
1007    impl ModelListener for WatchChanges {
1008        async fn before_save(
1009            &self,
1010            _cx: &mut SaveCx<'_>,
1011            rec: &mut Record,
1012            _op: Op,
1013        ) -> Result<(), ErrorBag> {
1014            self.seen
1015                .lock()
1016                .unwrap()
1017                .push((rec.has_original(), rec.changed("name")));
1018            Ok(())
1019        }
1020    }
1021
1022    #[tokio::test]
1023    async fn an_update_hands_listeners_the_pre_write_row() {
1024        let (db, _guard) = connect_test(&[]).await;
1025        let seen = Arc::new(Mutex::new(Vec::new()));
1026        let regs = vec![ModelListenerReg::all(Arc::new(WatchChanges {
1027            seen: seen.clone(),
1028        }))];
1029        let mut rec = Record::with_id("samples", 1);
1030        rec.set("name", "Stool");
1031
1032        save(
1033            SaveRequest {
1034                db: &db,
1035                listeners: &listeners(&regs, "samples"),
1036                persister: &SnapshotPersister,
1037                actor: &actor(),
1038                op: Op::Update,
1039                id: Some("1"),
1040            },
1041            &mut rec,
1042        )
1043        .await
1044        .unwrap();
1045
1046        assert_eq!(*seen.lock().unwrap(), [(true, true)]);
1047    }
1048
1049    #[tokio::test]
1050    async fn a_create_has_no_snapshot() {
1051        let (db, _guard) = connect_test(&[]).await;
1052        let seen = Arc::new(Mutex::new(Vec::new()));
1053        let regs = vec![ModelListenerReg::all(Arc::new(WatchChanges {
1054            seen: seen.clone(),
1055        }))];
1056        let mut rec = Record::new("samples");
1057        rec.set("name", "Chair");
1058
1059        save(
1060            SaveRequest {
1061                db: &db,
1062                listeners: &listeners(&regs, "samples"),
1063                persister: &SnapshotPersister,
1064                actor: &actor(),
1065                op: Op::Create,
1066                id: None,
1067            },
1068            &mut rec,
1069        )
1070        .await
1071        .unwrap();
1072
1073        assert_eq!(*seen.lock().unwrap(), [(false, false)]);
1074    }
1075
1076    #[tokio::test]
1077    async fn an_update_passes_its_id_through() {
1078        let (db, _guard) = connect_test(&[]).await;
1079        let spy = SpyPersister::default();
1080        let mut rec = Record::with_id("samples", 7);
1081
1082        save(
1083            SaveRequest {
1084                db: &db,
1085                listeners: &[],
1086                persister: &spy,
1087                actor: &actor(),
1088                op: Op::Update,
1089                id: Some("7"),
1090            },
1091            &mut rec,
1092        )
1093        .await
1094        .unwrap();
1095
1096        assert_eq!(spy.seen.lock().unwrap()[0].0, "update:7");
1097    }
1098
1099    #[tokio::test]
1100    async fn an_update_without_an_id_fails_rather_than_writing() {
1101        let (db, _guard) = connect_test(&[]).await;
1102        let spy = SpyPersister::default();
1103        let mut rec = Record::new("samples");
1104
1105        let err = save(
1106            SaveRequest {
1107                db: &db,
1108                listeners: &[],
1109                persister: &spy,
1110                actor: &actor(),
1111                op: Op::Update,
1112                id: None,
1113            },
1114            &mut rec,
1115        )
1116        .await
1117        .expect_err("an update needs an id");
1118
1119        assert!(matches!(err, SaveError::Failed(_)));
1120        assert!(spy.seen.lock().unwrap().is_empty());
1121    }
1122}