Skip to main content

ferro_projections/
relationship.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4/// Structural cardinality of a service-to-service relationship.
5///
6/// Standard ER cardinality covering the four relationship types.
7/// Each variant maps to a default [`NavigationHint`] via [`Cardinality::default_navigation`].
8#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
9#[serde(rename_all = "snake_case")]
10pub enum Cardinality {
11    OneToOne,
12    OneToMany,
13    ManyToOne,
14    ManyToMany,
15}
16
17impl Cardinality {
18    /// Returns the default navigation hint for this cardinality.
19    ///
20    /// - `OneToOne` -> `Inline` (embed related data in current view)
21    /// - `ManyToOne` -> `Link` (navigable link to parent entity)
22    /// - `OneToMany` -> `Nested` (nested list within current view)
23    /// - `ManyToMany` -> `Nested` (nested list within current view)
24    pub fn default_navigation(&self) -> NavigationHint {
25        match self {
26            Cardinality::OneToOne => NavigationHint::Inline,
27            Cardinality::ManyToOne => NavigationHint::Link,
28            Cardinality::OneToMany => NavigationHint::Nested,
29            Cardinality::ManyToMany => NavigationHint::Nested,
30        }
31    }
32}
33
34/// Presentational hint for how a relationship should be rendered in UI.
35///
36/// Bridges the gap between structural relationships and UI presentation.
37/// Defaults are derived from [`Cardinality`] and can be overridden per relationship.
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
39#[serde(rename_all = "snake_case")]
40pub enum NavigationHint {
41    /// Embed related data in current view (e.g., customer name on order card).
42    Inline,
43    /// Show as navigable link to related entity.
44    Link,
45    /// Show as separate tab in detail view.
46    Tab,
47    /// Show as nested list/table within current view.
48    Nested,
49    /// Relationship exists but not shown in default navigation.
50    Hidden,
51}
52
53/// A service-to-service relationship declaration.
54///
55/// Each service declares its own relationships independently. The `inverse` field
56/// is a documentation hint, not a hard reference. Relationships carry two dimensions:
57/// structural (cardinality) and presentational (navigation hint).
58///
59/// ```
60/// use ferro_projections::{RelationshipDef, Cardinality, NavigationHint};
61///
62/// let rel = RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
63///     .foreign_key("customer_id")
64///     .inverse("orders")
65///     .navigation(NavigationHint::Link)
66///     .description("Customer who placed this order");
67/// ```
68#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
69pub struct RelationshipDef {
70    /// Relationship name (e.g., "customer", "line_items").
71    pub name: String,
72    /// Target service name (e.g., "customer", "order_line_item").
73    pub target: String,
74    /// Structural cardinality of the relationship.
75    pub cardinality: Cardinality,
76    /// How the renderer should present this relationship.
77    pub navigation: NavigationHint,
78    /// Foreign key field name on the owning side.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub foreign_key: Option<String>,
81    /// Name of the inverse relationship on the target service.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub inverse: Option<String>,
84    /// Human-readable description of the relationship.
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub description: Option<String>,
87}
88
89impl RelationshipDef {
90    /// Creates a new relationship definition with default navigation from cardinality.
91    pub fn new(
92        name: impl Into<String>,
93        target: impl Into<String>,
94        cardinality: Cardinality,
95    ) -> Self {
96        Self {
97            name: name.into(),
98            target: target.into(),
99            navigation: cardinality.default_navigation(),
100            cardinality,
101            foreign_key: None,
102            inverse: None,
103            description: None,
104        }
105    }
106
107    /// Sets the foreign key field name.
108    pub fn foreign_key(mut self, fk: impl Into<String>) -> Self {
109        self.foreign_key = Some(fk.into());
110        self
111    }
112
113    /// Sets the inverse relationship name on the target service.
114    pub fn inverse(mut self, inverse: impl Into<String>) -> Self {
115        self.inverse = Some(inverse.into());
116        self
117    }
118
119    /// Overrides the default navigation hint.
120    pub fn navigation(mut self, hint: NavigationHint) -> Self {
121        self.navigation = hint;
122        self
123    }
124
125    /// Sets the relationship description.
126    pub fn description(mut self, desc: impl Into<String>) -> Self {
127        self.description = Some(desc.into());
128        self
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    // -- Type construction --
137
138    #[test]
139    fn relationship_def_new_sets_defaults() {
140        let rel = RelationshipDef::new("customer", "customer", Cardinality::ManyToOne);
141        assert_eq!(rel.name, "customer");
142        assert_eq!(rel.target, "customer");
143        assert_eq!(rel.cardinality, Cardinality::ManyToOne);
144        assert_eq!(rel.navigation, NavigationHint::Link);
145        assert!(rel.foreign_key.is_none());
146        assert!(rel.inverse.is_none());
147        assert!(rel.description.is_none());
148    }
149
150    #[test]
151    fn relationship_def_builder_chain() {
152        let rel = RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
153            .foreign_key("customer_id")
154            .inverse("orders")
155            .navigation(NavigationHint::Tab)
156            .description("Customer who placed this order");
157
158        assert_eq!(rel.name, "customer");
159        assert_eq!(rel.target, "customer");
160        assert_eq!(rel.cardinality, Cardinality::ManyToOne);
161        assert_eq!(rel.navigation, NavigationHint::Tab);
162        assert_eq!(rel.foreign_key.as_deref(), Some("customer_id"));
163        assert_eq!(rel.inverse.as_deref(), Some("orders"));
164        assert_eq!(
165            rel.description.as_deref(),
166            Some("Customer who placed this order")
167        );
168    }
169
170    #[test]
171    fn cardinality_default_navigation() {
172        assert_eq!(
173            Cardinality::OneToOne.default_navigation(),
174            NavigationHint::Inline
175        );
176        assert_eq!(
177            Cardinality::ManyToOne.default_navigation(),
178            NavigationHint::Link
179        );
180        assert_eq!(
181            Cardinality::OneToMany.default_navigation(),
182            NavigationHint::Nested
183        );
184        assert_eq!(
185            Cardinality::ManyToMany.default_navigation(),
186            NavigationHint::Nested
187        );
188    }
189
190    // -- Serde round-trip --
191
192    #[test]
193    fn relationship_def_serde_round_trip() {
194        let rel = RelationshipDef::new("customer", "customer", Cardinality::ManyToOne)
195            .foreign_key("customer_id")
196            .inverse("orders")
197            .navigation(NavigationHint::Link)
198            .description("Customer who placed this order");
199
200        let json = serde_json::to_string(&rel).unwrap();
201        let parsed: RelationshipDef = serde_json::from_str(&json).unwrap();
202        assert_eq!(rel, parsed);
203    }
204
205    #[test]
206    fn relationship_def_json_omits_none_fields() {
207        let rel = RelationshipDef::new("items", "item", Cardinality::OneToMany);
208        let json = serde_json::to_string(&rel).unwrap();
209        assert!(!json.contains("foreign_key"));
210        assert!(!json.contains("inverse"));
211        assert!(!json.contains("description"));
212        // Required fields are present
213        assert!(json.contains("name"));
214        assert!(json.contains("target"));
215        assert!(json.contains("cardinality"));
216        assert!(json.contains("navigation"));
217    }
218
219    #[test]
220    fn cardinality_serde_values() {
221        assert_eq!(
222            serde_json::to_string(&Cardinality::OneToOne).unwrap(),
223            r#""one_to_one""#
224        );
225        assert_eq!(
226            serde_json::to_string(&Cardinality::OneToMany).unwrap(),
227            r#""one_to_many""#
228        );
229        assert_eq!(
230            serde_json::to_string(&Cardinality::ManyToOne).unwrap(),
231            r#""many_to_one""#
232        );
233        assert_eq!(
234            serde_json::to_string(&Cardinality::ManyToMany).unwrap(),
235            r#""many_to_many""#
236        );
237
238        // Round-trip all variants
239        for card in [
240            Cardinality::OneToOne,
241            Cardinality::OneToMany,
242            Cardinality::ManyToOne,
243            Cardinality::ManyToMany,
244        ] {
245            let json = serde_json::to_string(&card).unwrap();
246            let parsed: Cardinality = serde_json::from_str(&json).unwrap();
247            assert_eq!(card, parsed);
248        }
249    }
250
251    #[test]
252    fn navigation_hint_serde_values() {
253        assert_eq!(
254            serde_json::to_string(&NavigationHint::Inline).unwrap(),
255            r#""inline""#
256        );
257        assert_eq!(
258            serde_json::to_string(&NavigationHint::Link).unwrap(),
259            r#""link""#
260        );
261        assert_eq!(
262            serde_json::to_string(&NavigationHint::Tab).unwrap(),
263            r#""tab""#
264        );
265        assert_eq!(
266            serde_json::to_string(&NavigationHint::Nested).unwrap(),
267            r#""nested""#
268        );
269        assert_eq!(
270            serde_json::to_string(&NavigationHint::Hidden).unwrap(),
271            r#""hidden""#
272        );
273
274        // Round-trip all variants
275        for hint in [
276            NavigationHint::Inline,
277            NavigationHint::Link,
278            NavigationHint::Tab,
279            NavigationHint::Nested,
280            NavigationHint::Hidden,
281        ] {
282            let json = serde_json::to_string(&hint).unwrap();
283            let parsed: NavigationHint = serde_json::from_str(&json).unwrap();
284            assert_eq!(hint, parsed);
285        }
286    }
287
288    // -- JSON Schema --
289
290    #[test]
291    fn relationship_def_json_schema() {
292        let schema = schemars::schema_for!(RelationshipDef);
293        let value = schema.to_value();
294        let props = value
295            .get("properties")
296            .expect("RelationshipDef schema must have properties");
297        let obj = props.as_object().unwrap();
298        assert!(obj.contains_key("name"), "missing 'name' property");
299        assert!(obj.contains_key("target"), "missing 'target' property");
300        assert!(
301            obj.contains_key("cardinality"),
302            "missing 'cardinality' property"
303        );
304        assert!(
305            obj.contains_key("navigation"),
306            "missing 'navigation' property"
307        );
308        assert!(
309            obj.contains_key("foreign_key"),
310            "missing 'foreign_key' property"
311        );
312        assert!(obj.contains_key("inverse"), "missing 'inverse' property");
313    }
314}