Skip to main content

postrust_graphql/schema/
mod.rs

1//! GraphQL schema generation from PostgreSQL schema cache.
2//!
3//! Builds a dynamic GraphQL schema from the database schema cache,
4//! creating query and mutation types for each table.
5
6pub mod object;
7pub mod relationship;
8
9use crate::input::mutation::{is_deletable, is_insertable, is_updatable};
10use crate::schema::object::{to_camel_case, to_pascal_case, TableObjectType};
11use crate::schema::relationship::RelationshipField;
12use postrust_core::schema_cache::{SchemaCache, Table};
13use std::collections::HashMap;
14
15/// Configuration for schema generation.
16#[derive(Debug, Clone)]
17pub struct SchemaConfig {
18    /// Schemas to expose in GraphQL (e.g., ["public"])
19    pub exposed_schemas: Vec<String>,
20    /// Whether to generate mutation types
21    pub enable_mutations: bool,
22    /// Whether to generate subscription types
23    pub enable_subscriptions: bool,
24    /// Prefix for query fields (e.g., "all" -> "allUsers")
25    pub query_prefix: Option<String>,
26    /// Suffix for query fields (e.g., "Collection" -> "usersCollection")
27    pub query_suffix: Option<String>,
28    /// Whether to use camelCase for field names
29    pub use_camel_case: bool,
30    /// Ceiling on rows a single query may return (`PGRST_MAX_ROWS`).
31    ///
32    /// Applied when a query supplies no `limit` of its own, and as an upper
33    /// bound when it supplies a larger one. `None` leaves queries unbounded.
34    pub max_rows: Option<i64>,
35}
36
37impl Default for SchemaConfig {
38    fn default() -> Self {
39        Self {
40            exposed_schemas: vec!["public".to_string()],
41            enable_mutations: true,
42            enable_subscriptions: false,
43            query_prefix: None,
44            query_suffix: None,
45            use_camel_case: true,
46            max_rows: None,
47        }
48    }
49}
50
51impl SchemaConfig {
52    /// Create a new schema config.
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Set the exposed schemas.
58    pub fn with_schemas(mut self, schemas: Vec<String>) -> Self {
59        self.exposed_schemas = schemas;
60        self
61    }
62
63    /// Enable or disable mutations.
64    pub fn with_mutations(mut self, enable: bool) -> Self {
65        self.enable_mutations = enable;
66        self
67    }
68
69    /// Enable or disable subscriptions.
70    pub fn with_subscriptions(mut self, enable: bool) -> Self {
71        self.enable_subscriptions = enable;
72        self
73    }
74
75    /// Check if a schema is exposed.
76    pub fn is_schema_exposed(&self, schema: &str) -> bool {
77        self.exposed_schemas.iter().any(|s| s == schema)
78    }
79
80    /// The schema whose tables get unqualified GraphQL names.
81    ///
82    /// This is the first exposed schema, mirroring how the REST surface treats
83    /// the first entry of `PGRST_DB_SCHEMAS` as the default.
84    pub fn default_schema(&self) -> &str {
85        self.exposed_schemas
86            .first()
87            .map(|s| s.as_str())
88            .unwrap_or("public")
89    }
90}
91
92/// Primary key columns of a table, as `(column name, PostgreSQL type)`.
93///
94/// `nominal_type` (the underlying `udt_name`) is used because it is always a
95/// castable type name.
96fn pk_columns_of(table: &Table) -> Vec<(String, String)> {
97    table
98        .pk_cols
99        .iter()
100        .map(|col_name| {
101            let pg_type = table
102                .get_column(col_name)
103                .map(|c| c.nominal_type.clone())
104                .unwrap_or_else(|| "text".to_string());
105            (col_name.clone(), pg_type)
106        })
107        .collect()
108}
109
110/// Base name used to derive a table's GraphQL type and field names.
111///
112/// Tables in the default schema keep their bare name, so a single-schema
113/// deployment is unaffected. Tables in any other exposed schema are prefixed
114/// with the schema, because GraphQL has one flat namespace: without this, a
115/// `users` table in both `public` and `api` would generate the same type and
116/// field names and one would silently replace the other.
117fn base_name_for(table: &Table, config: &SchemaConfig) -> String {
118    if table.schema == config.default_schema() {
119        table.name.clone()
120    } else {
121        format!("{}_{}", table.schema, table.name)
122    }
123}
124
125/// Represents a generated GraphQL schema.
126#[derive(Debug, Clone)]
127pub struct GeneratedSchema {
128    /// Object types for each table
129    pub object_types: HashMap<String, TableObjectType>,
130    /// Query fields
131    pub query_fields: Vec<QueryField>,
132    /// Mutation fields (if enabled)
133    pub mutation_fields: Vec<MutationField>,
134    /// Relationship fields for each type
135    pub relationship_fields: HashMap<String, Vec<RelationshipField>>,
136}
137
138impl GeneratedSchema {
139    /// Get an object type by name.
140    pub fn get_object_type(&self, name: &str) -> Option<&TableObjectType> {
141        self.object_types.get(name)
142    }
143
144    /// Get query fields for a table.
145    pub fn get_query_field(&self, table_name: &str) -> Option<&QueryField> {
146        self.query_fields
147            .iter()
148            .find(|f| f.table_name == table_name)
149    }
150
151    /// Get mutation fields for a table.
152    pub fn get_mutation_fields(&self, table_name: &str) -> Vec<&MutationField> {
153        self.mutation_fields
154            .iter()
155            .filter(|f| f.table_name == table_name)
156            .collect()
157    }
158
159    /// Get relationship fields for a type.
160    pub fn get_relationship_fields(&self, type_name: &str) -> Option<&Vec<RelationshipField>> {
161        self.relationship_fields.get(type_name)
162    }
163
164    /// Get all table names.
165    pub fn table_names(&self) -> Vec<&str> {
166        self.object_types
167            .values()
168            .map(|t| t.table.name.as_str())
169            .collect()
170    }
171
172    /// Get all type names.
173    pub fn type_names(&self) -> Vec<&str> {
174        self.object_types.keys().map(|s| s.as_str()).collect()
175    }
176}
177
178/// A query field for a table (e.g., users, userByPk).
179#[derive(Debug, Clone)]
180pub struct QueryField {
181    /// Field name (e.g., "users")
182    pub name: String,
183    /// Table name
184    pub table_name: String,
185    /// Schema the table lives in
186    pub schema_name: String,
187    /// GraphQL object type name (e.g., "Users")
188    pub type_name: String,
189    /// GraphQL return type
190    pub return_type: String,
191    /// Whether this returns a list
192    pub is_list: bool,
193    /// Whether this is a "by PK" query
194    pub is_by_pk: bool,
195    /// Primary key columns, as `(column name, PostgreSQL type)`.
196    ///
197    /// Populated for by-PK queries so the resolver can filter on the table's
198    /// actual key rather than assuming a column called `id`. Empty for list
199    /// queries.
200    pub pk_columns: Vec<(String, String)>,
201    /// Field description
202    pub description: Option<String>,
203}
204
205impl QueryField {
206    /// Create a list query field (e.g., users), named after the table.
207    pub fn list(table: &Table, config: &SchemaConfig) -> Self {
208        Self::list_named(table, config, &table.name)
209    }
210
211    /// Create a list query field using an explicit base name.
212    pub fn list_named(table: &Table, config: &SchemaConfig, base_name: &str) -> Self {
213        let type_name = to_pascal_case(base_name);
214        let field_name = if config.use_camel_case {
215            to_camel_case(base_name)
216        } else {
217            base_name.to_string()
218        };
219
220        let name = match (&config.query_prefix, &config.query_suffix) {
221            (Some(prefix), None) => format!("{}{}", prefix, to_pascal_case(&field_name)),
222            (None, Some(suffix)) => format!("{}{}", field_name, suffix),
223            (Some(prefix), Some(suffix)) => {
224                format!("{}{}{}", prefix, to_pascal_case(&field_name), suffix)
225            }
226            (None, None) => field_name,
227        };
228
229        Self {
230            name,
231            table_name: table.name.clone(),
232            schema_name: table.schema.clone(),
233            type_name: type_name.clone(),
234            return_type: format!("[{}!]!", type_name),
235            is_list: true,
236            is_by_pk: false,
237            pk_columns: Vec::new(),
238            description: Some(format!("Query {} records", table.name)),
239        }
240    }
241
242    /// Create a by-PK query field (e.g., userByPk), named after the table.
243    pub fn by_pk(table: &Table, config: &SchemaConfig) -> Option<Self> {
244        Self::by_pk_named(table, config, &table.name)
245    }
246
247    /// Create a by-PK query field using an explicit base name.
248    pub fn by_pk_named(table: &Table, config: &SchemaConfig, base_name: &str) -> Option<Self> {
249        if table.pk_cols.is_empty() {
250            return None;
251        }
252
253        let type_name = to_pascal_case(base_name);
254        let singular = singularize(base_name);
255        let field_name = if config.use_camel_case {
256            format!("{}ByPk", to_camel_case(&singular))
257        } else {
258            format!("{}_by_pk", singular)
259        };
260
261        // Carry the key columns and their types so the resolver can filter on
262        // the real primary key.
263        let pk_columns = pk_columns_of(table);
264
265        Some(Self {
266            name: field_name,
267            table_name: table.name.clone(),
268            schema_name: table.schema.clone(),
269            type_name: type_name.clone(),
270            return_type: type_name,
271            is_list: false,
272            is_by_pk: true,
273            pk_columns,
274            description: Some(format!("Get a single {} by primary key", singular)),
275        })
276    }
277}
278
279/// A mutation field for a table.
280#[derive(Debug, Clone)]
281pub struct MutationField {
282    /// Field name (e.g., "insertUsers")
283    pub name: String,
284    /// Table name
285    pub table_name: String,
286    /// Schema the table lives in
287    pub schema_name: String,
288    /// Mutation type
289    pub mutation_type: MutationType,
290    /// Primary key columns, as `(column name, PostgreSQL type)`.
291    ///
292    /// Populated for by-PK mutations so the resolver can target the row by its
293    /// key. Empty for bulk mutations.
294    pub pk_columns: Vec<(String, String)>,
295    /// GraphQL return type
296    pub return_type: String,
297    /// Field description
298    pub description: Option<String>,
299}
300
301/// Types of mutations.
302#[derive(Debug, Clone, Copy, PartialEq, Eq)]
303pub enum MutationType {
304    /// Insert multiple records
305    Insert,
306    /// Insert a single record
307    InsertOne,
308    /// Update records matching a filter
309    Update,
310    /// Update a single record by PK
311    UpdateByPk,
312    /// Delete records matching a filter
313    Delete,
314    /// Delete a single record by PK
315    DeleteByPk,
316}
317
318impl MutationField {
319    /// Create insert mutation fields for a table.
320    pub fn insert_fields(table: &Table, config: &SchemaConfig) -> Vec<Self> {
321        Self::insert_fields_named(table, config, &table.name)
322    }
323
324    /// As [`Self::insert_fields`], with an explicit base name for the generated
325    /// field and type names.
326    pub fn insert_fields_named(table: &Table, config: &SchemaConfig, base_name: &str) -> Vec<Self> {
327        if !is_insertable(table) {
328            return vec![];
329        }
330
331        let type_name = to_pascal_case(base_name);
332        let singular = singularize(base_name);
333
334        let mut fields = vec![];
335
336        // insert_users (batch insert)
337        let name = if config.use_camel_case {
338            format!("insert{}", to_pascal_case(base_name))
339        } else {
340            format!("insert_{}", base_name)
341        };
342        fields.push(Self {
343            name,
344            table_name: table.name.clone(),
345            schema_name: table.schema.clone(),
346            mutation_type: MutationType::Insert,
347            pk_columns: Vec::new(),
348            return_type: format!("[{}!]!", type_name),
349            description: Some(format!("Insert multiple {} records", table.name)),
350        });
351
352        // insert_user_one (single insert)
353        let name = if config.use_camel_case {
354            format!("insert{}One", to_pascal_case(&singular))
355        } else {
356            format!("insert_{}_one", singular)
357        };
358        fields.push(Self {
359            name,
360            table_name: table.name.clone(),
361            schema_name: table.schema.clone(),
362            mutation_type: MutationType::InsertOne,
363            pk_columns: Vec::new(),
364            return_type: type_name.clone(),
365            description: Some(format!("Insert a single {} record", singular)),
366        });
367
368        fields
369    }
370
371    /// Create update mutation fields for a table.
372    pub fn update_fields(table: &Table, config: &SchemaConfig) -> Vec<Self> {
373        Self::update_fields_named(table, config, &table.name)
374    }
375
376    /// As [`Self::update_fields`], with an explicit base name for the generated
377    /// field and type names.
378    pub fn update_fields_named(table: &Table, config: &SchemaConfig, base_name: &str) -> Vec<Self> {
379        if !is_updatable(table) {
380            return vec![];
381        }
382
383        let type_name = to_pascal_case(base_name);
384        let singular = singularize(base_name);
385
386        let mut fields = vec![];
387
388        // update_users (batch update)
389        let name = if config.use_camel_case {
390            format!("update{}", to_pascal_case(base_name))
391        } else {
392            format!("update_{}", base_name)
393        };
394        fields.push(Self {
395            name,
396            table_name: table.name.clone(),
397            schema_name: table.schema.clone(),
398            mutation_type: MutationType::Update,
399            pk_columns: Vec::new(),
400            return_type: format!("[{}!]!", type_name),
401            description: Some(format!("Update {} records", table.name)),
402        });
403
404        // update_user_by_pk (single update by PK)
405        if !table.pk_cols.is_empty() {
406            let name = if config.use_camel_case {
407                format!("update{}ByPk", to_pascal_case(&singular))
408            } else {
409                format!("update_{}_by_pk", singular)
410            };
411            fields.push(Self {
412                name,
413                table_name: table.name.clone(),
414                schema_name: table.schema.clone(),
415                mutation_type: MutationType::UpdateByPk,
416                pk_columns: pk_columns_of(table),
417                return_type: type_name,
418                description: Some(format!("Update a single {} by primary key", singular)),
419            });
420        }
421
422        fields
423    }
424
425    /// Create delete mutation fields for a table.
426    pub fn delete_fields(table: &Table, config: &SchemaConfig) -> Vec<Self> {
427        Self::delete_fields_named(table, config, &table.name)
428    }
429
430    /// As [`Self::delete_fields`], with an explicit base name for the generated
431    /// field and type names.
432    pub fn delete_fields_named(table: &Table, config: &SchemaConfig, base_name: &str) -> Vec<Self> {
433        if !is_deletable(table) {
434            return vec![];
435        }
436
437        let type_name = to_pascal_case(base_name);
438        let singular = singularize(base_name);
439
440        let mut fields = vec![];
441
442        // delete_users (batch delete)
443        let name = if config.use_camel_case {
444            format!("delete{}", to_pascal_case(base_name))
445        } else {
446            format!("delete_{}", base_name)
447        };
448        fields.push(Self {
449            name,
450            table_name: table.name.clone(),
451            schema_name: table.schema.clone(),
452            mutation_type: MutationType::Delete,
453            pk_columns: Vec::new(),
454            return_type: format!("[{}!]!", type_name),
455            description: Some(format!("Delete {} records", table.name)),
456        });
457
458        // delete_user_by_pk (single delete by PK)
459        if !table.pk_cols.is_empty() {
460            let name = if config.use_camel_case {
461                format!("delete{}ByPk", to_pascal_case(&singular))
462            } else {
463                format!("delete_{}_by_pk", singular)
464            };
465            fields.push(Self {
466                name,
467                table_name: table.name.clone(),
468                schema_name: table.schema.clone(),
469                mutation_type: MutationType::DeleteByPk,
470                pk_columns: pk_columns_of(table),
471                return_type: type_name,
472                description: Some(format!("Delete a single {} by primary key", singular)),
473            });
474        }
475
476        fields
477    }
478}
479
480/// Build a GraphQL schema from a schema cache.
481pub fn build_schema(schema_cache: &SchemaCache, config: &SchemaConfig) -> GeneratedSchema {
482    let mut object_types = HashMap::new();
483    let mut query_fields = Vec::new();
484    let mut mutation_fields = Vec::new();
485    let mut relationship_fields = HashMap::new();
486
487    // Tables are visited in a stable order: the cache is a hash map, and any
488    // name disambiguation below must not shift between restarts.
489    let mut tables: Vec<&Table> = schema_cache
490        .tables
491        .values()
492        .filter(|table| config.is_schema_exposed(&table.schema))
493        .collect();
494    tables.sort_by(|a, b| (&a.schema, &a.name).cmp(&(&b.schema, &b.name)));
495
496    // Base names already carry the schema for non-default schemas, so a clash
497    // here needs contrived naming (a `public.api_users` table alongside
498    // `api.users`). Resolve it with a numeric suffix rather than letting one
499    // table overwrite the other.
500    let mut used_base_names: HashMap<String, u32> = HashMap::new();
501    // (schema, table) -> resolved base name, needed when naming relationship
502    // targets.
503    let mut base_names: HashMap<(String, String), String> = HashMap::new();
504
505    for table in &tables {
506        let preferred = base_name_for(table, config);
507        let base_name = match used_base_names.get_mut(&preferred) {
508            None => {
509                used_base_names.insert(preferred.clone(), 1);
510                preferred
511            }
512            Some(count) => {
513                *count += 1;
514                let disambiguated = format!("{}_{}", preferred, count);
515                tracing::warn!(
516                    "GraphQL name collision: {}.{} would generate the same names as \
517                     an earlier table; exposing it as \"{}\" instead",
518                    table.schema,
519                    table.name,
520                    disambiguated
521                );
522                disambiguated
523            }
524        };
525
526        base_names.insert(
527            (table.schema.clone(), table.name.clone()),
528            base_name.clone(),
529        );
530    }
531
532    for table in tables {
533        let base_name = base_names
534            .get(&(table.schema.clone(), table.name.clone()))
535            .expect("every visited table has a resolved base name")
536            .clone();
537
538        // Create object type
539        let obj_type = TableObjectType::from_table_named(table, &base_name);
540        let type_name = obj_type.name.clone();
541
542        // Add query fields
543        query_fields.push(QueryField::list_named(table, config, &base_name));
544        if let Some(by_pk) = QueryField::by_pk_named(table, config, &base_name) {
545            query_fields.push(by_pk);
546        }
547
548        // Add mutation fields if enabled
549        if config.enable_mutations {
550            mutation_fields.extend(MutationField::insert_fields_named(
551                table, config, &base_name,
552            ));
553            mutation_fields.extend(MutationField::update_fields_named(
554                table, config, &base_name,
555            ));
556            mutation_fields.extend(MutationField::delete_fields_named(
557                table, config, &base_name,
558            ));
559        }
560
561        // Add relationship fields
562        let rels: Vec<RelationshipField> = schema_cache
563            .get_relationships(&table.qualified_identifier(), &table.schema)
564            .map(|relationships| {
565                relationships
566                    .iter()
567                    .filter_map(|rel| {
568                        // A relationship whose target is not exposed would
569                        // reference a GraphQL type that was never registered.
570                        let foreign = rel.foreign_table();
571                        let target_base =
572                            base_names.get(&(foreign.schema.clone(), foreign.name.clone()))?;
573                        Some(RelationshipField::from_relationship_named(rel, target_base))
574                    })
575                    .collect()
576            })
577            .unwrap_or_default();
578
579        if !rels.is_empty() {
580            relationship_fields.insert(type_name.clone(), rels);
581        }
582
583        object_types.insert(type_name, obj_type);
584    }
585
586    GeneratedSchema {
587        object_types,
588        query_fields,
589        mutation_fields,
590        relationship_fields,
591    }
592}
593
594/// Simple singularization for field names.
595fn singularize(s: &str) -> String {
596    if let Some(stem) = s.strip_suffix("ies") {
597        format!("{}y", stem)
598    } else if s.ends_with("ses") || s.ends_with("xes") || s.ends_with("ches") || s.ends_with("shes")
599    {
600        s.strip_suffix("es").unwrap_or(s).to_string()
601    } else if s.ends_with('s') && !s.ends_with("ss") {
602        s.strip_suffix('s').unwrap_or(s).to_string()
603    } else {
604        s.to_string()
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use indexmap::IndexMap;
612    use postrust_core::schema_cache::Column;
613    use pretty_assertions::assert_eq;
614
615    fn create_test_table(name: &str, insertable: bool, updatable: bool, deletable: bool) -> Table {
616        let mut columns = IndexMap::new();
617        columns.insert(
618            "id".into(),
619            Column {
620                name: "id".into(),
621                description: None,
622                nullable: false,
623                data_type: "integer".into(),
624                nominal_type: "int4".into(),
625                max_len: None,
626                default: Some("nextval('id_seq')".into()),
627                enum_values: vec![],
628                is_pk: true,
629                position: 1,
630            },
631        );
632        columns.insert(
633            "name".into(),
634            Column {
635                name: "name".into(),
636                description: None,
637                nullable: false,
638                data_type: "text".into(),
639                nominal_type: "text".into(),
640                max_len: None,
641                default: None,
642                enum_values: vec![],
643                is_pk: false,
644                position: 2,
645            },
646        );
647
648        Table {
649            schema: "public".into(),
650            name: name.into(),
651            description: None,
652            is_view: false,
653            insertable,
654            updatable,
655            deletable,
656            pk_cols: vec!["id".into()],
657            columns,
658        }
659    }
660
661    fn create_test_schema_cache() -> SchemaCache {
662        use std::collections::{HashMap, HashSet};
663
664        let mut tables = HashMap::new();
665
666        let users = create_test_table("users", true, true, true);
667        let posts = create_test_table("posts", true, true, true);
668        let comments = create_test_table("comments", true, false, false);
669
670        tables.insert(users.qualified_identifier(), users);
671        tables.insert(posts.qualified_identifier(), posts);
672        tables.insert(comments.qualified_identifier(), comments);
673
674        SchemaCache {
675            tables,
676            relationships: HashMap::new(),
677            routines: HashMap::new(),
678            timezones: HashSet::new(),
679            pg_version: 150000,
680        }
681    }
682
683    // ============================================================================
684    // SchemaConfig Tests
685    // ============================================================================
686
687    #[test]
688    fn test_schema_config_default() {
689        let config = SchemaConfig::default();
690        assert!(config.is_schema_exposed("public"));
691        assert!(!config.is_schema_exposed("private"));
692        assert!(config.enable_mutations);
693        assert!(!config.enable_subscriptions);
694    }
695
696    #[test]
697    fn test_schema_config_with_schemas() {
698        let config =
699            SchemaConfig::new().with_schemas(vec!["api".to_string(), "public".to_string()]);
700        assert!(config.is_schema_exposed("api"));
701        assert!(config.is_schema_exposed("public"));
702        assert!(!config.is_schema_exposed("private"));
703    }
704
705    #[test]
706    fn test_schema_config_mutations_disabled() {
707        let config = SchemaConfig::new().with_mutations(false);
708        assert!(!config.enable_mutations);
709    }
710
711    // ============================================================================
712    // QueryField Tests
713    // ============================================================================
714
715    #[test]
716    fn test_query_field_list() {
717        let table = create_test_table("users", true, true, true);
718        let config = SchemaConfig::default();
719        let field = QueryField::list(&table, &config);
720
721        assert_eq!(field.name, "users");
722        assert_eq!(field.return_type, "[Users!]!");
723        assert!(field.is_list);
724        assert!(!field.is_by_pk);
725    }
726
727    #[test]
728    fn test_query_field_list_with_prefix() {
729        let table = create_test_table("users", true, true, true);
730        let config = SchemaConfig {
731            query_prefix: Some("all".to_string()),
732            ..Default::default()
733        };
734        let field = QueryField::list(&table, &config);
735
736        assert_eq!(field.name, "allUsers");
737    }
738
739    #[test]
740    fn test_query_field_list_with_suffix() {
741        let table = create_test_table("users", true, true, true);
742        let config = SchemaConfig {
743            query_suffix: Some("Collection".to_string()),
744            ..Default::default()
745        };
746        let field = QueryField::list(&table, &config);
747
748        assert_eq!(field.name, "usersCollection");
749    }
750
751    #[test]
752    fn test_query_field_by_pk() {
753        let table = create_test_table("users", true, true, true);
754        let config = SchemaConfig::default();
755        let field = QueryField::by_pk(&table, &config).unwrap();
756
757        assert_eq!(field.name, "userByPk");
758        assert_eq!(field.return_type, "Users");
759        assert!(!field.is_list);
760        assert!(field.is_by_pk);
761    }
762
763    #[test]
764    fn test_query_field_by_pk_no_pk() {
765        let mut table = create_test_table("users", true, true, true);
766        table.pk_cols = vec![];
767        let config = SchemaConfig::default();
768        let field = QueryField::by_pk(&table, &config);
769
770        assert!(field.is_none());
771    }
772
773    // ============================================================================
774    // MutationField Tests
775    // ============================================================================
776
777    #[test]
778    fn test_mutation_field_insert() {
779        let table = create_test_table("users", true, true, true);
780        let config = SchemaConfig::default();
781        let fields = MutationField::insert_fields(&table, &config);
782
783        assert_eq!(fields.len(), 2);
784        assert_eq!(fields[0].name, "insertUsers");
785        assert_eq!(fields[0].mutation_type, MutationType::Insert);
786        assert_eq!(fields[1].name, "insertUserOne");
787        assert_eq!(fields[1].mutation_type, MutationType::InsertOne);
788    }
789
790    #[test]
791    fn test_mutation_field_insert_not_insertable() {
792        let table = create_test_table("users", false, true, true);
793        let config = SchemaConfig::default();
794        let fields = MutationField::insert_fields(&table, &config);
795
796        assert!(fields.is_empty());
797    }
798
799    #[test]
800    fn test_mutation_field_update() {
801        let table = create_test_table("users", true, true, true);
802        let config = SchemaConfig::default();
803        let fields = MutationField::update_fields(&table, &config);
804
805        assert_eq!(fields.len(), 2);
806        assert_eq!(fields[0].name, "updateUsers");
807        assert_eq!(fields[0].mutation_type, MutationType::Update);
808        assert_eq!(fields[1].name, "updateUserByPk");
809        assert_eq!(fields[1].mutation_type, MutationType::UpdateByPk);
810    }
811
812    #[test]
813    fn test_mutation_field_update_not_updatable() {
814        let table = create_test_table("users", true, false, true);
815        let config = SchemaConfig::default();
816        let fields = MutationField::update_fields(&table, &config);
817
818        assert!(fields.is_empty());
819    }
820
821    #[test]
822    fn test_mutation_field_delete() {
823        let table = create_test_table("users", true, true, true);
824        let config = SchemaConfig::default();
825        let fields = MutationField::delete_fields(&table, &config);
826
827        assert_eq!(fields.len(), 2);
828        assert_eq!(fields[0].name, "deleteUsers");
829        assert_eq!(fields[0].mutation_type, MutationType::Delete);
830        assert_eq!(fields[1].name, "deleteUserByPk");
831        assert_eq!(fields[1].mutation_type, MutationType::DeleteByPk);
832    }
833
834    #[test]
835    fn test_mutation_field_delete_not_deletable() {
836        let table = create_test_table("users", true, true, false);
837        let config = SchemaConfig::default();
838        let fields = MutationField::delete_fields(&table, &config);
839
840        assert!(fields.is_empty());
841    }
842
843    // ============================================================================
844    // Singularize Tests
845    // ============================================================================
846
847    #[test]
848    fn test_singularize() {
849        assert_eq!(singularize("users"), "user");
850        assert_eq!(singularize("posts"), "post");
851        assert_eq!(singularize("categories"), "category");
852        assert_eq!(singularize("boxes"), "box");
853        assert_eq!(singularize("matches"), "match");
854        assert_eq!(singularize("class"), "class");
855    }
856
857    // ============================================================================
858    // Build Schema Tests
859    // ============================================================================
860
861    #[test]
862    fn test_build_schema_object_types() {
863        let cache = create_test_schema_cache();
864        let config = SchemaConfig::default();
865        let schema = build_schema(&cache, &config);
866
867        assert_eq!(schema.object_types.len(), 3);
868        assert!(schema.get_object_type("Users").is_some());
869        assert!(schema.get_object_type("Posts").is_some());
870        assert!(schema.get_object_type("Comments").is_some());
871    }
872
873    #[test]
874    fn test_build_schema_query_fields() {
875        let cache = create_test_schema_cache();
876        let config = SchemaConfig::default();
877        let schema = build_schema(&cache, &config);
878
879        // 3 tables * 2 (list + byPk) = 6 query fields
880        assert_eq!(schema.query_fields.len(), 6);
881
882        // Check users query fields
883        let users_field = schema.get_query_field("users").unwrap();
884        assert_eq!(users_field.name, "users");
885        assert!(users_field.is_list);
886    }
887
888    #[test]
889    fn test_build_schema_mutation_fields() {
890        let cache = create_test_schema_cache();
891        let config = SchemaConfig::default();
892        let schema = build_schema(&cache, &config);
893
894        // users: 2 insert + 2 update + 2 delete = 6
895        // posts: 2 insert + 2 update + 2 delete = 6
896        // comments: 2 insert + 0 update + 0 delete = 2
897        // Total: 14
898        assert_eq!(schema.mutation_fields.len(), 14);
899
900        let users_mutations = schema.get_mutation_fields("users");
901        assert_eq!(users_mutations.len(), 6);
902    }
903
904    #[test]
905    fn test_build_schema_mutations_disabled() {
906        let cache = create_test_schema_cache();
907        let config = SchemaConfig::new().with_mutations(false);
908        let schema = build_schema(&cache, &config);
909
910        assert!(schema.mutation_fields.is_empty());
911    }
912
913    #[test]
914    fn test_build_schema_table_names() {
915        let cache = create_test_schema_cache();
916        let config = SchemaConfig::default();
917        let schema = build_schema(&cache, &config);
918
919        let names = schema.table_names();
920        assert_eq!(names.len(), 3);
921        assert!(names.contains(&"users"));
922        assert!(names.contains(&"posts"));
923        assert!(names.contains(&"comments"));
924    }
925
926    #[test]
927    fn test_build_schema_type_names() {
928        let cache = create_test_schema_cache();
929        let config = SchemaConfig::default();
930        let schema = build_schema(&cache, &config);
931
932        let names = schema.type_names();
933        assert_eq!(names.len(), 3);
934        assert!(names.contains(&"Users"));
935        assert!(names.contains(&"Posts"));
936        assert!(names.contains(&"Comments"));
937    }
938
939    #[test]
940    fn test_build_schema_exposed_schemas() {
941        let mut cache = create_test_schema_cache();
942
943        // Add a table in a different schema
944        let private_table = Table {
945            schema: "private".into(),
946            name: "secrets".into(),
947            description: None,
948            is_view: false,
949            insertable: true,
950            updatable: true,
951            deletable: true,
952            pk_cols: vec!["id".into()],
953            columns: indexmap::IndexMap::new(),
954        };
955        cache
956            .tables
957            .insert(private_table.qualified_identifier(), private_table);
958
959        let config = SchemaConfig::default(); // Only exposes "public"
960        let schema = build_schema(&cache, &config);
961
962        // Should only have 3 tables from public schema
963        assert_eq!(schema.object_types.len(), 3);
964        assert!(schema.get_object_type("Secrets").is_none());
965    }
966
967    // ============================================================================
968    // GeneratedSchema Tests
969    // ============================================================================
970
971    #[test]
972    fn test_generated_schema_get_object_type() {
973        let cache = create_test_schema_cache();
974        let config = SchemaConfig::default();
975        let schema = build_schema(&cache, &config);
976
977        let users = schema.get_object_type("Users").unwrap();
978        assert_eq!(users.table.name, "users");
979    }
980
981    #[test]
982    fn test_generated_schema_get_query_field() {
983        let cache = create_test_schema_cache();
984        let config = SchemaConfig::default();
985        let schema = build_schema(&cache, &config);
986
987        let field = schema.get_query_field("posts").unwrap();
988        assert_eq!(field.table_name, "posts");
989    }
990
991    #[test]
992    fn test_generated_schema_get_mutation_fields() {
993        let cache = create_test_schema_cache();
994        let config = SchemaConfig::default();
995        let schema = build_schema(&cache, &config);
996
997        let fields = schema.get_mutation_fields("comments");
998        // comments is only insertable
999        assert_eq!(fields.len(), 2); // insertComments + insertCommentOne
1000    }
1001}