Skip to main content

rustrails_model/
conversion.rs

1use std::any::type_name;
2
3use serde_json::Value;
4
5use crate::{Attributes, ModelNaming};
6
7/// Helpers for converting models into routing-friendly representations.
8pub trait Conversion: Attributes + ModelNaming {
9    /// Returns the model key as a list of values.
10    ///
11    /// The default implementation reads the `id` attribute and returns it when
12    /// present and non-null.
13    fn to_key(&self) -> Option<Vec<Value>> {
14        self.read_attribute("id")
15            .filter(|value| !matches!(value, Value::Null))
16            .map(|value| match value {
17                Value::Array(values) => values,
18                value => vec![value],
19            })
20    }
21
22    /// Returns the delimiter used when joining composite key components.
23    fn param_delimiter() -> &'static str {
24        "-"
25    }
26
27    /// Returns a URL-safe parameter string derived from [`Conversion::to_key`].
28    fn to_param(&self) -> Option<String> {
29        self.to_key().and_then(|values| {
30            let parts = values
31                .into_iter()
32                .map(value_to_param_component)
33                .collect::<Option<Vec<_>>>()?;
34
35            if parts.is_empty() {
36                None
37            } else {
38                Some(parts.join(Self::param_delimiter()))
39            }
40        })
41    }
42
43    /// Returns the partial template path for the model.
44    fn to_partial_path(&self) -> String {
45        let model_name = Self::model_name();
46        format!("{}/{}", model_name.route_key, model_name.element)
47    }
48
49    /// Returns the model name used by conversion helpers.
50    fn model_name_for_conversion() -> &'static str {
51        let type_name = type_name::<Self>();
52        type_name.rsplit("::").next().unwrap_or(type_name)
53    }
54}
55
56fn value_to_param_component(value: Value) -> Option<String> {
57    match value {
58        Value::Null => None,
59        Value::String(string) if string.is_empty() => None,
60        Value::String(string) => Some(string),
61        Value::Number(number) => Some(number.to_string()),
62        Value::Bool(boolean) => Some(boolean.to_string()),
63        other => Some(other.to_string()),
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use serde_json::{Value, json};
70
71    use super::Conversion;
72    use crate::{AttributeError, Attributes, ModelNaming};
73
74    #[derive(Debug, Clone, Default)]
75    struct TestUser {
76        id: Option<u64>,
77        name: String,
78    }
79
80    impl Attributes for TestUser {
81        fn attribute_names() -> &'static [&'static str] {
82            &["id", "name"]
83        }
84
85        fn read_attribute(&self, name: &str) -> Option<Value> {
86            match name {
87                "id" => Some(self.id.map_or(Value::Null, Value::from)),
88                "name" => Some(Value::String(self.name.clone())),
89                _ => None,
90            }
91        }
92
93        fn write_attribute(&mut self, name: &str, value: Value) -> Result<(), AttributeError> {
94            match (name, value) {
95                ("id", Value::Null) => {
96                    self.id = None;
97                    Ok(())
98                }
99                ("id", Value::Number(number)) => {
100                    let id = number
101                        .as_u64()
102                        .ok_or_else(|| AttributeError::TypeMismatch {
103                            attribute: "id".to_string(),
104                            expected: "u64".to_string(),
105                            actual: "number".to_string(),
106                        })?;
107                    self.id = Some(id);
108                    Ok(())
109                }
110                ("name", Value::String(name)) => {
111                    self.name = name;
112                    Ok(())
113                }
114                ("id", other) => Err(AttributeError::TypeMismatch {
115                    attribute: "id".to_string(),
116                    expected: "u64".to_string(),
117                    actual: other.to_string(),
118                }),
119                ("name", other) => Err(AttributeError::TypeMismatch {
120                    attribute: "name".to_string(),
121                    expected: "string".to_string(),
122                    actual: other.to_string(),
123                }),
124                (unknown, _) => Err(AttributeError::UnknownAttribute(unknown.to_string())),
125            }
126        }
127
128        fn assign_attributes(
129            &mut self,
130            attrs: std::collections::HashMap<String, Value>,
131        ) -> Result<(), AttributeError> {
132            for (name, value) in attrs {
133                self.write_attribute(&name, value)?;
134            }
135            Ok(())
136        }
137
138        fn attributes(&self) -> std::collections::HashMap<String, Value> {
139            let mut attributes = std::collections::HashMap::new();
140            attributes.insert("id".to_string(), self.id.map_or(Value::Null, Value::from));
141            attributes.insert("name".to_string(), Value::String(self.name.clone()));
142            attributes
143        }
144    }
145
146    impl ModelNaming for TestUser {}
147    impl Conversion for TestUser {}
148
149    #[test]
150    fn returns_key_from_id_attribute() {
151        let user = TestUser {
152            id: Some(7),
153            name: "Alice".to_string(),
154        };
155
156        assert_eq!(user.to_key(), Some(vec![json!(7)]));
157    }
158
159    #[test]
160    fn returns_none_when_id_is_missing() {
161        let user = TestUser::default();
162
163        assert_eq!(user.to_key(), None);
164        assert_eq!(user.to_param(), None);
165    }
166
167    #[test]
168    fn builds_param_and_partial_path() {
169        let user = TestUser {
170            id: Some(42),
171            name: "Alice".to_string(),
172        };
173
174        assert_eq!(user.to_param(), Some("42".to_string()));
175        assert_eq!(user.to_partial_path(), "test_users/test_user");
176        assert_eq!(TestUser::model_name_for_conversion(), "TestUser");
177    }
178    #[derive(Debug, Clone, Default)]
179    struct CompositeKeyRecord;
180
181    impl Attributes for CompositeKeyRecord {
182        fn attribute_names() -> &'static [&'static str] {
183            &[]
184        }
185
186        fn read_attribute(&self, _name: &str) -> Option<Value> {
187            None
188        }
189
190        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
191            Err(AttributeError::UnknownAttribute(name.to_string()))
192        }
193
194        fn attributes(&self) -> std::collections::HashMap<String, Value> {
195            std::collections::HashMap::new()
196        }
197    }
198
199    impl ModelNaming for CompositeKeyRecord {
200        fn model_name() -> crate::ModelName {
201            crate::ModelName::new("Person")
202        }
203    }
204
205    impl Conversion for CompositeKeyRecord {
206        fn to_key(&self) -> Option<Vec<Value>> {
207            Some(vec![json!("north"), json!(7)])
208        }
209    }
210
211    #[test]
212    fn to_param_supports_string_keys() {
213        struct StringKey;
214
215        impl Attributes for StringKey {
216            fn attribute_names() -> &'static [&'static str] {
217                &["id"]
218            }
219
220            fn read_attribute(&self, _name: &str) -> Option<Value> {
221                Some(Value::String("abc".to_string()))
222            }
223
224            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
225                Err(AttributeError::UnknownAttribute(name.to_string()))
226            }
227
228            fn attributes(&self) -> std::collections::HashMap<String, Value> {
229                std::collections::HashMap::from([(
230                    "id".to_string(),
231                    Value::String("abc".to_string()),
232                )])
233            }
234        }
235
236        impl ModelNaming for StringKey {}
237        impl Conversion for StringKey {}
238
239        assert_eq!(StringKey.to_param(), Some("abc".to_string()));
240    }
241
242    #[test]
243    fn to_param_joins_composite_keys_with_dashes() {
244        let record = CompositeKeyRecord;
245        assert_eq!(record.to_param(), Some("north-7".to_string()));
246    }
247
248    #[test]
249    fn to_param_returns_none_for_empty_string_key_component() {
250        struct EmptyKey;
251
252        impl Attributes for EmptyKey {
253            fn attribute_names() -> &'static [&'static str] {
254                &["id"]
255            }
256
257            fn read_attribute(&self, _name: &str) -> Option<Value> {
258                Some(Value::String(String::new()))
259            }
260
261            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
262                Err(AttributeError::UnknownAttribute(name.to_string()))
263            }
264
265            fn attributes(&self) -> std::collections::HashMap<String, Value> {
266                std::collections::HashMap::from([("id".to_string(), Value::String(String::new()))])
267            }
268        }
269
270        impl ModelNaming for EmptyKey {}
271        impl Conversion for EmptyKey {}
272
273        assert_eq!(EmptyKey.to_param(), None);
274    }
275
276    #[test]
277    fn to_partial_path_uses_irregular_route_keys() {
278        let record = CompositeKeyRecord;
279        assert_eq!(record.to_partial_path(), "people/person");
280    }
281
282    #[derive(Debug, Clone, Default)]
283    struct StringIdRecord;
284
285    impl Attributes for StringIdRecord {
286        fn attribute_names() -> &'static [&'static str] {
287            &["id"]
288        }
289
290        fn read_attribute(&self, _name: &str) -> Option<Value> {
291            Some(Value::String("abc".to_string()))
292        }
293
294        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
295            Err(AttributeError::UnknownAttribute(name.to_string()))
296        }
297
298        fn attributes(&self) -> std::collections::HashMap<String, Value> {
299            std::collections::HashMap::from([("id".to_string(), Value::String("abc".to_string()))])
300        }
301    }
302
303    impl ModelNaming for StringIdRecord {}
304    impl Conversion for StringIdRecord {}
305
306    #[derive(Debug, Clone, Default)]
307    struct BoolIdRecord;
308
309    impl Attributes for BoolIdRecord {
310        fn attribute_names() -> &'static [&'static str] {
311            &["id"]
312        }
313
314        fn read_attribute(&self, _name: &str) -> Option<Value> {
315            Some(Value::Bool(false))
316        }
317
318        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
319            Err(AttributeError::UnknownAttribute(name.to_string()))
320        }
321
322        fn attributes(&self) -> std::collections::HashMap<String, Value> {
323            std::collections::HashMap::from([("id".to_string(), Value::Bool(false))])
324        }
325    }
326
327    impl ModelNaming for BoolIdRecord {}
328    impl Conversion for BoolIdRecord {}
329
330    #[derive(Debug, Clone, Default)]
331    struct ZeroIdRecord;
332
333    impl Attributes for ZeroIdRecord {
334        fn attribute_names() -> &'static [&'static str] {
335            &["id"]
336        }
337
338        fn read_attribute(&self, _name: &str) -> Option<Value> {
339            Some(json!(0))
340        }
341
342        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
343            Err(AttributeError::UnknownAttribute(name.to_string()))
344        }
345
346        fn attributes(&self) -> std::collections::HashMap<String, Value> {
347            std::collections::HashMap::from([("id".to_string(), json!(0))])
348        }
349    }
350
351    impl ModelNaming for ZeroIdRecord {}
352    impl Conversion for ZeroIdRecord {}
353
354    #[derive(Debug, Clone, Default)]
355    struct ObjectKeyRecord;
356
357    impl Attributes for ObjectKeyRecord {
358        fn attribute_names() -> &'static [&'static str] {
359            &[]
360        }
361
362        fn read_attribute(&self, _name: &str) -> Option<Value> {
363            None
364        }
365
366        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
367            Err(AttributeError::UnknownAttribute(name.to_string()))
368        }
369
370        fn attributes(&self) -> std::collections::HashMap<String, Value> {
371            std::collections::HashMap::new()
372        }
373    }
374
375    impl ModelNaming for ObjectKeyRecord {}
376
377    impl Conversion for ObjectKeyRecord {
378        fn to_key(&self) -> Option<Vec<Value>> {
379            Some(vec![json!({ "region": "north" })])
380        }
381    }
382
383    #[derive(Debug, Clone, Default)]
384    struct ArrayKeyRecord;
385
386    impl Attributes for ArrayKeyRecord {
387        fn attribute_names() -> &'static [&'static str] {
388            &[]
389        }
390
391        fn read_attribute(&self, _name: &str) -> Option<Value> {
392            None
393        }
394
395        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
396            Err(AttributeError::UnknownAttribute(name.to_string()))
397        }
398
399        fn attributes(&self) -> std::collections::HashMap<String, Value> {
400            std::collections::HashMap::new()
401        }
402    }
403
404    impl ModelNaming for ArrayKeyRecord {}
405
406    impl Conversion for ArrayKeyRecord {
407        fn to_key(&self) -> Option<Vec<Value>> {
408            Some(vec![json!(["north", 7])])
409        }
410    }
411
412    #[derive(Debug, Clone, Default)]
413    struct NullCompositeKeyRecord;
414
415    impl Attributes for NullCompositeKeyRecord {
416        fn attribute_names() -> &'static [&'static str] {
417            &[]
418        }
419
420        fn read_attribute(&self, _name: &str) -> Option<Value> {
421            None
422        }
423
424        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
425            Err(AttributeError::UnknownAttribute(name.to_string()))
426        }
427
428        fn attributes(&self) -> std::collections::HashMap<String, Value> {
429            std::collections::HashMap::new()
430        }
431    }
432
433    impl ModelNaming for NullCompositeKeyRecord {}
434
435    impl Conversion for NullCompositeKeyRecord {
436        fn to_key(&self) -> Option<Vec<Value>> {
437            Some(vec![json!("north"), Value::Null])
438        }
439    }
440
441    #[derive(Debug, Clone, Default)]
442    struct EmptyCompositeKeyRecord;
443
444    impl Attributes for EmptyCompositeKeyRecord {
445        fn attribute_names() -> &'static [&'static str] {
446            &[]
447        }
448
449        fn read_attribute(&self, _name: &str) -> Option<Value> {
450            None
451        }
452
453        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
454            Err(AttributeError::UnknownAttribute(name.to_string()))
455        }
456
457        fn attributes(&self) -> std::collections::HashMap<String, Value> {
458            std::collections::HashMap::new()
459        }
460    }
461
462    impl ModelNaming for EmptyCompositeKeyRecord {}
463
464    impl Conversion for EmptyCompositeKeyRecord {
465        fn to_key(&self) -> Option<Vec<Value>> {
466            Some(Vec::new())
467        }
468    }
469
470    #[derive(Debug, Clone, Default)]
471    struct CustomPathRecord;
472
473    impl Attributes for CustomPathRecord {
474        fn attribute_names() -> &'static [&'static str] {
475            &[]
476        }
477
478        fn read_attribute(&self, _name: &str) -> Option<Value> {
479            None
480        }
481
482        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
483            Err(AttributeError::UnknownAttribute(name.to_string()))
484        }
485
486        fn attributes(&self) -> std::collections::HashMap<String, Value> {
487            std::collections::HashMap::new()
488        }
489    }
490
491    impl ModelNaming for CustomPathRecord {
492        fn model_name() -> crate::ModelName {
493            crate::ModelName::new("Admin::BillingAddress")
494        }
495    }
496
497    impl Conversion for CustomPathRecord {}
498
499    mod nested_types {
500        use super::*;
501
502        #[derive(Debug, Clone, Default)]
503        pub(super) struct AuditLog;
504
505        impl Attributes for AuditLog {
506            fn attribute_names() -> &'static [&'static str] {
507                &[]
508            }
509
510            fn read_attribute(&self, _name: &str) -> Option<Value> {
511                None
512            }
513
514            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
515                Err(AttributeError::UnknownAttribute(name.to_string()))
516            }
517
518            fn attributes(&self) -> std::collections::HashMap<String, Value> {
519                std::collections::HashMap::new()
520            }
521        }
522
523        impl ModelNaming for AuditLog {}
524        impl Conversion for AuditLog {}
525    }
526
527    #[derive(Debug, Clone, Default)]
528    struct InvoiceLine;
529
530    impl Attributes for InvoiceLine {
531        fn attribute_names() -> &'static [&'static str] {
532            &[]
533        }
534
535        fn read_attribute(&self, _name: &str) -> Option<Value> {
536            None
537        }
538
539        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
540            Err(AttributeError::UnknownAttribute(name.to_string()))
541        }
542
543        fn attributes(&self) -> std::collections::HashMap<String, Value> {
544            std::collections::HashMap::new()
545        }
546    }
547
548    impl ModelNaming for InvoiceLine {}
549    impl Conversion for InvoiceLine {}
550
551    #[test]
552    fn to_key_supports_string_ids_from_attributes() {
553        assert_eq!(StringIdRecord.to_key(), Some(vec![json!("abc")]));
554    }
555
556    #[test]
557    fn to_key_preserves_false_boolean_ids() {
558        assert_eq!(BoolIdRecord.to_key(), Some(vec![json!(false)]));
559    }
560
561    #[test]
562    fn to_param_supports_false_boolean_ids() {
563        assert_eq!(BoolIdRecord.to_param(), Some("false".to_string()));
564    }
565
566    #[test]
567    fn to_param_supports_zero_numeric_ids() {
568        assert_eq!(ZeroIdRecord.to_param(), Some("0".to_string()));
569    }
570
571    #[test]
572    fn to_param_uses_json_for_object_key_components() {
573        assert_eq!(
574            ObjectKeyRecord.to_param(),
575            Some("{\"region\":\"north\"}".to_string())
576        );
577    }
578
579    #[test]
580    fn to_param_uses_json_for_array_key_components() {
581        assert_eq!(ArrayKeyRecord.to_param(), Some("[\"north\",7]".to_string()));
582    }
583
584    #[test]
585    fn to_param_returns_none_when_a_composite_key_contains_null() {
586        assert_eq!(NullCompositeKeyRecord.to_param(), None);
587    }
588
589    #[test]
590    fn to_param_returns_none_when_to_key_is_empty() {
591        assert_eq!(EmptyCompositeKeyRecord.to_param(), None);
592    }
593
594    #[test]
595    fn to_partial_path_uses_the_model_name_route_key_and_element() {
596        assert_eq!(
597            CustomPathRecord.to_partial_path(),
598            "admin_billing_addresses/billing_address"
599        );
600    }
601
602    #[test]
603    fn model_name_for_conversion_uses_the_last_type_path_segment() {
604        assert_eq!(
605            nested_types::AuditLog::model_name_for_conversion(),
606            "AuditLog"
607        );
608    }
609
610    #[test]
611    fn to_partial_path_uses_default_model_naming_for_compound_types() {
612        assert_eq!(InvoiceLine.to_partial_path(), "invoice_lines/invoice_line");
613    }
614
615    #[test]
616    fn string_ids_round_trip_through_to_param() {
617        assert_eq!(StringIdRecord.to_param(), Some("abc".to_string()));
618    }
619
620    #[derive(Debug, Clone, Default)]
621    struct WhitespaceIdRecord;
622
623    impl Attributes for WhitespaceIdRecord {
624        fn attribute_names() -> &'static [&'static str] {
625            &["id"]
626        }
627
628        fn read_attribute(&self, _name: &str) -> Option<Value> {
629            Some(Value::String("  abc  ".to_string()))
630        }
631
632        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
633            Err(AttributeError::UnknownAttribute(name.to_string()))
634        }
635
636        fn attributes(&self) -> std::collections::HashMap<String, Value> {
637            std::collections::HashMap::from([(
638                "id".to_string(),
639                Value::String("  abc  ".to_string()),
640            )])
641        }
642    }
643
644    impl ModelNaming for WhitespaceIdRecord {}
645    impl Conversion for WhitespaceIdRecord {}
646
647    #[derive(Debug, Clone, Default)]
648    struct MixedCompositeKeyRecord;
649
650    impl Attributes for MixedCompositeKeyRecord {
651        fn attribute_names() -> &'static [&'static str] {
652            &[]
653        }
654
655        fn read_attribute(&self, _name: &str) -> Option<Value> {
656            None
657        }
658
659        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
660            Err(AttributeError::UnknownAttribute(name.to_string()))
661        }
662
663        fn attributes(&self) -> std::collections::HashMap<String, Value> {
664            std::collections::HashMap::new()
665        }
666    }
667
668    impl ModelNaming for MixedCompositeKeyRecord {
669        fn model_name() -> crate::ModelName {
670            crate::ModelName::new("WarehouseEntry")
671        }
672    }
673
674    impl Conversion for MixedCompositeKeyRecord {
675        fn to_key(&self) -> Option<Vec<Value>> {
676            Some(vec![json!("north"), json!(false), json!(0)])
677        }
678    }
679
680    #[derive(Debug, Clone, Default)]
681    struct AlternateCustomPathRecord;
682
683    impl Attributes for AlternateCustomPathRecord {
684        fn attribute_names() -> &'static [&'static str] {
685            &[]
686        }
687
688        fn read_attribute(&self, _name: &str) -> Option<Value> {
689            None
690        }
691
692        fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
693            Err(AttributeError::UnknownAttribute(name.to_string()))
694        }
695
696        fn attributes(&self) -> std::collections::HashMap<String, Value> {
697            std::collections::HashMap::new()
698        }
699    }
700
701    impl ModelNaming for AlternateCustomPathRecord {
702        fn model_name() -> crate::ModelName {
703            let mut model_name = crate::ModelName::new("AttackHelicopter");
704            model_name.element = "ah-64".to_string();
705            model_name
706        }
707    }
708
709    impl Conversion for AlternateCustomPathRecord {}
710
711    mod deeper_nested_types {
712        use super::*;
713
714        pub(crate) mod reports {
715            use super::*;
716
717            #[derive(Debug, Clone, Default)]
718            pub(crate) struct DailySummary;
719
720            impl Attributes for DailySummary {
721                fn attribute_names() -> &'static [&'static str] {
722                    &[]
723                }
724
725                fn read_attribute(&self, _name: &str) -> Option<Value> {
726                    None
727                }
728
729                fn write_attribute(
730                    &mut self,
731                    name: &str,
732                    _value: Value,
733                ) -> Result<(), AttributeError> {
734                    Err(AttributeError::UnknownAttribute(name.to_string()))
735                }
736
737                fn attributes(&self) -> std::collections::HashMap<String, Value> {
738                    std::collections::HashMap::new()
739                }
740            }
741
742            impl ModelNaming for DailySummary {}
743            impl Conversion for DailySummary {}
744        }
745    }
746
747    #[test]
748    fn to_param_preserves_surrounding_whitespace_in_string_ids() {
749        assert_eq!(WhitespaceIdRecord.to_key(), Some(vec![json!("  abc  ")]));
750        assert_eq!(WhitespaceIdRecord.to_param(), Some("  abc  ".to_string()));
751    }
752
753    #[test]
754    fn to_param_joins_mixed_composite_key_components() {
755        assert_eq!(
756            MixedCompositeKeyRecord.to_param(),
757            Some("north-false-0".to_string())
758        );
759    }
760
761    #[test]
762    fn to_partial_path_is_class_specific_for_custom_model_names() {
763        assert_eq!(
764            CustomPathRecord.to_partial_path(),
765            "admin_billing_addresses/billing_address"
766        );
767        assert_eq!(
768            AlternateCustomPathRecord.to_partial_path(),
769            "attack_helicopters/ah-64"
770        );
771    }
772
773    #[test]
774    fn model_name_for_conversion_uses_last_segment_for_deeply_nested_types() {
775        assert_eq!(
776            deeper_nested_types::reports::DailySummary::model_name_for_conversion(),
777            "DailySummary"
778        );
779    }
780
781    fn rails_contact(id: Option<u64>) -> TestUser {
782        TestUser {
783            id,
784            name: String::new(),
785        }
786    }
787
788    #[test]
789    fn test_rails_to_model_default_implementation_returns_self() {
790        let contact = rails_contact(Some(7));
791        assert_eq!(contact.to_model().to_param(), Some("7".to_string()));
792    }
793
794    #[test]
795    fn test_rails_to_key_default_implementation_returns_nil_for_new_records() {
796        assert_eq!(rails_contact(None).to_key(), None);
797    }
798
799    #[test]
800    fn test_rails_to_key_default_implementation_returns_the_id_in_an_array_for_persisted_records() {
801        assert_eq!(rails_contact(Some(1)).to_key(), Some(vec![json!(1)]));
802    }
803
804    #[test]
805    fn test_rails_to_key_does_not_double_wrap_composite_ids() {
806        #[derive(Debug, Clone, Default)]
807        struct ArrayIdRecord;
808
809        impl Attributes for ArrayIdRecord {
810            fn attribute_names() -> &'static [&'static str] {
811                &["id"]
812            }
813
814            fn read_attribute(&self, name: &str) -> Option<Value> {
815                (name == "id").then(|| json!(["north", 7]))
816            }
817
818            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
819                Err(AttributeError::UnknownAttribute(name.to_string()))
820            }
821
822            fn attributes(&self) -> std::collections::HashMap<String, Value> {
823                std::collections::HashMap::from([("id".to_string(), json!(["north", 7]))])
824            }
825        }
826
827        impl ModelNaming for ArrayIdRecord {}
828        impl Conversion for ArrayIdRecord {}
829
830        let record = ArrayIdRecord;
831        assert_eq!(record.to_key(), Some(vec![json!("north"), json!(7)]));
832        assert_eq!(record.to_param(), Some("north-7".to_string()));
833    }
834
835    #[test]
836    fn test_rails_to_param_default_implementation_returns_nil_for_new_records() {
837        assert_eq!(rails_contact(None).to_param(), None);
838    }
839
840    #[test]
841    fn test_rails_to_param_default_implementation_returns_a_string_of_ids_for_persisted_records() {
842        assert_eq!(rails_contact(Some(1)).to_param(), Some("1".to_string()));
843    }
844
845    #[test]
846    fn test_rails_to_param_returns_the_string_joined_by_dash() {
847        assert_eq!(CompositeKeyRecord.to_param(), Some("north-7".to_string()));
848    }
849
850    #[test]
851    fn test_rails_to_param_returns_nil_if_composite_id_is_incomplete() {
852        assert_eq!(NullCompositeKeyRecord.to_param(), None);
853    }
854
855    #[test]
856    fn test_rails_to_param_returns_nil_if_to_key_is_nil() {
857        #[derive(Debug, Clone, Default)]
858        struct NilKeyRecord;
859
860        impl Attributes for NilKeyRecord {
861            fn attribute_names() -> &'static [&'static str] {
862                &[]
863            }
864
865            fn read_attribute(&self, _name: &str) -> Option<Value> {
866                None
867            }
868
869            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
870                Err(AttributeError::UnknownAttribute(name.to_string()))
871            }
872
873            fn attributes(&self) -> std::collections::HashMap<String, Value> {
874                std::collections::HashMap::new()
875            }
876        }
877
878        impl ModelNaming for NilKeyRecord {}
879
880        impl Conversion for NilKeyRecord {
881            fn to_key(&self) -> Option<Vec<Value>> {
882                None
883            }
884        }
885
886        assert_eq!(NilKeyRecord.to_param(), None);
887    }
888
889    #[test]
890    fn test_rails_to_partial_path_default_implementation_returns_a_relative_path() {
891        assert_eq!(
892            rails_contact(Some(1)).to_partial_path(),
893            "test_users/test_user"
894        );
895        assert_eq!(InvoiceLine.to_partial_path(), "invoice_lines/invoice_line");
896    }
897
898    #[test]
899    fn test_rails_to_partial_path_handles_namespaced_models() {
900        #[derive(Debug, Clone, Default)]
901        struct Comanche;
902
903        impl Attributes for Comanche {
904            fn attribute_names() -> &'static [&'static str] {
905                &[]
906            }
907
908            fn read_attribute(&self, _name: &str) -> Option<Value> {
909                None
910            }
911
912            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
913                Err(AttributeError::UnknownAttribute(name.to_string()))
914            }
915
916            fn attributes(&self) -> std::collections::HashMap<String, Value> {
917                std::collections::HashMap::new()
918            }
919        }
920
921        impl ModelNaming for Comanche {
922            fn model_name() -> crate::ModelName {
923                let mut model_name = crate::ModelName::new("Helicopter::Comanche");
924                model_name.route_key = "helicopter/comanches".to_string();
925                model_name
926            }
927        }
928
929        impl Conversion for Comanche {}
930
931        assert_eq!(Comanche.to_partial_path(), "helicopter/comanches/comanche");
932    }
933
934    #[test]
935    fn test_rails_to_partial_path_handles_non_standard_model_name() {
936        assert_eq!(
937            AlternateCustomPathRecord.to_partial_path(),
938            "attack_helicopters/ah-64"
939        );
940    }
941
942    #[test]
943    fn test_rails_to_param_delimiter_allows_redefining_the_delimiter_used_in_to_param() {
944        #[derive(Debug, Clone, Default)]
945        struct SlashDelimitedRecord;
946
947        impl Attributes for SlashDelimitedRecord {
948            fn attribute_names() -> &'static [&'static str] {
949                &[]
950            }
951
952            fn read_attribute(&self, _name: &str) -> Option<Value> {
953                None
954            }
955
956            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
957                Err(AttributeError::UnknownAttribute(name.to_string()))
958            }
959
960            fn attributes(&self) -> std::collections::HashMap<String, Value> {
961                std::collections::HashMap::new()
962            }
963        }
964
965        impl ModelNaming for SlashDelimitedRecord {}
966        impl Conversion for SlashDelimitedRecord {
967            fn to_key(&self) -> Option<Vec<Value>> {
968                Some(vec![json!("north"), json!(7)])
969            }
970
971            fn param_delimiter() -> &'static str {
972                "/"
973            }
974        }
975
976        assert_eq!(SlashDelimitedRecord.to_param(), Some("north/7".to_string()));
977    }
978
979    #[test]
980    fn test_rails_to_param_delimiter_is_defined_per_class() {
981        #[derive(Debug, Clone, Default)]
982        struct DotDelimitedRecord;
983
984        impl Attributes for DotDelimitedRecord {
985            fn attribute_names() -> &'static [&'static str] {
986                &[]
987            }
988
989            fn read_attribute(&self, _name: &str) -> Option<Value> {
990                None
991            }
992
993            fn write_attribute(&mut self, name: &str, _value: Value) -> Result<(), AttributeError> {
994                Err(AttributeError::UnknownAttribute(name.to_string()))
995            }
996
997            fn attributes(&self) -> std::collections::HashMap<String, Value> {
998                std::collections::HashMap::new()
999            }
1000        }
1001
1002        impl ModelNaming for DotDelimitedRecord {}
1003        impl Conversion for DotDelimitedRecord {
1004            fn to_key(&self) -> Option<Vec<Value>> {
1005                Some(vec![json!("north"), json!(7)])
1006            }
1007
1008            fn param_delimiter() -> &'static str {
1009                "."
1010            }
1011        }
1012
1013        assert_eq!(CompositeKeyRecord.to_param(), Some("north-7".to_string()));
1014        assert_eq!(DotDelimitedRecord.to_param(), Some("north.7".to_string()));
1015    }
1016}