Skip to main content

postrust_graphql/schema/
relationship.rs

1//! Relationship to GraphQL field conversion.
2
3use crate::schema::object::to_pascal_case;
4use postrust_core::schema_cache::Relationship;
5
6/// Extract constraint name from a Relationship.
7fn get_constraint_name(rel: &Relationship) -> &str {
8    match rel {
9        Relationship::ForeignKey {
10            constraint_name, ..
11        } => constraint_name,
12        Relationship::Computed { function, .. } => &function.name,
13    }
14}
15
16/// Represents a GraphQL field derived from a database relationship.
17#[derive(Debug, Clone)]
18pub struct RelationshipField {
19    /// Field name (derived from foreign table name).
20    pub name: String,
21    /// Target GraphQL type name.
22    pub target_type: String,
23    /// Whether this returns a list (O2M, M2M) or single object (M2O, O2O).
24    pub is_list: bool,
25    /// The original relationship.
26    pub relationship: Relationship,
27    /// Description for the field.
28    pub description: Option<String>,
29}
30
31impl RelationshipField {
32    /// Create a GraphQL field from a database relationship.
33    pub fn from_relationship(rel: &Relationship) -> Self {
34        let base_name = rel.foreign_table().name.clone();
35        Self::from_relationship_named(rel, &base_name)
36    }
37
38    /// Create a GraphQL field using an explicit base name for the target.
39    ///
40    /// The base name must match the one the target table's object type was
41    /// generated with, otherwise the field would reference a type that was
42    /// never registered.
43    pub fn from_relationship_named(rel: &Relationship, target_base_name: &str) -> Self {
44        let is_list = !rel.is_to_one();
45
46        // Generate field name from the target's base name, so a relationship
47        // into a non-default schema is named consistently with its type.
48        let name = if is_list {
49            pluralize(target_base_name)
50        } else {
51            singularize(target_base_name)
52        };
53
54        let target_type = to_pascal_case(target_base_name);
55
56        let description = Some(format!(
57            "Related {} via {}",
58            if is_list { "records" } else { "record" },
59            get_constraint_name(rel)
60        ));
61
62        Self {
63            name,
64            target_type,
65            is_list,
66            relationship: rel.clone(),
67            description,
68        }
69    }
70
71    /// Get the GraphQL type string.
72    pub fn type_string(&self) -> String {
73        if self.is_list {
74            format!("[{}!]!", self.target_type)
75        } else {
76            self.target_type.clone()
77        }
78    }
79
80    /// Get the join columns for this relationship.
81    pub fn join_columns(&self) -> Vec<(String, String)> {
82        self.relationship.join_columns()
83    }
84}
85
86/// Simple pluralization (adds 's' or 'es').
87fn pluralize(s: &str) -> String {
88    // If already ends with 's' (but not 'ss'), assume it's already plural
89    if s.ends_with('s') && !s.ends_with("ss") {
90        return s.to_string();
91    }
92
93    if s.ends_with('x') || s.ends_with("ch") || s.ends_with("sh") || s.ends_with("ss") {
94        format!("{}es", s)
95    } else if s.ends_with('y') && !s.ends_with("ey") && !s.ends_with("ay") && !s.ends_with("oy") {
96        format!("{}ies", &s[..s.len() - 1])
97    } else {
98        format!("{}s", s)
99    }
100}
101
102/// Simple singularization (removes trailing 's').
103fn singularize(s: &str) -> String {
104    if let Some(stem) = s.strip_suffix("ies") {
105        format!("{}y", stem)
106    } else if s.ends_with("ses") || s.ends_with("xes") || s.ends_with("ches") || s.ends_with("shes")
107    {
108        s.strip_suffix("es").unwrap_or(s).to_string()
109    } else if s.ends_with('s') && !s.ends_with("ss") {
110        s.strip_suffix('s').unwrap_or(s).to_string()
111    } else {
112        s.to_string()
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use postrust_core::api_request::QualifiedIdentifier;
120    use postrust_core::schema_cache::Cardinality;
121    use pretty_assertions::assert_eq;
122
123    fn create_m2o_relationship() -> Relationship {
124        // orders.user_id -> users.id (Many-to-One)
125        Relationship::ForeignKey {
126            table: QualifiedIdentifier::new("public", "orders"),
127            foreign_table: QualifiedIdentifier::new("public", "users"),
128            is_self: false,
129            cardinality: Cardinality::M2O {
130                constraint: "orders_user_id_fkey".into(),
131                columns: vec![("user_id".into(), "id".into())],
132            },
133            table_is_view: false,
134            foreign_table_is_view: false,
135            constraint_name: "orders_user_id_fkey".into(),
136        }
137    }
138
139    fn create_o2m_relationship() -> Relationship {
140        // users.id -> orders.user_id (One-to-Many)
141        Relationship::ForeignKey {
142            table: QualifiedIdentifier::new("public", "users"),
143            foreign_table: QualifiedIdentifier::new("public", "orders"),
144            is_self: false,
145            cardinality: Cardinality::O2M {
146                constraint: "orders_user_id_fkey".into(),
147                columns: vec![("id".into(), "user_id".into())],
148            },
149            table_is_view: false,
150            foreign_table_is_view: false,
151            constraint_name: "orders_user_id_fkey".into(),
152        }
153    }
154
155    fn create_o2o_relationship() -> Relationship {
156        // users.id -> user_profiles.user_id (One-to-One)
157        Relationship::ForeignKey {
158            table: QualifiedIdentifier::new("public", "users"),
159            foreign_table: QualifiedIdentifier::new("public", "user_profiles"),
160            is_self: false,
161            cardinality: Cardinality::O2O {
162                constraint: "user_profiles_user_id_fkey".into(),
163                columns: vec![("id".into(), "user_id".into())],
164                is_parent: true,
165            },
166            table_is_view: false,
167            foreign_table_is_view: false,
168            constraint_name: "user_profiles_user_id_fkey".into(),
169        }
170    }
171
172    #[test]
173    fn test_pluralize() {
174        assert_eq!(pluralize("user"), "users");
175        assert_eq!(pluralize("order"), "orders");
176        assert_eq!(pluralize("category"), "categories");
177        assert_eq!(pluralize("box"), "boxes");
178        assert_eq!(pluralize("match"), "matches");
179        assert_eq!(pluralize("dish"), "dishes");
180        assert_eq!(pluralize("key"), "keys"); // 'ey' ending
181        assert_eq!(pluralize("day"), "days"); // 'ay' ending
182    }
183
184    #[test]
185    fn test_singularize() {
186        assert_eq!(singularize("users"), "user");
187        assert_eq!(singularize("orders"), "order");
188        assert_eq!(singularize("categories"), "category");
189        assert_eq!(singularize("boxes"), "box");
190        assert_eq!(singularize("matches"), "match");
191        assert_eq!(singularize("class"), "class"); // ends with 'ss'
192    }
193
194    #[test]
195    fn test_m2o_relationship_field() {
196        let rel = create_m2o_relationship();
197        let field = RelationshipField::from_relationship(&rel);
198
199        assert_eq!(field.name, "user"); // Singular for M2O
200        assert_eq!(field.target_type, "Users");
201        assert!(!field.is_list); // Returns single object
202    }
203
204    #[test]
205    fn test_o2m_relationship_field() {
206        let rel = create_o2m_relationship();
207        let field = RelationshipField::from_relationship(&rel);
208
209        assert_eq!(field.name, "orders"); // Plural for O2M
210        assert_eq!(field.target_type, "Orders");
211        assert!(field.is_list); // Returns list
212    }
213
214    #[test]
215    fn test_o2o_relationship_field() {
216        let rel = create_o2o_relationship();
217        let field = RelationshipField::from_relationship(&rel);
218
219        assert_eq!(field.name, "user_profile"); // Singular for O2O
220        assert_eq!(field.target_type, "UserProfiles");
221        assert!(!field.is_list); // Returns single object
222    }
223
224    #[test]
225    fn test_relationship_type_string_list() {
226        let rel = create_o2m_relationship();
227        let field = RelationshipField::from_relationship(&rel);
228
229        assert_eq!(field.type_string(), "[Orders!]!");
230    }
231
232    #[test]
233    fn test_relationship_type_string_single() {
234        let rel = create_m2o_relationship();
235        let field = RelationshipField::from_relationship(&rel);
236
237        assert_eq!(field.type_string(), "Users");
238    }
239
240    #[test]
241    fn test_relationship_join_columns() {
242        let rel = create_m2o_relationship();
243        let field = RelationshipField::from_relationship(&rel);
244
245        let columns = field.join_columns();
246        assert_eq!(columns.len(), 1);
247        assert_eq!(columns[0], ("user_id".into(), "id".into()));
248    }
249
250    #[test]
251    fn test_relationship_description() {
252        let rel = create_m2o_relationship();
253        let field = RelationshipField::from_relationship(&rel);
254
255        assert!(field.description.is_some());
256        assert!(field
257            .description
258            .as_ref()
259            .unwrap()
260            .contains("orders_user_id_fkey"));
261    }
262
263    #[test]
264    fn test_m2m_relationship_field() {
265        // users -> tags via user_tags junction
266        let rel = Relationship::ForeignKey {
267            table: QualifiedIdentifier::new("public", "users"),
268            foreign_table: QualifiedIdentifier::new("public", "tags"),
269            is_self: false,
270            cardinality: Cardinality::M2M(postrust_core::schema_cache::Junction {
271                table: QualifiedIdentifier::new("public", "user_tags"),
272                constraint1: "user_tags_user_id_fkey".into(),
273                constraint2: "user_tags_tag_id_fkey".into(),
274                source_columns: vec![("id".into(), "user_id".into())],
275                target_columns: vec![("tag_id".into(), "id".into())],
276            }),
277            table_is_view: false,
278            foreign_table_is_view: false,
279            constraint_name: "user_tags_user_id_fkey".into(),
280        };
281
282        let field = RelationshipField::from_relationship(&rel);
283
284        assert_eq!(field.name, "tags"); // Plural for M2M
285        assert_eq!(field.target_type, "Tags");
286        assert!(field.is_list);
287    }
288}