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