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/// Validation error.
46#[derive(Debug, Clone, PartialEq)]
47pub struct ValidationError {
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 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    /// - Has table_name field
226    /// - Has measures array (at least one measure)
227    /// - Has dimensions object
228    /// - Denormalized filters are valid
229    fn validate_fact_tables(&self, ir: &AuthoringIR) -> Result<()> {
230        for (table_name, metadata) in &ir.fact_tables {
231            // Validate table name follows tf_* pattern
232            if !table_name.starts_with("tf_") {
233                return Err(FraiseQLError::Validation {
234                    message: format!("Fact table '{}' must start with 'tf_' prefix", table_name),
235                    path:    Some(format!("fact_tables.{}", table_name)),
236                });
237            }
238
239            // Validate metadata is an object
240            let obj = metadata.as_object().ok_or_else(|| FraiseQLError::Validation {
241                message: format!("Fact table '{}' metadata must be an object", table_name),
242                path:    Some(format!("fact_tables.{}", table_name)),
243            })?;
244
245            // Validate measures exist and is an array
246            let measures = obj.get("measures").ok_or_else(|| FraiseQLError::Validation {
247                message: format!("Fact table '{}' missing 'measures' field", table_name),
248                path:    Some(format!("fact_tables.{}.measures", table_name)),
249            })?;
250
251            let measures_arr = measures.as_array().ok_or_else(|| FraiseQLError::Validation {
252                message: format!("Fact table '{}' measures must be an array", table_name),
253                path:    Some(format!("fact_tables.{}.measures", table_name)),
254            })?;
255
256            if measures_arr.is_empty() {
257                return Err(FraiseQLError::Validation {
258                    message: format!("Fact table '{}' must have at least one measure", table_name),
259                    path:    Some(format!("fact_tables.{}.measures", table_name)),
260                });
261            }
262
263            // Validate each measure has required fields
264            for (idx, measure) in measures_arr.iter().enumerate() {
265                let measure_obj = measure.as_object().ok_or_else(|| FraiseQLError::Validation {
266                    message: format!(
267                        "Fact table '{}' measure {} must be an object",
268                        table_name, idx
269                    ),
270                    path:    Some(format!("fact_tables.{}.measures[{}]", table_name, idx)),
271                })?;
272
273                // Validate measure has name field
274                if !measure_obj.contains_key("name") {
275                    return Err(FraiseQLError::Validation {
276                        message: format!(
277                            "Fact table '{}' measure {} missing 'name' field",
278                            table_name, idx
279                        ),
280                        path:    Some(format!("fact_tables.{}.measures[{}]", table_name, idx)),
281                    });
282                }
283
284                // Validate measure has sql_type field
285                if !measure_obj.contains_key("sql_type") {
286                    return Err(FraiseQLError::Validation {
287                        message: format!(
288                            "Fact table '{}' measure {} missing 'sql_type' field",
289                            table_name, idx
290                        ),
291                        path:    Some(format!("fact_tables.{}.measures[{}]", table_name, idx)),
292                    });
293                }
294            }
295
296            // Validate dimensions exist
297            let dimensions = obj.get("dimensions").ok_or_else(|| FraiseQLError::Validation {
298                message: format!("Fact table '{}' missing 'dimensions' field", table_name),
299                path:    Some(format!("fact_tables.{}.dimensions", table_name)),
300            })?;
301
302            let dimensions_obj =
303                dimensions.as_object().ok_or_else(|| FraiseQLError::Validation {
304                    message: format!("Fact table '{}' dimensions must be an object", table_name),
305                    path:    Some(format!("fact_tables.{}.dimensions", table_name)),
306                })?;
307
308            // Validate dimension has name field
309            if !dimensions_obj.contains_key("name") {
310                return Err(FraiseQLError::Validation {
311                    message: format!("Fact table '{}' dimensions missing 'name' field", table_name),
312                    path:    Some(format!("fact_tables.{}.dimensions", table_name)),
313                });
314            }
315
316            // Validate denormalized_filters is an array (if present)
317            if let Some(filters) = obj.get("denormalized_filters") {
318                if !filters.is_array() {
319                    return Err(FraiseQLError::Validation {
320                        message: format!(
321                            "Fact table '{}' denormalized_filters must be an array",
322                            table_name
323                        ),
324                        path:    Some(format!("fact_tables.{}.denormalized_filters", table_name)),
325                    });
326                }
327            }
328        }
329
330        Ok(())
331    }
332
333    /// Validate aggregate types follow required structure.
334    ///
335    /// Aggregate types must:
336    /// - Have a `count` field (always available)
337    /// - Have measure aggregate fields (e.g., revenue_sum, quantity_avg)
338    /// - GroupByInput types must have Boolean fields
339    /// - HavingInput types must have comparison operator suffixes
340    fn validate_aggregate_types(&self, ir: &AuthoringIR) -> Result<()> {
341        // Find aggregate types (those ending with "Aggregate")
342        for ir_type in &ir.types {
343            if ir_type.name.ends_with("Aggregate") {
344                // Validate has count field
345                let has_count = ir_type.fields.iter().any(|f| f.name == "count");
346                if !has_count {
347                    return Err(FraiseQLError::Validation {
348                        message: format!(
349                            "Aggregate type '{}' must have a 'count' field",
350                            ir_type.name
351                        ),
352                        path:    Some(format!("types.{}.fields", ir_type.name)),
353                    });
354                }
355            }
356
357            // Validate GroupByInput types
358            if ir_type.name.ends_with("GroupByInput") {
359                for field in &ir_type.fields {
360                    // All fields must be Boolean type
361                    if field.field_type != "Boolean" && field.field_type != "Boolean!" {
362                        return Err(FraiseQLError::Validation {
363                            message: format!(
364                                "GroupByInput type '{}' field '{}' must be Boolean, got '{}'",
365                                ir_type.name, field.name, field.field_type
366                            ),
367                            path:    Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
368                        });
369                    }
370                }
371            }
372
373            // Validate HavingInput types
374            if ir_type.name.ends_with("HavingInput") {
375                for field in &ir_type.fields {
376                    // Field names must have operator suffixes (_eq, _gt, _gte, _lt, _lte)
377                    let valid_suffixes = ["_eq", "_neq", "_gt", "_gte", "_lt", "_lte"];
378                    let has_valid_suffix = valid_suffixes.iter().any(|s| field.name.ends_with(s));
379
380                    if !has_valid_suffix {
381                        return Err(FraiseQLError::Validation {
382                            message: format!(
383                                "HavingInput type '{}' field '{}' must have operator suffix (_eq, _neq, _gt, _gte, _lt, _lte)",
384                                ir_type.name, field.name
385                            ),
386                            path:    Some(format!("types.{}.fields.{}", ir_type.name, field.name)),
387                        });
388                    }
389                }
390            }
391        }
392
393        Ok(())
394    }
395}
396
397impl Default for SchemaValidator {
398    fn default() -> Self {
399        Self::new()
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use serde_json::json;
406
407    use super::{
408        super::ir::{IRField, IRType},
409        *,
410    };
411
412    #[test]
413    fn test_validator_new() {
414        let validator = SchemaValidator::new();
415        let ir = AuthoringIR::new();
416        let result = validator.validate(ir.clone());
417        assert!(result.is_ok());
418    }
419
420    #[test]
421    fn test_validate_empty_ir() {
422        let validator = SchemaValidator::new();
423        let ir = AuthoringIR::new();
424        let result = validator.validate(ir);
425        assert!(result.is_ok());
426    }
427
428    #[test]
429    fn test_validate_fact_table_with_valid_metadata() {
430        let validator = SchemaValidator::new();
431        let mut ir = AuthoringIR::new();
432
433        let metadata = json!({
434            "table_name": "tf_sales",
435            "measures": [
436                {"name": "revenue", "sql_type": "Decimal", "nullable": false}
437            ],
438            "dimensions": {
439                "name": "data",
440                "paths": []
441            },
442            "denormalized_filters": []
443        });
444
445        ir.fact_tables.insert("tf_sales".to_string(), metadata);
446
447        let result = validator.validate(ir);
448        assert!(result.is_ok());
449    }
450
451    #[test]
452    fn test_validate_fact_table_invalid_prefix() {
453        let validator = SchemaValidator::new();
454        let mut ir = AuthoringIR::new();
455
456        let metadata = json!({
457            "measures": [{"name": "revenue", "sql_type": "Decimal"}],
458            "dimensions": {"name": "data"}
459        });
460
461        ir.fact_tables.insert("sales".to_string(), metadata);
462
463        let result = validator.validate(ir);
464        assert!(result.is_err());
465        if let Err(FraiseQLError::Validation { message, .. }) = result {
466            assert!(message.contains("must start with 'tf_' prefix"));
467        }
468    }
469
470    #[test]
471    fn test_validate_fact_table_missing_measures() {
472        let validator = SchemaValidator::new();
473        let mut ir = AuthoringIR::new();
474
475        let metadata = json!({
476            "dimensions": {"name": "data"}
477        });
478
479        ir.fact_tables.insert("tf_sales".to_string(), metadata);
480
481        let result = validator.validate(ir);
482        assert!(result.is_err());
483        if let Err(FraiseQLError::Validation { message, .. }) = result {
484            assert!(message.contains("missing 'measures' field"));
485        }
486    }
487
488    #[test]
489    fn test_validate_fact_table_empty_measures() {
490        let validator = SchemaValidator::new();
491        let mut ir = AuthoringIR::new();
492
493        let metadata = json!({
494            "measures": [],
495            "dimensions": {"name": "data"}
496        });
497
498        ir.fact_tables.insert("tf_sales".to_string(), metadata);
499
500        let result = validator.validate(ir);
501        assert!(result.is_err());
502        if let Err(FraiseQLError::Validation { message, .. }) = result {
503            assert!(message.contains("must have at least one measure"));
504        }
505    }
506
507    #[test]
508    fn test_validate_fact_table_measure_missing_name() {
509        let validator = SchemaValidator::new();
510        let mut ir = AuthoringIR::new();
511
512        let metadata = json!({
513            "measures": [
514                {"sql_type": "Decimal"}
515            ],
516            "dimensions": {"name": "data"}
517        });
518
519        ir.fact_tables.insert("tf_sales".to_string(), metadata);
520
521        let result = validator.validate(ir);
522        assert!(result.is_err());
523        if let Err(FraiseQLError::Validation { message, .. }) = result {
524            assert!(message.contains("missing 'name' field"));
525        }
526    }
527
528    #[test]
529    fn test_validate_fact_table_measure_missing_sql_type() {
530        let validator = SchemaValidator::new();
531        let mut ir = AuthoringIR::new();
532
533        let metadata = json!({
534            "measures": [
535                {"name": "revenue"}
536            ],
537            "dimensions": {"name": "data"}
538        });
539
540        ir.fact_tables.insert("tf_sales".to_string(), metadata);
541
542        let result = validator.validate(ir);
543        assert!(result.is_err());
544        if let Err(FraiseQLError::Validation { message, .. }) = result {
545            assert!(message.contains("missing 'sql_type' field"));
546        }
547    }
548
549    #[test]
550    fn test_validate_fact_table_missing_dimensions() {
551        let validator = SchemaValidator::new();
552        let mut ir = AuthoringIR::new();
553
554        let metadata = json!({
555            "measures": [
556                {"name": "revenue", "sql_type": "Decimal"}
557            ]
558        });
559
560        ir.fact_tables.insert("tf_sales".to_string(), metadata);
561
562        let result = validator.validate(ir);
563        assert!(result.is_err());
564        if let Err(FraiseQLError::Validation { message, .. }) = result {
565            assert!(message.contains("missing 'dimensions' field"));
566        }
567    }
568
569    #[test]
570    fn test_validate_fact_table_dimensions_missing_name() {
571        let validator = SchemaValidator::new();
572        let mut ir = AuthoringIR::new();
573
574        let metadata = json!({
575            "measures": [
576                {"name": "revenue", "sql_type": "Decimal"}
577            ],
578            "dimensions": {
579                "paths": []
580            }
581        });
582
583        ir.fact_tables.insert("tf_sales".to_string(), metadata);
584
585        let result = validator.validate(ir);
586        assert!(result.is_err());
587        if let Err(FraiseQLError::Validation { message, .. }) = result {
588            assert!(message.contains("dimensions missing 'name' field"));
589        }
590    }
591
592    #[test]
593    fn test_validate_fact_table_invalid_filters() {
594        let validator = SchemaValidator::new();
595        let mut ir = AuthoringIR::new();
596
597        let metadata = json!({
598            "measures": [
599                {"name": "revenue", "sql_type": "Decimal"}
600            ],
601            "dimensions": {"name": "data"},
602            "denormalized_filters": "not an array"
603        });
604
605        ir.fact_tables.insert("tf_sales".to_string(), metadata);
606
607        let result = validator.validate(ir);
608        assert!(result.is_err());
609        if let Err(FraiseQLError::Validation { message, .. }) = result {
610            assert!(message.contains("denormalized_filters must be an array"));
611        }
612    }
613
614    #[test]
615    fn test_validate_aggregate_type_missing_count() {
616        let validator = SchemaValidator::new();
617        let mut ir = AuthoringIR::new();
618
619        ir.types.push(IRType {
620            name:        "SalesAggregate".to_string(),
621            fields:      vec![IRField {
622                name:        "revenue_sum".to_string(),
623                field_type:  "Float".to_string(),
624                nullable:    true,
625                description: None,
626                sql_column:  None,
627            }],
628            sql_source:  None,
629            description: None,
630        });
631
632        let result = validator.validate(ir);
633        assert!(result.is_err());
634        if let Err(FraiseQLError::Validation { message, .. }) = result {
635            assert!(message.contains("must have a 'count' field"));
636        }
637    }
638
639    #[test]
640    fn test_validate_aggregate_type_with_count() {
641        let validator = SchemaValidator::new();
642        let mut ir = AuthoringIR::new();
643
644        ir.types.push(IRType {
645            name:        "SalesAggregate".to_string(),
646            fields:      vec![
647                IRField {
648                    name:        "count".to_string(),
649                    field_type:  "Int!".to_string(),
650                    nullable:    false,
651                    description: None,
652                    sql_column:  None,
653                },
654                IRField {
655                    name:        "revenue_sum".to_string(),
656                    field_type:  "Float".to_string(),
657                    nullable:    true,
658                    description: None,
659                    sql_column:  None,
660                },
661            ],
662            sql_source:  None,
663            description: None,
664        });
665
666        let result = validator.validate(ir);
667        assert!(result.is_ok());
668    }
669
670    #[test]
671    fn test_validate_group_by_input_invalid_field_type() {
672        let validator = SchemaValidator::new();
673        let mut ir = AuthoringIR::new();
674
675        ir.types.push(IRType {
676            name:        "SalesGroupByInput".to_string(),
677            fields:      vec![IRField {
678                name:        "category".to_string(),
679                field_type:  "String".to_string(), // Should be Boolean
680                nullable:    true,
681                description: None,
682                sql_column:  None,
683            }],
684            sql_source:  None,
685            description: None,
686        });
687
688        let result = validator.validate(ir);
689        assert!(result.is_err());
690        if let Err(FraiseQLError::Validation { message, .. }) = result {
691            assert!(message.contains("must be Boolean"));
692        }
693    }
694
695    #[test]
696    fn test_validate_group_by_input_valid() {
697        let validator = SchemaValidator::new();
698        let mut ir = AuthoringIR::new();
699
700        ir.types.push(IRType {
701            name:        "SalesGroupByInput".to_string(),
702            fields:      vec![IRField {
703                name:        "category".to_string(),
704                field_type:  "Boolean".to_string(),
705                nullable:    true,
706                description: None,
707                sql_column:  None,
708            }],
709            sql_source:  None,
710            description: None,
711        });
712
713        let result = validator.validate(ir);
714        assert!(result.is_ok());
715    }
716
717    #[test]
718    fn test_validate_having_input_invalid_suffix() {
719        let validator = SchemaValidator::new();
720        let mut ir = AuthoringIR::new();
721
722        ir.types.push(IRType {
723            name:        "SalesHavingInput".to_string(),
724            fields:      vec![IRField {
725                name:        "count".to_string(), // Missing operator suffix
726                field_type:  "Int".to_string(),
727                nullable:    true,
728                description: None,
729                sql_column:  None,
730            }],
731            sql_source:  None,
732            description: None,
733        });
734
735        let result = validator.validate(ir);
736        assert!(result.is_err());
737        if let Err(FraiseQLError::Validation { message, .. }) = result {
738            assert!(message.contains("must have operator suffix"));
739        }
740    }
741
742    #[test]
743    fn test_validate_having_input_valid() {
744        let validator = SchemaValidator::new();
745        let mut ir = AuthoringIR::new();
746
747        ir.types.push(IRType {
748            name:        "SalesHavingInput".to_string(),
749            fields:      vec![
750                IRField {
751                    name:        "count_gt".to_string(),
752                    field_type:  "Int".to_string(),
753                    nullable:    true,
754                    description: None,
755                    sql_column:  None,
756                },
757                IRField {
758                    name:        "revenue_sum_gte".to_string(),
759                    field_type:  "Float".to_string(),
760                    nullable:    true,
761                    description: None,
762                    sql_column:  None,
763                },
764            ],
765            sql_source:  None,
766            description: None,
767        });
768
769        let result = validator.validate(ir);
770        assert!(result.is_ok());
771    }
772
773    // =========================================================================
774    // Type and Query Validation Tests
775    // =========================================================================
776
777    #[test]
778    fn test_extract_base_type() {
779        assert_eq!(extract_base_type("String"), "String");
780        assert_eq!(extract_base_type("String!"), "String");
781        assert_eq!(extract_base_type("[String]"), "String");
782        assert_eq!(extract_base_type("[String!]"), "String");
783        assert_eq!(extract_base_type("[String!]!"), "String");
784        assert_eq!(extract_base_type("  User  "), "User");
785    }
786
787    #[test]
788    fn test_validate_type_with_valid_references() {
789        let validator = SchemaValidator::new();
790        let mut ir = AuthoringIR::new();
791
792        // Define User type
793        ir.types.push(IRType {
794            name:        "User".to_string(),
795            fields:      vec![
796                IRField {
797                    name:        "id".to_string(),
798                    field_type:  "ID!".to_string(),
799                    nullable:    false,
800                    description: None,
801                    sql_column:  None,
802                },
803                IRField {
804                    name:        "name".to_string(),
805                    field_type:  "String!".to_string(),
806                    nullable:    false,
807                    description: None,
808                    sql_column:  None,
809                },
810            ],
811            sql_source:  Some("v_user".to_string()),
812            description: None,
813        });
814
815        // Define Post type that references User
816        ir.types.push(IRType {
817            name:        "Post".to_string(),
818            fields:      vec![
819                IRField {
820                    name:        "id".to_string(),
821                    field_type:  "ID!".to_string(),
822                    nullable:    false,
823                    description: None,
824                    sql_column:  None,
825                },
826                IRField {
827                    name:        "author".to_string(),
828                    field_type:  "User".to_string(),
829                    nullable:    true,
830                    description: None,
831                    sql_column:  None,
832                },
833            ],
834            sql_source:  Some("v_post".to_string()),
835            description: None,
836        });
837
838        let result = validator.validate(ir);
839        assert!(result.is_ok());
840    }
841
842    #[test]
843    fn test_validate_type_with_invalid_reference() {
844        let validator = SchemaValidator::new();
845        let mut ir = AuthoringIR::new();
846
847        ir.types.push(IRType {
848            name:        "Post".to_string(),
849            fields:      vec![IRField {
850                name:        "author".to_string(),
851                field_type:  "NonExistentType".to_string(),
852                nullable:    true,
853                description: None,
854                sql_column:  None,
855            }],
856            sql_source:  None,
857            description: None,
858        });
859
860        let result = validator.validate(ir);
861        assert!(result.is_err());
862        if let Err(FraiseQLError::Validation { message, .. }) = result {
863            assert!(message.contains("references unknown type"));
864            assert!(message.contains("NonExistentType"));
865        }
866    }
867
868    #[test]
869    fn test_validate_type_empty_name() {
870        let validator = SchemaValidator::new();
871        let mut ir = AuthoringIR::new();
872
873        ir.types.push(IRType {
874            name:        String::new(),
875            fields:      vec![],
876            sql_source:  None,
877            description: None,
878        });
879
880        let result = validator.validate(ir);
881        assert!(result.is_err());
882        if let Err(FraiseQLError::Validation { message, .. }) = result {
883            assert!(message.contains("name cannot be empty"));
884        }
885    }
886
887    #[test]
888    fn test_validate_query_with_valid_return_type() {
889        use super::super::ir::{AutoParams, IRArgument, IRQuery};
890
891        let validator = SchemaValidator::new();
892        let mut ir = AuthoringIR::new();
893
894        // Define User type
895        ir.types.push(IRType {
896            name:        "User".to_string(),
897            fields:      vec![IRField {
898                name:        "id".to_string(),
899                field_type:  "ID!".to_string(),
900                nullable:    false,
901                description: None,
902                sql_column:  None,
903            }],
904            sql_source:  Some("v_user".to_string()),
905            description: None,
906        });
907
908        // Define query that returns User
909        ir.queries.push(IRQuery {
910            name:         "user".to_string(),
911            return_type:  "User".to_string(),
912            returns_list: false,
913            nullable:     true,
914            arguments:    vec![IRArgument {
915                name:          "id".to_string(),
916                arg_type:      "ID!".to_string(),
917                nullable:      false,
918                default_value: None,
919                description:   None,
920            }],
921            sql_source:   Some("v_user".to_string()),
922            description:  None,
923            auto_params:  AutoParams::default(),
924        });
925
926        let result = validator.validate(ir);
927        assert!(result.is_ok());
928    }
929
930    #[test]
931    fn test_validate_query_with_invalid_return_type() {
932        use super::super::ir::{AutoParams, IRQuery};
933
934        let validator = SchemaValidator::new();
935        let mut ir = AuthoringIR::new();
936
937        ir.queries.push(IRQuery {
938            name:         "unknownQuery".to_string(),
939            return_type:  "NonExistentType".to_string(),
940            returns_list: false,
941            nullable:     true,
942            arguments:    vec![],
943            sql_source:   None,
944            description:  None,
945            auto_params:  AutoParams::default(),
946        });
947
948        let result = validator.validate(ir);
949        assert!(result.is_err());
950        if let Err(FraiseQLError::Validation { message, .. }) = result {
951            assert!(message.contains("returns unknown type"));
952            assert!(message.contains("NonExistentType"));
953        }
954    }
955
956    #[test]
957    fn test_validate_query_with_scalar_return_type() {
958        use super::super::ir::{AutoParams, IRQuery};
959
960        let validator = SchemaValidator::new();
961        let mut ir = AuthoringIR::new();
962
963        // Query returning scalar type (no custom type needed)
964        ir.queries.push(IRQuery {
965            name:         "serverTime".to_string(),
966            return_type:  "DateTime".to_string(),
967            returns_list: false,
968            nullable:     false,
969            arguments:    vec![],
970            sql_source:   None,
971            description:  None,
972            auto_params:  AutoParams::default(),
973        });
974
975        let result = validator.validate(ir);
976        assert!(result.is_ok());
977    }
978
979    #[test]
980    fn test_validate_query_empty_name() {
981        use super::super::ir::{AutoParams, IRQuery};
982
983        let validator = SchemaValidator::new();
984        let mut ir = AuthoringIR::new();
985
986        ir.queries.push(IRQuery {
987            name:         String::new(),
988            return_type:  "String".to_string(),
989            returns_list: false,
990            nullable:     true,
991            arguments:    vec![],
992            sql_source:   None,
993            description:  None,
994            auto_params:  AutoParams::default(),
995        });
996
997        let result = validator.validate(ir);
998        assert!(result.is_err());
999        if let Err(FraiseQLError::Validation { message, .. }) = result {
1000            assert!(message.contains("Query name cannot be empty"));
1001        }
1002    }
1003
1004    #[test]
1005    fn test_validate_list_type_references() {
1006        let validator = SchemaValidator::new();
1007        let mut ir = AuthoringIR::new();
1008
1009        // Define User type
1010        ir.types.push(IRType {
1011            name:        "User".to_string(),
1012            fields:      vec![
1013                IRField {
1014                    name:        "id".to_string(),
1015                    field_type:  "ID!".to_string(),
1016                    nullable:    false,
1017                    description: None,
1018                    sql_column:  None,
1019                },
1020                IRField {
1021                    name:        "friends".to_string(),
1022                    field_type:  "[User!]".to_string(), // List of Users
1023                    nullable:    true,
1024                    description: None,
1025                    sql_column:  None,
1026                },
1027            ],
1028            sql_source:  None,
1029            description: None,
1030        });
1031
1032        let result = validator.validate(ir);
1033        assert!(result.is_ok());
1034    }
1035
1036    #[test]
1037    fn test_validate_builtin_scalar_types() {
1038        let validator = SchemaValidator::new();
1039        let mut ir = AuthoringIR::new();
1040
1041        // Test all builtin scalars are recognized in type fields
1042        ir.types.push(IRType {
1043            name:        "TestType".to_string(),
1044            fields:      vec![
1045                IRField {
1046                    name:        "id".to_string(),
1047                    field_type:  "ID".to_string(),
1048                    nullable:    true,
1049                    description: None,
1050                    sql_column:  None,
1051                },
1052                IRField {
1053                    name:        "name".to_string(),
1054                    field_type:  "String".to_string(),
1055                    nullable:    true,
1056                    description: None,
1057                    sql_column:  None,
1058                },
1059                IRField {
1060                    name:        "age".to_string(),
1061                    field_type:  "Int".to_string(),
1062                    nullable:    true,
1063                    description: None,
1064                    sql_column:  None,
1065                },
1066                IRField {
1067                    name:        "rating".to_string(),
1068                    field_type:  "Float".to_string(),
1069                    nullable:    true,
1070                    description: None,
1071                    sql_column:  None,
1072                },
1073                IRField {
1074                    name:        "active".to_string(),
1075                    field_type:  "Boolean".to_string(),
1076                    nullable:    true,
1077                    description: None,
1078                    sql_column:  None,
1079                },
1080                IRField {
1081                    name:        "created".to_string(),
1082                    field_type:  "DateTime".to_string(),
1083                    nullable:    true,
1084                    description: None,
1085                    sql_column:  None,
1086                },
1087                IRField {
1088                    name:        "uid".to_string(),
1089                    field_type:  "UUID".to_string(),
1090                    nullable:    true,
1091                    description: None,
1092                    sql_column:  None,
1093                },
1094            ],
1095            sql_source:  None,
1096            description: None,
1097        });
1098
1099        let result = validator.validate(ir);
1100        assert!(result.is_ok(), "All builtin scalars should be recognized");
1101    }
1102
1103    #[test]
1104    fn test_validate_rich_scalar_types() {
1105        let validator = SchemaValidator::new();
1106        let mut ir = AuthoringIR::new();
1107
1108        // Test some rich scalars are recognized
1109        ir.types.push(IRType {
1110            name:        "Contact".to_string(),
1111            fields:      vec![
1112                IRField {
1113                    name:        "email".to_string(),
1114                    field_type:  "Email".to_string(),
1115                    nullable:    true,
1116                    description: None,
1117                    sql_column:  None,
1118                },
1119                IRField {
1120                    name:        "phone".to_string(),
1121                    field_type:  "PhoneNumber".to_string(),
1122                    nullable:    true,
1123                    description: None,
1124                    sql_column:  None,
1125                },
1126                IRField {
1127                    name:        "url".to_string(),
1128                    field_type:  "URL".to_string(),
1129                    nullable:    true,
1130                    description: None,
1131                    sql_column:  None,
1132                },
1133                IRField {
1134                    name:        "ip".to_string(),
1135                    field_type:  "IPAddress".to_string(),
1136                    nullable:    true,
1137                    description: None,
1138                    sql_column:  None,
1139                },
1140            ],
1141            sql_source:  None,
1142            description: None,
1143        });
1144
1145        let result = validator.validate(ir);
1146        assert!(result.is_ok(), "Rich scalars should be recognized");
1147    }
1148}