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, text_cast};
14use laterite_core::strata::async_trait;
15use laterite_core::validation::ErrorBag;
16use laterite_core::Db;
17use sea_query::{Alias, Expr, Query, SimpleExpr};
18
19use crate::form::FormConfig;
20
21/// Why a persist failed.
22pub enum SaveError {
23    /// A persist-time domain check (for example "re-parenting is not supported"):
24    /// the form re-renders 422 with these per-field messages.
25    Invalid(ErrorBag),
26    /// The write itself failed: logged, and the form re-renders the generic
27    /// banner.
28    Failed(String),
29}
30
31/// Replaces the default insert/update for one form. An implementor owns its own
32/// transaction when the write is multi-statement. `data` is the validated
33/// submission (all text; `""` means empty/none).
34#[async_trait]
35pub trait Persister: Send + Sync + 'static {
36    /// Persists a new record, returning its id.
37    async fn create(&self, db: &Db, data: &HashMap<String, String>) -> Result<i64, SaveError>;
38    /// Persists an edit to the record with primary key `id`.
39    async fn update(
40        &self,
41        db: &Db,
42        id: &str,
43        data: &HashMap<String, String>,
44    ) -> Result<(), SaveError>;
45}
46
47/// A registered persister: its dotted `vendor.name` key and the handler.
48pub struct PersisterReg {
49    pub name: String,
50    pub persister: Arc<dyn Persister>,
51}
52
53impl PersisterReg {
54    pub fn new(name: impl Into<String>, persister: Arc<dyn Persister>) -> Self {
55        Self {
56            name: name.into(),
57            persister,
58        }
59    }
60}
61
62/// The persister registry, keyed by name.
63pub type PersisterRegistry = HashMap<String, Arc<dyn Persister>>;
64
65/// The built-in write: a descriptor-driven single-statement insert/update over
66/// the form's own columns, what every form gets unless it names a custom one.
67/// Trusts validated identifiers (the form guards them before calling).
68pub(crate) struct DefaultPersister {
69    entity: String,
70    id_field: String,
71    columns: Vec<String>,
72}
73
74impl DefaultPersister {
75    pub(crate) fn from_config(config: &FormConfig) -> Self {
76        Self {
77            entity: config.entity.clone(),
78            id_field: config.id_field.clone(),
79            columns: config.fields.iter().map(|f| f.name.clone()).collect(),
80        }
81    }
82}
83
84#[async_trait]
85impl Persister for DefaultPersister {
86    async fn create(&self, db: &Db, data: &HashMap<String, String>) -> Result<i64, SaveError> {
87        // The PK is a database-assigned auto-increment id, so the insert lists
88        // only the descriptor's fields. Scope the builder so it drops before the
89        // await, keeping the future `Send`.
90        let stmt = {
91            let vals: Vec<SimpleExpr> = self
92                .columns
93                .iter()
94                .map(|c| {
95                    // Absent or empty binds SQL NULL, not "", so an optional
96                    // non-text column stores correctly rather than failing.
97                    let v: Option<String> = data.get(c).filter(|s| !s.is_empty()).cloned();
98                    v.into()
99                })
100                .collect();
101            Query::insert()
102                .into_table(Alias::new(&self.entity))
103                .columns(self.columns.iter().map(Alias::new))
104                .values_panic(vals)
105                .to_owned()
106        };
107        insert_returning_id(db, stmt, Alias::new(&self.id_field))
108            .await
109            .map_err(|e| SaveError::Failed(e.to_string()))
110    }
111
112    async fn update(
113        &self,
114        db: &Db,
115        id: &str,
116        data: &HashMap<String, String>,
117    ) -> Result<(), SaveError> {
118        let (sql, values) = {
119            let mut update = Query::update();
120            update.table(Alias::new(&self.entity));
121            for column in &self.columns {
122                // Absent or empty clears to SQL NULL rather than storing "", so an
123                // optional non-text column edits correctly.
124                let v: Option<String> = data.get(column).filter(|s| !s.is_empty()).cloned();
125                update.value(Alias::new(column), v);
126            }
127            update.and_where(
128                Expr::col(Alias::new(&self.id_field))
129                    .cast_as(Alias::new(text_cast(db.backend)))
130                    .eq(id),
131            );
132            to_sql(db.backend, update)
133        };
134        bind_values(sqlx::query(&sql), values)
135            .execute(&db.pool)
136            .await
137            .map(|_| ())
138            .map_err(|e| SaveError::Failed(e.to_string()))
139    }
140}