rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use std::collections::HashSet;

use serde_json::{Map, Value};

/// Metadata describing generated accessors for a JSON-backed store column.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoreAccessorConfig {
    /// The backing JSON column name.
    pub store_field: String,
    /// The generated accessor names.
    pub accessors: Vec<String>,
}

/// Trait implemented by records that expose JSON-backed stores.
pub trait Store {
    /// Returns store accessor metadata for the record type.
    fn store_accessors() -> &'static [StoreAccessorConfig] {
        &[]
    }
}

/// Declares accessor metadata for a JSON-backed store column.
#[must_use]
pub fn store_accessor(store_field: &str, accessors: &[&str]) -> StoreAccessorConfig {
    StoreAccessorConfig {
        store_field: store_field.to_owned(),
        accessors: accessors
            .iter()
            .map(|accessor| (*accessor).to_owned())
            .collect(),
    }
}

/// Mutable JSON-backed key-value store with typed helper accessors.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct JsonStore {
    values: Map<String, Value>,
}

impl JsonStore {
    /// Creates an empty store.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Builds a store from a JSON object map.
    #[must_use]
    pub fn from_map(values: Map<String, Value>) -> Self {
        Self { values }
    }

    /// Returns the raw JSON object map.
    #[must_use]
    pub fn as_map(&self) -> &Map<String, Value> {
        &self.values
    }

    /// Gets the raw value for `key`.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&Value> {
        self.values.get(key)
    }

    /// Sets the raw JSON value for `key`.
    pub fn set(&mut self, key: &str, value: Value) {
        self.values.insert(key.to_owned(), value);
    }

    /// Returns a string value for `key` when present and correctly typed.
    #[must_use]
    pub fn get_string(&self, key: &str) -> Option<&str> {
        self.get(key).and_then(Value::as_str)
    }

    /// Sets a string value for `key`.
    pub fn set_string(&mut self, key: &str, value: impl Into<String>) {
        self.set(key, Value::String(value.into()));
    }

    /// Returns an integer value for `key` when present and correctly typed.
    #[must_use]
    pub fn get_i64(&self, key: &str) -> Option<i64> {
        self.get(key).and_then(Value::as_i64)
    }

    /// Sets an integer value for `key`.
    pub fn set_i64(&mut self, key: &str, value: i64) {
        self.set(key, Value::from(value));
    }

    /// Returns a boolean value for `key` when present and correctly typed.
    #[must_use]
    pub fn get_bool(&self, key: &str) -> Option<bool> {
        self.get(key).and_then(Value::as_bool)
    }

    /// Sets a boolean value for `key`.
    pub fn set_bool(&mut self, key: &str, value: bool) {
        self.set(key, Value::from(value));
    }

    /// Returns `true` when every accessor name is unique.
    #[must_use]
    pub fn accessors_are_unique(config: &StoreAccessorConfig) -> bool {
        let mut unique = HashSet::new();
        config
            .accessors
            .iter()
            .all(|accessor| unique.insert(accessor))
    }
}

#[cfg(test)]
mod tests {
    use serde_json::{Map, json};

    use super::{JsonStore, Store, StoreAccessorConfig, store_accessor};

    #[derive(Debug)]
    struct SettingsRecord;

    impl Store for SettingsRecord {
        fn store_accessors() -> &'static [StoreAccessorConfig] {
            static ACCESSORS: std::sync::LazyLock<Vec<StoreAccessorConfig>> =
                std::sync::LazyLock::new(|| {
                    vec![store_accessor(
                        "settings",
                        &["timezone", "dark_mode", "login_count"],
                    )]
                });
            ACCESSORS.as_slice()
        }
    }

    #[derive(Debug)]
    struct EmptyStoreRecord;

    impl Store for EmptyStoreRecord {}

    #[test]
    fn store_accessor_preserves_store_field_and_accessors() {
        let config = store_accessor("settings", &["timezone", "dark_mode"]);
        assert_eq!(config.store_field, "settings");
        assert_eq!(config.accessors, vec!["timezone", "dark_mode"]);
    }

    #[test]
    fn json_store_starts_empty() {
        let store = JsonStore::new();
        assert!(store.as_map().is_empty());
    }

    #[test]
    fn json_store_reads_and_writes_raw_values() {
        let mut store = JsonStore::new();
        store.set("timezone", json!("UTC"));
        assert_eq!(store.get("timezone"), Some(&json!("UTC")));
    }

    #[test]
    fn json_store_supports_string_getters_and_setters() {
        let mut store = JsonStore::new();
        store.set_string("timezone", "UTC");
        assert_eq!(store.get_string("timezone"), Some("UTC"));
    }

    #[test]
    fn json_store_supports_integer_getters_and_setters() {
        let mut store = JsonStore::new();
        store.set_i64("login_count", 5);
        assert_eq!(store.get_i64("login_count"), Some(5));
    }

    #[test]
    fn json_store_supports_boolean_getters_and_setters() {
        let mut store = JsonStore::new();
        store.set_bool("dark_mode", true);
        assert_eq!(store.get_bool("dark_mode"), Some(true));
    }

    #[test]
    fn json_store_returns_none_for_wrong_types() {
        let mut store = JsonStore::new();
        store.set("timezone", json!(1));
        assert_eq!(store.get_string("timezone"), None);
    }

    #[test]
    fn json_store_can_be_built_from_map() {
        let map = Map::from_iter([(String::from("timezone"), json!("UTC"))]);
        let store = JsonStore::from_map(map);
        assert_eq!(store.get_string("timezone"), Some("UTC"));
    }

    #[test]
    fn accessors_are_unique_rejects_duplicates() {
        let config = store_accessor("settings", &["timezone", "timezone"]);
        assert!(!JsonStore::accessors_are_unique(&config));
    }

    #[test]
    fn accessors_are_unique_accepts_distinct_accessors() {
        let config = store_accessor("settings", &["timezone", "dark_mode"]);
        assert!(JsonStore::accessors_are_unique(&config));
    }

    #[test]
    fn store_trait_defaults_to_declared_accessors() {
        assert_eq!(SettingsRecord::store_accessors().len(), 1);
        assert_eq!(SettingsRecord::store_accessors()[0].store_field, "settings");
    }

    #[test]
    fn replacing_a_string_value_preserves_unrelated_keys() {
        let mut store = JsonStore::from_map(Map::from_iter([
            (String::from("timezone"), json!("UTC")),
            (String::from("dark_mode"), json!(true)),
        ]));

        store.set_string("timezone", "PST");

        assert_eq!(store.get_string("timezone"), Some("PST"));
        assert_eq!(store.get_bool("dark_mode"), Some(true));
    }

    #[test]
    fn replacing_a_value_with_a_new_type_updates_typed_accessors() {
        let mut store = JsonStore::new();
        store.set_i64("login_count", 5);

        store.set_string("login_count", "five");

        assert_eq!(store.get_i64("login_count"), None);
        assert_eq!(store.get_string("login_count"), Some("five"));
    }

    #[test]
    fn replacing_one_key_keeps_other_existing_values_intact() {
        let mut store = JsonStore::from_map(Map::from_iter([
            (String::from("timezone"), json!("UTC")),
            (String::from("login_count"), json!(2)),
            (String::from("dark_mode"), json!(false)),
        ]));

        store.set_i64("login_count", 3);

        assert_eq!(store.get_string("timezone"), Some("UTC"));
        assert_eq!(store.get_i64("login_count"), Some(3));
        assert_eq!(store.get_bool("dark_mode"), Some(false));
    }

    #[test]
    fn as_map_exposes_nested_raw_values_without_rewriting_them() {
        let mut store = JsonStore::new();
        let profile = json!({"locale": "en", "tags": ["admin", "beta"]});

        store.set("profile", profile.clone());

        assert_eq!(store.as_map().get("profile"), Some(&profile));
    }

    #[test]
    fn type_specific_getters_return_none_for_incompatible_values() {
        let mut store = JsonStore::new();
        store.set("dark_mode", json!("yes"));
        store.set("login_count", json!({"count": 3}));

        assert_eq!(store.get_bool("dark_mode"), None);
        assert_eq!(store.get_i64("login_count"), None);
    }

    #[test]
    fn store_trait_defaults_to_empty_accessor_metadata() {
        assert!(EmptyStoreRecord::store_accessors().is_empty());
    }
}