rustrails-record 0.1.2

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

use rustrails_support::string_ext::StringExt;
use serde_json::Value;

/// Describes a normalization to apply to an attribute before persistence.
pub struct Normalization {
    /// The attribute name to normalize.
    pub attribute: &'static str,
    /// The normalization function.
    pub normalizer: fn(Value) -> Value,
}

/// Trait for models that declare normalizations.
pub trait Normalizable {
    /// Returns the normalizations declared for this model.
    fn normalizations() -> &'static [Normalization] {
        &[]
    }

    /// Applies all normalizations to the given attribute map.
    fn normalize_attributes(attrs: &mut HashMap<String, Value>) {
        for normalization in Self::normalizations() {
            if let Some(value) = attrs.remove(normalization.attribute) {
                attrs.insert(
                    normalization.attribute.to_owned(),
                    (normalization.normalizer)(value),
                );
            }
        }
    }
}

fn normalize_string(value: Value, normalizer: impl FnOnce(&str) -> String) -> Value {
    match value {
        Value::String(content) => Value::String(normalizer(&content)),
        other => other,
    }
}

/// Strips leading and trailing whitespace from string values.
#[must_use]
pub fn strip_normalizer(value: Value) -> Value {
    normalize_string(value, |content| content.trim().to_owned())
}

/// Downcases string values.
#[must_use]
pub fn downcase_normalizer(value: Value) -> Value {
    normalize_string(value, |content| content.to_lowercase())
}

/// Squishes whitespace by collapsing internal runs to a single space.
#[must_use]
pub fn squish_normalizer(value: Value) -> Value {
    normalize_string(value, StringExt::squish)
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use serde_json::{Value, json};

    use super::{
        Normalizable, Normalization, downcase_normalizer, squish_normalizer, strip_normalizer,
    };

    struct NormalizedUser;

    impl Normalizable for NormalizedUser {
        fn normalizations() -> &'static [Normalization] {
            &[
                Normalization {
                    attribute: "email",
                    normalizer: strip_normalizer,
                },
                Normalization {
                    attribute: "name",
                    normalizer: squish_normalizer,
                },
                Normalization {
                    attribute: "handle",
                    normalizer: downcase_normalizer,
                },
            ]
        }
    }

    #[test]
    fn strip_normalizer_trims_whitespace() {
        assert_eq!(strip_normalizer(json!("  hello  ")), json!("hello"));
    }

    #[test]
    fn downcase_normalizer_lowercases_strings() {
        assert_eq!(downcase_normalizer(json!("MiXeD")), json!("mixed"));
    }

    #[test]
    fn squish_normalizer_collapses_internal_whitespace() {
        assert_eq!(
            squish_normalizer(json!("  hello   \n   world  ")),
            json!("hello world")
        );
    }

    #[test]
    fn normalize_attributes_applies_normalizers_to_matching_keys() {
        let mut attrs = HashMap::from([
            ("email".to_owned(), json!("  alice@example.com  ")),
            ("name".to_owned(), json!("  Alice   Example  ")),
            ("handle".to_owned(), json!("AliceExample")),
        ]);

        NormalizedUser::normalize_attributes(&mut attrs);

        assert_eq!(attrs.get("email"), Some(&json!("alice@example.com")));
        assert_eq!(attrs.get("name"), Some(&json!("Alice Example")));
        assert_eq!(attrs.get("handle"), Some(&json!("aliceexample")));
    }

    #[test]
    fn normalize_attributes_ignores_keys_without_normalizers() {
        let mut attrs = HashMap::from([
            ("email".to_owned(), json!("  alice@example.com  ")),
            ("role".to_owned(), json!("  Admin  ")),
        ]);

        NormalizedUser::normalize_attributes(&mut attrs);

        assert_eq!(attrs.get("email"), Some(&json!("alice@example.com")));
        assert_eq!(attrs.get("role"), Some(&json!("  Admin  ")));
    }

    #[test]
    fn normalizers_leave_non_string_values_unchanged() {
        let value = json!(42);

        assert_eq!(strip_normalizer(value.clone()), Value::from(42));
        assert_eq!(downcase_normalizer(value.clone()), Value::from(42));
        assert_eq!(squish_normalizer(value), Value::from(42));
    }
}