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    /// Build the field for a table, under a name chosen by the caller.
117    ///
118    /// Separate from the unnamed constructor because a custom table name has
119    /// to reach the field before its type name is derived from it.
120    pub fn from_table_named(
121        table: &Table,
122        base_name: &str,
123        names: &crate::names::NameOverrides,
124    ) -> Self {
125        // Named exactly like the table. A GraphQL type is conventionally
126        // PascalCase, but the schema a Hasura client was generated against
127        // spells this `author`, and a client cannot be told otherwise: the
128        // type name is in its generated types, its fragments and its cache
129        // keys.
130        let name = base_name.to_string();
131        // Columns first, then the computed ones. A computed column named
132        // after a real one is left out for the same reason a relationship is:
133        // two fields of one name abort the schema build, and the column is the
134        // table's own data.
135        let mut fields: Vec<GraphQLField> = table
136            .columns
137            .values()
138            .map(|column| {
139                let mut field = GraphQLField::from_column(column);
140                // A column may be exposed under another name. This is the one
141                // place the schema decides that; every resolver reads it back
142                // the other way, from the field to the column.
143                if let Some(given) = names.column(&table.schema, &table.name, &column.name) {
144                    field.name = given.to_string();
145                }
146                // A description given in metadata replaces the column's own
147                // comment, and an empty one removes it.
148                if let Some(comment) =
149                    names.column_comment(&table.schema, &table.name, &column.name)
150                {
151                    field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
152                }
153                field
154            })
155            .collect();
156        let mut computed: Vec<(&String, &postrust_core::schema_cache::ComputedColumn)> =
157            table.computed_columns.iter().collect();
158        computed.sort_by_key(|(name, _)| (*name).clone());
159        for (function, definition) in computed {
160            // A computed field is named in Hasura's metadata rather than after
161            // its function, so a migrated schema says which is which.
162            let exposed = names
163                .computed_field(&table.schema, &table.name, function)
164                .unwrap_or(function);
165            if fields.iter().any(|f| f.name == exposed) {
166                continue;
167            }
168            let mut field = Self::computed_field(exposed, definition);
169            if let Some(comment) = names.computed_comment(&table.schema, &table.name, function) {
170                field.description = Some(comment.to_string()).filter(|c| !c.is_empty());
171            }
172            fields.push(field);
173        }
174
175        let description = names
176            .table_comment(&table.schema, &table.name)
177            .map(str::to_string)
178            .or_else(|| table.description.clone());
179
180        Self {
181            table: table.clone(),
182            name,
183            // Nothing has narrowed the read yet, so the two lists start equal.
184            // A role's schema splits them where its object type is built.
185            writable_fields: fields.clone(),
186            fields,
187            description,
188        }
189    }
190
191    /// Get the GraphQL type name.
192    pub fn name(&self) -> &str {
193        &self.name
194    }
195
196    /// Get the description from table comment.
197    pub fn description(&self) -> Option<&str> {
198        self.description.as_deref()
199    }
200
201    /// The name of the table itself, which is not always the type's: a
202    /// configured base name renames the type and leaves the table alone.
203    pub fn table_name(&self) -> &str {
204        &self.table.name
205    }
206
207    /// Get all fields.
208    pub fn fields(&self) -> &[GraphQLField] {
209        &self.fields
210    }
211
212    /// Get a field by name.
213    pub fn get_field(&self, name: &str) -> Option<&GraphQLField> {
214        self.fields.iter().find(|f| f.name == name)
215    }
216
217    /// Check if a field exists.
218    pub fn has_field(&self, name: &str) -> bool {
219        self.get_field(name).is_some()
220    }
221
222    /// Get primary key fields.
223    pub fn pk_fields(&self) -> Vec<&GraphQLField> {
224        self.fields.iter().filter(|f| f.is_pk).collect()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use indexmap::IndexMap;
232    use pretty_assertions::assert_eq;
233
234    fn create_test_table() -> Table {
235        let mut columns = IndexMap::new();
236        columns.insert(
237            "id".into(),
238            Column {
239                name: "id".into(),
240                description: Some("Primary key".into()),
241                nullable: false,
242                data_type: "integer".into(),
243                nominal_type: "int4".into(),
244                max_len: None,
245                default: Some("nextval('users_id_seq')".into()),
246                enum_values: vec![],
247                is_pk: true,
248                position: 1,
249                always_generated: false,
250                domain_type: None,
251            },
252        );
253        columns.insert(
254            "name".into(),
255            Column {
256                name: "name".into(),
257                description: Some("User name".into()),
258                nullable: false,
259                data_type: "text".into(),
260                nominal_type: "text".into(),
261                max_len: None,
262                default: None,
263                enum_values: vec![],
264                is_pk: false,
265                position: 2,
266                always_generated: false,
267                domain_type: None,
268            },
269        );
270        columns.insert(
271            "email".into(),
272            Column {
273                name: "email".into(),
274                description: None,
275                nullable: true,
276                data_type: "text".into(),
277                nominal_type: "text".into(),
278                max_len: None,
279                default: None,
280                enum_values: vec![],
281                is_pk: false,
282                position: 3,
283                always_generated: false,
284                domain_type: None,
285            },
286        );
287        columns.insert(
288            "metadata".into(),
289            Column {
290                name: "metadata".into(),
291                description: Some("JSON metadata".into()),
292                nullable: true,
293                data_type: "jsonb".into(),
294                nominal_type: "jsonb".into(),
295                max_len: None,
296                default: None,
297                enum_values: vec![],
298                is_pk: false,
299                position: 4,
300                always_generated: false,
301                domain_type: None,
302            },
303        );
304
305        Table {
306            schema: "public".into(),
307            name: "users".into(),
308            description: Some("User accounts".into()),
309            is_view: false,
310            insertable: true,
311            updatable: true,
312            deletable: true,
313            pk_cols: vec!["id".into()],
314            unique_constraints: Vec::new(),
315            columns,
316            computed_columns: Default::default(),
317            is_partitioned: false,
318        }
319    }
320
321    #[test]
322    fn test_table_to_graphql_object_name() {
323        let table = create_test_table();
324        let obj = TableObjectType::from_table(&table);
325
326        assert_eq!(obj.name(), "users"); // named exactly like the table
327    }
328
329    #[test]
330    fn test_table_to_graphql_object_description() {
331        let table = create_test_table();
332        let obj = TableObjectType::from_table(&table);
333
334        assert_eq!(obj.description(), Some("User accounts"));
335    }
336
337    #[test]
338    fn test_table_to_graphql_object_fields() {
339        let table = create_test_table();
340        let obj = TableObjectType::from_table(&table);
341        let fields = obj.fields();
342
343        assert_eq!(fields.len(), 4);
344        assert!(obj.has_field("id"));
345        assert!(obj.has_field("name"));
346        assert!(obj.has_field("email"));
347        assert!(obj.has_field("metadata"));
348    }
349
350    #[test]
351    fn test_field_types() {
352        let table = create_test_table();
353        let obj = TableObjectType::from_table(&table);
354
355        let id_field = obj.get_field("id").unwrap();
356        assert_eq!(id_field.graphql_type, GraphQLType::Int);
357
358        let name_field = obj.get_field("name").unwrap();
359        assert_eq!(name_field.graphql_type, GraphQLType::String);
360
361        let metadata_field = obj.get_field("metadata").unwrap();
362        assert_eq!(metadata_field.graphql_type, GraphQLType::Json);
363    }
364
365    #[test]
366    fn test_field_nullability() {
367        let table = create_test_table();
368        let obj = TableObjectType::from_table(&table);
369
370        let id_field = obj.get_field("id").unwrap();
371        assert!(!id_field.nullable); // PK is never nullable
372
373        let name_field = obj.get_field("name").unwrap();
374        assert!(!name_field.nullable); // Not nullable in DB
375
376        let email_field = obj.get_field("email").unwrap();
377        assert!(email_field.nullable); // Nullable in DB
378    }
379
380    #[test]
381    fn test_field_descriptions() {
382        let table = create_test_table();
383        let obj = TableObjectType::from_table(&table);
384
385        let id_field = obj.get_field("id").unwrap();
386        assert_eq!(id_field.description, Some("Primary key".into()));
387
388        let email_field = obj.get_field("email").unwrap();
389        assert_eq!(email_field.description, None);
390    }
391
392    #[test]
393    fn test_field_type_string() {
394        let table = create_test_table();
395        let obj = TableObjectType::from_table(&table);
396
397        let id_field = obj.get_field("id").unwrap();
398        assert_eq!(id_field.type_string(), "Int!"); // Non-null
399
400        let email_field = obj.get_field("email").unwrap();
401        assert_eq!(email_field.type_string(), "String"); // Nullable
402    }
403
404    #[test]
405    fn test_pk_fields() {
406        let table = create_test_table();
407        let obj = TableObjectType::from_table(&table);
408
409        let pk_fields = obj.pk_fields();
410        assert_eq!(pk_fields.len(), 1);
411        assert_eq!(pk_fields[0].name, "id");
412    }
413
414    #[test]
415    fn test_table_with_underscore_name() {
416        let mut table = create_test_table();
417        table.name = "user_accounts".into();
418
419        let obj = TableObjectType::from_table(&table);
420        assert_eq!(obj.name(), "user_accounts");
421    }
422}