Skip to main content

rustrails_record/
store.rs

1use std::collections::HashSet;
2
3use serde_json::{Map, Value};
4
5/// Metadata describing generated accessors for a JSON-backed store column.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct StoreAccessorConfig {
8    /// The backing JSON column name.
9    pub store_field: String,
10    /// The generated accessor names.
11    pub accessors: Vec<String>,
12}
13
14/// Trait implemented by records that expose JSON-backed stores.
15pub trait Store {
16    /// Returns store accessor metadata for the record type.
17    fn store_accessors() -> &'static [StoreAccessorConfig] {
18        &[]
19    }
20}
21
22/// Declares accessor metadata for a JSON-backed store column.
23#[must_use]
24pub fn store_accessor(store_field: &str, accessors: &[&str]) -> StoreAccessorConfig {
25    StoreAccessorConfig {
26        store_field: store_field.to_owned(),
27        accessors: accessors
28            .iter()
29            .map(|accessor| (*accessor).to_owned())
30            .collect(),
31    }
32}
33
34/// Mutable JSON-backed key-value store with typed helper accessors.
35#[derive(Debug, Clone, Default, PartialEq)]
36pub struct JsonStore {
37    values: Map<String, Value>,
38}
39
40impl JsonStore {
41    /// Creates an empty store.
42    #[must_use]
43    pub fn new() -> Self {
44        Self::default()
45    }
46
47    /// Builds a store from a JSON object map.
48    #[must_use]
49    pub fn from_map(values: Map<String, Value>) -> Self {
50        Self { values }
51    }
52
53    /// Returns the raw JSON object map.
54    #[must_use]
55    pub fn as_map(&self) -> &Map<String, Value> {
56        &self.values
57    }
58
59    /// Gets the raw value for `key`.
60    #[must_use]
61    pub fn get(&self, key: &str) -> Option<&Value> {
62        self.values.get(key)
63    }
64
65    /// Sets the raw JSON value for `key`.
66    pub fn set(&mut self, key: &str, value: Value) {
67        self.values.insert(key.to_owned(), value);
68    }
69
70    /// Returns a string value for `key` when present and correctly typed.
71    #[must_use]
72    pub fn get_string(&self, key: &str) -> Option<&str> {
73        self.get(key).and_then(Value::as_str)
74    }
75
76    /// Sets a string value for `key`.
77    pub fn set_string(&mut self, key: &str, value: impl Into<String>) {
78        self.set(key, Value::String(value.into()));
79    }
80
81    /// Returns an integer value for `key` when present and correctly typed.
82    #[must_use]
83    pub fn get_i64(&self, key: &str) -> Option<i64> {
84        self.get(key).and_then(Value::as_i64)
85    }
86
87    /// Sets an integer value for `key`.
88    pub fn set_i64(&mut self, key: &str, value: i64) {
89        self.set(key, Value::from(value));
90    }
91
92    /// Returns a boolean value for `key` when present and correctly typed.
93    #[must_use]
94    pub fn get_bool(&self, key: &str) -> Option<bool> {
95        self.get(key).and_then(Value::as_bool)
96    }
97
98    /// Sets a boolean value for `key`.
99    pub fn set_bool(&mut self, key: &str, value: bool) {
100        self.set(key, Value::from(value));
101    }
102
103    /// Returns `true` when every accessor name is unique.
104    #[must_use]
105    pub fn accessors_are_unique(config: &StoreAccessorConfig) -> bool {
106        let mut unique = HashSet::new();
107        config
108            .accessors
109            .iter()
110            .all(|accessor| unique.insert(accessor))
111    }
112}
113
114#[cfg(test)]
115mod tests {
116    use serde_json::{Map, json};
117
118    use super::{JsonStore, Store, StoreAccessorConfig, store_accessor};
119
120    #[derive(Debug)]
121    struct SettingsRecord;
122
123    impl Store for SettingsRecord {
124        fn store_accessors() -> &'static [StoreAccessorConfig] {
125            static ACCESSORS: std::sync::LazyLock<Vec<StoreAccessorConfig>> =
126                std::sync::LazyLock::new(|| {
127                    vec![store_accessor(
128                        "settings",
129                        &["timezone", "dark_mode", "login_count"],
130                    )]
131                });
132            ACCESSORS.as_slice()
133        }
134    }
135
136    #[derive(Debug)]
137    struct EmptyStoreRecord;
138
139    impl Store for EmptyStoreRecord {}
140
141    #[test]
142    fn store_accessor_preserves_store_field_and_accessors() {
143        let config = store_accessor("settings", &["timezone", "dark_mode"]);
144        assert_eq!(config.store_field, "settings");
145        assert_eq!(config.accessors, vec!["timezone", "dark_mode"]);
146    }
147
148    #[test]
149    fn json_store_starts_empty() {
150        let store = JsonStore::new();
151        assert!(store.as_map().is_empty());
152    }
153
154    #[test]
155    fn json_store_reads_and_writes_raw_values() {
156        let mut store = JsonStore::new();
157        store.set("timezone", json!("UTC"));
158        assert_eq!(store.get("timezone"), Some(&json!("UTC")));
159    }
160
161    #[test]
162    fn json_store_supports_string_getters_and_setters() {
163        let mut store = JsonStore::new();
164        store.set_string("timezone", "UTC");
165        assert_eq!(store.get_string("timezone"), Some("UTC"));
166    }
167
168    #[test]
169    fn json_store_supports_integer_getters_and_setters() {
170        let mut store = JsonStore::new();
171        store.set_i64("login_count", 5);
172        assert_eq!(store.get_i64("login_count"), Some(5));
173    }
174
175    #[test]
176    fn json_store_supports_boolean_getters_and_setters() {
177        let mut store = JsonStore::new();
178        store.set_bool("dark_mode", true);
179        assert_eq!(store.get_bool("dark_mode"), Some(true));
180    }
181
182    #[test]
183    fn json_store_returns_none_for_wrong_types() {
184        let mut store = JsonStore::new();
185        store.set("timezone", json!(1));
186        assert_eq!(store.get_string("timezone"), None);
187    }
188
189    #[test]
190    fn json_store_can_be_built_from_map() {
191        let map = Map::from_iter([(String::from("timezone"), json!("UTC"))]);
192        let store = JsonStore::from_map(map);
193        assert_eq!(store.get_string("timezone"), Some("UTC"));
194    }
195
196    #[test]
197    fn accessors_are_unique_rejects_duplicates() {
198        let config = store_accessor("settings", &["timezone", "timezone"]);
199        assert!(!JsonStore::accessors_are_unique(&config));
200    }
201
202    #[test]
203    fn accessors_are_unique_accepts_distinct_accessors() {
204        let config = store_accessor("settings", &["timezone", "dark_mode"]);
205        assert!(JsonStore::accessors_are_unique(&config));
206    }
207
208    #[test]
209    fn store_trait_defaults_to_declared_accessors() {
210        assert_eq!(SettingsRecord::store_accessors().len(), 1);
211        assert_eq!(SettingsRecord::store_accessors()[0].store_field, "settings");
212    }
213
214    #[test]
215    fn replacing_a_string_value_preserves_unrelated_keys() {
216        let mut store = JsonStore::from_map(Map::from_iter([
217            (String::from("timezone"), json!("UTC")),
218            (String::from("dark_mode"), json!(true)),
219        ]));
220
221        store.set_string("timezone", "PST");
222
223        assert_eq!(store.get_string("timezone"), Some("PST"));
224        assert_eq!(store.get_bool("dark_mode"), Some(true));
225    }
226
227    #[test]
228    fn replacing_a_value_with_a_new_type_updates_typed_accessors() {
229        let mut store = JsonStore::new();
230        store.set_i64("login_count", 5);
231
232        store.set_string("login_count", "five");
233
234        assert_eq!(store.get_i64("login_count"), None);
235        assert_eq!(store.get_string("login_count"), Some("five"));
236    }
237
238    #[test]
239    fn replacing_one_key_keeps_other_existing_values_intact() {
240        let mut store = JsonStore::from_map(Map::from_iter([
241            (String::from("timezone"), json!("UTC")),
242            (String::from("login_count"), json!(2)),
243            (String::from("dark_mode"), json!(false)),
244        ]));
245
246        store.set_i64("login_count", 3);
247
248        assert_eq!(store.get_string("timezone"), Some("UTC"));
249        assert_eq!(store.get_i64("login_count"), Some(3));
250        assert_eq!(store.get_bool("dark_mode"), Some(false));
251    }
252
253    #[test]
254    fn as_map_exposes_nested_raw_values_without_rewriting_them() {
255        let mut store = JsonStore::new();
256        let profile = json!({"locale": "en", "tags": ["admin", "beta"]});
257
258        store.set("profile", profile.clone());
259
260        assert_eq!(store.as_map().get("profile"), Some(&profile));
261    }
262
263    #[test]
264    fn type_specific_getters_return_none_for_incompatible_values() {
265        let mut store = JsonStore::new();
266        store.set("dark_mode", json!("yes"));
267        store.set("login_count", json!({"count": 3}));
268
269        assert_eq!(store.get_bool("dark_mode"), None);
270        assert_eq!(store.get_i64("login_count"), None);
271    }
272
273    #[test]
274    fn store_trait_defaults_to_empty_accessor_metadata() {
275        assert!(EmptyStoreRecord::store_accessors().is_empty());
276    }
277}