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"
26pub(crate) fn 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}