Skip to main content

laterite_admin/settings/
store.rs

1//! The settings store: typed settings models stored as one JSON blob per code.
2//!
3//! A settings model is a plain serde struct, stored as one JSON value keyed by
4//! a stable code, with compile-time-typed access. A generic, untyped get/set is
5//! also provided for the settings controller, which renders and saves any
6//! registered settings item without knowing its concrete type. The JSON is
7//! stored as text, so the store is portable across backends.
8
9use chrono::{SecondsFormat, Utc};
10use laterite_core::strata::*;
11use serde::de::DeserializeOwned;
12use serde::Serialize;
13use thiserror::Error;
14
15#[derive(Iden)]
16pub(crate) enum Settings {
17    Table,
18    Code,
19    Value,
20    UpdatedAt,
21}
22
23#[derive(Debug, Error)]
24pub enum SettingsError {
25    #[error("database error")]
26    Database(#[from] sqlx::Error),
27    #[error("settings serialization error")]
28    Serde(#[from] serde_json::Error),
29}
30
31/// A settings model: a serde struct with a stable storage code and a default.
32///
33/// Give fields `#[serde(default)]` so a stored value missing a field (an older
34/// save, or a newly added field) deserializes cleanly.
35pub trait SettingsModel: Serialize + DeserializeOwned + Default {
36    /// The stable storage key. Never change it once shipped.
37    const CODE: &'static str;
38}
39
40/// Loads a typed settings model, returning its `Default` when unset.
41pub async fn load<T: SettingsModel>(db: &Db) -> Result<T, SettingsError> {
42    match get(db, T::CODE).await? {
43        Some(value) => Ok(serde_json::from_value(value)?),
44        None => Ok(T::default()),
45    }
46}
47
48/// Upserts a typed settings model.
49pub async fn save<T: SettingsModel>(db: &Db, model: &T) -> Result<(), SettingsError> {
50    set(db, T::CODE, &serde_json::to_value(model)?).await
51}
52
53/// Reads the raw JSON value for a code, for the generic settings controller.
54pub async fn get(db: &Db, code: &str) -> Result<Option<serde_json::Value>, SettingsError> {
55    let (sql, values) = build(
56        db.backend,
57        Query::select()
58            .column(Settings::Value)
59            .from(Settings::Table)
60            .and_where(Expr::col(Settings::Code).eq(code))
61            .to_owned(),
62    );
63    let row = bind_values(sqlx::query(&sql), values)
64        .fetch_optional(&db.pool)
65        .await?;
66    match row {
67        Some(r) => {
68            let text = r.get_text("value")?;
69            Ok(Some(serde_json::from_str(&text)?))
70        }
71        None => Ok(None),
72    }
73}
74
75/// Upserts the raw JSON value for a code.
76pub async fn set(db: &Db, code: &str, value: &serde_json::Value) -> Result<(), SettingsError> {
77    let json = serde_json::to_string(value)?;
78    let now = Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true);
79    let (sql, values) = build(
80        db.backend,
81        Query::insert()
82            .into_table(Settings::Table)
83            .columns([Settings::Code, Settings::Value, Settings::UpdatedAt])
84            .values_panic([code.into(), json.into(), now.into()])
85            .on_conflict(
86                OnConflict::column(Settings::Code)
87                    .update_columns([Settings::Value, Settings::UpdatedAt])
88                    .to_owned(),
89            )
90            .to_owned(),
91    );
92    bind_values(sqlx::query(&sql), values)
93        .execute(&db.pool)
94        .await?;
95    Ok(())
96}
97
98#[cfg(test)]
99mod tests {
100    use super::*;
101    use serde::Deserialize;
102
103    /// A fresh test database with the settings migration applied, on whichever
104    /// backend the run targets. Hold the returned guard for the test's lifetime.
105    async fn test_db() -> (Db, laterite_core::testing::TestGuard) {
106        laterite_core::testing::connect_test(&[super::super::migrations::migrations()]).await
107    }
108
109    #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
110    struct LogSettings {
111        #[serde(default)]
112        log_events: bool,
113        #[serde(default)]
114        log_requests: bool,
115    }
116
117    impl SettingsModel for LogSettings {
118        const CODE: &'static str = "test.log";
119    }
120
121    #[tokio::test]
122    async fn typed_round_trip_and_default() {
123        let (db, _guard) = test_db().await;
124
125        // Unset resolves to the model's Default.
126        assert_eq!(
127            load::<LogSettings>(&db).await.unwrap(),
128            LogSettings::default()
129        );
130
131        // Save then load round-trips.
132        let saved = LogSettings {
133            log_events: true,
134            log_requests: false,
135        };
136        save(&db, &saved).await.unwrap();
137        assert_eq!(load::<LogSettings>(&db).await.unwrap(), saved);
138
139        // The untyped get sees the same value.
140        let raw = get(&db, LogSettings::CODE).await.unwrap().unwrap();
141        assert_eq!(raw["log_events"], serde_json::json!(true));
142    }
143
144    #[tokio::test]
145    async fn missing_field_uses_serde_default() {
146        let (db, _guard) = test_db().await;
147
148        // A stored value missing a field deserializes with that field defaulted.
149        set(
150            &db,
151            LogSettings::CODE,
152            &serde_json::json!({ "log_events": true }),
153        )
154        .await
155        .unwrap();
156        assert_eq!(
157            load::<LogSettings>(&db).await.unwrap(),
158            LogSettings {
159                log_events: true,
160                log_requests: false,
161            }
162        );
163    }
164}