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