Skip to main content

laterite_admin/
persist.rs

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