Skip to main content

fraiseql_core/validation/
compile_time.rs

1//! Compile-time validation for cross-field rules and schema consistency.
2//!
3//! This module validates Elo expressions at schema compilation time, ensuring:
4//! - Field references exist and are properly typed
5//! - Cross-field rules reference compatible types
6//! - SQL constraints can be generated
7//! - No circular dependencies or invalid rules
8//!
9//! Elo is an expression language by Bernard Lambeau: <https://elo-lang.org/>
10
11use std::collections::{HashMap, HashSet};
12
13/// Schema context for compile-time validation
14#[derive(Debug, Clone)]
15pub struct SchemaContext {
16    /// Type definitions: `type_name` -> fields
17    pub types:  HashMap<String, TypeDef>,
18    /// Field types: (`type_name`, `field_name`) -> `field_type`
19    pub fields: HashMap<(String, String), FieldType>,
20}
21
22/// Type definition
23#[derive(Debug, Clone)]
24pub struct TypeDef {
25    /// Name of the GraphQL type.
26    pub name:   String,
27    /// Names of the fields declared on this type.
28    pub fields: Vec<String>,
29}
30
31/// Field type information
32#[derive(Debug, Clone, PartialEq, Eq, Hash)]
33#[non_exhaustive]
34pub enum FieldType {
35    /// UTF-8 text field.
36    String,
37    /// 64-bit signed integer field.
38    Integer,
39    /// 64-bit floating-point field.
40    Float,
41    /// Boolean field.
42    Boolean,
43    /// Calendar date without time.
44    Date,
45    /// Timestamp with timezone.
46    DateTime,
47    /// User-defined scalar type; the inner string holds the type name.
48    Custom(String),
49}
50
51impl FieldType {
52    /// Check if two types are comparable
53    #[must_use]
54    pub fn is_comparable_with(&self, other: &FieldType) -> bool {
55        match (self, other) {
56            // Same types are always comparable
57            (a, b) if a == b => true,
58            // Numeric types are comparable with each other; date/datetime are interchangeable
59            (FieldType::Integer, FieldType::Float)
60            | (FieldType::Float, FieldType::Integer)
61            | (FieldType::Date, FieldType::DateTime)
62            | (FieldType::DateTime, FieldType::Date) => true,
63            // Everything else is not comparable
64            _ => false,
65        }
66    }
67}
68
69/// Compile-time validation result
70#[derive(Debug, Clone)]
71pub struct CompileTimeValidationResult {
72    /// Whether the rule is valid.
73    pub valid:          bool,
74    /// All errors found during validation.
75    pub errors:         Vec<CompileTimeError>,
76    /// Non-fatal warnings from validation.
77    pub warnings:       Vec<String>,
78    /// SQL constraint expression derived from the rule, if generation succeeded.
79    pub sql_constraint: Option<String>,
80}
81
82/// Compile-time validation error
83#[derive(Debug, Clone)]
84pub struct CompileTimeError {
85    /// Field path where the error occurred.
86    pub field:      String,
87    /// Human-readable error description.
88    pub message:    String,
89    /// Optional suggestion for how to fix the error.
90    pub suggestion: Option<String>,
91}
92
93/// Compile-time validator for cross-field rules
94#[derive(Debug)]
95pub struct CompileTimeValidator {
96    context: SchemaContext,
97}
98
99impl CompileTimeValidator {
100    /// Create a new compile-time validator
101    #[must_use]
102    pub const fn new(context: SchemaContext) -> Self {
103        Self { context }
104    }
105
106    /// Validate a cross-field rule
107    #[must_use]
108    pub fn validate_cross_field_rule(
109        &self,
110        type_name: &str,
111        left_field: &str,
112        operator: &str,
113        right_field: &str,
114    ) -> CompileTimeValidationResult {
115        let mut errors = Vec::new();
116        let warnings = Vec::new();
117
118        // Check if type exists
119        if !self.context.types.contains_key(type_name) {
120            return CompileTimeValidationResult {
121                valid: false,
122                errors: vec![CompileTimeError {
123                    field:      type_name.to_string(),
124                    message:    format!("Type '{}' not found in schema", type_name),
125                    suggestion: Some("Check that the type is defined".to_string()),
126                }],
127                warnings,
128                sql_constraint: None,
129            };
130        }
131
132        // Check if left field exists
133        let left_key = (type_name.to_string(), left_field.to_string());
134        let Some(left_type) = self.context.fields.get(&left_key) else {
135            errors.push(CompileTimeError {
136                field:      left_field.to_string(),
137                message:    format!("Field '{}' not found in type '{}'", left_field, type_name),
138                suggestion: Some(self.suggest_field(type_name, left_field)),
139            });
140            return CompileTimeValidationResult {
141                valid: false,
142                errors,
143                warnings,
144                sql_constraint: None,
145            };
146        };
147
148        // Check if right field exists
149        let right_key = (type_name.to_string(), right_field.to_string());
150        let Some(right_type) = self.context.fields.get(&right_key) else {
151            errors.push(CompileTimeError {
152                field:      right_field.to_string(),
153                message:    format!("Field '{}' not found in type '{}'", right_field, type_name),
154                suggestion: Some(self.suggest_field(type_name, right_field)),
155            });
156            return CompileTimeValidationResult {
157                valid: false,
158                errors,
159                warnings,
160                sql_constraint: None,
161            };
162        };
163
164        // Check if types are comparable
165        if !left_type.is_comparable_with(right_type) {
166            errors.push(CompileTimeError {
167                field:      format!("{} {} {}", left_field, operator, right_field),
168                message:    format!("Cannot compare {:?} with {:?}", left_type, right_type),
169                suggestion: Some("Ensure both fields have comparable types".to_string()),
170            });
171            return CompileTimeValidationResult {
172                valid: false,
173                errors,
174                warnings,
175                sql_constraint: None,
176            };
177        }
178
179        // Generate SQL constraint
180        let sql_constraint = self.generate_sql_constraint(
181            type_name,
182            left_field,
183            operator,
184            right_field,
185            left_type,
186            right_type,
187        );
188
189        CompileTimeValidationResult {
190            valid: true,
191            errors,
192            warnings,
193            sql_constraint,
194        }
195    }
196
197    /// Validate an ELO expression at compile time
198    #[must_use]
199    pub fn validate_elo_expression(
200        &self,
201        type_name: &str,
202        expression: &str,
203    ) -> CompileTimeValidationResult {
204        let mut errors = Vec::new();
205        let warnings = Vec::new();
206
207        // Check if type exists
208        if !self.context.types.contains_key(type_name) {
209            return CompileTimeValidationResult {
210                valid: false,
211                errors: vec![CompileTimeError {
212                    field:      type_name.to_string(),
213                    message:    format!("Type '{}' not found in schema", type_name),
214                    suggestion: None,
215                }],
216                warnings,
217                sql_constraint: None,
218            };
219        }
220
221        // Extract field references from expression
222        let field_refs = self.extract_field_references(expression);
223
224        // Validate each field reference
225        for field_name in field_refs {
226            let field_key = (type_name.to_string(), field_name.clone());
227            if !self.context.fields.contains_key(&field_key) {
228                errors.push(CompileTimeError {
229                    field:      field_name.clone(),
230                    message:    format!("Field '{}' not found in type '{}'", field_name, type_name),
231                    suggestion: Some(self.suggest_field(type_name, &field_name)),
232                });
233            }
234        }
235
236        // Check for valid operators
237        let valid_operators = vec!["<", ">", "<=", ">=", "==", "!=", "&&", "||", "!"];
238        for op in valid_operators {
239            if expression.contains(op) {
240                // Operator found and is valid
241            }
242        }
243
244        CompileTimeValidationResult {
245            valid: errors.is_empty(),
246            errors,
247            warnings,
248            sql_constraint: None,
249        }
250    }
251
252    /// Extract field references from an expression
253    pub(crate) fn extract_field_references(&self, expression: &str) -> Vec<String> {
254        let mut fields = HashSet::new();
255        let mut tokens = Vec::new();
256        let mut current_token = String::new();
257        let mut in_string = false;
258        let mut string_char = ' ';
259        let mut escape = false;
260
261        // First pass: tokenize the expression, respecting quotes
262        for ch in expression.chars() {
263            // Handle escape sequences
264            if escape {
265                escape = false;
266                current_token.push(ch);
267                continue;
268            }
269
270            if ch == '\\' && in_string {
271                escape = true;
272                current_token.push(ch);
273                continue;
274            }
275
276            // Track if we're inside a quoted string
277            if !in_string && (ch == '"' || ch == '\'') {
278                in_string = true;
279                string_char = ch;
280                current_token.push(ch);
281            } else if in_string && ch == string_char {
282                in_string = false;
283                current_token.push(ch);
284            } else if !in_string && (ch.is_whitespace() || ch == '(' || ch == ')') {
285                if !current_token.is_empty() {
286                    tokens.push(current_token.clone());
287                    current_token.clear();
288                }
289            } else {
290                current_token.push(ch);
291            }
292        }
293
294        if !current_token.is_empty() {
295            tokens.push(current_token);
296        }
297
298        // Second pass: extract field references from tokens
299        let infix_operators = ["matches", "in", "contains"];
300
301        for (i, token) in tokens.iter().enumerate() {
302            // Skip quoted strings
303            if token.starts_with('"') || token.starts_with('\'') {
304                continue;
305            }
306
307            // Skip if this token is an infix operator
308            if infix_operators.contains(&token.as_str()) {
309                continue;
310            }
311
312            // Skip if the previous token was an infix operator (it's the RHS of the operator)
313            if i > 0 && infix_operators.contains(&tokens[i - 1].as_str()) {
314                continue;
315            }
316
317            // Skip reserved keywords
318            if token == "true"
319                || token == "false"
320                || token == "null"
321                || token == "and"
322                || token == "or"
323                || token == "not"
324            {
325                continue;
326            }
327
328            // Skip if starts with uppercase (likely type names, not field references)
329            if token.chars().next().is_some_and(|ch| ch.is_uppercase()) {
330                continue;
331            }
332
333            // Extract field references (lowercase identifiers)
334            if token.chars().next().is_some_and(|ch| ch.is_lowercase()) {
335                fields.insert(token.clone());
336            }
337        }
338
339        fields.into_iter().collect()
340    }
341
342    /// Generate SQL constraint from cross-field rule
343    fn generate_sql_constraint(
344        &self,
345        _type_name: &str,
346        left_field: &str,
347        operator: &str,
348        right_field: &str,
349        left_type: &FieldType,
350        _right_type: &FieldType,
351    ) -> Option<String> {
352        // Map ELO operators to SQL operators
353        let sql_op = match operator {
354            "<" | "lt" => "<",
355            "<=" | "lte" => "<=",
356            ">" | "gt" => ">",
357            ">=" | "gte" => ">=",
358            "==" | "eq" => "=",
359            "!=" | "neq" => "!=",
360            _ => return None,
361        };
362
363        // Build constraint based on field type, quoting column names to avoid SQL injection.
364        let left_quoted = format!("\"{}\"", left_field.replace('"', "\"\""));
365        let right_quoted = format!("\"{}\"", right_field.replace('"', "\"\""));
366        let constraint = match left_type {
367            FieldType::Date
368            | FieldType::DateTime
369            | FieldType::Integer
370            | FieldType::Float
371            | FieldType::String => {
372                format!("CHECK ({} {} {})", left_quoted, sql_op, right_quoted)
373            },
374            _ => return None,
375        };
376
377        Some(constraint)
378    }
379
380    /// Suggest a field name if typo is likely
381    fn suggest_field(&self, type_name: &str, _attempted_field: &str) -> String {
382        let Some(type_def) = self.context.types.get(type_name) else {
383            return "Check schema definition".to_string();
384        };
385
386        // Simple suggestion: show available fields
387        let available = type_def.fields.join(", ");
388        format!("Available fields: {}", available)
389    }
390}