use serde::Serialize;
use serde_json::Value;
use crate::RecordError;
pub trait ReadonlyAttributes {
fn readonly_attributes() -> &'static [&'static str] {
&[]
}
}
#[must_use]
pub fn attr_readonly(fields: &[&str]) -> Vec<String> {
fields.iter().map(|field| (*field).to_owned()).collect()
}
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())
);
}