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