lodviz_core 0.3.0

Core visualization primitives and data structures for lodviz
Documentation
/// Formula engine for calculated fields
///
/// Provides Excel-like formula evaluation for creating derived columns
/// from existing DataTable data.
///
/// # Supported Features
/// - Arithmetic: `+`, `-`, `*`, `/`, `%`, `^` (power)
/// - Comparisons: `==`, `!=`, `<`, `>`, `<=`, `>=`
/// - Logical: `&&`, `||`, `!`
/// - Functions: `min`, `max`, `floor`, `ceil`, `round`, `abs`, `len`, `str::concat`, `if`
/// - String operations: `+` for concatenation
/// - Column references: Use column names directly as variables
///
/// # Examples
///
/// ```rust,ignore
/// use lodviz_core::core::formulas::eval_formula;
/// use lodviz_core::core::field_value::{DataRow, FieldValue};
///
/// let mut row = DataRow::new();
/// row.insert("price".to_string(), FieldValue::Numeric(100.0));
/// row.insert("quantity".to_string(), FieldValue::Numeric(5.0));
/// row.insert("tax_rate".to_string(), FieldValue::Numeric(0.2));
///
/// // Calculate total with tax
/// let result = eval_formula("price * quantity * (1 + tax_rate)", &row);
/// assert_eq!(result.unwrap(), FieldValue::Numeric(600.0));
///
/// // Conditional logic
/// let result = eval_formula("if(quantity > 10, \"Bulk\", \"Retail\")", &row);
/// assert_eq!(result.unwrap(), FieldValue::Text("Retail".to_string()));
/// ```
use crate::core::field_value::{DataRow, FieldValue};
use evalexpr::{ContextWithMutableVariables, HashMapContext, Value as EvalValue};

/// Evaluate a formula expression against a DataRow
///
/// Returns the computed `FieldValue` or an error string if evaluation fails.
///
/// # Formula Syntax
/// - Column names are referenced as variables: `Revenue - Cost`
/// - Arithmetic: `price * 1.2`, `quantity / 2`
/// - Comparisons: `value > 100`, `status == "Active"`
/// - Conditionals: `if(condition, true_value, false_value)`
/// - String concat: `first_name + " " + last_name`
///
/// # Errors
/// - Missing column references
/// - Type mismatches
/// - Syntax errors in formula
/// - Division by zero
pub fn eval_formula(formula: &str, row: &DataRow) -> Result<FieldValue, String> {
    let mut context = HashMapContext::new();

    // Populate context with row values
    for (col_name, field_value) in row {
        let eval_val = field_value_to_eval(field_value);
        context
            .set_value(col_name.clone(), eval_val)
            .map_err(|e| format!("Failed to set variable '{}': {}", col_name, e))?;
    }

    // Evaluate expression
    let result = evalexpr::eval_with_context(formula, &context)
        .map_err(|e| format!("Formula evaluation error: {}", e))?;

    // Convert result back to FieldValue
    eval_to_field_value(&result)
}

/// Convert FieldValue to evalexpr Value
fn field_value_to_eval(field: &FieldValue) -> EvalValue {
    match field {
        FieldValue::Numeric(n) => EvalValue::Float(*n),
        FieldValue::Timestamp(t) => EvalValue::Float(*t),
        FieldValue::Text(s) => EvalValue::String(s.clone()),
        FieldValue::Bool(b) => EvalValue::Boolean(*b),
        FieldValue::Null => EvalValue::Empty,
    }
}

/// Convert evalexpr Value to FieldValue
fn eval_to_field_value(val: &EvalValue) -> Result<FieldValue, String> {
    match val {
        EvalValue::Float(f) => Ok(FieldValue::Numeric(*f)),
        EvalValue::Int(i) => Ok(FieldValue::Numeric(*i as f64)),
        EvalValue::String(s) => Ok(FieldValue::Text(s.clone())),
        EvalValue::Boolean(b) => Ok(FieldValue::Bool(*b)),
        EvalValue::Empty => Ok(FieldValue::Null),
        EvalValue::Tuple(_) => Err("Tuple results are not supported".to_string()),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn sample_row() -> DataRow {
        let mut row = HashMap::new();
        row.insert("price".to_string(), FieldValue::Numeric(100.0));
        row.insert("quantity".to_string(), FieldValue::Numeric(5.0));
        row.insert("tax_rate".to_string(), FieldValue::Numeric(0.2));
        row.insert("category".to_string(), FieldValue::Text("A".to_string()));
        row.insert("active".to_string(), FieldValue::Bool(true));
        row
    }

    #[test]
    fn test_arithmetic() {
        let row = sample_row();

        // Basic arithmetic
        let result = eval_formula("price * quantity", &row).unwrap();
        assert_eq!(result, FieldValue::Numeric(500.0));

        // With parentheses
        let result = eval_formula("price * quantity * (1 + tax_rate)", &row).unwrap();
        assert_eq!(result, FieldValue::Numeric(600.0));

        // Division
        let result = eval_formula("price / 2", &row).unwrap();
        assert_eq!(result, FieldValue::Numeric(50.0));
    }

    #[test]
    fn test_comparison() {
        let row = sample_row();

        // Greater than
        let result = eval_formula("quantity > 3", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(true));

        // Equal
        let result = eval_formula("category == \"A\"", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(true));

        // Not equal
        let result = eval_formula("price != 99", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(true));
    }

    #[test]
    fn test_logical() {
        let row = sample_row();

        // AND
        let result = eval_formula("active && quantity > 3", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(true));

        // OR
        let result = eval_formula("quantity < 1 || active", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(true));

        // NOT
        let result = eval_formula("!active", &row).unwrap();
        assert_eq!(result, FieldValue::Bool(false));
    }

    #[test]
    fn test_string_concat() {
        let mut row = HashMap::new();
        row.insert(
            "first_name".to_string(),
            FieldValue::Text("John".to_string()),
        );
        row.insert("last_name".to_string(), FieldValue::Text("Doe".to_string()));

        let result = eval_formula("first_name + \" \" + last_name", &row).unwrap();
        assert_eq!(result, FieldValue::Text("John Doe".to_string()));
    }

    #[test]
    fn test_missing_column() {
        let row = sample_row();

        let result = eval_formula("nonexistent_column * 2", &row);
        assert!(result.is_err());
        assert!(result.unwrap_err().contains("evaluation error"));
    }

    #[test]
    fn test_syntax_error() {
        let row = sample_row();

        let result = eval_formula("price * * quantity", &row);
        assert!(result.is_err());
    }

    #[test]
    fn test_type_coercion() {
        let row = sample_row();

        // Boolean to number in arithmetic context (evalexpr handles this)
        let result = eval_formula("if(active, 1, 0)", &row).unwrap();
        assert_eq!(result, FieldValue::Numeric(1.0));
    }

    #[test]
    fn test_conditional_if() {
        let row = sample_row();

        // Numeric result
        let result = eval_formula("if(quantity > 10, 100, 50)", &row).unwrap();
        assert_eq!(result, FieldValue::Numeric(50.0));

        // String result
        let result = eval_formula("if(quantity > 10, \"Bulk\", \"Retail\")", &row).unwrap();
        assert_eq!(result, FieldValue::Text("Retail".to_string()));
    }

    #[test]
    fn test_complex_formula() {
        let row = sample_row();

        // Revenue calculation with discount and tax
        let result = eval_formula(
            "price * quantity * (1 - if(quantity > 10, 0.1, 0)) * (1 + tax_rate)",
            &row,
        )
        .unwrap();
        assert_eq!(result, FieldValue::Numeric(600.0)); // No discount since qty <= 10
    }
}