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