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