Skip to main content

bhtune_db/models/
settings.rs

1use chrono::{DateTime, Utc};
2use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
3
4use crate::error::{DbError, DbResult};
5
6// settings {{{1
7
8/// One row of `settings`: an app-wide key/value pair (e.g. the history retention policy).
9#[derive(Debug, Clone, PartialEq)]
10pub struct SettingRow {
11    pub key: String,
12    pub value: serde_json::Value,
13    pub updated_at: DateTime<Utc>,
14}
15
16impl SettingRow {
17    /// Loads one app-wide setting by key, returning `None` when it has not been stored yet.
18    ///
19    /// The database constraint guarantees that `value` is syntactically valid JSON, but the
20    /// shape is intentionally left to the feature that owns the key. Parsing it here keeps
21    /// callers from having to duplicate the TEXT-to-JSON conversion.
22    pub async fn get(pool: &SqlitePool, key: &str) -> DbResult<Option<Self>> {
23        let row = sqlx::query("SELECT key, value, updated_at FROM settings WHERE key = ?")
24            .bind(key)
25            .fetch_optional(pool)
26            .await
27            .map_err(DbError::Query)?;
28
29        row.map(setting_from_row).transpose()
30    }
31
32    /// Inserts or replaces one app-wide setting and returns the stored row.
33    ///
34    /// `updated_at` is supplied by the caller so database tests and clock ownership remain
35    /// deterministic, matching the other repository methods in this module.
36    pub async fn upsert(
37        pool: &SqlitePool,
38        key: &str,
39        value: &serde_json::Value,
40        updated_at: DateTime<Utc>,
41    ) -> DbResult<Self> {
42        let value_json = value.to_string();
43        let row = sqlx::query(
44            "INSERT INTO settings (key, value, updated_at)
45             VALUES (?, ?, ?)
46             ON CONFLICT(key) DO UPDATE SET
47                 value = excluded.value,
48                 updated_at = excluded.updated_at
49             RETURNING key, value, updated_at",
50        )
51        .bind(key)
52        .bind(value_json)
53        .bind(updated_at)
54        .fetch_one(pool)
55        .await
56        .map_err(DbError::Query)?;
57
58        setting_from_row(row)
59    }
60}
61
62fn setting_from_row(row: SqliteRow) -> DbResult<SettingRow> {
63    let value_json: String = row.try_get("value").map_err(DbError::Query)?;
64    let value = serde_json::from_str(&value_json).map_err(|source| DbError::InvalidJsonShape {
65        column: "settings.value",
66        source,
67    })?;
68
69    Ok(SettingRow {
70        key: row.try_get("key").map_err(DbError::Query)?,
71        value,
72        updated_at: row.try_get("updated_at").map_err(DbError::Query)?,
73    })
74}
75// }}}1
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[tokio::test]
82    async fn malformed_setting_json_is_reported_as_invalid_shape() {
83        let pool = crate::connect_in_memory().await.unwrap();
84        sqlx::query("CREATE TABLE malformed_settings (key TEXT, value TEXT, updated_at TEXT)")
85            .execute(&pool)
86            .await
87            .unwrap();
88        sqlx::query("INSERT INTO malformed_settings (key, value, updated_at) VALUES (?, ?, ?)")
89            .bind("draft")
90            .bind("{not-json")
91            .bind(chrono::Utc::now())
92            .execute(&pool)
93            .await
94            .unwrap();
95
96        let row = sqlx::query("SELECT key, value, updated_at FROM malformed_settings")
97            .fetch_one(&pool)
98            .await
99            .unwrap();
100        let err = setting_from_row(row).unwrap_err();
101        assert!(matches!(
102            err,
103            DbError::InvalidJsonShape {
104                column: "settings.value",
105                ..
106            }
107        ));
108    }
109}