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