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}
165
166/// The key the operator's own value for a settings code is kept under in
167/// their preferences.
168pub(crate) fn preference_key(code: &str) -> String {
169    format!("preferences.{code}")
170}
171
172/// A typed per-operator counterpart of [`SettingsModel`]: the same shape,
173/// stored once per operator among their preferences rather than once per
174/// deployment. `CODE` is the storage key, namespaced to the module.
175pub trait PreferencesModel: Serialize + DeserializeOwned + Default {
176    const CODE: &'static str;
177}
178
179/// What reading or writing an operator's own value can fail on.
180#[derive(Debug, thiserror::Error)]
181pub enum PreferencesError {
182    #[error("preference storage error")]
183    Storage(#[from] laterite_auth::AuthError),
184    #[error("preference serialization error")]
185    Serde(#[from] serde_json::Error),
186}
187
188/// One operator's stored model, or the default when they have none.
189pub async fn load_for<T: PreferencesModel>(db: &Db, user_id: i64) -> Result<T, PreferencesError> {
190    match get_for(db, user_id, T::CODE).await? {
191        Some(value) => Ok(serde_json::from_value(value)?),
192        None => Ok(T::default()),
193    }
194}
195
196/// Stores one operator's model.
197pub async fn save_for<T: PreferencesModel>(
198    db: &Db,
199    user_id: i64,
200    model: &T,
201) -> Result<(), PreferencesError> {
202    set_for(db, user_id, T::CODE, &serde_json::to_value(model)?).await
203}
204
205/// One operator's raw value for `code`.
206pub async fn get_for(
207    db: &Db,
208    user_id: i64,
209    code: &str,
210) -> Result<Option<serde_json::Value>, PreferencesError> {
211    let stored = laterite_auth::store::user_preference(db, user_id, &preference_key(code)).await?;
212    match stored {
213        Some(text) => Ok(Some(serde_json::from_str(&text)?)),
214        None => Ok(None),
215    }
216}
217
218/// Stores one operator's raw value for `code`.
219pub async fn set_for(
220    db: &Db,
221    user_id: i64,
222    code: &str,
223    value: &serde_json::Value,
224) -> Result<(), PreferencesError> {
225    let json = serde_json::to_string(value)?;
226    laterite_auth::store::set_user_preference(db, user_id, &preference_key(code), &json).await?;
227    Ok(())
228}