fraiseql-core 2.10.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! Compile-time validation for cross-field rules and schema consistency.
//!
//! This module validates Elo expressions at schema compilation time, ensuring:
//! - Field references exist and are properly typed
//! - Cross-field rules reference compatible types
//! - SQL constraints can be generated
//! - No circular dependencies or invalid rules
//!
//! Elo is an expression language by Bernard Lambeau: <https://elo-lang.org/>

use std::collections::{HashMap, HashSet};

/// Schema context for compile-time validation
#[derive(Debug, Clone)]
pub struct SchemaContext {
    /// Type definitions: `type_name` -> fields
    pub types:  HashMap<String, TypeDef>,
    /// Field types: (`type_name`, `field_name`) -> `field_type`
    pub fields: HashMap<(String, String), FieldType>,
}

/// Type definition
#[derive(Debug, Clone)]
pub struct TypeDef {
    /// Name of the GraphQL type.
    pub name:   String,
    /// Names of the fields declared on this type.
    pub fields: Vec<String>,
}

/// Field type information
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum FieldType {
    /// UTF-8 text field.
    String,
    /// 64-bit signed integer field.
    Integer,
    /// 64-bit floating-point field.
    Float,
    /// Boolean field.
    Boolean,
    /// Calendar date without time.
    Date,
    /// Timestamp with timezone.
    DateTime,
    /// User-defined scalar type; the inner string holds the type name.
    Custom(String),
}

impl FieldType {
    /// Check if two types are comparable
    #[must_use]
    pub fn is_comparable_with(&self, other: &FieldType) -> bool {
        match (self, other) {
            // Same types are always comparable
            (a, b) if a == b => true,
            // Numeric types are comparable with each other; date/datetime are interchangeable
            (FieldType::Integer, FieldType::Float)
            | (FieldType::Float, FieldType::Integer)
            | (FieldType::Date, FieldType::DateTime)
            | (FieldType::DateTime, FieldType::Date) => true,
            // Everything else is not comparable
            _ => false,
        }
    }
}

/// Compile-time validation result
#[derive(Debug, Clone)]
pub struct CompileTimeValidationResult {
    /// Whether the rule is valid.
    pub valid:          bool,
    /// All errors found during validation.
    pub errors:         Vec<CompileTimeError>,
    /// Non-fatal warnings from validation.
    pub warnings:       Vec<String>,
    /// SQL constraint expression derived from the rule, if generation succeeded.
    pub sql_constraint: Option<String>,
}

/// Compile-time validation error
#[derive(Debug, Clone)]
pub struct CompileTimeError {
    /// Field path where the error occurred.
    pub field:      String,
    /// Human-readable error description.
    pub message:    String,
    /// Optional suggestion for how to fix the error.
    pub suggestion: Option<String>,
}

/// Compile-time validator for cross-field rules
#[derive(Debug)]
pub struct CompileTimeValidator {
    context: SchemaContext,
}

impl CompileTimeValidator {
    /// Create a new compile-time validator
    #[must_use]
    pub const fn new(context: SchemaContext) -> Self {
        Self { context }
    }

    /// Validate a cross-field rule
    #[must_use]
    pub fn validate_cross_field_rule(
        &self,
        type_name: &str,
        left_field: &str,
        operator: &str,
        right_field: &str,
    ) -> CompileTimeValidationResult {
        let mut errors = Vec::new();
        let warnings = Vec::new();

        // Check if type exists
        if !self.context.types.contains_key(type_name) {
            return CompileTimeValidationResult {
                valid: false,
                errors: vec![CompileTimeError {
                    field:      type_name.to_string(),
                    message:    format!("Type '{}' not found in schema", type_name),
                    suggestion: Some("Check that the type is defined".to_string()),
                }],
                warnings,
                sql_constraint: None,
            };
        }

        // Check if left field exists
        let left_key = (type_name.to_string(), left_field.to_string());
        let Some(left_type) = self.context.fields.get(&left_key) else {
            errors.push(CompileTimeError {
                field:      left_field.to_string(),
                message:    format!("Field '{}' not found in type '{}'", left_field, type_name),
                suggestion: Some(self.suggest_field(type_name, left_field)),
            });
            return CompileTimeValidationResult {
                valid: false,
                errors,
                warnings,
                sql_constraint: None,
            };
        };

        // Check if right field exists
        let right_key = (type_name.to_string(), right_field.to_string());
        let Some(right_type) = self.context.fields.get(&right_key) else {
            errors.push(CompileTimeError {
                field:      right_field.to_string(),
                message:    format!("Field '{}' not found in type '{}'", right_field, type_name),
                suggestion: Some(self.suggest_field(type_name, right_field)),
            });
            return CompileTimeValidationResult {
                valid: false,
                errors,
                warnings,
                sql_constraint: None,
            };
        };

        // Check if types are comparable
        if !left_type.is_comparable_with(right_type) {
            errors.push(CompileTimeError {
                field:      format!("{} {} {}", left_field, operator, right_field),
                message:    format!("Cannot compare {:?} with {:?}", left_type, right_type),
                suggestion: Some("Ensure both fields have comparable types".to_string()),
            });
            return CompileTimeValidationResult {
                valid: false,
                errors,
                warnings,
                sql_constraint: None,
            };
        }

        // Generate SQL constraint
        let sql_constraint = self.generate_sql_constraint(
            type_name,
            left_field,
            operator,
            right_field,
            left_type,
            right_type,
        );

        CompileTimeValidationResult {
            valid: true,
            errors,
            warnings,
            sql_constraint,
        }
    }

    /// Validate an ELO expression at compile time
    #[must_use]
    pub fn validate_elo_expression(
        &self,
        type_name: &str,
        expression: &str,
    ) -> CompileTimeValidationResult {
        let mut errors = Vec::new();
        let warnings = Vec::new();

        // Check if type exists
        if !self.context.types.contains_key(type_name) {
            return CompileTimeValidationResult {
                valid: false,
                errors: vec![CompileTimeError {
                    field:      type_name.to_string(),
                    message:    format!("Type '{}' not found in schema", type_name),
                    suggestion: None,
                }],
                warnings,
                sql_constraint: None,
            };
        }

        // Extract field references from expression
        let field_refs = self.extract_field_references(expression);

        // Validate each field reference
        for field_name in field_refs {
            let field_key = (type_name.to_string(), field_name.clone());
            if !self.context.fields.contains_key(&field_key) {
                errors.push(CompileTimeError {
                    field:      field_name.clone(),
                    message:    format!("Field '{}' not found in type '{}'", field_name, type_name),
                    suggestion: Some(self.suggest_field(type_name, &field_name)),
                });
            }
        }

        // Check for valid operators
        let valid_operators = vec!["<", ">", "<=", ">=", "==", "!=", "&&", "||", "!"];
        for op in valid_operators {
            if expression.contains(op) {
                // Operator found and is valid
            }
        }

        CompileTimeValidationResult {
            valid: errors.is_empty(),
            errors,
            warnings,
            sql_constraint: None,
        }
    }

    /// Extract field references from an expression
    pub(crate) fn extract_field_references(&self, expression: &str) -> Vec<String> {
        let mut fields = HashSet::new();
        let mut tokens = Vec::new();
        let mut current_token = String::new();
        let mut in_string = false;
        let mut string_char = ' ';
        let mut escape = false;

        // First pass: tokenize the expression, respecting quotes
        for ch in expression.chars() {
            // Handle escape sequences
            if escape {
                escape = false;
                current_token.push(ch);
                continue;
            }

            if ch == '\\' && in_string {
                escape = true;
                current_token.push(ch);
                continue;
            }

            // Track if we're inside a quoted string
            if !in_string && (ch == '"' || ch == '\'') {
                in_string = true;
                string_char = ch;
                current_token.push(ch);
            } else if in_string && ch == string_char {
                in_string = false;
                current_token.push(ch);
            } else if !in_string && (ch.is_whitespace() || ch == '(' || ch == ')') {
                if !current_token.is_empty() {
                    tokens.push(current_token.clone());
                    current_token.clear();
                }
            } else {
                current_token.push(ch);
            }
        }

        if !current_token.is_empty() {
            tokens.push(current_token);
        }

        // Second pass: extract field references from tokens
        let infix_operators = ["matches", "in", "contains"];

        for (i, token) in tokens.iter().enumerate() {
            // Skip quoted strings
            if token.starts_with('"') || token.starts_with('\'') {
                continue;
            }

            // Skip if this token is an infix operator
            if infix_operators.contains(&token.as_str()) {
                continue;
            }

            // Skip if the previous token was an infix operator (it's the RHS of the operator)
            if i > 0 && infix_operators.contains(&tokens[i - 1].as_str()) {
                continue;
            }

            // Skip reserved keywords
            if token == "true"
                || token == "false"
                || token == "null"
                || token == "and"
                || token == "or"
                || token == "not"
            {
                continue;
            }

            // Skip if starts with uppercase (likely type names, not field references)
            if token.chars().next().is_some_and(|ch| ch.is_uppercase()) {
                continue;
            }

            // Extract field references (lowercase identifiers)
            if token.chars().next().is_some_and(|ch| ch.is_lowercase()) {
                fields.insert(token.clone());
            }
        }

        fields.into_iter().collect()
    }

    /// Generate SQL constraint from cross-field rule
    fn generate_sql_constraint(
        &self,
        _type_name: &str,
        left_field: &str,
        operator: &str,
        right_field: &str,
        left_type: &FieldType,
        _right_type: &FieldType,
    ) -> Option<String> {
        // Map ELO operators to SQL operators
        let sql_op = match operator {
            "<" | "lt" => "<",
            "<=" | "lte" => "<=",
            ">" | "gt" => ">",
            ">=" | "gte" => ">=",
            "==" | "eq" => "=",
            "!=" | "neq" => "!=",
            _ => return None,
        };

        // Build constraint based on field type, quoting column names to avoid SQL injection.
        let left_quoted = format!("\"{}\"", left_field.replace('"', "\"\""));
        let right_quoted = format!("\"{}\"", right_field.replace('"', "\"\""));
        let constraint = match left_type {
            FieldType::Date
            | FieldType::DateTime
            | FieldType::Integer
            | FieldType::Float
            | FieldType::String => {
                format!("CHECK ({} {} {})", left_quoted, sql_op, right_quoted)
            },
            _ => return None,
        };

        Some(constraint)
    }

    /// Suggest a field name if typo is likely
    fn suggest_field(&self, type_name: &str, _attempted_field: &str) -> String {
        let Some(type_def) = self.context.types.get(type_name) else {
            return "Check schema definition".to_string();
        };

        // Simple suggestion: show available fields
        let available = type_def.fields.join(", ");
        format!("Available fields: {}", available)
    }
}