rustrails-record 0.1.2

ORM layer (ActiveRecord equivalent)
Documentation
use serde::Serialize;
use serde_json::Value;

use crate::RecordError;

/// Trait implemented by records that declare readonly attributes.
pub trait ReadonlyAttributes {
    /// Returns the fields that must not change after creation.
    fn readonly_attributes() -> &'static [&'static str] {
        &[]
    }
}

/// Builds readonly metadata from a field list.
#[must_use]
pub fn attr_readonly(fields: &[&str]) -> Vec<String> {
    fields.iter().map(|field| (*field).to_owned()).collect()
}

/// Validates that readonly fields are unchanged between two serializable values.
pub fn verify_readonly_update<T>(
    original: &T,
    updated: &T,
    readonly_fields: &[&str],
) -> Result<(), RecordError>
where
    T: Serialize,
{
    let original =
        serde_json::to_value(original).map_err(|error| RecordError::Invalid(error.to_string()))?;
    let updated =
        serde_json::to_value(updated).map_err(|error| RecordError::Invalid(error.to_string()))?;

    for field in readonly_fields {
        if field_changed(&original, &updated, field) {
            return Err(RecordError::Invalid(format!("{field} is readonly")));
        }
    }

    Ok(())
}

fn field_changed(original: &Value, updated: &Value, field: &str) -> bool {
    original.as_object().and_then(|value| value.get(field))
        != updated.as_object().and_then(|value| value.get(field))
}

#[cfg(test)]
mod tests {
    use serde::Serialize;

    use super::{ReadonlyAttributes, attr_readonly, verify_readonly_update};

    #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
    struct UserRecord {
        id: i64,
        email: String,
        name: String,
        role: String,
    }

    impl ReadonlyAttributes for UserRecord {
        fn readonly_attributes() -> &'static [&'static str] {
            &["id", "email"]
        }
    }

    fn user() -> UserRecord {
        UserRecord {
            id: 1,
            email: "alice@example.com".to_owned(),
            name: "Alice".to_owned(),
            role: "member".to_owned(),
        }
    }

    #[test]
    fn attr_readonly_preserves_field_order() {
        assert_eq!(attr_readonly(&["id", "email"]), vec!["id", "email"]);
    }

    #[test]
    fn verify_readonly_update_accepts_unchanged_records() {
        let original = user();
        let updated = user();
        assert!(verify_readonly_update(&original, &updated, &["id", "email"]).is_ok());
    }

    #[test]
    fn verify_readonly_update_allows_mutable_fields_to_change() {
        let original = user();
        let mut updated = user();
        updated.name = "Alicia".to_owned();
        updated.role = "admin".to_owned();

        assert!(verify_readonly_update(&original, &updated, &["id", "email"]).is_ok());
    }

    #[test]
    fn verify_readonly_update_rejects_changed_id() {
        let original = user();
        let mut updated = user();
        updated.id = 2;

        assert_eq!(
            verify_readonly_update(&original, &updated, &["id"]).map_err(|error| error.to_string()),
            Err("record invalid: id is readonly".to_owned())
        );
    }

    #[test]
    fn verify_readonly_update_rejects_changed_email() {
        let original = user();
        let mut updated = user();
        updated.email = "other@example.com".to_owned();

        assert_eq!(
            verify_readonly_update(&original, &updated, &["email"])
                .map_err(|error| error.to_string()),
            Err("record invalid: email is readonly".to_owned())
        );
    }

    #[test]
    fn verify_readonly_update_ignores_unknown_fields() {
        let original = user();
        let updated = user();
        assert!(verify_readonly_update(&original, &updated, &["missing"]).is_ok());
    }

    #[test]
    fn trait_default_is_empty() {
        struct NoReadonly;
        impl ReadonlyAttributes for NoReadonly {}

        assert!(NoReadonly::readonly_attributes().is_empty());
    }

    #[test]
    fn trait_returns_declared_fields() {
        assert_eq!(UserRecord::readonly_attributes(), &["id", "email"]);
    }

    macro_rules! readonly_change_case {
        ($name:ident, $field:ident, $value:expr, $expected:expr) => {
            #[test]
            fn $name() {
                let original = user();
                let mut updated = user();
                updated.$field = $value;
                let result = verify_readonly_update(&original, &updated, &[stringify!($field)]);
                assert_eq!(result.map_err(|error| error.to_string()), $expected);
            }
        };
    }

    readonly_change_case!(
        readonly_id_change_case,
        id,
        9,
        Err("record invalid: id is readonly".to_owned())
    );
    readonly_change_case!(
        readonly_email_change_case,
        email,
        "new@example.com".to_owned(),
        Err("record invalid: email is readonly".to_owned())
    );
    readonly_change_case!(
        readonly_name_change_case,
        name,
        "Alicia".to_owned(),
        Err("record invalid: name is readonly".to_owned())
    );
}