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