Skip to main content

fraiseql_core/compiler/
validator.rs

1//! Schema validator - validates IR for correctness.
2//!
3//! # Validation Rules
4//!
5//! - Type references are valid
6//! - SQL bindings exist
7//! - No circular dependencies
8//! - Auth rules are valid
9//! - Analytics fact table metadata is valid
10//! - Aggregate types follow required structure
11
12use super::ir::AuthoringIR;
13use crate::{
14    error::{FraiseQLError, Result},
15    schema::is_known_scalar,
16};
17
18/// Extract the base type name from a GraphQL type string.
19///
20/// Removes list brackets, non-null markers, and whitespace.
21/// Examples:
22/// - "String!" -> "String"
23/// - "[User]" -> "User"
24/// - "[User!]!" -> "User"
25/// - "Int" -> "Int"
26fn extract_base_type(type_str: &str) -> &str {
27    let s = type_str.trim();
28
29    // Remove list brackets and non-null markers
30    let s = s.trim_start_matches('[').trim_end_matches(']');
31    let s = s.trim_end_matches('!').trim_start_matches('!');
32
33    // Handle nested cases like "[User!]!"
34    let s = s.trim_start_matches('[').trim_end_matches(']');
35    let s = s.trim_end_matches('!');
36
37    s.trim()
38}
39
40/// Check if a type is valid (either a known scalar or defined type).
41fn is_valid_type(base_type: &str, defined_types: &std::collections::HashSet<&str>) -> bool {
42    is_known_scalar(base_type) || defined_types.contains(base_type)
43}
44
45/// Schema validation error produced by [`SchemaValidator`].
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SchemaValidationError {
48    /// Error message.
49    pub message:  String,
50    /// Location in schema.
51    pub location: String,
52}
53
54/// Schema validator.
55pub struct SchemaValidator {
56    // Validator state
57}
58
59impl SchemaValidator {
60    /// Create new validator.
61    #[must_use]
62    pub const fn new() -> Self {
63        Self {}
64    }
65
66    /// Validate IR.
67    ///
68    /// # Arguments
69    ///
70    /// * `ir` - Authoring IR to validate
71    ///
72    /// # Returns
73    ///
74    /// Validated IR (potentially with transformations)
75    ///
76    /// # Errors
77    ///
78    /// Returns error if validation fails.
79    pub fn validate(&self, ir: AuthoringIR) -> Result<AuthoringIR> {
80        // Comprehensive type coverage validation for all operation types
81        // Note: validate_queries() also validates mutations and subscriptions
82        // See lines 220-260 for full validation logic
83        self.validate_types(&ir)?;
84        self.validate_queries(&ir)?;
85
86        // Analytics validation
87        if !ir.fact_tables.is_empty() {
88            self.validate_fact_tables(&ir)?;
89        }
90
91        // Validate aggregate types (regardless of fact_tables)
92        // This ensures aggregate types in the schema follow the required structure
93        self.validate_aggregate_types(&ir)?;
94
95        Ok(ir)
96    }
97
98    /// Validate type definitions.
99    fn validate_types(&self, ir: &AuthoringIR) -> Result<()> {
100        // Collect all defined type names
101        let defined_types: std::collections::HashSet<&str> =
102            ir.types.iter().map(|t| t.name.as_str()).collect();
103
104        // Validate each type
105        for ir_type in &ir.types {
106            // Validate type name is not empty
107            if ir_type.name.is_empty() {
108                return Err(FraiseQLError::Validation {
109                    message: "Type name cannot be empty".to_string(),
110                    path:    Some("types".to_string()),
111                });
112            }
113
114            // Validate field types reference valid types
115            for field in &ir_type.fields {
116                let base_type = extract_base_type(&field.field_type);
117
118                // Skip validation for list markers and check if type is valid
119                if !base_type.is_empty() && !is_valid_type(base_type, &defined_types) {
120                    return Err(FraiseQLError::Validation {
121                        message: format!(
122                            "Type '{}' field '{}' references unknown type '{}'",
123                            ir_type.name, field.name, base_type
124                        ),
125                        path:    Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
126                    });
127                }
128            }
129        }
130
131        Ok(())
132    }
133
134    /// Validate query definitions.
135    fn validate_queries(&self, ir: &AuthoringIR) -> Result<()> {
136        // Collect all defined type names
137        let defined_types: std::collections::HashSet<&str> =
138            ir.types.iter().map(|t| t.name.as_str()).collect();
139
140        // Validate each query
141        for query in &ir.queries {
142            // Validate query name is not empty
143            if query.name.is_empty() {
144                return Err(FraiseQLError::Validation {
145                    message: "Query name cannot be empty".to_string(),
146                    path:    Some("queries".to_string()),
147                });
148            }
149
150            // Validate return type exists
151            let base_type = extract_base_type(&query.return_type);
152            if !is_valid_type(base_type, &defined_types) {
153                return Err(FraiseQLError::Validation {
154                    message: format!(
155                        "Query '{}' returns unknown type '{}'",
156                        query.name, query.return_type
157                    ),
158                    path:    Some(format!("queries.{}.return_type", query.name)),
159                });
160            }
161
162            // Validate argument types
163            for arg in &query.arguments {
164                let base_type = extract_base_type(&arg.arg_type);
165                if !is_valid_type(base_type, &defined_types) {
166                    return Err(FraiseQLError::Validation {
167                        message: format!(
168                            "Query '{}' argument '{}' has unknown type '{}'",
169                            query.name, arg.name, arg.arg_type
170                        ),
171                        path:    Some(format!("queries.{}.arguments.{}", query.name, arg.name)),
172                    });
173                }
174            }
175        }
176
177        // Validate mutations
178        for mutation in &ir.mutations {
179            if mutation.name.is_empty() {
180                return Err(FraiseQLError::Validation {
181                    message: "Mutation name cannot be empty".to_string(),
182                    path:    Some("mutations".to_string()),
183                });
184            }
185
186            let base_type = extract_base_type(&mutation.return_type);
187            if !is_valid_type(base_type, &defined_types) {
188                return Err(FraiseQLError::Validation {
189                    message: format!(
190                        "Mutation '{}' returns unknown type '{}'",
191                        mutation.name, mutation.return_type
192                    ),
193                    path:    Some(format!("mutations.{}.return_type", mutation.name)),
194                });
195            }
196        }
197
198        // Validate subscriptions
199        for subscription in &ir.subscriptions {
200            if subscription.name.is_empty() {
201                return Err(FraiseQLError::Validation {
202                    message: "Subscription name cannot be empty".to_string(),
203                    path:    Some("subscriptions".to_string()),
204                });
205            }
206
207            let base_type = extract_base_type(&subscription.return_type);
208            if !is_valid_type(base_type, &defined_types) {
209                return Err(FraiseQLError::Validation {
210                    message: format!(
211                        "Subscription '{}' returns unknown type '{}'",
212                        subscription.name, subscription.return_type
213                    ),
214                    path:    Some(format!("subscriptions.{}.return_type", subscription.name)),
215                });
216            }
217        }
218
219        Ok(())
220    }
221
222    /// Validate fact table metadata structure.
223    ///
224    /// Ensures that fact table metadata follows the required structure:
225    /// - Table name uses `tf_*` prefix
226    /// - Has at least one measure
227    fn validate_fact_tables(&self, ir: &AuthoringIR) -> Result<()> {
228        for (table_name, metadata) in &ir.fact_tables {
229            // Validate table name follows tf_* pattern
230            if !table_name.starts_with("tf_") {
231                return Err(FraiseQLError::Validation {
232                    message: format!("Fact table '{}' must start with 'tf_' prefix", table_name),
233                    path:    Some(format!("fact_tables.{}", table_name)),
234                });
235            }
236
237            if metadata.measures.is_empty() {
238                return Err(FraiseQLError::Validation {
239                    message: format!("Fact table '{}' must have at least one measure", table_name),
240                    path:    Some(format!("fact_tables.{}.measures", table_name)),
241                });
242            }
243
244            // Validate dimensions name is not empty
245            if metadata.dimensions.name.is_empty() {
246                return Err(FraiseQLError::Validation {
247                    message: format!("Fact table '{}' dimensions missing 'name' field", table_name),
248                    path:    Some(format!("fact_tables.{}.dimensions", table_name)),
249                });
250            }
251        }
252
253        Ok(())
254    }
255
256    /// Validate aggregate types follow required structure.
257    ///
258    /// Aggregate types must:
259    /// - Have a `count` field (always available)
260    /// - Have measure aggregate fields (e.g., `revenue_sum`, `quantity_avg`)
261    /// - `GroupByInput` types must have Boolean fields
262    /// - `HavingInput` types must have comparison operator suffixes
263    fn validate_aggregate_types(&self, ir: &AuthoringIR) -> Result<()> {
264        // Find aggregate types (those ending with "Aggregate")
265        for ir_type in &ir.types {
266            if ir_type.name.ends_with("Aggregate") {
267                // Validate has count field
268                let has_count = ir_type.fields.iter().any(|f| f.name == "count");
269                if !has_count {
270                    return Err(FraiseQLError::Validation {
271                        message: format!(
272                            "Aggregate type '{}' must have a 'count' field",
273                            ir_type.name
274                        ),
275                        path:    Some(format!("types.{}.fields", ir_type.name)),
276                    });
277                }
278            }
279
280            // Validate GroupByInput types
281            if ir_type.name.ends_with("GroupByInput") {
282                for field in &ir_type.fields {
283                    // All fields must be Boolean type
284                    if field.field_type != "Boolean" && field.field_type != "Boolean!" {
285                        return Err(FraiseQLError::Validation {
286                            message: format!(
287                                "GroupByInput type '{}' field '{}' must be Boolean, got '{}'",
288                                ir_type.name, field.name, field.field_type
289                            ),
290                            path:    Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
291                        });
292                    }
293                }
294            }
295
296            // Validate HavingInput types
297            if ir_type.name.ends_with("HavingInput") {
298                for field in &ir_type.fields {
299                    // Field names must have operator suffixes (_eq, _gt, _gte, _lt, _lte)
300                    let valid_suffixes = ["_eq", "_neq", "_gt", "_gte", "_lt", "_lte"];
301                    let has_valid_suffix = valid_suffixes.iter().any(|s| field.name.ends_with(s));
302
303                    if !has_valid_suffix {
304                        return Err(FraiseQLError::Validation {
305                            message: format!(
306                                "HavingInput type '{}' field '{}' must have operator suffix (_eq, _neq, _gt, _gte, _lt, _lte)",
307                                ir_type.name, field.name
308                            ),
309                            path:    Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
310                        });
311                    }
312                }
313            }
314        }
315
316        Ok(())
317    }
318}
319
320impl Default for SchemaValidator {
321    fn default() -> Self {
322        Self::new()
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::{
329        super::ir::{IRField, IRType},
330        *,
331    };
332    use crate::compiler::fact_table::{DimensionColumn, FactTableMetadata, MeasureColumn, SqlType};
333
334    #[test]
335    fn test_validator_new() {
336        let validator = SchemaValidator::new();
337        let ir = AuthoringIR::new();
338        validator
339            .validate(ir)
340            .unwrap_or_else(|e| panic!("validate new IR should succeed: {e}"));
341    }
342
343    #[test]
344    fn test_validate_empty_ir() {
345        let validator = SchemaValidator::new();
346        let ir = AuthoringIR::new();
347        validator
348            .validate(ir)
349            .unwrap_or_else(|e| panic!("validate empty IR should succeed: {e}"));
350    }
351
352    fn make_fact_table(measures: Vec<MeasureColumn>, dim_name: &str) -> FactTableMetadata {
353        FactTableMetadata {
354            table_name: String::new(),
355            measures,
356            dimensions: DimensionColumn {
357                name:  dim_name.to_string(),
358                paths: vec![],
359            },
360            denormalized_filters: vec![],
361            calendar_dimensions: vec![],
362        }
363    }
364
365    #[test]
366    fn test_validate_fact_table_with_valid_metadata() {
367        let validator = SchemaValidator::new();
368        let mut ir = AuthoringIR::new();
369        ir.fact_tables.insert(
370            "tf_sales".to_string(),
371            make_fact_table(
372                vec![MeasureColumn {
373                    name:     "revenue".to_string(),
374                    sql_type: SqlType::Decimal,
375                    nullable: false,
376                }],
377                "data",
378            ),
379        );
380        validator.validate(ir).unwrap_or_else(|e| {
381            panic!("validate fact table with valid metadata should succeed: {e}")
382        });
383    }
384
385    #[test]
386    fn test_validate_fact_table_invalid_prefix() {
387        let validator = SchemaValidator::new();
388        let mut ir = AuthoringIR::new();
389        ir.fact_tables.insert(
390            "sales".to_string(),
391            make_fact_table(
392                vec![MeasureColumn {
393                    name:     "revenue".to_string(),
394                    sql_type: SqlType::Decimal,
395                    nullable: false,
396                }],
397                "data",
398            ),
399        );
400        let result = validator.validate(ir);
401        assert!(
402            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("must start with 'tf_' prefix")),
403            "expected Validation error about tf_ prefix, got: {result:?}"
404        );
405    }
406
407    #[test]
408    fn test_validate_fact_table_empty_measures() {
409        let validator = SchemaValidator::new();
410        let mut ir = AuthoringIR::new();
411        ir.fact_tables.insert("tf_sales".to_string(), make_fact_table(vec![], "data"));
412        let result = validator.validate(ir);
413        assert!(
414            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("must have at least one measure")),
415            "expected Validation error about empty measures, got: {result:?}"
416        );
417    }
418
419    #[test]
420    fn test_validate_fact_table_dimensions_missing_name() {
421        let validator = SchemaValidator::new();
422        let mut ir = AuthoringIR::new();
423        ir.fact_tables.insert(
424            "tf_sales".to_string(),
425            make_fact_table(
426                vec![MeasureColumn {
427                    name:     "revenue".to_string(),
428                    sql_type: SqlType::Decimal,
429                    nullable: false,
430                }],
431                "",
432            ),
433        );
434        let result = validator.validate(ir);
435        assert!(
436            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("dimensions missing 'name' field")),
437            "expected Validation error about missing dimensions name, got: {result:?}"
438        );
439    }
440
441    #[test]
442    fn test_validate_aggregate_type_missing_count() {
443        let validator = SchemaValidator::new();
444        let mut ir = AuthoringIR::new();
445
446        ir.types.push(IRType {
447            name:        "SalesAggregate".to_string(),
448            fields:      vec![IRField {
449                name:        "revenue_sum".to_string(),
450                field_type:  "Float".to_string(),
451                nullable:    true,
452                description: None,
453                sql_column:  None,
454            }],
455            sql_source:  None,
456            description: None,
457        });
458
459        let result = validator.validate(ir);
460        assert!(
461            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("must have a 'count' field")),
462            "expected Validation error about missing count field, got: {result:?}"
463        );
464    }
465
466    #[test]
467    fn test_validate_aggregate_type_with_count() {
468        let validator = SchemaValidator::new();
469        let mut ir = AuthoringIR::new();
470
471        ir.types.push(IRType {
472            name:        "SalesAggregate".to_string(),
473            fields:      vec![
474                IRField {
475                    name:        "count".to_string(),
476                    field_type:  "Int!".to_string(),
477                    nullable:    false,
478                    description: None,
479                    sql_column:  None,
480                },
481                IRField {
482                    name:        "revenue_sum".to_string(),
483                    field_type:  "Float".to_string(),
484                    nullable:    true,
485                    description: None,
486                    sql_column:  None,
487                },
488            ],
489            sql_source:  None,
490            description: None,
491        });
492
493        validator
494            .validate(ir)
495            .unwrap_or_else(|e| panic!("validate aggregate type with count should succeed: {e}"));
496    }
497
498    #[test]
499    fn test_validate_group_by_input_invalid_field_type() {
500        let validator = SchemaValidator::new();
501        let mut ir = AuthoringIR::new();
502
503        ir.types.push(IRType {
504            name:        "SalesGroupByInput".to_string(),
505            fields:      vec![IRField {
506                name:        "category".to_string(),
507                field_type:  "String".to_string(), // Should be Boolean
508                nullable:    true,
509                description: None,
510                sql_column:  None,
511            }],
512            sql_source:  None,
513            description: None,
514        });
515
516        let result = validator.validate(ir);
517        assert!(
518            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("must be Boolean")),
519            "expected Validation error about Boolean requirement, got: {result:?}"
520        );
521    }
522
523    #[test]
524    fn test_validate_group_by_input_valid() {
525        let validator = SchemaValidator::new();
526        let mut ir = AuthoringIR::new();
527
528        ir.types.push(IRType {
529            name:        "SalesGroupByInput".to_string(),
530            fields:      vec![IRField {
531                name:        "category".to_string(),
532                field_type:  "Boolean".to_string(),
533                nullable:    true,
534                description: None,
535                sql_column:  None,
536            }],
537            sql_source:  None,
538            description: None,
539        });
540
541        validator.validate(ir).unwrap_or_else(|e| {
542            panic!("validate group by input with Boolean fields should succeed: {e}")
543        });
544    }
545
546    #[test]
547    fn test_validate_having_input_invalid_suffix() {
548        let validator = SchemaValidator::new();
549        let mut ir = AuthoringIR::new();
550
551        ir.types.push(IRType {
552            name:        "SalesHavingInput".to_string(),
553            fields:      vec![IRField {
554                name:        "count".to_string(), // Missing operator suffix
555                field_type:  "Int".to_string(),
556                nullable:    true,
557                description: None,
558                sql_column:  None,
559            }],
560            sql_source:  None,
561            description: None,
562        });
563
564        let result = validator.validate(ir);
565        assert!(
566            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("must have operator suffix")),
567            "expected Validation error about operator suffix, got: {result:?}"
568        );
569    }
570
571    #[test]
572    fn test_validate_having_input_valid() {
573        let validator = SchemaValidator::new();
574        let mut ir = AuthoringIR::new();
575
576        ir.types.push(IRType {
577            name:        "SalesHavingInput".to_string(),
578            fields:      vec![
579                IRField {
580                    name:        "count_gt".to_string(),
581                    field_type:  "Int".to_string(),
582                    nullable:    true,
583                    description: None,
584                    sql_column:  None,
585                },
586                IRField {
587                    name:        "revenue_sum_gte".to_string(),
588                    field_type:  "Float".to_string(),
589                    nullable:    true,
590                    description: None,
591                    sql_column:  None,
592                },
593            ],
594            sql_source:  None,
595            description: None,
596        });
597
598        validator.validate(ir).unwrap_or_else(|e| {
599            panic!("validate having input with valid suffixes should succeed: {e}")
600        });
601    }
602
603    // =========================================================================
604    // Type and Query Validation Tests
605    // =========================================================================
606
607    #[test]
608    fn test_extract_base_type() {
609        assert_eq!(extract_base_type("String"), "String");
610        assert_eq!(extract_base_type("String!"), "String");
611        assert_eq!(extract_base_type("[String]"), "String");
612        assert_eq!(extract_base_type("[String!]"), "String");
613        assert_eq!(extract_base_type("[String!]!"), "String");
614        assert_eq!(extract_base_type("  User  "), "User");
615    }
616
617    #[test]
618    fn test_validate_type_with_valid_references() {
619        let validator = SchemaValidator::new();
620        let mut ir = AuthoringIR::new();
621
622        // Define User type
623        ir.types.push(IRType {
624            name:        "User".to_string(),
625            fields:      vec![
626                IRField {
627                    name:        "id".to_string(),
628                    field_type:  "ID!".to_string(),
629                    nullable:    false,
630                    description: None,
631                    sql_column:  None,
632                },
633                IRField {
634                    name:        "name".to_string(),
635                    field_type:  "String!".to_string(),
636                    nullable:    false,
637                    description: None,
638                    sql_column:  None,
639                },
640            ],
641            sql_source:  Some("v_user".to_string()),
642            description: None,
643        });
644
645        // Define Post type that references User
646        ir.types.push(IRType {
647            name:        "Post".to_string(),
648            fields:      vec![
649                IRField {
650                    name:        "id".to_string(),
651                    field_type:  "ID!".to_string(),
652                    nullable:    false,
653                    description: None,
654                    sql_column:  None,
655                },
656                IRField {
657                    name:        "author".to_string(),
658                    field_type:  "User".to_string(),
659                    nullable:    true,
660                    description: None,
661                    sql_column:  None,
662                },
663            ],
664            sql_source:  Some("v_post".to_string()),
665            description: None,
666        });
667
668        validator
669            .validate(ir)
670            .unwrap_or_else(|e| panic!("validate type with valid references should succeed: {e}"));
671    }
672
673    #[test]
674    fn test_validate_type_with_invalid_reference() {
675        let validator = SchemaValidator::new();
676        let mut ir = AuthoringIR::new();
677
678        ir.types.push(IRType {
679            name:        "Post".to_string(),
680            fields:      vec![IRField {
681                name:        "author".to_string(),
682                field_type:  "NonExistentType".to_string(),
683                nullable:    true,
684                description: None,
685                sql_column:  None,
686            }],
687            sql_source:  None,
688            description: None,
689        });
690
691        let result = validator.validate(ir);
692        assert!(
693            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("references unknown type") && message.contains("NonExistentType")),
694            "expected Validation error about unknown type reference, got: {result:?}"
695        );
696    }
697
698    #[test]
699    fn test_validate_type_empty_name() {
700        let validator = SchemaValidator::new();
701        let mut ir = AuthoringIR::new();
702
703        ir.types.push(IRType {
704            name:        String::new(),
705            fields:      vec![],
706            sql_source:  None,
707            description: None,
708        });
709
710        let result = validator.validate(ir);
711        assert!(
712            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("name cannot be empty")),
713            "expected Validation error about empty type name, got: {result:?}"
714        );
715    }
716
717    #[test]
718    fn test_validate_query_with_valid_return_type() {
719        use super::super::ir::{AutoParams, IRArgument, IRQuery};
720
721        let validator = SchemaValidator::new();
722        let mut ir = AuthoringIR::new();
723
724        // Define User type
725        ir.types.push(IRType {
726            name:        "User".to_string(),
727            fields:      vec![IRField {
728                name:        "id".to_string(),
729                field_type:  "ID!".to_string(),
730                nullable:    false,
731                description: None,
732                sql_column:  None,
733            }],
734            sql_source:  Some("v_user".to_string()),
735            description: None,
736        });
737
738        // Define query that returns User
739        ir.queries.push(IRQuery {
740            name:         "user".to_string(),
741            return_type:  "User".to_string(),
742            returns_list: false,
743            nullable:     true,
744            arguments:    vec![IRArgument {
745                name:          "id".to_string(),
746                arg_type:      "ID!".to_string(),
747                nullable:      false,
748                default_value: None,
749                description:   None,
750            }],
751            sql_source:   Some("v_user".to_string()),
752            description:  None,
753            auto_params:  AutoParams::default(),
754        });
755
756        validator.validate(ir).unwrap_or_else(|e| {
757            panic!("validate query with valid return type should succeed: {e}")
758        });
759    }
760
761    #[test]
762    fn test_validate_query_with_invalid_return_type() {
763        use super::super::ir::{AutoParams, IRQuery};
764
765        let validator = SchemaValidator::new();
766        let mut ir = AuthoringIR::new();
767
768        ir.queries.push(IRQuery {
769            name:         "unknownQuery".to_string(),
770            return_type:  "NonExistentType".to_string(),
771            returns_list: false,
772            nullable:     true,
773            arguments:    vec![],
774            sql_source:   None,
775            description:  None,
776            auto_params:  AutoParams::default(),
777        });
778
779        let result = validator.validate(ir);
780        assert!(
781            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("returns unknown type") && message.contains("NonExistentType")),
782            "expected Validation error about unknown return type, got: {result:?}"
783        );
784    }
785
786    #[test]
787    fn test_validate_query_with_scalar_return_type() {
788        use super::super::ir::{AutoParams, IRQuery};
789
790        let validator = SchemaValidator::new();
791        let mut ir = AuthoringIR::new();
792
793        // Query returning scalar type (no custom type needed)
794        ir.queries.push(IRQuery {
795            name:         "serverTime".to_string(),
796            return_type:  "DateTime".to_string(),
797            returns_list: false,
798            nullable:     false,
799            arguments:    vec![],
800            sql_source:   None,
801            description:  None,
802            auto_params:  AutoParams::default(),
803        });
804
805        validator.validate(ir).unwrap_or_else(|e| {
806            panic!("validate query with scalar return type should succeed: {e}")
807        });
808    }
809
810    #[test]
811    fn test_validate_query_empty_name() {
812        use super::super::ir::{AutoParams, IRQuery};
813
814        let validator = SchemaValidator::new();
815        let mut ir = AuthoringIR::new();
816
817        ir.queries.push(IRQuery {
818            name:         String::new(),
819            return_type:  "String".to_string(),
820            returns_list: false,
821            nullable:     true,
822            arguments:    vec![],
823            sql_source:   None,
824            description:  None,
825            auto_params:  AutoParams::default(),
826        });
827
828        let result = validator.validate(ir);
829        assert!(
830            matches!(&result, Err(FraiseQLError::Validation { message, .. }) if message.contains("Query name cannot be empty")),
831            "expected Validation error about empty query name, got: {result:?}"
832        );
833    }
834
835    #[test]
836    fn test_validate_list_type_references() {
837        let validator = SchemaValidator::new();
838        let mut ir = AuthoringIR::new();
839
840        // Define User type
841        ir.types.push(IRType {
842            name:        "User".to_string(),
843            fields:      vec![
844                IRField {
845                    name:        "id".to_string(),
846                    field_type:  "ID!".to_string(),
847                    nullable:    false,
848                    description: None,
849                    sql_column:  None,
850                },
851                IRField {
852                    name:        "friends".to_string(),
853                    field_type:  "[User!]".to_string(), // List of Users
854                    nullable:    true,
855                    description: None,
856                    sql_column:  None,
857                },
858            ],
859            sql_source:  None,
860            description: None,
861        });
862
863        validator
864            .validate(ir)
865            .unwrap_or_else(|e| panic!("validate list type references should succeed: {e}"));
866    }
867
868    #[test]
869    fn test_validate_builtin_scalar_types() {
870        let validator = SchemaValidator::new();
871        let mut ir = AuthoringIR::new();
872
873        // Test all builtin scalars are recognized in type fields
874        ir.types.push(IRType {
875            name:        "TestType".to_string(),
876            fields:      vec![
877                IRField {
878                    name:        "id".to_string(),
879                    field_type:  "ID".to_string(),
880                    nullable:    true,
881                    description: None,
882                    sql_column:  None,
883                },
884                IRField {
885                    name:        "name".to_string(),
886                    field_type:  "String".to_string(),
887                    nullable:    true,
888                    description: None,
889                    sql_column:  None,
890                },
891                IRField {
892                    name:        "age".to_string(),
893                    field_type:  "Int".to_string(),
894                    nullable:    true,
895                    description: None,
896                    sql_column:  None,
897                },
898                IRField {
899                    name:        "rating".to_string(),
900                    field_type:  "Float".to_string(),
901                    nullable:    true,
902                    description: None,
903                    sql_column:  None,
904                },
905                IRField {
906                    name:        "active".to_string(),
907                    field_type:  "Boolean".to_string(),
908                    nullable:    true,
909                    description: None,
910                    sql_column:  None,
911                },
912                IRField {
913                    name:        "created".to_string(),
914                    field_type:  "DateTime".to_string(),
915                    nullable:    true,
916                    description: None,
917                    sql_column:  None,
918                },
919                IRField {
920                    name:        "uid".to_string(),
921                    field_type:  "UUID".to_string(),
922                    nullable:    true,
923                    description: None,
924                    sql_column:  None,
925                },
926            ],
927            sql_source:  None,
928            description: None,
929        });
930
931        validator
932            .validate(ir)
933            .unwrap_or_else(|e| panic!("all builtin scalars should be recognized: {e}"));
934    }
935
936    #[test]
937    fn test_validate_rich_scalar_types() {
938        let validator = SchemaValidator::new();
939        let mut ir = AuthoringIR::new();
940
941        // Test some rich scalars are recognized
942        ir.types.push(IRType {
943            name:        "Contact".to_string(),
944            fields:      vec![
945                IRField {
946                    name:        "email".to_string(),
947                    field_type:  "Email".to_string(),
948                    nullable:    true,
949                    description: None,
950                    sql_column:  None,
951                },
952                IRField {
953                    name:        "phone".to_string(),
954                    field_type:  "PhoneNumber".to_string(),
955                    nullable:    true,
956                    description: None,
957                    sql_column:  None,
958                },
959                IRField {
960                    name:        "url".to_string(),
961                    field_type:  "URL".to_string(),
962                    nullable:    true,
963                    description: None,
964                    sql_column:  None,
965                },
966                IRField {
967                    name:        "ip".to_string(),
968                    field_type:  "IPAddress".to_string(),
969                    nullable:    true,
970                    description: None,
971                    sql_column:  None,
972                },
973            ],
974            sql_source:  None,
975            description: None,
976        });
977
978        validator
979            .validate(ir)
980            .unwrap_or_else(|e| panic!("rich scalars should be recognized: {e}"));
981    }
982}