Skip to main content

rustrails_record/
readonly_attributes.rs

1use serde::Serialize;
2use serde_json::Value;
3
4use crate::RecordError;
5
6/// Trait implemented by records that declare readonly attributes.
7pub trait ReadonlyAttributes {
8    /// Returns the fields that must not change after creation.
9    fn readonly_attributes() -> &'static [&'static str] {
10        &[]
11    }
12}
13
14/// Builds readonly metadata from a field list.
15#[must_use]
16pub fn attr_readonly(fields: &[&str]) -> Vec<String> {
17    fields.iter().map(|field| (*field).to_owned()).collect()
18}
19
20/// Validates that readonly fields are unchanged between two serializable values.
21pub fn verify_readonly_update<T>(
22    original: &T,
23    updated: &T,
24    readonly_fields: &[&str],
25) -> Result<(), RecordError>
26where
27    T: Serialize,
28{
29    let original =
30        serde_json::to_value(original).map_err(|error| RecordError::Invalid(error.to_string()))?;
31    let updated =
32        serde_json::to_value(updated).map_err(|error| RecordError::Invalid(error.to_string()))?;
33
34    for field in readonly_fields {
35        if field_changed(&original, &updated, field) {
36            return Err(RecordError::Invalid(format!("{field} is readonly")));
37        }
38    }
39
40    Ok(())
41}
42
43fn field_changed(original: &Value, updated: &Value, field: &str) -> bool {
44    original.as_object().and_then(|value| value.get(field))
45        != updated.as_object().and_then(|value| value.get(field))
46}
47
48#[cfg(test)]
49mod tests {
50    use serde::Serialize;
51
52    use super::{ReadonlyAttributes, attr_readonly, verify_readonly_update};
53
54    #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
55    struct UserRecord {
56        id: i64,
57        email: String,
58        name: String,
59        role: String,
60    }
61
62    impl ReadonlyAttributes for UserRecord {
63        fn readonly_attributes() -> &'static [&'static str] {
64            &["id", "email"]
65        }
66    }
67
68    fn user() -> UserRecord {
69        UserRecord {
70            id: 1,
71            email: "alice@example.com".to_owned(),
72            name: "Alice".to_owned(),
73            role: "member".to_owned(),
74        }
75    }
76
77    #[test]
78    fn attr_readonly_preserves_field_order() {
79        assert_eq!(attr_readonly(&["id", "email"]), vec!["id", "email"]);
80    }
81
82    #[test]
83    fn verify_readonly_update_accepts_unchanged_records() {
84        let original = user();
85        let updated = user();
86        assert!(verify_readonly_update(&original, &updated, &["id", "email"]).is_ok());
87    }
88
89    #[test]
90    fn verify_readonly_update_allows_mutable_fields_to_change() {
91        let original = user();
92        let mut updated = user();
93        updated.name = "Alicia".to_owned();
94        updated.role = "admin".to_owned();
95
96        assert!(verify_readonly_update(&original, &updated, &["id", "email"]).is_ok());
97    }
98
99    #[test]
100    fn verify_readonly_update_rejects_changed_id() {
101        let original = user();
102        let mut updated = user();
103        updated.id = 2;
104
105        assert_eq!(
106            verify_readonly_update(&original, &updated, &["id"]).map_err(|error| error.to_string()),
107            Err("record invalid: id is readonly".to_owned())
108        );
109    }
110
111    #[test]
112    fn verify_readonly_update_rejects_changed_email() {
113        let original = user();
114        let mut updated = user();
115        updated.email = "other@example.com".to_owned();
116
117        assert_eq!(
118            verify_readonly_update(&original, &updated, &["email"])
119                .map_err(|error| error.to_string()),
120            Err("record invalid: email is readonly".to_owned())
121        );
122    }
123
124    #[test]
125    fn verify_readonly_update_ignores_unknown_fields() {
126        let original = user();
127        let updated = user();
128        assert!(verify_readonly_update(&original, &updated, &["missing"]).is_ok());
129    }
130
131    #[test]
132    fn trait_default_is_empty() {
133        struct NoReadonly;
134        impl ReadonlyAttributes for NoReadonly {}
135
136        assert!(NoReadonly::readonly_attributes().is_empty());
137    }
138
139    #[test]
140    fn trait_returns_declared_fields() {
141        assert_eq!(UserRecord::readonly_attributes(), &["id", "email"]);
142    }
143
144    macro_rules! readonly_change_case {
145        ($name:ident, $field:ident, $value:expr, $expected:expr) => {
146            #[test]
147            fn $name() {
148                let original = user();
149                let mut updated = user();
150                updated.$field = $value;
151                let result = verify_readonly_update(&original, &updated, &[stringify!($field)]);
152                assert_eq!(result.map_err(|error| error.to_string()), $expected);
153            }
154        };
155    }
156
157    readonly_change_case!(
158        readonly_id_change_case,
159        id,
160        9,
161        Err("record invalid: id is readonly".to_owned())
162    );
163    readonly_change_case!(
164        readonly_email_change_case,
165        email,
166        "new@example.com".to_owned(),
167        Err("record invalid: email is readonly".to_owned())
168    );
169    readonly_change_case!(
170        readonly_name_change_case,
171        name,
172        "Alicia".to_owned(),
173        Err("record invalid: name is readonly".to_owned())
174    );
175}