rustrails_record/
readonly_attributes.rs1use serde::Serialize;
2use serde_json::Value;
3
4use crate::RecordError;
5
6pub trait ReadonlyAttributes {
8 fn readonly_attributes() -> &'static [&'static str] {
10 &[]
11 }
12}
13
14#[must_use]
16pub fn attr_readonly(fields: &[&str]) -> Vec<String> {
17 fields.iter().map(|field| (*field).to_owned()).collect()
18}
19
20pub 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}