Skip to main content

postrust_graphql/schema/
relationship.rs

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