Skip to main content

rustrails_model/
attributes.rs

1use std::collections::HashMap;
2
3use serde_json::Value;
4use thiserror::Error;
5
6/// Errors raised while dynamically reading or writing model attributes.
7#[derive(Debug, Clone, PartialEq, Eq, Error)]
8pub enum AttributeError {
9    /// The attribute name does not exist on the model.
10    #[error("unknown attribute: {0}")]
11    UnknownAttribute(String),
12    /// The provided value does not match the attribute's expected type.
13    #[error("type mismatch for {attribute}: expected {expected}, got {actual}")]
14    TypeMismatch {
15        /// Name of the attribute that rejected the value.
16        attribute: String,
17        /// Human-readable description of the expected type.
18        expected: String,
19        /// Human-readable description of the actual type.
20        actual: String,
21    },
22    /// The attribute exists but cannot be written through dynamic assignment.
23    #[error("attribute {0} is readonly")]
24    Readonly(String),
25}
26
27/// Dynamic attribute access for model types.
28///
29/// Implementations bridge strongly typed Rust fields to `serde_json::Value` so
30/// higher-level APIs can read and assign attributes generically.
31pub trait Attributes {
32    /// Returns the canonical list of attribute names for the model type.
33    fn attribute_names() -> &'static [&'static str];
34
35    /// Reads a single attribute as a dynamic JSON value.
36    fn read_attribute(&self, name: &str) -> Option<Value>;
37
38    /// Writes a single attribute from a dynamic JSON value.
39    fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError>;
40
41    /// Assigns many attributes by delegating to [`Attributes::write_attribute`].
42    fn assign_attributes(&mut self, attrs: HashMap<String, Value>) -> Result<(), AttributeError> {
43        for (name, value) in attrs {
44            self.write_attribute(&name, value)?;
45        }
46        Ok(())
47    }
48
49    /// Returns every current attribute value keyed by its attribute name.
50    fn attributes(&self) -> HashMap<String, Value>;
51
52    /// Returns `true` when the provided attribute name belongs to the model.
53    fn has_attribute(name: &str) -> bool {
54        Self::attribute_names().contains(&name)
55    }
56}
57
58#[cfg(test)]
59mod tests {
60    use std::collections::HashMap;
61
62    use rustrails_support::ignored_rails_test;
63    use serde_json::{Value, json};
64
65    use super::{AttributeError, Attributes};
66
67    #[derive(Debug, Clone, PartialEq, Eq)]
68    struct TestUser {
69        id: u64,
70        name: String,
71        active: bool,
72    }
73
74    impl TestUser {
75        fn new() -> Self {
76            Self {
77                id: 1,
78                name: "Alice".to_owned(),
79                active: true,
80            }
81        }
82    }
83
84    impl Attributes for TestUser {
85        fn attribute_names() -> &'static [&'static str] {
86            &["id", "name", "active"]
87        }
88
89        fn read_attribute(&self, name: &str) -> Option<Value> {
90            match name {
91                "id" => Some(Value::from(self.id)),
92                "name" => Some(Value::String(self.name.clone())),
93                "active" => Some(Value::Bool(self.active)),
94                _ => None,
95            }
96        }
97
98        fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
99            match name {
100                "id" => Err(AttributeError::Readonly(name.to_owned())),
101                "name" => match value {
102                    Value::String(text) => {
103                        self.name = text;
104                        Ok(())
105                    }
106                    other => Err(AttributeError::TypeMismatch {
107                        attribute: name.to_owned(),
108                        expected: "string".to_owned(),
109                        actual: value_kind(&other).to_owned(),
110                    }),
111                },
112                "active" => match value {
113                    Value::Bool(flag) => {
114                        self.active = flag;
115                        Ok(())
116                    }
117                    other => Err(AttributeError::TypeMismatch {
118                        attribute: name.to_owned(),
119                        expected: "boolean".to_owned(),
120                        actual: value_kind(&other).to_owned(),
121                    }),
122                },
123                _ => Err(AttributeError::UnknownAttribute(name.to_owned())),
124            }
125        }
126
127        fn attributes(&self) -> HashMap<String, Value> {
128            HashMap::from([
129                ("id".to_owned(), Value::from(self.id)),
130                ("name".to_owned(), Value::String(self.name.clone())),
131                ("active".to_owned(), Value::Bool(self.active)),
132            ])
133        }
134    }
135
136    fn value_kind(value: &Value) -> &'static str {
137        match value {
138            Value::Null => "null",
139            Value::Bool(_) => "boolean",
140            Value::Number(_) => "number",
141            Value::String(_) => "string",
142            Value::Array(_) => "array",
143            Value::Object(_) => "object",
144        }
145    }
146
147    #[test]
148    fn attribute_names_and_has_attribute_report_known_fields() {
149        assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
150        assert!(TestUser::has_attribute("name"));
151        assert!(!TestUser::has_attribute("email"));
152    }
153
154    #[test]
155    fn read_attribute_returns_dynamic_values() {
156        let user = TestUser::new();
157
158        assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
159        assert_eq!(
160            user.read_attribute("name"),
161            Some(Value::String("Alice".to_owned()))
162        );
163        assert_eq!(user.read_attribute("active"), Some(Value::Bool(true)));
164        assert_eq!(user.read_attribute("missing"), None);
165    }
166
167    #[test]
168    fn write_attribute_updates_supported_fields() {
169        let mut user = TestUser::new();
170
171        let result = user.write_attribute("name", Value::String("Bob".to_owned()));
172
173        assert_eq!(result, Ok(()));
174        assert_eq!(user.name, "Bob");
175        assert_eq!(
176            user.read_attribute("name"),
177            Some(Value::String("Bob".to_owned()))
178        );
179    }
180
181    #[test]
182    fn write_attribute_rejects_unknown_attributes() {
183        let mut user = TestUser::new();
184
185        let result = user.write_attribute("email", Value::String("a@example.test".to_owned()));
186
187        assert_eq!(
188            result,
189            Err(AttributeError::UnknownAttribute("email".to_owned()))
190        );
191    }
192
193    #[test]
194    fn write_attribute_reports_type_mismatch() {
195        let mut user = TestUser::new();
196
197        let result = user.write_attribute("active", json!("yes"));
198
199        assert_eq!(
200            result,
201            Err(AttributeError::TypeMismatch {
202                attribute: "active".to_owned(),
203                expected: "boolean".to_owned(),
204                actual: "string".to_owned(),
205            })
206        );
207    }
208
209    #[test]
210    fn write_attribute_rejects_readonly_attributes() {
211        let mut user = TestUser::new();
212
213        let result = user.write_attribute("id", Value::from(2_u64));
214
215        assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
216    }
217
218    #[test]
219    fn assign_attributes_updates_multiple_fields() {
220        let mut user = TestUser::new();
221        let attrs = HashMap::from([
222            ("name".to_owned(), Value::String("Carol".to_owned())),
223            ("active".to_owned(), Value::Bool(false)),
224        ]);
225
226        let result = user.assign_attributes(attrs);
227
228        assert_eq!(result, Ok(()));
229        assert_eq!(user.name, "Carol");
230        assert!(!user.active);
231    }
232
233    #[test]
234    fn attributes_returns_complete_snapshot() {
235        let user = TestUser::new();
236        let attrs = user.attributes();
237
238        assert_eq!(attrs.get("id"), Some(&Value::from(1_u64)));
239        assert_eq!(attrs.get("name"), Some(&Value::String("Alice".to_owned())));
240        assert_eq!(attrs.get("active"), Some(&Value::Bool(true)));
241    }
242    #[test]
243    fn assign_attributes_rejects_unknown_keys() {
244        let mut user = TestUser::new();
245        let result = user.assign_attributes(HashMap::from([(
246            "email".to_owned(),
247            Value::String("alice@example.test".to_owned()),
248        )]));
249
250        assert_eq!(
251            result,
252            Err(AttributeError::UnknownAttribute("email".to_owned()))
253        );
254    }
255
256    #[test]
257    fn assign_attributes_rejects_type_mismatches() {
258        let mut user = TestUser::new();
259        let result = user.assign_attributes(HashMap::from([(
260            "active".to_owned(),
261            Value::String("yes".to_owned()),
262        )]));
263
264        assert_eq!(
265            result,
266            Err(AttributeError::TypeMismatch {
267                attribute: "active".to_owned(),
268                expected: "boolean".to_owned(),
269                actual: "string".to_owned(),
270            })
271        );
272    }
273
274    #[test]
275    fn attributes_snapshot_reflects_successful_writes() {
276        let mut user = TestUser::new();
277        let result = user.write_attribute("name", Value::String("Dana".to_owned()));
278
279        assert_eq!(result, Ok(()));
280        assert_eq!(
281            user.attributes().get("name"),
282            Some(&Value::String("Dana".to_owned()))
283        );
284    }
285
286    #[test]
287    fn has_attribute_is_case_sensitive() {
288        assert!(TestUser::has_attribute("name"));
289        assert!(!TestUser::has_attribute("Name"));
290    }
291
292    #[test]
293    fn assign_attributes_with_empty_map_is_a_noop() {
294        let mut user = TestUser::new();
295        let result = user.assign_attributes(HashMap::new());
296
297        assert_eq!(result, Ok(()));
298        assert_eq!(user, TestUser::new());
299    }
300
301    #[test]
302    fn attribute_names_order_is_stable() {
303        assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
304        assert_eq!(TestUser::attribute_names(), &["id", "name", "active"]);
305    }
306
307    #[test]
308    fn has_attribute_rejects_empty_name() {
309        assert!(!TestUser::has_attribute(""));
310    }
311
312    #[test]
313    fn read_attribute_reflects_updated_name_after_write() {
314        let mut user = TestUser::new();
315        assert_eq!(
316            user.write_attribute("name", Value::String("Beatrice".to_owned())),
317            Ok(())
318        );
319
320        assert_eq!(
321            user.read_attribute("name"),
322            Some(Value::String("Beatrice".to_owned()))
323        );
324    }
325
326    #[test]
327    fn read_attribute_reflects_updated_active_after_write() {
328        let mut user = TestUser::new();
329        assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
330
331        assert_eq!(user.read_attribute("active"), Some(Value::Bool(false)));
332    }
333
334    #[test]
335    fn write_attribute_updates_active_flag() {
336        let mut user = TestUser::new();
337
338        assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
339        assert!(!user.active);
340    }
341
342    #[test]
343    fn write_attribute_name_rejects_numbers() {
344        let mut user = TestUser::new();
345
346        assert_eq!(
347            user.write_attribute("name", Value::from(42)),
348            Err(AttributeError::TypeMismatch {
349                attribute: "name".to_owned(),
350                expected: "string".to_owned(),
351                actual: "number".to_owned(),
352            })
353        );
354    }
355
356    #[test]
357    fn write_attribute_name_rejects_null() {
358        let mut user = TestUser::new();
359
360        assert_eq!(
361            user.write_attribute("name", Value::Null),
362            Err(AttributeError::TypeMismatch {
363                attribute: "name".to_owned(),
364                expected: "string".to_owned(),
365                actual: "null".to_owned(),
366            })
367        );
368    }
369
370    #[test]
371    fn write_attribute_name_rejects_arrays() {
372        let mut user = TestUser::new();
373
374        assert_eq!(
375            user.write_attribute("name", json!(["Alice", "Bob"])),
376            Err(AttributeError::TypeMismatch {
377                attribute: "name".to_owned(),
378                expected: "string".to_owned(),
379                actual: "array".to_owned(),
380            })
381        );
382    }
383
384    #[test]
385    fn write_attribute_name_rejects_objects() {
386        let mut user = TestUser::new();
387
388        assert_eq!(
389            user.write_attribute("name", json!({ "first": "Alice" })),
390            Err(AttributeError::TypeMismatch {
391                attribute: "name".to_owned(),
392                expected: "string".to_owned(),
393                actual: "object".to_owned(),
394            })
395        );
396    }
397
398    #[test]
399    fn write_attribute_active_rejects_numbers() {
400        let mut user = TestUser::new();
401
402        assert_eq!(
403            user.write_attribute("active", Value::from(1)),
404            Err(AttributeError::TypeMismatch {
405                attribute: "active".to_owned(),
406                expected: "boolean".to_owned(),
407                actual: "number".to_owned(),
408            })
409        );
410    }
411
412    #[test]
413    fn assign_attributes_rejects_readonly_keys() {
414        let mut user = TestUser::new();
415        let result = user.assign_attributes(HashMap::from([("id".to_owned(), Value::from(2_u64))]));
416
417        assert_eq!(result, Err(AttributeError::Readonly("id".to_owned())));
418    }
419
420    #[test]
421    fn attributes_snapshot_reflects_boolean_update() {
422        let mut user = TestUser::new();
423        assert_eq!(user.write_attribute("active", Value::Bool(false)), Ok(()));
424
425        assert_eq!(user.attributes().get("active"), Some(&Value::Bool(false)));
426    }
427
428    #[test]
429    fn unknown_attribute_error_display_is_human_readable() {
430        assert_eq!(
431            AttributeError::UnknownAttribute("email".to_owned()).to_string(),
432            "unknown attribute: email"
433        );
434    }
435
436    #[test]
437    fn type_mismatch_error_display_is_human_readable() {
438        assert_eq!(
439            AttributeError::TypeMismatch {
440                attribute: "active".to_owned(),
441                expected: "boolean".to_owned(),
442                actual: "string".to_owned(),
443            }
444            .to_string(),
445            "type mismatch for active: expected boolean, got string"
446        );
447    }
448
449    #[test]
450    fn readonly_error_display_is_human_readable() {
451        assert_eq!(
452            AttributeError::Readonly("id".to_owned()).to_string(),
453            "attribute id is readonly"
454        );
455    }
456    #[test]
457    fn failed_write_keeps_previous_successful_value() {
458        let mut user = TestUser::new();
459        assert_eq!(
460            user.write_attribute("name", Value::String("Bob".to_owned())),
461            Ok(())
462        );
463
464        assert_eq!(
465            user.write_attribute("name", Value::from(42)),
466            Err(AttributeError::TypeMismatch {
467                attribute: "name".to_owned(),
468                expected: "string".to_owned(),
469                actual: "number".to_owned(),
470            })
471        );
472
473        assert_eq!(user.name, "Bob");
474        assert_eq!(
475            user.read_attribute("name"),
476            Some(Value::String("Bob".to_owned()))
477        );
478    }
479
480    #[test]
481    fn failed_assign_does_not_rollback_previous_successful_writes() {
482        let mut user = TestUser::new();
483        assert_eq!(
484            user.write_attribute("name", Value::String("Bob".to_owned())),
485            Ok(())
486        );
487
488        let result = user.assign_attributes(HashMap::from([(
489            "active".to_owned(),
490            Value::String("yes".to_owned()),
491        )]));
492
493        assert_eq!(
494            result,
495            Err(AttributeError::TypeMismatch {
496                attribute: "active".to_owned(),
497                expected: "boolean".to_owned(),
498                actual: "string".to_owned(),
499            })
500        );
501        assert_eq!(user.name, "Bob");
502        assert!(user.active);
503    }
504
505    #[test]
506    fn readonly_write_leaves_existing_id_unchanged() {
507        let mut user = TestUser::new();
508
509        assert_eq!(
510            user.write_attribute("id", Value::from(2_u64)),
511            Err(AttributeError::Readonly("id".to_owned()))
512        );
513
514        assert_eq!(user.read_attribute("id"), Some(Value::from(1_u64)));
515        assert_eq!(user.attributes().get("id"), Some(&Value::from(1_u64)));
516    }
517
518    #[derive(Debug, Clone)]
519    struct SpecialAttributeModel {
520        values: HashMap<String, Value>,
521    }
522
523    impl SpecialAttributeModel {
524        fn new() -> Self {
525            Self {
526                values: HashMap::from([
527                    ("foo bar".to_owned(), json!("value of foo bar")),
528                    ("a?b".to_owned(), json!("value of a?b")),
529                    ("begin".to_owned(), json!("value of begin")),
530                    ("end".to_owned(), json!("value of end")),
531                ]),
532            }
533        }
534    }
535
536    impl Attributes for SpecialAttributeModel {
537        fn attribute_names() -> &'static [&'static str] {
538            &["foo bar", "a?b", "begin", "end"]
539        }
540
541        fn read_attribute(&self, name: &str) -> Option<Value> {
542            self.values.get(name).cloned()
543        }
544
545        fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
546            if Self::has_attribute(name) {
547                self.values.insert(name.to_owned(), value);
548                Ok(())
549            } else {
550                Err(AttributeError::UnknownAttribute(name.to_owned()))
551            }
552        }
553
554        fn attributes(&self) -> HashMap<String, Value> {
555            self.values.clone()
556        }
557    }
558
559    ignored_rails_test!(
560        test_method_missing_works_correctly_even_if_attributes_method_is_not_defined,
561        "Rails-specific: Rust attributes require an Attributes impl at compile time instead of Ruby method_missing dispatch"
562    );
563
564    ignored_rails_test!(
565        test_unrelated_classes_should_not_share_attribute_method_matchers,
566        "Rails-specific: rustrails-model has no per-class attribute method matcher registry"
567    );
568
569    ignored_rails_test!(
570        test_define_attribute_method_generates_attribute_method,
571        "Rails-specific: rustrails-model exposes read_attribute/write_attribute instead of generating Ruby methods at runtime"
572    );
573
574    ignored_rails_test!(
575        test_define_attribute_methods_defines_alias_attribute_methods_after_undefining,
576        "Rails-specific: rustrails-model has no runtime alias_attribute or undefine_attribute_methods metaprogramming API"
577    );
578
579    ignored_rails_test!(
580        test_define_attribute_method_does_not_generate_attribute_method_if_already_defined_in_attribute_module,
581        "Rails-specific: rustrails-model does not synthesize attribute reader methods into generated modules"
582    );
583
584    ignored_rails_test!(
585        test_define_attribute_method_generates_a_method_that_is_already_defined_on_the_host,
586        "Rails-specific: rustrails-model does not override or generate host methods for attributes"
587    );
588
589    #[test]
590    fn test_define_attribute_method_generates_attribute_method_with_invalid_identifier_characters()
591    {
592        let mut model = SpecialAttributeModel::new();
593
594        assert_eq!(model.read_attribute("a?b"), Some(json!("value of a?b")));
595        assert_eq!(model.write_attribute("a?b", json!("updated")), Ok(()));
596        assert_eq!(model.read_attribute("a?b"), Some(json!("updated")));
597    }
598
599    ignored_rails_test!(
600        test_define_attribute_methods_works_passing_multiple_arguments,
601        "Rails-specific: rustrails-model does not batch-generate Ruby attribute methods from a variadic API"
602    );
603
604    ignored_rails_test!(
605        test_define_attribute_methods_generates_attribute_methods,
606        "Rails-specific: rustrails-model exposes explicit attribute access traits instead of generated methods"
607    );
608
609    ignored_rails_test!(
610        test_alias_attribute_generates_attribute_aliases_lookup_hash,
611        "Rails-specific: rustrails-model has no alias_attribute registry for alternate method names"
612    );
613
614    #[test]
615    fn test_define_attribute_methods_generates_attribute_methods_with_spaces_in_their_names() {
616        let mut model = SpecialAttributeModel::new();
617
618        assert_eq!(
619            model.read_attribute("foo bar"),
620            Some(json!("value of foo bar"))
621        );
622        assert_eq!(model.write_attribute("foo bar", json!("renamed")), Ok(()));
623        assert_eq!(model.read_attribute("foo bar"), Some(json!("renamed")));
624    }
625
626    ignored_rails_test!(
627        test_alias_attribute_works_with_attributes_with_spaces_in_their_names,
628        "Rails-specific: rustrails-model can address string attribute names with spaces but has no alias_attribute support"
629    );
630
631    ignored_rails_test!(
632        test_alias_attribute_works_with_attributes_named_as_a_ruby_keyword,
633        "Rails-specific: rustrails-model accepts string keys like begin/end but has no alias_attribute API"
634    );
635
636    ignored_rails_test!(
637        test_undefine_attribute_methods_removes_attribute_methods,
638        "Rails-specific: rustrails-model does not define or undefine Ruby methods for attributes"
639    );
640
641    ignored_rails_test!(
642        test_undefine_attribute_methods_undefines_alias_attribute_methods,
643        "Rails-specific: rustrails-model has no alias_attribute or undefine_attribute_methods metaprogramming hooks"
644    );
645
646    ignored_rails_test!(
647        test_accessing_a_suffixed_attribute,
648        "Rails-specific: rustrails-model has no attribute_method_suffix dispatch API"
649    );
650
651    ignored_rails_test!(
652        test_defined_attribute_does_not_expand_positional_hash_argument,
653        "Rails-specific: rustrails-model has no generated Ruby methods with positional hash argument semantics"
654    );
655
656    ignored_rails_test!(
657        test_should_not_interfere_with_method_missing_if_the_attr_has_a_private_or_protected_method,
658        "Rails-specific: rustrails-model has no Ruby visibility or method_missing interception for attributes"
659    );
660
661    ignored_rails_test!(
662        test_should_not_interfere_with_respond_to_if_the_attribute_has_a_private_or_protected_method,
663        "Rails-specific: rustrails-model has no Ruby respond_to? or private/protected method dispatch layer"
664    );
665
666    ignored_rails_test!(
667        test_should_use_attribute_missing_to_dispatch_a_missing_attribute,
668        "Rails-specific: rustrails-model has no attribute_missing callback for unresolved Ruby methods"
669    );
670
671    ignored_rails_test!(
672        test_name_clashes_are_handled,
673        "Rails-specific: rustrails-model does not synthesize overlapping Ruby method names for attributes"
674    );
675
676    ignored_rails_test!(
677        test_alias_attribute_respects_user_defined_method,
678        "Rails-specific: rustrails-model has no alias_attribute behavior to reconcile with user-defined Ruby methods"
679    );
680
681    ignored_rails_test!(
682        test_alias_attribute_respects_user_defined_method_in_parent_classes,
683        "Rails-specific: rustrails-model has no alias_attribute inheritance behavior over Ruby method lookup"
684    );
685}