Skip to main content

fraiseql_core/compiler/
codegen.rs

1//! Code generator - produces final `CompiledSchema` JSON.
2//!
3//! # Overview
4//!
5//! Takes validated IR and SQL templates, produces runtime-optimized
6//! `CompiledSchema` ready for execution.
7
8use super::ir::{
9    AuthoringIR, IRArgument, IREnum, IREnumValue, IRField, IRInputField, IRInputType, IRInterface,
10    IRMutation, IRUnion,
11};
12#[allow(unused_imports)] // Reason: used only in doc links for `# Errors` sections
13use crate::error::FraiseQLError;
14use crate::{
15    error::Result,
16    schema::{
17        ArgumentDefinition, AutoParams as SchemaAutoParams, CompiledSchema, CursorType,
18        DeprecationInfo, EnumDefinition, EnumValueDefinition, FieldDefinition, FieldDenyPolicy,
19        FieldType, InputFieldDefinition, InputObjectDefinition, InterfaceDefinition,
20        MutationDefinition, QueryDefinition, SubscriptionDefinition, TypeDefinition,
21        UnionDefinition,
22        domain_types::{SqlSource, TypeName},
23    },
24    validation::{CustomTypeDef, CustomTypeRegistry, CustomTypeRegistryConfig},
25};
26
27/// Code generator.
28pub struct CodeGenerator {
29    /// Enable SQL template optimization.
30    ///
31    /// **Reserved for a future SQL optimization pass** (constant folding,
32    /// predicate push-down, redundant-join elimination, etc.).  The flag is
33    /// stored and exposed via [`CodeGenerator::optimize`] so callers can
34    /// configure it today; it is not yet branched upon inside
35    /// [`CodeGenerator::generate`].  `sql_projection_hint` on each
36    /// [`TypeDefinition`] is the placeholder populated by that future pass.
37    optimize: bool,
38}
39
40impl CodeGenerator {
41    /// Create new code generator.
42    #[must_use]
43    pub const fn new(optimize: bool) -> Self {
44        Self { optimize }
45    }
46
47    /// Generate a compiled schema from the intermediate representation.
48    ///
49    /// # Architecture: Schema vs. Templates vs. Metadata
50    ///
51    /// FraiseQL separates schema definition from execution artifacts:
52    ///
53    /// ## What This Function Generates
54    ///
55    /// The `CompiledSchema` contains the **schema definition** - types, fields, enums,
56    /// interfaces, unions, and query/mutation/subscription signatures. This is what
57    /// GraphQL introspection tools query and what the runtime uses for query validation.
58    ///
59    /// ## What This Function Does NOT Generate
60    ///
61    /// ### 1. SQL Templates
62    ///
63    /// SQL templates are managed separately by the compilation pipeline.
64    /// They are:
65    /// - Generated in a separate compiler pass after schema validation
66    /// - Kept separate to allow schema reuse across database backends
67    /// - Passed to the runtime executor independently
68    ///
69    /// **Why**: Allows updating SQL generation strategy without recompiling schema
70    /// definitions. For example, you could optimize SQL templates without changing
71    /// type definitions.
72    ///
73    /// ### 2. Fact Tables
74    ///
75    /// Fact table metadata is populated by the compiler from `ir.fact_tables` in a
76    /// separate initialization pass. This maintains:
77    /// - Clean separation of concerns (schema def vs. analytics metadata)
78    /// - Ability to update fact table configuration independently
79    /// - Clear data flow through compilation pipeline
80    ///
81    /// **Why**: Fact tables are configuration-driven metadata. Keeping them separate
82    /// allows analytics tuning without affecting core schema.
83    ///
84    /// # Parameters
85    ///
86    /// * `ir` - The intermediate representation generated by the compiler
87    ///
88    /// # Errors
89    ///
90    /// Returns a `Result` with compilation errors if schema validation fails.
91    ///
92    /// # Examples
93    ///
94    /// ```rust
95    /// use fraiseql_core::compiler::{CodeGenerator, AuthoringIR};
96    /// # use fraiseql_core::error::Result;
97    /// # fn example() -> Result<()> {
98    /// let authoring_ir = AuthoringIR::new();
99    /// let codegen = CodeGenerator::new(true);
100    /// let compiled_schema = codegen.generate(&authoring_ir)?;
101    /// // Runtime uses compiled_schema for query execution
102    /// # Ok(())
103    /// # }
104    /// ```
105    ///
106    /// # See Also
107    ///
108    /// - (Compilation): SQL template generation
109    /// - Compiler module documentation for pipeline overview
110    ///
111    /// # Errors
112    ///
113    /// Returns [`FraiseQLError::Validation`] if the IR contains invalid field types,
114    /// unknown types, or conflicting definitions. Returns [`FraiseQLError::Internal`]
115    /// for unexpected code-generation failures.
116    pub fn generate(&self, ir: &AuthoringIR) -> Result<CompiledSchema> {
117        let types = ir
118            .types
119            .iter()
120            .map(|t| {
121                TypeDefinition {
122                    name:                TypeName::new(t.name.clone()),
123                    sql_source:          SqlSource::new(
124                        t.sql_source.clone().unwrap_or_else(|| t.name.clone()),
125                    ),
126                    jsonb_column:        "data".to_string(),
127                    fields:              Self::map_fields(&t.fields),
128                    description:         t.description.clone(),
129                    sql_projection_hint: None, // Populated during optimization pass
130                    implements:          Vec::new(), /* Note: IR doesn't have interface
131                                                * implementation yet */
132                    requires_role:       None,
133                    is_error:            false,
134                    relay:               false,
135                    relationships:       Vec::new(),
136                }
137            })
138            .collect();
139
140        let queries = ir
141            .queries
142            .iter()
143            .map(|q| {
144                QueryDefinition {
145                    name:                q.name.clone(),
146                    return_type:         q.return_type.clone(),
147                    returns_list:        q.returns_list,
148                    nullable:            q.nullable,
149                    arguments:           Self::map_arguments(&q.arguments),
150                    sql_source:          q.sql_source.clone(),
151                    description:         q.description.clone(),
152                    auto_params:         SchemaAutoParams {
153                        has_where:    q.auto_params.has_where,
154                        has_order_by: q.auto_params.has_order_by,
155                        has_limit:    q.auto_params.has_limit,
156                        has_offset:   q.auto_params.has_offset,
157                    },
158                    deprecation:         None, // Note: IR doesn't have deprecation info yet
159                    jsonb_column:        "data".to_string(), // Default to "data" column
160                    relay:               false,
161                    relay_cursor_column: None,
162                    relay_cursor_type:   CursorType::default(),
163                    inject_params:       indexmap::IndexMap::default(),
164                    cache_ttl_seconds:   None,
165                    additional_views:    vec![],
166                    requires_role:       None,
167                    rest_path:           None,
168                    rest_method:         None,
169                }
170            })
171            .collect();
172
173        let mutations = ir.mutations.iter().map(Self::map_mutation).collect();
174
175        let subscriptions = ir
176            .subscriptions
177            .iter()
178            .map(|s| {
179                SubscriptionDefinition {
180                    name:          s.name.clone(),
181                    return_type:   s.return_type.clone(),
182                    arguments:     Self::map_arguments(&s.arguments),
183                    description:   s.description.clone(),
184                    topic:         None, // Populated from decorator topic binding
185                    filter:        None, // Populated from decorator filters
186                    fields:        Vec::new(), // Populated from decorator field selection
187                    filter_fields: Vec::new(), // Populated from decorator filter_fields
188                    deprecation:   None, // Note: IR subscriptions don't have deprecation yet
189                }
190            })
191            .collect();
192
193        // Map enums
194        let enums = ir.enums.iter().map(Self::map_enum).collect();
195
196        // Map interfaces
197        let interfaces = ir.interfaces.iter().map(Self::map_interface).collect();
198
199        // Map unions
200        let unions = ir.unions.iter().map(Self::map_union).collect();
201
202        // Map input types
203        let input_types = ir.input_types.iter().map(Self::map_input_type).collect();
204
205        // Build custom type registry from IRScalars
206        let custom_scalars = Self::build_custom_type_registry(&ir.scalars)?;
207
208        let mut schema = CompiledSchema {
209            types,
210            enums,
211            input_types,
212            interfaces,
213            unions,
214            queries,
215            mutations,
216            subscriptions,
217            // Directives are custom decorators applied to schema elements. The IR doesn't currently
218            // have custom directive definitions. They will be populated when decorator support is
219            // added.
220            directives: Vec::new(),
221            // Fact tables are populated by the compiler from ir.fact_tables in a separate pass.
222            // This maintains clear separation between schema definition (type system) and analytics
223            // metadata (fact table configuration). Empty initially; populated during post-codegen
224            // pass.
225            fact_tables: std::collections::HashMap::new(),
226            // Observers are populated from ir.observers. Currently empty; will be populated
227            // when observer support is added to the IR layer.
228            observers: Vec::new(),
229            // Federation metadata: populated if Apollo Federation is configured
230            federation: None,
231            // Security configuration: populated from fraiseql.toml
232            security: None,
233            // Raw schema SDL: populated for federation _service query
234            observers_config: None,
235            subscriptions_config: None,
236            validation_config: None,
237            debug_config: None,
238            mcp_config: None,
239            schema_sdl: None,
240            // Embed the current format version so the server can detect compiler/runtime skew.
241            schema_format_version: Some(crate::schema::CURRENT_SCHEMA_FORMAT_VERSION),
242            // Custom scalar types registry
243            custom_scalars,
244            // Index fields are private; fill with defaults and build below.
245            ..CompiledSchema::default()
246        };
247        schema.build_indexes();
248        Ok(schema)
249    }
250
251    /// Map IR fields to compiled schema fields.
252    fn map_fields(ir_fields: &[IRField]) -> Vec<FieldDefinition> {
253        ir_fields
254            .iter()
255            .map(|f| {
256                let field_type = FieldType::parse(&f.field_type);
257                FieldDefinition {
258                    name: f.name.clone().into(),
259                    field_type,
260                    nullable: f.nullable,
261                    description: f.description.clone(),
262                    default_value: None,  // Fields don't have defaults in GraphQL
263                    vector_config: None,  // Would be set if field_type is Vector
264                    alias: None,          // Aliases come from query, not schema
265                    deprecation: None,    // Note: IR fields don't have deprecation yet
266                    requires_scope: None, // Note: IR fields don't have scope requirements yet
267                    on_deny: FieldDenyPolicy::default(),
268                    encryption: None,
269                }
270            })
271            .collect()
272    }
273
274    /// Map IR arguments to compiled schema arguments.
275    fn map_arguments(ir_args: &[IRArgument]) -> Vec<ArgumentDefinition> {
276        ir_args
277            .iter()
278            .map(|a| {
279                let arg_type = FieldType::parse(&a.arg_type);
280                ArgumentDefinition {
281                    name: a.name.clone(),
282                    arg_type,
283                    nullable: a.nullable,
284                    default_value: a.default_value.clone(),
285                    description: a.description.clone(),
286                    deprecation: None, // Note: IR args don't have deprecation yet
287                }
288            })
289            .collect()
290    }
291
292    /// Map IR mutation to compiled schema mutation.
293    fn map_mutation(m: &IRMutation) -> MutationDefinition {
294        use super::ir::MutationOperation as IRMutationOp;
295        use crate::schema::MutationOperation;
296
297        // The compiled schema MutationOperation needs a table name for Insert/Update/Delete.
298        // The IR doesn't carry a sql_source field, so we derive the table from the return type.
299        // sql_source is set to the same value so the executor can locate the SQL function even
300        // when the operation.table fallback path is not exercised.
301        let operation = match m.operation {
302            IRMutationOp::Create => MutationOperation::Insert {
303                table: m.return_type.to_lowercase(), // Infer table from return type
304            },
305            IRMutationOp::Update => MutationOperation::Update {
306                table: m.return_type.to_lowercase(),
307            },
308            IRMutationOp::Delete => MutationOperation::Delete {
309                table: m.return_type.to_lowercase(),
310            },
311            IRMutationOp::Custom => MutationOperation::Custom,
312        };
313
314        let sql_source = match &operation {
315            MutationOperation::Insert { table }
316            | MutationOperation::Update { table }
317            | MutationOperation::Delete { table }
318                if !table.is_empty() =>
319            {
320                Some(table.clone())
321            },
322            _ => None,
323        };
324
325        MutationDefinition {
326            name: m.name.clone(),
327            return_type: m.return_type.clone(),
328            arguments: Self::map_arguments(&m.arguments),
329            description: m.description.clone(),
330            operation,
331            deprecation: None, // Note: IR mutations don't have deprecation yet
332            sql_source,
333            inject_params: indexmap::IndexMap::default(),
334            invalidates_fact_tables: vec![],
335            invalidates_views: vec![],
336            rest_path: None,
337            rest_method: None,
338            upsert_function: None,
339        }
340    }
341
342    /// Map IR enum to compiled schema enum.
343    fn map_enum(e: &IREnum) -> EnumDefinition {
344        EnumDefinition {
345            name:        e.name.clone(),
346            values:      e.values.iter().map(Self::map_enum_value).collect(),
347            description: e.description.clone(),
348        }
349    }
350
351    /// Map IR enum value to compiled schema enum value.
352    fn map_enum_value(v: &IREnumValue) -> EnumValueDefinition {
353        EnumValueDefinition {
354            name:        v.name.clone(),
355            description: v.description.clone(),
356            deprecation: v.deprecation_reason.as_ref().map(|reason| DeprecationInfo {
357                reason: Some(reason.clone()),
358            }),
359        }
360    }
361
362    /// Map IR interface to compiled schema interface.
363    fn map_interface(i: &IRInterface) -> InterfaceDefinition {
364        InterfaceDefinition {
365            name:        i.name.clone(),
366            fields:      Self::map_fields(&i.fields),
367            description: i.description.clone(),
368        }
369    }
370
371    /// Map IR union to compiled schema union.
372    fn map_union(u: &IRUnion) -> UnionDefinition {
373        UnionDefinition {
374            name:         u.name.clone(),
375            member_types: u.types.clone(),
376            description:  u.description.clone(),
377        }
378    }
379
380    /// Map IR input type to compiled schema input object.
381    fn map_input_type(i: &IRInputType) -> InputObjectDefinition {
382        InputObjectDefinition {
383            name:        i.name.clone(),
384            fields:      Self::map_input_fields(&i.fields),
385            description: i.description.clone(),
386            metadata:    None,
387        }
388    }
389
390    /// Map IR input fields to compiled schema input fields.
391    fn map_input_fields(ir_fields: &[IRInputField]) -> Vec<InputFieldDefinition> {
392        ir_fields
393            .iter()
394            .map(|f| {
395                InputFieldDefinition {
396                    name:             f.name.clone(),
397                    field_type:       f.field_type.clone(), /* InputFieldDefinition uses String,
398                                                             * not FieldType */
399                    description:      f.description.clone(),
400                    default_value:    f.default_value.as_ref().map(|v| v.to_json().to_string()),
401                    deprecation:      None, // Note: IR input fields don't have deprecation yet
402                    validation_rules: Vec::new(), /* Validation rules set separately from @validate
403                                             * directives */
404                }
405            })
406            .collect()
407    }
408
409    /// Build custom type registry from `IRScalars`.
410    fn build_custom_type_registry(
411        ir_scalars: &[super::ir::IRScalar],
412    ) -> Result<CustomTypeRegistry> {
413        let registry = CustomTypeRegistry::new(CustomTypeRegistryConfig::default());
414
415        for ir_scalar in ir_scalars {
416            let def = CustomTypeDef {
417                name:             ir_scalar.name.clone(),
418                description:      ir_scalar.description.clone(),
419                specified_by_url: ir_scalar.specified_by_url.clone(),
420                validation_rules: ir_scalar.validation_rules.clone(),
421                elo_expression:   None, // ELO expressions are handled separately
422                base_type:        ir_scalar.base_type.clone(),
423            };
424
425            registry.register(ir_scalar.name.clone(), def)?;
426        }
427
428        Ok(registry)
429    }
430
431    /// Check if optimization is enabled.
432    #[must_use]
433    pub const fn optimize(&self) -> bool {
434        self.optimize
435    }
436}
437
438#[cfg(test)]
439mod tests {
440    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable
441
442    use super::{
443        super::ir::{AutoParams, IRArgument, IRField, IRQuery, IRSubscription, IRType},
444        *,
445    };
446    use crate::schema::{CursorType, GraphQLValue};
447
448    #[test]
449    fn test_code_generator_new() {
450        let generator = CodeGenerator::new(true);
451        assert!(generator.optimize());
452
453        let generator = CodeGenerator::new(false);
454        assert!(!generator.optimize());
455    }
456
457    #[test]
458    fn test_generate_empty_schema() {
459        let generator = CodeGenerator::new(true);
460        let ir = AuthoringIR::new();
461
462        let schema = generator
463            .generate(&ir)
464            .unwrap_or_else(|e| panic!("generate empty schema should succeed: {e}"));
465        assert!(schema.types.is_empty());
466        assert!(schema.queries.is_empty());
467    }
468
469    #[test]
470    fn test_generate_types_with_fields() {
471        let generator = CodeGenerator::new(true);
472        let mut ir = AuthoringIR::new();
473
474        ir.types.push(IRType {
475            name:        "User".to_string(),
476            fields:      vec![
477                IRField {
478                    name:        "id".to_string(),
479                    field_type:  "ID!".to_string(),
480                    nullable:    false,
481                    description: Some("User ID".to_string()),
482                    sql_column:  Some("id".to_string()),
483                },
484                IRField {
485                    name:        "name".to_string(),
486                    field_type:  "String".to_string(),
487                    nullable:    true,
488                    description: None,
489                    sql_column:  None,
490                },
491                IRField {
492                    name:        "age".to_string(),
493                    field_type:  "Int".to_string(),
494                    nullable:    true,
495                    description: None,
496                    sql_column:  None,
497                },
498            ],
499            sql_source:  Some("v_user".to_string()),
500            description: Some("User type".to_string()),
501        });
502
503        let schema = generator
504            .generate(&ir)
505            .unwrap_or_else(|e| panic!("generate types with fields should succeed: {e}"));
506        assert_eq!(schema.types.len(), 1);
507
508        let user_type = &schema.types[0];
509        assert_eq!(user_type.name, "User");
510        assert_eq!(user_type.sql_source, "v_user");
511        assert_eq!(user_type.fields.len(), 3);
512
513        // Check field types were parsed correctly
514        assert_eq!(user_type.fields[0].name, "id");
515        assert_eq!(user_type.fields[0].field_type, FieldType::Id);
516        assert!(!user_type.fields[0].nullable);
517
518        assert_eq!(user_type.fields[1].name, "name");
519        assert_eq!(user_type.fields[1].field_type, FieldType::String);
520
521        assert_eq!(user_type.fields[2].name, "age");
522        assert_eq!(user_type.fields[2].field_type, FieldType::Int);
523    }
524
525    #[test]
526    fn test_generate_queries_with_arguments() {
527        let generator = CodeGenerator::new(true);
528        let mut ir = AuthoringIR::new();
529
530        ir.types.push(IRType {
531            name:        "User".to_string(),
532            fields:      vec![],
533            sql_source:  None,
534            description: None,
535        });
536
537        ir.queries.push(IRQuery {
538            name:         "user".to_string(),
539            return_type:  "User".to_string(),
540            returns_list: false,
541            nullable:     true,
542            arguments:    vec![IRArgument {
543                name:          "id".to_string(),
544                arg_type:      "ID!".to_string(),
545                nullable:      false,
546                default_value: None,
547                description:   Some("User ID to fetch".to_string()),
548            }],
549            sql_source:   Some("v_user".to_string()),
550            description:  Some("Fetch a single user".to_string()),
551            auto_params:  AutoParams::default(),
552        });
553
554        ir.queries.push(IRQuery {
555            name:         "users".to_string(),
556            return_type:  "User".to_string(),
557            returns_list: true,
558            nullable:     false,
559            arguments:    vec![IRArgument {
560                name:          "limit".to_string(),
561                arg_type:      "Int".to_string(),
562                nullable:      true,
563                default_value: Some(GraphQLValue::Int(10)),
564                description:   None,
565            }],
566            sql_source:   Some("v_user".to_string()),
567            description:  None,
568            auto_params:  AutoParams {
569                has_where:    true,
570                has_order_by: true,
571                has_limit:    true,
572                has_offset:   true,
573            },
574        });
575
576        let schema = generator
577            .generate(&ir)
578            .unwrap_or_else(|e| panic!("generate queries with arguments should succeed: {e}"));
579        assert_eq!(schema.queries.len(), 2);
580
581        // Check single user query
582        let user_query = &schema.queries[0];
583        assert_eq!(user_query.name, "user");
584        assert!(!user_query.returns_list);
585        assert!(user_query.nullable);
586        assert_eq!(user_query.arguments.len(), 1);
587        assert_eq!(user_query.arguments[0].name, "id");
588        assert_eq!(user_query.arguments[0].arg_type, FieldType::Id);
589
590        // Check users query with auto_params — assert all fields
591        let users_query = &schema.queries[1];
592        assert_eq!(users_query.name, "users");
593        assert!(users_query.returns_list);
594        assert_eq!(
595            users_query.sql_source.as_deref(),
596            Some("v_user"),
597            "codegen must thread sql_source from IR"
598        );
599        assert!(users_query.auto_params.has_where);
600        assert!(users_query.auto_params.has_order_by);
601        assert_eq!(users_query.arguments[0].default_value, Some(GraphQLValue::Int(10)));
602        assert_eq!(users_query.relay_cursor_type, CursorType::Int64);
603        assert!(users_query.inject_params.is_empty());
604        assert!(users_query.cache_ttl_seconds.is_none());
605    }
606
607    #[test]
608    fn test_generate_mutations() {
609        use super::super::ir::MutationOperation as IRMutationOp;
610
611        let generator = CodeGenerator::new(true);
612        let mut ir = AuthoringIR::new();
613
614        ir.mutations.push(IRMutation {
615            name:        "createUser".to_string(),
616            return_type: "User".to_string(),
617            nullable:    false,
618            arguments:   vec![IRArgument {
619                name:          "name".to_string(),
620                arg_type:      "String!".to_string(),
621                nullable:      false,
622                default_value: None,
623                description:   None,
624            }],
625            description: Some("Create a new user".to_string()),
626            operation:   IRMutationOp::Create,
627        });
628
629        let schema = generator
630            .generate(&ir)
631            .unwrap_or_else(|e| panic!("generate mutations should succeed: {e}"));
632        assert_eq!(schema.mutations.len(), 1);
633
634        let mutation = &schema.mutations[0];
635        assert_eq!(mutation.name, "createUser");
636        // Insert operation should be inferred from Create
637        assert!(matches!(
638            &mutation.operation,
639            crate::schema::MutationOperation::Insert { table } if table == "user"
640        ));
641        // sql_source must be populated from operation.table so the executor can call
642        // the SQL function without the "has no sql_source configured" error (issue #53).
643        assert_eq!(
644            mutation.sql_source,
645            Some("user".to_string()),
646            "codegen must thread sql_source from IR"
647        );
648        assert_eq!(mutation.arguments.len(), 1);
649        assert!(mutation.inject_params.is_empty());
650        assert!(mutation.invalidates_fact_tables.is_empty());
651        assert!(mutation.invalidates_views.is_empty());
652    }
653
654    #[test]
655    fn test_generate_subscriptions() {
656        let generator = CodeGenerator::new(true);
657        let mut ir = AuthoringIR::new();
658
659        ir.subscriptions.push(IRSubscription {
660            name:        "userCreated".to_string(),
661            return_type: "User".to_string(),
662            arguments:   vec![IRArgument {
663                name:          "tenantId".to_string(),
664                arg_type:      "ID!".to_string(),
665                nullable:      false,
666                default_value: None,
667                description:   None,
668            }],
669            description: Some("Subscribe to user creation events".to_string()),
670        });
671
672        let schema = generator
673            .generate(&ir)
674            .unwrap_or_else(|e| panic!("generate subscriptions should succeed: {e}"));
675        assert_eq!(schema.subscriptions.len(), 1);
676
677        let subscription = &schema.subscriptions[0];
678        assert_eq!(subscription.name, "userCreated");
679        assert_eq!(subscription.return_type, "User");
680        assert_eq!(subscription.arguments.len(), 1);
681        assert_eq!(subscription.arguments[0].name, "tenantId");
682    }
683
684    #[test]
685    fn test_field_type_parsing_list_types() {
686        let generator = CodeGenerator::new(true);
687        let mut ir = AuthoringIR::new();
688
689        ir.types.push(IRType {
690            name:        "Post".to_string(),
691            fields:      vec![
692                IRField {
693                    name:        "tags".to_string(),
694                    field_type:  "[String]".to_string(),
695                    nullable:    true,
696                    description: None,
697                    sql_column:  None,
698                },
699                IRField {
700                    name:        "comments".to_string(),
701                    field_type:  "[Comment!]!".to_string(),
702                    nullable:    false,
703                    description: None,
704                    sql_column:  None,
705                },
706            ],
707            sql_source:  None,
708            description: None,
709        });
710
711        let schema = generator
712            .generate(&ir)
713            .unwrap_or_else(|e| panic!("generate list type fields should succeed: {e}"));
714        let post_type = &schema.types[0];
715
716        // [String] -> List(String)
717        assert!(matches!(
718            &post_type.fields[0].field_type,
719            FieldType::List(inner) if **inner == FieldType::String
720        ));
721
722        // [Comment!]! -> List(Object("Comment"))
723        assert!(matches!(
724            &post_type.fields[1].field_type,
725            FieldType::List(inner) if matches!(**inner, FieldType::Object(ref name) if name == "Comment")
726        ));
727    }
728
729    #[test]
730    fn test_generate_enums() {
731        use super::super::ir::{IREnum, IREnumValue};
732
733        let generator = CodeGenerator::new(true);
734        let mut ir = AuthoringIR::new();
735
736        ir.enums.push(IREnum {
737            name:        "OrderStatus".to_string(),
738            values:      vec![
739                IREnumValue {
740                    name:               "PENDING".to_string(),
741                    description:        Some("Order is pending".to_string()),
742                    deprecation_reason: None,
743                },
744                IREnumValue {
745                    name:               "COMPLETED".to_string(),
746                    description:        None,
747                    deprecation_reason: None,
748                },
749                IREnumValue {
750                    name:               "CANCELLED".to_string(),
751                    description:        None,
752                    deprecation_reason: Some("Use REJECTED instead".to_string()),
753                },
754            ],
755            description: Some("Possible order statuses".to_string()),
756        });
757
758        let schema = generator
759            .generate(&ir)
760            .unwrap_or_else(|e| panic!("generate enums should succeed: {e}"));
761        assert_eq!(schema.enums.len(), 1);
762
763        let order_status = &schema.enums[0];
764        assert_eq!(order_status.name, "OrderStatus");
765        assert_eq!(order_status.values.len(), 3);
766        assert_eq!(order_status.values[0].name, "PENDING");
767        assert_eq!(order_status.values[0].description, Some("Order is pending".to_string()));
768        assert!(order_status.values[2].deprecation.is_some());
769    }
770
771    #[test]
772    fn test_generate_interfaces() {
773        use super::super::ir::IRInterface;
774
775        let generator = CodeGenerator::new(true);
776        let mut ir = AuthoringIR::new();
777
778        ir.interfaces.push(IRInterface {
779            name:        "Node".to_string(),
780            fields:      vec![IRField {
781                name:        "id".to_string(),
782                field_type:  "ID!".to_string(),
783                nullable:    false,
784                description: Some("Unique identifier".to_string()),
785                sql_column:  None,
786            }],
787            description: Some("An object with an ID".to_string()),
788        });
789
790        let schema = generator
791            .generate(&ir)
792            .unwrap_or_else(|e| panic!("generate interfaces should succeed: {e}"));
793        assert_eq!(schema.interfaces.len(), 1);
794
795        let node = &schema.interfaces[0];
796        assert_eq!(node.name, "Node");
797        assert_eq!(node.fields.len(), 1);
798        assert_eq!(node.fields[0].name, "id");
799        assert_eq!(node.fields[0].field_type, FieldType::Id);
800    }
801
802    #[test]
803    fn test_generate_unions() {
804        use super::super::ir::IRUnion;
805
806        let generator = CodeGenerator::new(true);
807        let mut ir = AuthoringIR::new();
808
809        ir.unions.push(IRUnion {
810            name:        "SearchResult".to_string(),
811            types:       vec![
812                "User".to_string(),
813                "Post".to_string(),
814                "Comment".to_string(),
815            ],
816            description: Some("Possible search result types".to_string()),
817        });
818
819        let schema = generator
820            .generate(&ir)
821            .unwrap_or_else(|e| panic!("generate unions should succeed: {e}"));
822        assert_eq!(schema.unions.len(), 1);
823
824        let search_result = &schema.unions[0];
825        assert_eq!(search_result.name, "SearchResult");
826        assert_eq!(search_result.member_types.len(), 3);
827        assert_eq!(search_result.member_types[0], "User");
828    }
829
830    #[test]
831    fn test_generate_input_types() {
832        use super::super::ir::{IRInputField, IRInputType};
833
834        let generator = CodeGenerator::new(true);
835        let mut ir = AuthoringIR::new();
836
837        ir.input_types.push(IRInputType {
838            name:        "CreateUserInput".to_string(),
839            fields:      vec![
840                IRInputField {
841                    name:          "name".to_string(),
842                    field_type:    "String!".to_string(),
843                    nullable:      false,
844                    default_value: None,
845                    description:   Some("User's name".to_string()),
846                },
847                IRInputField {
848                    name:          "age".to_string(),
849                    field_type:    "Int".to_string(),
850                    nullable:      true,
851                    default_value: Some(GraphQLValue::Int(18)),
852                    description:   None,
853                },
854            ],
855            description: Some("Input for creating a user".to_string()),
856        });
857
858        let schema = generator
859            .generate(&ir)
860            .unwrap_or_else(|e| panic!("generate input types should succeed: {e}"));
861        assert_eq!(schema.input_types.len(), 1);
862
863        let create_user = &schema.input_types[0];
864        assert_eq!(create_user.name, "CreateUserInput");
865        assert_eq!(create_user.fields.len(), 2);
866        assert_eq!(create_user.fields[0].name, "name");
867        assert_eq!(create_user.fields[1].default_value, Some("18".to_string()));
868    }
869
870    #[test]
871    fn test_generate_with_empty_scalars() {
872        let generator = CodeGenerator::new(true);
873        let mut ir = AuthoringIR::new();
874        ir.scalars = vec![];
875
876        let schema = generator
877            .generate(&ir)
878            .unwrap_or_else(|e| panic!("generate with empty scalars should succeed: {e}"));
879        assert_eq!(schema.custom_scalars.count(), 0);
880    }
881
882    #[test]
883    fn test_generate_with_single_custom_scalar() {
884        use super::super::ir::IRScalar;
885
886        let generator = CodeGenerator::new(true);
887        let mut ir = AuthoringIR::new();
888
889        ir.scalars.push(IRScalar {
890            name:             "LibraryCode".to_string(),
891            description:      Some("Unique library book identifier".to_string()),
892            specified_by_url: None,
893            validation_rules: vec![],
894            base_type:        Some("String".to_string()),
895        });
896
897        let schema = generator
898            .generate(&ir)
899            .unwrap_or_else(|e| panic!("generate with single custom scalar should succeed: {e}"));
900        assert_eq!(schema.custom_scalars.count(), 1);
901        assert!(schema.custom_scalars.exists("LibraryCode"));
902
903        let def = schema.custom_scalars.get("LibraryCode").unwrap();
904        assert_eq!(def.name, "LibraryCode");
905        assert_eq!(def.description, Some("Unique library book identifier".to_string()));
906        assert_eq!(def.base_type, Some("String".to_string()));
907    }
908
909    #[test]
910    fn test_generate_with_multiple_custom_scalars() {
911        use super::super::ir::IRScalar;
912
913        let generator = CodeGenerator::new(true);
914        let mut ir = AuthoringIR::new();
915
916        ir.scalars.push(IRScalar {
917            name:             "LibraryCode".to_string(),
918            description:      None,
919            specified_by_url: None,
920            validation_rules: vec![],
921            base_type:        Some("String".to_string()),
922        });
923
924        ir.scalars.push(IRScalar {
925            name:             "StudentID".to_string(),
926            description:      Some("University student identifier".to_string()),
927            specified_by_url: None,
928            validation_rules: vec![],
929            base_type:        None,
930        });
931
932        ir.scalars.push(IRScalar {
933            name:             "PatientID".to_string(),
934            description:      Some("Hospital patient identifier".to_string()),
935            specified_by_url: Some("https://hl7.org/".to_string()),
936            validation_rules: vec![],
937            base_type:        Some("String".to_string()),
938        });
939
940        let schema = generator.generate(&ir).unwrap_or_else(|e| {
941            panic!("generate with multiple custom scalars should succeed: {e}")
942        });
943        assert_eq!(schema.custom_scalars.count(), 3);
944
945        // Verify all scalars are registered
946        assert!(schema.custom_scalars.exists("LibraryCode"));
947        assert!(schema.custom_scalars.exists("StudentID"));
948        assert!(schema.custom_scalars.exists("PatientID"));
949
950        // Verify metadata preserved
951        let patient_id = schema.custom_scalars.get("PatientID").unwrap();
952        assert_eq!(patient_id.specified_by_url, Some("https://hl7.org/".to_string()));
953    }
954
955    #[test]
956    fn test_generate_preserves_scalar_description() {
957        use super::super::ir::IRScalar;
958
959        let generator = CodeGenerator::new(true);
960        let mut ir = AuthoringIR::new();
961
962        ir.scalars.push(IRScalar {
963            name:             "Email".to_string(),
964            description:      Some("RFC 5322 compliant email address".to_string()),
965            specified_by_url: Some("https://tools.ietf.org/html/rfc5322".to_string()),
966            validation_rules: vec![],
967            base_type:        Some("String".to_string()),
968        });
969
970        let schema = generator.generate(&ir).unwrap_or_else(|e| {
971            panic!("generate preserving scalar description should succeed: {e}")
972        });
973        let email_def = schema.custom_scalars.get("Email").unwrap();
974
975        assert_eq!(email_def.description, Some("RFC 5322 compliant email address".to_string()));
976        assert_eq!(
977            email_def.specified_by_url,
978            Some("https://tools.ietf.org/html/rfc5322".to_string())
979        );
980    }
981
982    #[test]
983    fn test_generate_scalars_with_validation_rules() {
984        use super::super::ir::IRScalar;
985        use crate::validation::ValidationRule;
986
987        let generator = CodeGenerator::new(true);
988        let mut ir = AuthoringIR::new();
989
990        ir.scalars.push(IRScalar {
991            name:             "StudentID".to_string(),
992            description:      None,
993            specified_by_url: None,
994            validation_rules: vec![ValidationRule::Length {
995                min: Some(5),
996                max: Some(15),
997            }],
998            base_type:        Some("String".to_string()),
999        });
1000
1001        let schema = generator.generate(&ir).unwrap_or_else(|e| {
1002            panic!("generate scalars with validation rules should succeed: {e}")
1003        });
1004        let student_id = schema.custom_scalars.get("StudentID").unwrap();
1005
1006        assert_eq!(student_id.validation_rules.len(), 1);
1007    }
1008}