fraiseql-core 2.2.0

Core execution engine for FraiseQL v2 - Compiled GraphQL over SQL
Documentation
//! JavaScript code generation from ELO validation expressions.
//!
//! Translates ELO expressions into JavaScript validator functions that can be
//! used for client-side validation, ensuring the same rules apply on both the
//! server (Rust) and the client (browser/Node.js).
//!
//! # Example
//!
//! ```
//! use fraiseql_core::validation::js_codegen::JsCodegen;
//!
//! let codegen = JsCodegen::new();
//! let js = codegen.emit_validator("User", "age >= 18 && length(email) > 0");
//! assert!(js.contains("function validate_User"));
//! ```

/// JavaScript code generator for ELO expressions.
#[derive(Debug, Clone, Default)]
pub struct JsCodegen {
    _private: (),
}

impl JsCodegen {
    /// Create a new JavaScript code generator.
    #[must_use]
    pub const fn new() -> Self {
        Self { _private: () }
    }

    /// Emit a JavaScript validator function for a single type + expression.
    ///
    /// Returns a self-contained ES module `export function validate_<Type>(data)`.
    #[must_use]
    pub fn emit_validator(&self, type_name: &str, expression: &str) -> String {
        let js_expr = self.elo_to_js(expression);
        format!(
            "export function validate_{type_name}(data) {{\n\
             \x20 const errors = [];\n\
             \x20 if (!({js_expr})) errors.push({{ field: null, rule: {rule_str} }});\n\
             \x20 return {{ valid: errors.length === 0, errors }};\n\
             }}",
            rule_str = Self::js_string_literal(expression),
        )
    }

    /// Emit a JavaScript module with validators for multiple types.
    ///
    /// Each entry is `(type_name, elo_expression)`.
    #[must_use]
    pub fn emit_module(&self, validators: &[(&str, &str)]) -> String {
        let mut parts = Vec::with_capacity(validators.len() + 1);
        parts.push("// Generated by FraiseQL — do not edit manually.".to_string());

        for (type_name, expression) in validators {
            parts.push(String::new());
            parts.push(self.emit_validator(type_name, expression));
        }

        parts.join("\n")
    }

    /// Translate an ELO expression to a JavaScript boolean expression.
    #[must_use]
    pub fn elo_to_js(&self, expression: &str) -> String {
        let expr = expression.trim();

        // Handle logical AND
        if let Some(idx) = find_op_outside_parens(expr, "&&") {
            let left = self.elo_to_js(&expr[..idx]);
            let right = self.elo_to_js(&expr[idx + 2..]);
            return format!("({left} && {right})");
        }

        // Handle logical OR
        if let Some(idx) = find_op_outside_parens(expr, "||") {
            let left = self.elo_to_js(&expr[..idx]);
            let right = self.elo_to_js(&expr[idx + 2..]);
            return format!("({left} || {right})");
        }

        // Handle negation
        if let Some(inner) = expr.strip_prefix('!') {
            let inner_js = self.elo_to_js(inner.trim());
            return format!("!{inner_js}");
        }

        // Strip matching outer parentheses
        if expr.starts_with('(') && expr.ends_with(')') && has_matching_parens(expr) {
            let inner = &expr[1..expr.len() - 1];
            let inner_js = self.elo_to_js(inner);
            return format!("({inner_js})");
        }

        // Handle comparison operators
        for op in &["<=", ">=", "!=", "==", "<", ">"] {
            if let Some(idx) = expr.find(op) {
                let left = self.elo_operand_to_js(expr[..idx].trim());
                let right = self.elo_operand_to_js(expr[idx + op.len()..].trim());
                let js_op = match *op {
                    "==" => "===",
                    "!=" => "!==",
                    other => other,
                };
                return format!("{left} {js_op} {right}");
            }
        }

        // Standalone function call or value
        self.elo_operand_to_js(expr)
    }

    /// Translate an operand (field, literal, function) to JS.
    fn elo_operand_to_js(&self, operand: &str) -> String {
        let operand = operand.trim();

        // Numeric literal
        if operand.parse::<f64>().is_ok() {
            return operand.to_string();
        }

        // Boolean literal
        if operand == "true" || operand == "false" {
            return operand.to_string();
        }

        // Null literal
        if operand == "null" {
            return "null".to_string();
        }

        // String literal — pass through
        if (operand.starts_with('"') && operand.ends_with('"'))
            || (operand.starts_with('\'') && operand.ends_with('\''))
        {
            return operand.to_string();
        }

        // Function calls
        if let Some(rest) = operand.strip_prefix("length(") {
            if let Some(field) = rest.strip_suffix(')') {
                let field = field.trim();
                return format!("(data.{field} || '').length");
            }
        }

        if let Some(rest) = operand.strip_prefix("age(") {
            if let Some(field) = rest.strip_suffix(')') {
                let field = field.trim();
                // Inline age calculation in JS
                return format!(
                    "(() => {{ \
                        const b = new Date(data.{field}); \
                        const t = new Date(); \
                        let a = t.getFullYear() - b.getFullYear(); \
                        if (t.getMonth() < b.getMonth() || \
                            (t.getMonth() === b.getMonth() && t.getDate() < b.getDate())) a--; \
                        return a; \
                    }})()"
                );
            }
        }

        if let Some(rest) = operand.strip_prefix("matches(") {
            if let Some(args) = rest.strip_suffix(')') {
                let parts: Vec<&str> = args.splitn(2, ',').collect();
                if parts.len() == 2 {
                    let field = parts[0].trim();
                    let pattern = parts[1].trim();
                    return format!("new RegExp({pattern}).test(data.{field})");
                }
            }
        }

        if let Some(rest) = operand.strip_prefix("contains(") {
            if let Some(args) = rest.strip_suffix(')') {
                let parts: Vec<&str> = args.splitn(2, ',').collect();
                if parts.len() == 2 {
                    let field = parts[0].trim();
                    let needle = parts[1].trim();
                    return format!("(data.{field} || '').includes({needle})");
                }
            }
        }

        // Field reference (dot notation supported)
        format!("data.{operand}")
    }

    /// Produce a JS string literal (double-quoted, escaped).
    fn js_string_literal(s: &str) -> String {
        let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
        format!("\"{escaped}\"")
    }
}

/// Find a two-char operator outside of parentheses and quotes.
fn find_op_outside_parens(expr: &str, op: &str) -> Option<usize> {
    let mut depth = 0i32;
    let mut in_string = false;
    let bytes = expr.as_bytes();

    for i in 0..bytes.len() {
        let ch = bytes[i];
        if ch == b'"' || ch == b'\'' {
            in_string = !in_string;
            continue;
        }
        if in_string {
            continue;
        }
        if ch == b'(' {
            depth += 1;
        } else if ch == b')' {
            depth -= 1;
        } else if depth == 0 && i + op.len() <= bytes.len() && &expr[i..i + op.len()] == op {
            return Some(i);
        }
    }

    None
}

/// Check if the outer parens of `expr` match (i.e. `(...)` not `(...) op (...)`).
fn has_matching_parens(expr: &str) -> bool {
    let mut depth = 0i32;
    for (i, ch) in expr.chars().enumerate() {
        match ch {
            '(' => depth += 1,
            ')' => {
                depth -= 1;
                if depth == 0 && i < expr.len() - 1 {
                    return false;
                }
            },
            _ => {},
        }
    }
    depth == 0
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)] // Reason: test code, panics are acceptable

    use super::*;

    #[test]
    fn test_simple_comparison() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("age >= 18");
        assert_eq!(js, "data.age >= 18");
    }

    #[test]
    fn test_equality_uses_strict() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("role == \"admin\"");
        assert_eq!(js, "data.role === \"admin\"");
    }

    #[test]
    fn test_inequality_uses_strict() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("role != \"banned\"");
        assert_eq!(js, "data.role !== \"banned\"");
    }

    #[test]
    fn test_and_operator() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("age >= 18 && verified == true");
        assert_eq!(js, "(data.age >= 18 && data.verified === true)");
    }

    #[test]
    fn test_or_operator() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("role == \"admin\" || role == \"mod\"");
        assert_eq!(js, "(data.role === \"admin\" || data.role === \"mod\")");
    }

    #[test]
    fn test_length_function() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("length(name) <= 255");
        assert_eq!(js, "(data.name || '').length <= 255");
    }

    #[test]
    fn test_contains_function() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("contains(email, \"@\")");
        assert_eq!(js, "(data.email || '').includes(\"@\")");
    }

    #[test]
    fn test_matches_function() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("matches(email, \"^.+@.+$\")");
        assert_eq!(js, "new RegExp(\"^.+@.+$\").test(data.email)");
    }

    #[test]
    fn test_age_function() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("age(birthDate) >= 18");
        assert!(js.contains("getFullYear"));
        assert!(js.contains(">= 18"));
    }

    #[test]
    fn test_emit_validator() {
        let cg = JsCodegen::new();
        let js = cg.emit_validator("User", "age >= 18");
        assert!(js.contains("export function validate_User(data)"));
        assert!(js.contains("data.age >= 18"));
        assert!(js.contains("errors.push"));
    }

    #[test]
    fn test_emit_module() {
        let cg = JsCodegen::new();
        let js = cg.emit_module(&[("User", "age >= 18"), ("Order", "total > 0")]);
        assert!(js.contains("validate_User"));
        assert!(js.contains("validate_Order"));
        assert!(js.contains("// Generated by FraiseQL"));
    }

    #[test]
    fn test_negation() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("!(verified == false)");
        assert!(js.contains('!'));
    }

    #[test]
    fn test_boolean_literal() {
        let cg = JsCodegen::new();
        let js = cg.elo_to_js("verified == true");
        assert_eq!(js, "data.verified === true");
    }
}