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::{Actor, AttrValue, Db, ModelListener, Op, Record, SaveCx, SavedCx};
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/// Replaces the default insert/update for one form. An implementor owns its own
34/// transaction when the write is multi-statement. `rec` holds the validated
35/// submission plus anything the listeners added; `rec.to_text_map()` gives the
36/// all-text shape if that suits an implementation better.
37#[async_trait]
38pub trait Persister: Send + Sync + 'static {
39    /// Persists a new record, returning its id.
40    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError>;
41    /// Persists an edit to the record with primary key `id`.
42    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError>;
43
44    /// The row as it stands, read in the same transaction, so listeners can see
45    /// what an update changes. Returning `None` (the default) means this
46    /// persister supplies no snapshot and change-based listeners sit out.
47    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError> {
48        let _ = (cx, id);
49        Ok(None)
50    }
51}
52
53/// Binds one attribute for the storage layer.
54///
55/// Absent, null, and empty text all bind SQL `NULL`, so an optional non-text
56/// column stores correctly instead of failing on `""`. Booleans bind as integers
57/// (the one representation every backend indexes), and timestamps and JSON bind
58/// as text.
59fn bind(value: Option<&AttrValue>) -> SimpleExpr {
60    match value {
61        None | Some(AttrValue::Null) => Option::<String>::None.into(),
62        Some(AttrValue::Int(i)) => (*i).into(),
63        Some(AttrValue::Float(f)) => (*f).into(),
64        Some(AttrValue::Bool(b)) => i32::from(*b).into(),
65        Some(AttrValue::Text(s)) if s.is_empty() => Option::<String>::None.into(),
66        Some(other) => other.to_text().into(),
67    }
68}
69
70/// Runs the listeners around a persister write: each `before_save` may change
71/// the record or refuse the write, then the persister writes, then each
72/// `after_save` runs. `after_save` is not atomic with the write.
73///
74/// `id` is the row to update, and is ignored on a create.
75pub(crate) struct SaveRequest<'a> {
76    pub db: &'a Db,
77    pub listeners: &'a [Arc<dyn ModelListener>],
78    pub persister: &'a dyn Persister,
79    /// Who is performing the write; reaches listeners and the persister.
80    pub actor: &'a Actor,
81    pub op: Op,
82    /// The row to update; `None` on a create.
83    pub id: Option<&'a str>,
84}
85
86pub(crate) async fn save(req: SaveRequest<'_>, rec: &mut Record) -> Result<(), SaveError> {
87    let SaveRequest {
88        db,
89        listeners,
90        persister,
91        actor,
92        op,
93        id,
94    } = req;
95    let mut tx = db
96        .pool
97        .begin()
98        .await
99        .map_err(|e| SaveError::Failed(e.to_string()))?;
100
101    let outcome = save_in(
102        &mut tx,
103        db.backend,
104        listeners,
105        persister,
106        actor.clone(),
107        rec,
108        op,
109        id,
110    )
111    .await;
112    match outcome {
113        Ok(()) => tx
114            .commit()
115            .await
116            .map_err(|e| SaveError::Failed(e.to_string()))?,
117        Err(e) => {
118            // A veto or a failed write rolls the whole save back, snapshot read
119            // included.
120            let _ = tx.rollback().await;
121            return Err(e);
122        }
123    }
124
125    // After the commit, so a listener here cannot roll the write back.
126    let saved = SavedCx::new(db, actor);
127    for listener in listeners {
128        listener.after_save(&saved, rec, op).await;
129    }
130    Ok(())
131}
132
133/// Everything a save does inside the transaction: read the pre-write row, run
134/// the listeners, then write.
135#[allow(clippy::too_many_arguments)]
136async fn save_in(
137    tx: &mut sqlx::Transaction<'_, sqlx::Any>,
138    backend: laterite_core::migration::DbBackend,
139    listeners: &[Arc<dyn ModelListener>],
140    persister: &dyn Persister,
141    actor: Actor,
142    rec: &mut Record,
143    op: Op,
144    id: Option<&str>,
145) -> Result<(), SaveError> {
146    let mut cx = SaveCx::new(backend, tx, actor);
147
148    if op == Op::Update {
149        if let Some(id) = id {
150            if let Some(previous) = persister.load(&mut cx, id).await? {
151                rec.set_original(previous);
152            }
153        }
154    }
155
156    for listener in listeners {
157        listener
158            .before_save(&mut cx, rec, op)
159            .await
160            .map_err(SaveError::Invalid)?;
161    }
162
163    match op {
164        Op::Create => {
165            let new_id = persister.create(&mut cx, rec).await?;
166            rec.set_id(new_id);
167        }
168        Op::Update => {
169            let id = id.ok_or_else(|| SaveError::Failed("update without an id".to_string()))?;
170            persister.update(&mut cx, id, rec).await?;
171        }
172        // `Op` is non-exhaustive; a stage added later needs its arm here.
173        _ => return Err(SaveError::Failed(format!("unsupported operation {op:?}"))),
174    }
175    Ok(())
176}
177
178/// A registered persister: its dotted `vendor.name` key and the handler.
179pub struct PersisterReg {
180    pub name: String,
181    pub persister: Arc<dyn Persister>,
182}
183
184impl PersisterReg {
185    pub fn new(name: impl Into<String>, persister: Arc<dyn Persister>) -> Self {
186        Self {
187            name: name.into(),
188            persister,
189        }
190    }
191}
192
193/// The persister registry, keyed by name.
194pub type PersisterRegistry = HashMap<String, Arc<dyn Persister>>;
195
196/// The built-in write: a descriptor-driven single-statement insert/update over
197/// the form's own columns, what every form gets unless it names a custom one.
198/// Trusts validated identifiers (the form guards them before calling).
199pub(crate) struct DefaultPersister {
200    entity: String,
201    id_field: String,
202    columns: Vec<String>,
203}
204
205impl DefaultPersister {
206    /// The columns to write: the descriptor's own fields, plus any attribute a
207    /// listener added, minus the database-assigned id. Widening this way means a
208    /// listener-injected `created_at` is written rather than silently dropped;
209    /// the form admits only descriptor fields, so nothing a request submitted can
210    /// reach a column it did not declare.
211    fn write_columns(&self, rec: &Record) -> Vec<String> {
212        let mut cols = self.columns.clone();
213        for (key, _) in rec.iter() {
214            if key != self.id_field && !cols.iter().any(|c| c == key) {
215                cols.push(key.to_string());
216            }
217        }
218        cols.retain(|c| c != &self.id_field && crate::sql::valid_ident(c));
219        cols
220    }
221
222    pub(crate) fn from_config(config: &FormConfig) -> Self {
223        Self {
224            entity: config.entity.clone(),
225            id_field: config.id_field.clone(),
226            columns: config.fields.iter().map(|f| f.name.clone()).collect(),
227        }
228    }
229}
230
231#[async_trait]
232impl Persister for DefaultPersister {
233    async fn create(&self, cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError> {
234        // The PK is database-assigned, so the insert never lists it. Scope the
235        // builder so it drops before the await, keeping the future `Send`.
236        let stmt = {
237            let cols = self.write_columns(rec);
238            let vals: Vec<SimpleExpr> = cols.iter().map(|c| bind(rec.get(c))).collect();
239            Query::insert()
240                .into_table(Alias::new(&self.entity))
241                .columns(cols.iter().map(Alias::new))
242                .values_panic(vals)
243                .to_owned()
244        };
245        let backend = cx.backend();
246        insert_returning_id_on(backend, cx.conn(), stmt, Alias::new(&self.id_field))
247            .await
248            .map_err(|e| SaveError::Failed(e.to_string()))
249    }
250
251    async fn update(&self, cx: &mut SaveCx<'_>, id: &str, rec: &Record) -> Result<(), SaveError> {
252        let backend = cx.backend();
253        let (sql, values) = {
254            let mut update = Query::update();
255            update.table(Alias::new(&self.entity));
256            for column in self.write_columns(rec) {
257                update.value(Alias::new(&column), bind(rec.get(&column)));
258            }
259            update.and_where(
260                Expr::col(Alias::new(&self.id_field))
261                    .cast_as(Alias::new(text_cast(backend)))
262                    .eq(id),
263            );
264            to_sql(backend, update)
265        };
266        bind_values(sqlx::query(&sql), values)
267            .execute(cx.conn())
268            .await
269            .map(|_| ())
270            .map_err(|e| SaveError::Failed(e.to_string()))
271    }
272
273    /// Reads the row's declared columns in the transaction, so a listener sees
274    /// what an update changes. Values come back as text, the shape `sqlx::Any`
275    /// gives for a mixed row.
276    async fn load(&self, cx: &mut SaveCx<'_>, id: &str) -> Result<Option<Record>, SaveError> {
277        let backend = cx.backend();
278        let (sql, values) = {
279            let mut select = Query::select();
280            select.from(Alias::new(&self.entity));
281            for column in &self.columns {
282                select.expr_as(
283                    Expr::col(Alias::new(column)).cast_as(Alias::new(text_cast(backend))),
284                    Alias::new(column),
285                );
286            }
287            select.and_where(
288                Expr::col(Alias::new(&self.id_field))
289                    .cast_as(Alias::new(text_cast(backend)))
290                    .eq(id),
291            );
292            to_sql(backend, select)
293        };
294        let row = bind_values(sqlx::query(&sql), values)
295            .fetch_optional(cx.conn())
296            .await
297            .map_err(|e| SaveError::Failed(e.to_string()))?;
298        let Some(row) = row else {
299            return Ok(None);
300        };
301        let mut previous = Record::new(&self.entity);
302        for column in &self.columns {
303            match row.get_text_opt(column) {
304                Ok(Some(v)) => {
305                    previous.set(column.clone(), v);
306                }
307                Ok(None) => {
308                    previous.set(column.clone(), AttrValue::Null);
309                }
310                Err(e) => return Err(SaveError::Failed(e.to_string())),
311            }
312        }
313        Ok(Some(previous))
314    }
315}
316
317#[cfg(test)]
318mod pipeline_tests {
319    use super::*;
320    use laterite_core::testing::connect_test;
321    use laterite_core::{ListenerTarget, ModelListenerReg, Text};
322    use std::sync::Mutex;
323
324    /// Records what reached the persister, so a test can assert on the write.
325    #[derive(Default)]
326    struct SpyPersister {
327        seen: Mutex<Vec<(String, HashMap<String, String>)>>,
328    }
329
330    #[async_trait]
331    impl Persister for SpyPersister {
332        async fn create(&self, _cx: &mut SaveCx<'_>, rec: &Record) -> Result<i64, SaveError> {
333            self.seen
334                .lock()
335                .unwrap()
336                .push(("create".into(), rec.to_text_map()));
337            Ok(42)
338        }
339        async fn update(
340            &self,
341            _cx: &mut SaveCx<'_>,
342            id: &str,
343            rec: &Record,
344        ) -> Result<(), SaveError> {
345            self.seen
346                .lock()
347                .unwrap()
348                .push((format!("update:{id}"), rec.to_text_map()));
349            Ok(())
350        }
351    }
352
353    /// Sets an attribute before the write, and appends its name to a shared log
354    /// after it.
355    struct Stamp {
356        key: &'static str,
357        log: Arc<Mutex<Vec<&'static str>>>,
358    }
359
360    #[async_trait]
361    impl ModelListener for Stamp {
362        async fn before_save(
363            &self,
364            _cx: &mut SaveCx<'_>,
365            rec: &mut Record,
366            _op: Op,
367        ) -> Result<(), ErrorBag> {
368            rec.set(self.key, "set");
369            Ok(())
370        }
371        async fn after_save(&self, _cx: &SavedCx<'_>, _rec: &Record, _op: Op) {
372            self.log.lock().unwrap().push(self.key);
373        }
374    }
375
376    /// Refuses every write.
377    struct Veto;
378
379    #[async_trait]
380    impl ModelListener for Veto {
381        async fn before_save(
382            &self,
383            _cx: &mut SaveCx<'_>,
384            _rec: &mut Record,
385            _op: Op,
386        ) -> Result<(), ErrorBag> {
387            let mut bag = ErrorBag::default();
388            // A plain Text, not `t!`: a test fixture must not enter the catalog.
389            bag.add("name", Text::new("Not allowed"));
390            Err(bag)
391        }
392    }
393
394    fn actor() -> Actor {
395        Actor::system("test")
396    }
397
398    fn listeners(regs: &[ModelListenerReg], entity: &str) -> Vec<Arc<dyn ModelListener>> {
399        regs.iter()
400            .filter(|r| r.matches(entity))
401            .map(|r| r.listener.clone())
402            .collect()
403    }
404
405    #[tokio::test]
406    async fn a_listener_mutation_reaches_the_persister_and_the_id_comes_back() {
407        let (db, _guard) = connect_test(&[]).await;
408        let spy = SpyPersister::default();
409        let log = Arc::new(Mutex::new(Vec::new()));
410        let regs = vec![ModelListenerReg::all(Arc::new(Stamp {
411            key: "created_at",
412            log: log.clone(),
413        }))];
414        let mut rec = Record::new("samples");
415        rec.set("name", "Chair");
416
417        save(
418            SaveRequest {
419                db: &db,
420                listeners: &listeners(&regs, "samples"),
421                persister: &spy,
422                actor: &actor(),
423                op: Op::Create,
424                id: None,
425            },
426            &mut rec,
427        )
428        .await
429        .unwrap();
430
431        let seen = spy.seen.lock().unwrap();
432        assert_eq!(seen[0].0, "create");
433        assert_eq!(seen[0].1.get("created_at").map(String::as_str), Some("set"));
434        assert_eq!(rec.id(), Some(42), "the create id lands on the record");
435        assert_eq!(*log.lock().unwrap(), ["created_at"]);
436    }
437
438    #[tokio::test]
439    async fn a_veto_stops_the_write_and_returns_field_messages() {
440        let (db, _guard) = connect_test(&[]).await;
441        let spy = SpyPersister::default();
442        let regs = vec![ModelListenerReg::all(Arc::new(Veto))];
443        let mut rec = Record::new("samples");
444
445        let err = save(
446            SaveRequest {
447                db: &db,
448                listeners: &listeners(&regs, "samples"),
449                persister: &spy,
450                actor: &actor(),
451                op: Op::Create,
452                id: None,
453            },
454            &mut rec,
455        )
456        .await
457        .expect_err("the veto refuses the write");
458
459        match err {
460            SaveError::Invalid(bag) => assert_eq!(bag.messages("name").len(), 1),
461            SaveError::Failed(e) => panic!("expected a field error, got {e}"),
462        }
463        assert!(
464            spy.seen.lock().unwrap().is_empty(),
465            "nothing reached the persister"
466        );
467    }
468
469    #[tokio::test]
470    async fn listeners_run_in_registration_order() {
471        let (db, _guard) = connect_test(&[]).await;
472        let log = Arc::new(Mutex::new(Vec::new()));
473        let regs = vec![
474            ModelListenerReg::all(Arc::new(Stamp {
475                key: "first",
476                log: log.clone(),
477            })),
478            ModelListenerReg::all(Arc::new(Stamp {
479                key: "second",
480                log: log.clone(),
481            })),
482        ];
483        let mut rec = Record::new("samples");
484
485        save(
486            SaveRequest {
487                db: &db,
488                listeners: &listeners(&regs, "samples"),
489                persister: &SpyPersister::default(),
490                actor: &actor(),
491                op: Op::Create,
492                id: None,
493            },
494            &mut rec,
495        )
496        .await
497        .unwrap();
498
499        assert_eq!(*log.lock().unwrap(), ["first", "second"]);
500    }
501
502    #[tokio::test]
503    async fn a_target_selects_which_listeners_run() {
504        let (db, _guard) = connect_test(&[]).await;
505        let log = Arc::new(Mutex::new(Vec::new()));
506        let regs = vec![
507            ModelListenerReg::all(Arc::new(Stamp {
508                key: "everywhere",
509                log: log.clone(),
510            })),
511            ModelListenerReg::for_entity(
512                "orders",
513                Arc::new(Stamp {
514                    key: "orders_only",
515                    log: log.clone(),
516                }),
517            ),
518        ];
519        let mut rec = Record::new("samples");
520
521        save(
522            SaveRequest {
523                db: &db,
524                listeners: &listeners(&regs, "samples"),
525                persister: &SpyPersister::default(),
526                actor: &actor(),
527                op: Op::Create,
528                id: None,
529            },
530            &mut rec,
531        )
532        .await
533        .unwrap();
534
535        assert_eq!(
536            *log.lock().unwrap(),
537            ["everywhere"],
538            "another entity's listener stays out"
539        );
540        assert!(matches!(regs[1].target, ListenerTarget::Entity(ref e) if e == "orders"));
541    }
542
543    #[tokio::test]
544    async fn a_listener_injected_attribute_is_written_not_dropped() {
545        let (db, _guard) = connect_test(&[]).await;
546        let spy = SpyPersister::default();
547        let log = Arc::new(Mutex::new(Vec::new()));
548        let regs = vec![ModelListenerReg::all(Arc::new(Stamp {
549            key: "created_at",
550            log,
551        }))];
552        let mut rec = Record::new("samples");
553        rec.set("name", "Chair");
554
555        save(
556            SaveRequest {
557                db: &db,
558                listeners: &listeners(&regs, "samples"),
559                persister: &spy,
560                actor: &actor(),
561                op: Op::Create,
562                id: None,
563            },
564            &mut rec,
565        )
566        .await
567        .unwrap();
568
569        let default = DefaultPersister {
570            entity: "samples".into(),
571            id_field: "id".into(),
572            columns: vec!["name".into()],
573        };
574        // The descriptor knows only `name`; the listener's key widens the write.
575        assert_eq!(default.write_columns(&rec), ["name", "created_at"]);
576    }
577
578    #[test]
579    fn the_id_column_is_never_written() {
580        let default = DefaultPersister {
581            entity: "samples".into(),
582            id_field: "id".into(),
583            columns: vec!["name".into()],
584        };
585        let mut rec = Record::with_id("samples", 3);
586        rec.set("name", "Chair").set("id", 3i64);
587        assert_eq!(default.write_columns(&rec), ["name"]);
588    }
589
590    #[test]
591    fn binding_maps_each_kind_to_its_storage_form() {
592        // Absent, null and empty text all bind NULL, so an optional non-text
593        // column stores rather than failing on "".
594        let null: SimpleExpr = Option::<String>::None.into();
595        assert_eq!(bind(None), null);
596        assert_eq!(bind(Some(&AttrValue::Null)), null);
597        assert_eq!(bind(Some(&AttrValue::Text(String::new()))), null);
598        assert_eq!(bind(Some(&AttrValue::Int(7))), 7i64.into());
599        // Booleans store as integers, the representation every backend indexes.
600        assert_eq!(bind(Some(&AttrValue::Bool(true))), 1i32.into());
601        assert_eq!(
602            bind(Some(&AttrValue::Text("Chair".into()))),
603            Some("Chair".to_string()).into()
604        );
605    }
606
607    /// Supplies a pre-write snapshot, as the built-in persister does.
608    struct SnapshotPersister;
609
610    #[async_trait]
611    impl Persister for SnapshotPersister {
612        async fn create(&self, _cx: &mut SaveCx<'_>, _rec: &Record) -> Result<i64, SaveError> {
613            Ok(1)
614        }
615        async fn update(
616            &self,
617            _cx: &mut SaveCx<'_>,
618            _id: &str,
619            _rec: &Record,
620        ) -> Result<(), SaveError> {
621            Ok(())
622        }
623        async fn load(&self, _cx: &mut SaveCx<'_>, _id: &str) -> Result<Option<Record>, SaveError> {
624            let mut previous = Record::new("samples");
625            previous.set("name", "Chair");
626            Ok(Some(previous))
627        }
628    }
629
630    /// Reads the snapshot in `before_save` and records what it saw.
631    struct WatchChanges {
632        seen: Arc<Mutex<Vec<(bool, bool)>>>,
633    }
634
635    #[async_trait]
636    impl ModelListener for WatchChanges {
637        async fn before_save(
638            &self,
639            _cx: &mut SaveCx<'_>,
640            rec: &mut Record,
641            _op: Op,
642        ) -> Result<(), ErrorBag> {
643            self.seen
644                .lock()
645                .unwrap()
646                .push((rec.has_original(), rec.changed("name")));
647            Ok(())
648        }
649    }
650
651    #[tokio::test]
652    async fn an_update_hands_listeners_the_pre_write_row() {
653        let (db, _guard) = connect_test(&[]).await;
654        let seen = Arc::new(Mutex::new(Vec::new()));
655        let regs = vec![ModelListenerReg::all(Arc::new(WatchChanges {
656            seen: seen.clone(),
657        }))];
658        let mut rec = Record::with_id("samples", 1);
659        rec.set("name", "Stool");
660
661        save(
662            SaveRequest {
663                db: &db,
664                listeners: &listeners(&regs, "samples"),
665                persister: &SnapshotPersister,
666                actor: &actor(),
667                op: Op::Update,
668                id: Some("1"),
669            },
670            &mut rec,
671        )
672        .await
673        .unwrap();
674
675        assert_eq!(*seen.lock().unwrap(), [(true, true)]);
676    }
677
678    #[tokio::test]
679    async fn a_create_has_no_snapshot() {
680        let (db, _guard) = connect_test(&[]).await;
681        let seen = Arc::new(Mutex::new(Vec::new()));
682        let regs = vec![ModelListenerReg::all(Arc::new(WatchChanges {
683            seen: seen.clone(),
684        }))];
685        let mut rec = Record::new("samples");
686        rec.set("name", "Chair");
687
688        save(
689            SaveRequest {
690                db: &db,
691                listeners: &listeners(&regs, "samples"),
692                persister: &SnapshotPersister,
693                actor: &actor(),
694                op: Op::Create,
695                id: None,
696            },
697            &mut rec,
698        )
699        .await
700        .unwrap();
701
702        assert_eq!(*seen.lock().unwrap(), [(false, false)]);
703    }
704
705    #[tokio::test]
706    async fn an_update_passes_its_id_through() {
707        let (db, _guard) = connect_test(&[]).await;
708        let spy = SpyPersister::default();
709        let mut rec = Record::with_id("samples", 7);
710
711        save(
712            SaveRequest {
713                db: &db,
714                listeners: &[],
715                persister: &spy,
716                actor: &actor(),
717                op: Op::Update,
718                id: Some("7"),
719            },
720            &mut rec,
721        )
722        .await
723        .unwrap();
724
725        assert_eq!(spy.seen.lock().unwrap()[0].0, "update:7");
726    }
727
728    #[tokio::test]
729    async fn an_update_without_an_id_fails_rather_than_writing() {
730        let (db, _guard) = connect_test(&[]).await;
731        let spy = SpyPersister::default();
732        let mut rec = Record::new("samples");
733
734        let err = save(
735            SaveRequest {
736                db: &db,
737                listeners: &[],
738                persister: &spy,
739                actor: &actor(),
740                op: Op::Update,
741                id: None,
742            },
743            &mut rec,
744        )
745        .await
746        .expect_err("an update needs an id");
747
748        assert!(matches!(err, SaveError::Failed(_)));
749        assert!(spy.seen.lock().unwrap().is_empty());
750    }
751}