Skip to main content

rustrails_record/
reflection.rs

1use std::collections::HashMap;
2
3use serde_json::{Value, json};
4
5use crate::associations::{AssociationMeta, AssociationType, HasAssociations};
6
7/// A reflection view over declared association metadata.
8#[derive(Debug, Clone, PartialEq)]
9pub struct AssociationReflection {
10    /// The association kind.
11    pub kind: AssociationType,
12    /// The association name.
13    pub name: String,
14    /// Association options encoded as JSON-compatible values.
15    pub options: HashMap<String, Value>,
16}
17
18/// Reflection helpers backed by [`crate::associations::AssociationRegistry`].
19pub trait Reflection: HasAssociations {
20    /// Returns the reflection for a single association.
21    #[must_use]
22    fn reflect_on_association(name: &str) -> Option<AssociationReflection> {
23        Self::associations()
24            .get(name)
25            .map(AssociationReflection::from)
26    }
27
28    /// Returns every declared association reflection in declaration order.
29    #[must_use]
30    fn reflect_on_all_associations() -> Vec<AssociationReflection> {
31        Self::associations()
32            .all()
33            .iter()
34            .map(AssociationReflection::from)
35            .collect()
36    }
37}
38
39impl From<&AssociationMeta> for AssociationReflection {
40    fn from(meta: &AssociationMeta) -> Self {
41        let mut options = HashMap::from([
42            (
43                "target_table".to_owned(),
44                Value::String(meta.target_table.clone()),
45            ),
46            (
47                "foreign_key".to_owned(),
48                Value::String(meta.foreign_key.clone()),
49            ),
50            (
51                "primary_key".to_owned(),
52                Value::String(meta.primary_key.clone()),
53            ),
54            ("polymorphic".to_owned(), Value::Bool(meta.polymorphic)),
55        ]);
56
57        if let Some(through) = &meta.through {
58            options.insert("through".to_owned(), Value::String(through.clone()));
59        }
60        if let Some(dependent) = meta.dependent {
61            options.insert("dependent".to_owned(), json!(format!("{dependent:?}")));
62        }
63
64        Self {
65            kind: meta.association_type,
66            name: meta.name.clone(),
67            options,
68        }
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use std::sync::LazyLock;
75
76    use sea_orm::{ActiveModelTrait, EntityTrait};
77    use serde_json::json;
78
79    use super::{AssociationReflection, Reflection};
80    use crate::{
81        Record, RecordState,
82        associations::{
83            AssociationRegistry, AssociationType, BelongsToBuilder, DependentAction,
84            HasAndBelongsToManyBuilder, HasAssociations, HasManyBuilder, HasOneBuilder,
85        },
86        base::test_support::test_user,
87    };
88
89    #[derive(Debug, Default)]
90    struct ReflectionRecord {
91        state: RecordState,
92    }
93
94    impl Record for ReflectionRecord {
95        type Entity = test_user::Entity;
96
97        fn table_name() -> &'static str {
98            "test_users"
99        }
100
101        fn id(&self) -> Option<i64> {
102            None
103        }
104
105        fn record_state(&self) -> RecordState {
106            self.state
107        }
108
109        fn set_record_state(&mut self, state: RecordState) {
110            self.state = state;
111        }
112
113        fn from_sea_model(_model: <Self::Entity as EntityTrait>::Model) -> Self {
114            Self {
115                state: RecordState::Persisted,
116            }
117        }
118
119        fn to_active_model(&self) -> <Self::Entity as EntityTrait>::ActiveModel
120        where
121            <Self::Entity as EntityTrait>::ActiveModel: ActiveModelTrait,
122        {
123            <test_user::ActiveModel as Default>::default()
124        }
125    }
126
127    static TEST_ASSOCIATIONS: LazyLock<AssociationRegistry> = LazyLock::new(|| {
128        let mut registry = AssociationRegistry::new();
129        registry.add(
130            HasManyBuilder::new("comments")
131                .dependent(DependentAction::Destroy)
132                .build(),
133        );
134        registry.add(HasOneBuilder::new("profile").build());
135        registry.add(BelongsToBuilder::new("account").build());
136        registry.add(
137            HasAndBelongsToManyBuilder::new("roles")
138                .through("accounts_roles")
139                .build(),
140        );
141        registry
142    });
143
144    impl HasAssociations for ReflectionRecord {
145        fn associations() -> &'static AssociationRegistry {
146            &TEST_ASSOCIATIONS
147        }
148    }
149
150    impl Reflection for ReflectionRecord {}
151
152    #[test]
153    fn reflect_on_association_returns_matching_metadata() {
154        let reflection = ReflectionRecord::reflect_on_association("comments")
155            .expect("comments reflection should exist");
156
157        assert_eq!(reflection.kind, AssociationType::HasMany);
158        assert_eq!(reflection.name, "comments");
159        assert_eq!(reflection.options.get("dependent"), Some(&json!("Destroy")));
160    }
161
162    #[test]
163    fn reflect_on_association_returns_none_for_unknown_name() {
164        assert!(ReflectionRecord::reflect_on_association("missing").is_none());
165    }
166
167    #[test]
168    fn reflect_on_all_associations_preserves_declaration_order() {
169        let names = ReflectionRecord::reflect_on_all_associations()
170            .into_iter()
171            .map(|reflection| reflection.name)
172            .collect::<Vec<_>>();
173
174        assert_eq!(names, vec!["comments", "profile", "account", "roles"]);
175    }
176
177    #[test]
178    fn reflection_includes_through_option_when_present() {
179        let reflection = ReflectionRecord::reflect_on_association("roles")
180            .expect("roles reflection should exist");
181        assert_eq!(
182            reflection.options.get("through"),
183            Some(&json!("accounts_roles"))
184        );
185    }
186
187    #[test]
188    fn reflection_includes_core_key_options() {
189        let reflection = ReflectionRecord::reflect_on_association("account")
190            .expect("account reflection should exist");
191        assert!(reflection.options.contains_key("target_table"));
192        assert!(reflection.options.contains_key("foreign_key"));
193        assert!(reflection.options.contains_key("primary_key"));
194        assert!(reflection.options.contains_key("polymorphic"));
195    }
196
197    #[test]
198    fn reflection_omits_optional_keys_when_not_configured() {
199        let reflection = ReflectionRecord::reflect_on_association("profile")
200            .expect("profile reflection should exist");
201
202        assert!(!reflection.options.contains_key("through"));
203        assert!(!reflection.options.contains_key("dependent"));
204    }
205
206    #[test]
207    fn reflection_with_dependent_does_not_include_null_through_option() {
208        let reflection = ReflectionRecord::reflect_on_association("comments")
209            .expect("comments reflection should exist");
210
211        assert!(!reflection.options.contains_key("through"));
212        assert_eq!(reflection.options.get("dependent"), Some(&json!("Destroy")));
213    }
214
215    #[test]
216    fn reflection_with_through_does_not_include_null_dependent_option() {
217        let reflection = ReflectionRecord::reflect_on_association("roles")
218            .expect("roles reflection should exist");
219
220        assert_eq!(
221            reflection.options.get("through"),
222            Some(&json!("accounts_roles"))
223        );
224        assert!(!reflection.options.contains_key("dependent"));
225    }
226
227    #[test]
228    fn reflection_options_match_underlying_metadata_values() {
229        let meta = ReflectionRecord::associations()
230            .get("account")
231            .expect("account meta should exist");
232        let reflection = AssociationReflection::from(meta);
233
234        assert_eq!(
235            reflection.options.get("target_table"),
236            Some(&json!(meta.target_table.as_str()))
237        );
238        assert_eq!(
239            reflection.options.get("foreign_key"),
240            Some(&json!(meta.foreign_key.as_str()))
241        );
242        assert_eq!(
243            reflection.options.get("primary_key"),
244            Some(&json!(meta.primary_key.as_str()))
245        );
246        assert_eq!(
247            reflection.options.get("polymorphic"),
248            Some(&json!(meta.polymorphic))
249        );
250    }
251
252    #[test]
253    fn reflect_on_all_associations_round_trips_individual_lookups() {
254        for reflection in ReflectionRecord::reflect_on_all_associations() {
255            assert_eq!(
256                ReflectionRecord::reflect_on_association(&reflection.name),
257                Some(reflection)
258            );
259        }
260    }
261
262    #[test]
263    fn association_reflection_can_be_built_from_meta() {
264        let meta = ReflectionRecord::associations()
265            .get("profile")
266            .expect("meta should exist");
267        let reflection = AssociationReflection::from(meta);
268
269        assert_eq!(reflection.kind, AssociationType::HasOne);
270        assert_eq!(reflection.name, "profile");
271    }
272}