Skip to main content

postrust_graphql/schema/
object.rs

1//! Table to GraphQL ObjectType conversion.
2
3use crate::types::{pg_type_to_graphql, GraphQLType};
4use postrust_core::schema_cache::{Column, Table};
5
6/// Represents a GraphQL field derived from a database column.
7#[derive(Debug, Clone)]
8pub struct GraphQLField {
9    /// Field name (same as column name).
10    pub name: String,
11    /// Field description from column comment.
12    pub description: Option<String>,
13    /// GraphQL type for this field.
14    pub graphql_type: GraphQLType,
15    /// Whether the field is nullable.
16    pub nullable: bool,
17    /// Whether this is a primary key field.
18    pub is_pk: bool,
19    /// The arguments a computed field takes from the caller, as `(name,
20    /// PostgreSQL type, required)`. Empty for a column, and for a computed
21    /// field that takes only the row and the session.
22    pub arguments: Vec<(String, String, bool)>,
23}
24
25impl GraphQLField {
26    /// Create a GraphQL field from a database column.
27    pub fn from_column(column: &Column) -> Self {
28        let graphql_type = pg_type_to_graphql(&column.nominal_type);
29        let nullable = column.nullable && !column.is_pk;
30
31        Self {
32            name: column.name.clone(),
33            description: column.description.clone(),
34            graphql_type,
35            nullable,
36            is_pk: column.is_pk,
37            arguments: Vec::new(),
38        }
39    }
40
41    /// Get the GraphQL type string with nullability.
42    pub fn type_string(&self) -> String {
43        let base = format!("{}", self.graphql_type);
44        if self.nullable {
45            base
46        } else {
47            format!("{}!", base)
48        }
49    }
50}
51
52/// Represents a GraphQL ObjectType derived from a database table.
53#[derive(Debug, Clone)]
54pub struct TableObjectType {
55    /// The original table.
56    pub table: Table,
57    /// GraphQL type name (PascalCase).
58    pub name: String,
59    /// Fields derived from columns: the ones a read may name.
60    ///
61    /// Empty where the role may write this table and not read it, which is a
62    /// table with no GraphQL type -- see [`crate::role`].
63    pub fields: Vec<GraphQLField>,
64    /// Fields a write may name, before the write permission narrows them.
65    ///
66    /// The same list before the read permission was applied, which is what
67    /// makes a column a role may set without seeing expressible: `fields` is
68    /// what the type shows and this is what the insert and update inputs are
69    /// built from. Equal to `fields` where nothing narrowed them.
70    pub writable_fields: Vec<GraphQLField>,
71    /// The type's description: the table's comment, or the one metadata gave
72    /// instead. `Some("")` is a description that was given and is empty, which
73    /// is how metadata says the type has none.
74    pub description: Option<String>,
75}
76
77impl TableObjectType {
78    /// Create a GraphQL ObjectType from a database table.
79    ///
80    /// The type is named after the table. Use [`Self::from_table_named`] when
81    /// the name must be disambiguated (for example when the same table name
82    /// appears in more than one exposed schema).
83    pub fn from_table(table: &Table) -> Self {
84        Self::from_table_named(table, &table.name, &crate::names::NameOverrides::default())
85    }
86
87    /// Create a GraphQL ObjectType using an explicit base name.
88    /// A field for a computed column.
89    ///
90    /// A function of one argument of the table's own row type, which
91    /// PostgreSQL lets a query read as though it were a column. Always
92    /// nullable: the function decides, and nothing in the catalogue says it
93    /// cannot return null.
94    fn computed_field(
95        name: &str,
96        computed: &postrust_core::schema_cache::ComputedColumn,
97    ) -> GraphQLField {
98        GraphQLField {
99            name: name.to_string(),
100            // The function it calls, where it has no comment of its own --
101            // which is what Hasura says, and is the one thing a reader of the
102            // schema cannot otherwise find out.
103            description: computed.description.clone().or_else(|| {
104                Some(format!(
105                    "A computed field, executes function \"{}\"",
106                    computed.function.name
107                ))
108            }),
109            graphql_type: pg_type_to_graphql(&computed.data_type),
110            nullable: true,
111            is_pk: false,
112            arguments: Vec::new(),
113        }
114    }
115
116    pub fn from_table_named(
117        table: &Table,
118        base_name: &str,
119        names: &crate::names::NameOverrides,
120    ) -> Self {
121        // Named exactly like the table. A GraphQL type is conventionally
122        // PascalCase, but the schema a Hasura client was generated against
123        // spells this `author`, and a client cannot be told otherwise: the
124        // type name is in its generated types, its fragments and its cache
125        // keys.
126        let name = base_name.to_string();
127        // Columns first, then the computed ones. A computed column named
128        // after a real one is left out for the same reason a relationship is:
129        // two fields of one name abort the schema build, and the column is the
130        // table's own data.
131        let mut fields: Vec<GraphQLField> = table
132            .columns
133            .values()
134            .map(|column| {
135                let mut field = GraphQLField::from_column(column);
136                // A column may be exposed under another name. This is the one
137                // place the schema decides that; every resolver reads it back
138                // the other way, from the field to the column.
139                if let Some(given) = names.column(&table.schema, &table.name, &column.name) {
140                    field.name = given.to_string();
141                }
142                // A description given in metadata replaces the column's own
143                // comment, and an empty one removes it.
144                if let Some(comment) =
145                    names.column_comment(&table.schema, &table.name, &column.name)
146                {
147                    field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
148                }
149                field
150            })
151            .collect();
152        let mut computed: Vec<(&String, &postrust_core::schema_cache::ComputedColumn)> =
153            table.computed_columns.iter().collect();
154        computed.sort_by_key(|(name, _)| (*name).clone());
155        for (function, definition) in computed {
156            // A computed field is named in Hasura's metadata rather than after
157            // its function, so a migrated schema says which is which.
158            let exposed = names
159                .computed_field(&table.schema, &table.name, function)
160                .unwrap_or(function);
161            if fields.iter().any(|f| f.name == exposed) {
162                continue;
163            }
164            let mut field = Self::computed_field(exposed, definition);
165            if let Some(comment) = names.computed_comment(&table.schema, &table.name, function) {
166                field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
167            }
168            fields.push(field);
169        }
170
171        let description = names
172            .table_comment(&table.schema, &table.name)
173            .map(str::to_string)
174            .or_else(|| table.description.clone());
175
176        Self {
177            table: table.clone(),
178            name,
179            // Nothing has narrowed the read yet, so the two lists start equal.
180            // A role's schema splits them where its object type is built.
181            writable_fields: fields.clone(),
182            fields,
183            description,
184        }
185    }
186
187    /// Get the GraphQL type name.
188    pub fn name(&self) -> &str {
189        &self.name
190    }
191
192    /// Get the description from table comment.
193    pub fn description(&self) -> Option<&str> {
194        self.description.as_deref()
195    }
196
197    /// The name of the table itself, which is not always the type's: a
198    /// configured base name renames the type and leaves the table alone.
199    pub fn table_name(&self) -> &str {
200        &self.table.name
201    }
202
203    /// Get all fields.
204    pub fn fields(&self) -> &[GraphQLField] {
205        &self.fields
206    }
207
208    /// Get a field by name.
209    pub fn get_field(&self, name: &str) -> Option<&GraphQLField> {
210        self.fields.iter().find(|f| f.name == name)
211    }
212
213    /// Check if a field exists.
214    pub fn has_field(&self, name: &str) -> bool {
215        self.get_field(name).is_some()
216    }
217
218    /// Get primary key fields.
219    pub fn pk_fields(&self) -> Vec<&GraphQLField> {
220        self.fields.iter().filter(|f| f.is_pk).collect()
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use indexmap::IndexMap;
228    use pretty_assertions::assert_eq;
229
230    fn create_test_table() -> Table {
231        let mut columns = IndexMap::new();
232        columns.insert(
233            "id".into(),
234            Column {
235                name: "id".into(),
236                description: Some("Primary key".into()),
237                nullable: false,
238                data_type: "integer".into(),
239                nominal_type: "int4".into(),
240                max_len: None,
241                default: Some("nextval('users_id_seq')".into()),
242                enum_values: vec![],
243                is_pk: true,
244                position: 1,
245                always_generated: false,
246                domain_type: None,
247            },
248        );
249        columns.insert(
250            "name".into(),
251            Column {
252                name: "name".into(),
253                description: Some("User name".into()),
254                nullable: false,
255                data_type: "text".into(),
256                nominal_type: "text".into(),
257                max_len: None,
258                default: None,
259                enum_values: vec![],
260                is_pk: false,
261                position: 2,
262                always_generated: false,
263                domain_type: None,
264            },
265        );
266        columns.insert(
267            "email".into(),
268            Column {
269                name: "email".into(),
270                description: None,
271                nullable: true,
272                data_type: "text".into(),
273                nominal_type: "text".into(),
274                max_len: None,
275                default: None,
276                enum_values: vec![],
277                is_pk: false,
278                position: 3,
279                always_generated: false,
280                domain_type: None,
281            },
282        );
283        columns.insert(
284            "metadata".into(),
285            Column {
286                name: "metadata".into(),
287                description: Some("JSON metadata".into()),
288                nullable: true,
289                data_type: "jsonb".into(),
290                nominal_type: "jsonb".into(),
291                max_len: None,
292                default: None,
293                enum_values: vec![],
294                is_pk: false,
295                position: 4,
296                always_generated: false,
297                domain_type: None,
298            },
299        );
300
301        Table {
302            schema: "public".into(),
303            name: "users".into(),
304            description: Some("User accounts".into()),
305            is_view: false,
306            insertable: true,
307            updatable: true,
308            deletable: true,
309            pk_cols: vec!["id".into()],
310            unique_constraints: Vec::new(),
311            columns,
312            computed_columns: Default::default(),
313            is_partitioned: false,
314        }
315    }
316
317    #[test]
318    fn test_table_to_graphql_object_name() {
319        let table = create_test_table();
320        let obj = TableObjectType::from_table(&table);
321
322        assert_eq!(obj.name(), "users"); // named exactly like the table
323    }
324
325    #[test]
326    fn test_table_to_graphql_object_description() {
327        let table = create_test_table();
328        let obj = TableObjectType::from_table(&table);
329
330        assert_eq!(obj.description(), Some("User accounts"));
331    }
332
333    #[test]
334    fn test_table_to_graphql_object_fields() {
335        let table = create_test_table();
336        let obj = TableObjectType::from_table(&table);
337        let fields = obj.fields();
338
339        assert_eq!(fields.len(), 4);
340        assert!(obj.has_field("id"));
341        assert!(obj.has_field("name"));
342        assert!(obj.has_field("email"));
343        assert!(obj.has_field("metadata"));
344    }
345
346    #[test]
347    fn test_field_types() {
348        let table = create_test_table();
349        let obj = TableObjectType::from_table(&table);
350
351        let id_field = obj.get_field("id").unwrap();
352        assert_eq!(id_field.graphql_type, GraphQLType::Int);
353
354        let name_field = obj.get_field("name").unwrap();
355        assert_eq!(name_field.graphql_type, GraphQLType::String);
356
357        let metadata_field = obj.get_field("metadata").unwrap();
358        assert_eq!(metadata_field.graphql_type, GraphQLType::Json);
359    }
360
361    #[test]
362    fn test_field_nullability() {
363        let table = create_test_table();
364        let obj = TableObjectType::from_table(&table);
365
366        let id_field = obj.get_field("id").unwrap();
367        assert!(!id_field.nullable); // PK is never nullable
368
369        let name_field = obj.get_field("name").unwrap();
370        assert!(!name_field.nullable); // Not nullable in DB
371
372        let email_field = obj.get_field("email").unwrap();
373        assert!(email_field.nullable); // Nullable in DB
374    }
375
376    #[test]
377    fn test_field_descriptions() {
378        let table = create_test_table();
379        let obj = TableObjectType::from_table(&table);
380
381        let id_field = obj.get_field("id").unwrap();
382        assert_eq!(id_field.description, Some("Primary key".into()));
383
384        let email_field = obj.get_field("email").unwrap();
385        assert_eq!(email_field.description, None);
386    }
387
388    #[test]
389    fn test_field_type_string() {
390        let table = create_test_table();
391        let obj = TableObjectType::from_table(&table);
392
393        let id_field = obj.get_field("id").unwrap();
394        assert_eq!(id_field.type_string(), "Int!"); // Non-null
395
396        let email_field = obj.get_field("email").unwrap();
397        assert_eq!(email_field.type_string(), "String"); // Nullable
398    }
399
400    #[test]
401    fn test_pk_fields() {
402        let table = create_test_table();
403        let obj = TableObjectType::from_table(&table);
404
405        let pk_fields = obj.pk_fields();
406        assert_eq!(pk_fields.len(), 1);
407        assert_eq!(pk_fields[0].name, "id");
408    }
409
410    #[test]
411    fn test_table_with_underscore_name() {
412        let mut table = create_test_table();
413        table.name = "user_accounts".into();
414
415        let obj = TableObjectType::from_table(&table);
416        assert_eq!(obj.name(), "user_accounts");
417    }
418}