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}
20
21impl GraphQLField {
22    /// Create a GraphQL field from a database column.
23    pub fn from_column(column: &Column) -> Self {
24        let graphql_type = pg_type_to_graphql(&column.nominal_type);
25        let nullable = column.nullable && !column.is_pk;
26
27        Self {
28            name: column.name.clone(),
29            description: column.description.clone(),
30            graphql_type,
31            nullable,
32            is_pk: column.is_pk,
33        }
34    }
35
36    /// Get the GraphQL type string with nullability.
37    pub fn type_string(&self) -> String {
38        let base = format!("{}", self.graphql_type);
39        if self.nullable {
40            base
41        } else {
42            format!("{}!", base)
43        }
44    }
45}
46
47/// Represents a GraphQL ObjectType derived from a database table.
48#[derive(Debug, Clone)]
49pub struct TableObjectType {
50    /// The original table.
51    pub table: Table,
52    /// GraphQL type name (PascalCase).
53    pub name: String,
54    /// Fields derived from columns.
55    pub fields: Vec<GraphQLField>,
56}
57
58impl TableObjectType {
59    /// Create a GraphQL ObjectType from a database table.
60    ///
61    /// The type is named after the table. Use [`Self::from_table_named`] when
62    /// the name must be disambiguated (for example when the same table name
63    /// appears in more than one exposed schema).
64    pub fn from_table(table: &Table) -> Self {
65        Self::from_table_named(table, &table.name)
66    }
67
68    /// Create a GraphQL ObjectType using an explicit base name.
69    pub fn from_table_named(table: &Table, base_name: &str) -> Self {
70        let name = to_pascal_case(base_name);
71        let fields = table
72            .columns
73            .values()
74            .map(GraphQLField::from_column)
75            .collect();
76
77        Self {
78            table: table.clone(),
79            name,
80            fields,
81        }
82    }
83
84    /// Get the GraphQL type name.
85    pub fn name(&self) -> &str {
86        &self.name
87    }
88
89    /// Get the description from table comment.
90    pub fn description(&self) -> Option<&str> {
91        self.table.description.as_deref()
92    }
93
94    /// Get all fields.
95    pub fn fields(&self) -> &[GraphQLField] {
96        &self.fields
97    }
98
99    /// Get a field by name.
100    pub fn get_field(&self, name: &str) -> Option<&GraphQLField> {
101        self.fields.iter().find(|f| f.name == name)
102    }
103
104    /// Check if a field exists.
105    pub fn has_field(&self, name: &str) -> bool {
106        self.get_field(name).is_some()
107    }
108
109    /// Get primary key fields.
110    pub fn pk_fields(&self) -> Vec<&GraphQLField> {
111        self.fields.iter().filter(|f| f.is_pk).collect()
112    }
113}
114
115/// Convert a snake_case string to PascalCase.
116pub fn to_pascal_case(s: &str) -> String {
117    s.split('_')
118        .map(|word| {
119            let mut chars = word.chars();
120            match chars.next() {
121                Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
122                None => String::new(),
123            }
124        })
125        .collect()
126}
127
128/// Convert a snake_case string to camelCase.
129pub fn to_camel_case(s: &str) -> String {
130    let pascal = to_pascal_case(s);
131    let mut chars = pascal.chars();
132    match chars.next() {
133        Some(first) => first.to_lowercase().collect::<String>() + chars.as_str(),
134        None => String::new(),
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use indexmap::IndexMap;
142    use pretty_assertions::assert_eq;
143
144    fn create_test_table() -> Table {
145        let mut columns = IndexMap::new();
146        columns.insert(
147            "id".into(),
148            Column {
149                name: "id".into(),
150                description: Some("Primary key".into()),
151                nullable: false,
152                data_type: "integer".into(),
153                nominal_type: "int4".into(),
154                max_len: None,
155                default: Some("nextval('users_id_seq')".into()),
156                enum_values: vec![],
157                is_pk: true,
158                position: 1,
159            },
160        );
161        columns.insert(
162            "name".into(),
163            Column {
164                name: "name".into(),
165                description: Some("User name".into()),
166                nullable: false,
167                data_type: "text".into(),
168                nominal_type: "text".into(),
169                max_len: None,
170                default: None,
171                enum_values: vec![],
172                is_pk: false,
173                position: 2,
174            },
175        );
176        columns.insert(
177            "email".into(),
178            Column {
179                name: "email".into(),
180                description: None,
181                nullable: true,
182                data_type: "text".into(),
183                nominal_type: "text".into(),
184                max_len: None,
185                default: None,
186                enum_values: vec![],
187                is_pk: false,
188                position: 3,
189            },
190        );
191        columns.insert(
192            "metadata".into(),
193            Column {
194                name: "metadata".into(),
195                description: Some("JSON metadata".into()),
196                nullable: true,
197                data_type: "jsonb".into(),
198                nominal_type: "jsonb".into(),
199                max_len: None,
200                default: None,
201                enum_values: vec![],
202                is_pk: false,
203                position: 4,
204            },
205        );
206
207        Table {
208            schema: "public".into(),
209            name: "users".into(),
210            description: Some("User accounts".into()),
211            is_view: false,
212            insertable: true,
213            updatable: true,
214            deletable: true,
215            pk_cols: vec!["id".into()],
216            columns,
217        }
218    }
219
220    #[test]
221    fn test_to_pascal_case() {
222        assert_eq!(to_pascal_case("users"), "Users");
223        assert_eq!(to_pascal_case("user_accounts"), "UserAccounts");
224        assert_eq!(to_pascal_case("my_table_name"), "MyTableName");
225        assert_eq!(to_pascal_case(""), "");
226    }
227
228    #[test]
229    fn test_to_camel_case() {
230        assert_eq!(to_camel_case("user_id"), "userId");
231        assert_eq!(to_camel_case("my_field"), "myField");
232        assert_eq!(to_camel_case("name"), "name");
233    }
234
235    #[test]
236    fn test_table_to_graphql_object_name() {
237        let table = create_test_table();
238        let obj = TableObjectType::from_table(&table);
239
240        assert_eq!(obj.name(), "Users"); // PascalCase
241    }
242
243    #[test]
244    fn test_table_to_graphql_object_description() {
245        let table = create_test_table();
246        let obj = TableObjectType::from_table(&table);
247
248        assert_eq!(obj.description(), Some("User accounts"));
249    }
250
251    #[test]
252    fn test_table_to_graphql_object_fields() {
253        let table = create_test_table();
254        let obj = TableObjectType::from_table(&table);
255        let fields = obj.fields();
256
257        assert_eq!(fields.len(), 4);
258        assert!(obj.has_field("id"));
259        assert!(obj.has_field("name"));
260        assert!(obj.has_field("email"));
261        assert!(obj.has_field("metadata"));
262    }
263
264    #[test]
265    fn test_field_types() {
266        let table = create_test_table();
267        let obj = TableObjectType::from_table(&table);
268
269        let id_field = obj.get_field("id").unwrap();
270        assert_eq!(id_field.graphql_type, GraphQLType::Int);
271
272        let name_field = obj.get_field("name").unwrap();
273        assert_eq!(name_field.graphql_type, GraphQLType::String);
274
275        let metadata_field = obj.get_field("metadata").unwrap();
276        assert_eq!(metadata_field.graphql_type, GraphQLType::Json);
277    }
278
279    #[test]
280    fn test_field_nullability() {
281        let table = create_test_table();
282        let obj = TableObjectType::from_table(&table);
283
284        let id_field = obj.get_field("id").unwrap();
285        assert!(!id_field.nullable); // PK is never nullable
286
287        let name_field = obj.get_field("name").unwrap();
288        assert!(!name_field.nullable); // Not nullable in DB
289
290        let email_field = obj.get_field("email").unwrap();
291        assert!(email_field.nullable); // Nullable in DB
292    }
293
294    #[test]
295    fn test_field_descriptions() {
296        let table = create_test_table();
297        let obj = TableObjectType::from_table(&table);
298
299        let id_field = obj.get_field("id").unwrap();
300        assert_eq!(id_field.description, Some("Primary key".into()));
301
302        let email_field = obj.get_field("email").unwrap();
303        assert_eq!(email_field.description, None);
304    }
305
306    #[test]
307    fn test_field_type_string() {
308        let table = create_test_table();
309        let obj = TableObjectType::from_table(&table);
310
311        let id_field = obj.get_field("id").unwrap();
312        assert_eq!(id_field.type_string(), "Int!"); // Non-null
313
314        let email_field = obj.get_field("email").unwrap();
315        assert_eq!(email_field.type_string(), "String"); // Nullable
316    }
317
318    #[test]
319    fn test_pk_fields() {
320        let table = create_test_table();
321        let obj = TableObjectType::from_table(&table);
322
323        let pk_fields = obj.pk_fields();
324        assert_eq!(pk_fields.len(), 1);
325        assert_eq!(pk_fields[0].name, "id");
326    }
327
328    #[test]
329    fn test_table_with_underscore_name() {
330        let mut table = create_test_table();
331        table.name = "user_accounts".into();
332
333        let obj = TableObjectType::from_table(&table);
334        assert_eq!(obj.name(), "UserAccounts");
335    }
336}