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