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