Files
dreport/layout-engine/src/expr_eval.rs

393 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use dexpr::ast::value::Value as DexprValue;
use dexpr::compiler::Compiler;
use dexpr::vm::VM;
use serde_json::Value;
/// Expression evaluator for calculated_text elements using dexpr engine.
/// Supports arithmetic, string ops, comparisons, conditionals, methods, and more.
///
/// Data JSON's top-level keys are set as global variables in dexpr.
/// Expressions like `firma.unvan` or `toplamlar.kdv + toplamlar.araToplam` work directly.
pub fn evaluate_expression(expr: &str, data: &Value) -> String {
if expr.is_empty() {
return String::new();
}
let mut compiler = Compiler::new();
let bytecode = match compiler.compile_from_source(expr) {
Ok((bc, _)) => bc,
Err(_) => return String::new(),
};
let mut vm = VM::new(&bytecode);
// Set each top-level key in data as a dexpr global
if let Value::Object(map) = data {
for (key, val) in map {
if let Ok(dval) = DexprValue::from_json_value(val) {
vm.set_global(key, dval);
}
}
}
match vm.execute() {
Ok(result) => dexpr_value_to_string(&result),
Err(_) => String::new(),
}
}
/// Convert dexpr Value to display string
fn dexpr_value_to_string(val: &DexprValue) -> String {
match val {
DexprValue::Null => String::new(),
DexprValue::Boolean(b) => b.to_string(),
DexprValue::Number(n) => {
// Format: no trailing zeros for integers
if n.scale() == 0 {
n.to_string()
} else {
n.normalize().to_string()
}
}
DexprValue::String(s) => s.to_string(),
DexprValue::NumberList(list) => {
let items: Vec<String> = list.iter().map(|n| n.to_string()).collect();
format!("[{}]", items.join(", "))
}
DexprValue::StringList(list) => {
let items: Vec<String> = list.iter().map(|s| s.to_string()).collect();
format!("[{}]", items.join(", "))
}
DexprValue::Object(map) => {
let items: Vec<String> = map
.iter()
.map(|(k, v)| format!("{}: {}", k, dexpr_value_to_string(v)))
.collect();
format!("{{{}}}", items.join(", "))
}
DexprValue::List(list) => {
let items: Vec<String> = list.iter().map(|v| dexpr_value_to_string(v)).collect();
format!("[{}]", items.join(", "))
}
}
}
/// Format result with given format type (varsayılan Türk formatı)
pub fn apply_format(value: &str, format: Option<&str>) -> String {
apply_format_with_config(
value,
format,
&dreport_core::models::FormatConfig::default(),
)
}
/// Format result with given format type and config
pub fn apply_format_with_config(
value: &str,
format: Option<&str>,
config: &dreport_core::models::FormatConfig,
) -> String {
match format {
Some("currency") => format_currency(value, config),
Some("percentage") => format_percentage(value),
Some("number") => format_number_str(value, config),
_ => value.to_string(),
}
}
fn format_currency(value: &str, config: &dreport_core::models::FormatConfig) -> String {
use dexpr::Decimal;
let Ok(d) = value.parse::<Decimal>() else {
return value.to_string();
};
// Round to 2 decimal places using Decimal — no float precision loss
// MidpointAwayFromZero: 1.005 → 1.01 (currency convention)
let rounded = d
.abs()
.round_dp_with_strategy(2, rust_decimal::RoundingStrategy::MidpointAwayFromZero);
// Extract integer and fractional parts from the rounded Decimal
let truncated = rounded.trunc();
let frac_part = rounded - truncated;
let integer = truncated.to_string().parse::<i64>().unwrap_or(0);
let frac = (frac_part * Decimal::from(100))
.trunc()
.to_string()
.parse::<i64>()
.unwrap_or(0);
let int_str = format_with_thousands(integer, &config.thousands_separator);
let sign = if d.is_sign_negative() { "-" } else { "" };
if config.currency_position == "prefix" {
format!(
"{}{}{}{}{:02}",
config.currency_symbol, sign, int_str, config.decimal_separator, frac
)
} else {
format!(
"{}{}{}{:02} {}",
sign, int_str, config.decimal_separator, frac, config.currency_symbol
)
}
}
fn format_percentage(value: &str) -> String {
if let Ok(n) = value.parse::<f64>() {
format!("%{:.2}", n)
} else {
value.to_string()
}
}
fn format_number_str(value: &str, config: &dreport_core::models::FormatConfig) -> String {
if let Ok(n) = value.parse::<f64>() {
if n == n.floor() && n.abs() < 1e15 {
format_with_thousands(n.abs() as i64, &config.thousands_separator)
} else {
// Ondalık ayırıcıyı config'den al
let formatted = format!("{:.2}", n);
formatted.replace('.', &config.decimal_separator)
}
} else {
value.to_string()
}
}
fn format_with_thousands(n: i64, separator: &str) -> String {
let s = n.to_string();
let len = s.len();
if len <= 3 {
return s;
}
let mut result = String::new();
for (i, ch) in s.chars().enumerate() {
if i > 0 && (len - i).is_multiple_of(3) {
result.push_str(separator);
}
result.push(ch);
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_simple_path() {
let data = json!({"firma": {"unvan": "Acme A.Ş."}});
assert_eq!(evaluate_expression("firma.unvan", &data), "Acme A.Ş.");
}
#[test]
fn test_arithmetic() {
let data = json!({"toplamlar": {"araToplam": 16000, "kdv": 2880}});
assert_eq!(
evaluate_expression("toplamlar.araToplam + toplamlar.kdv", &data),
"18880"
);
}
#[test]
fn test_multiplication() {
let data = json!({"toplamlar": {"araToplam": 16000}});
assert_eq!(
evaluate_expression("toplamlar.araToplam * 0.20", &data),
"3200"
);
}
#[test]
fn test_string_concat() {
let data = json!({"fatura": {"no": "FTR-001"}});
assert_eq!(
evaluate_expression("\"Fatura No: \" + fatura.no", &data),
"Fatura No: FTR-001"
);
}
#[test]
fn test_ternary() {
let data = json!({"fatura": {"tutar": 5000}});
assert_eq!(
evaluate_expression(
"if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end",
&data
),
"Borclu"
);
}
#[test]
fn test_ternary_false() {
let data = json!({"fatura": {"tutar": 0}});
assert_eq!(
evaluate_expression(
"if fatura.tutar > 0 then \"Borclu\" else \"Alacakli\" end",
&data
),
"Alacakli"
);
}
#[test]
fn test_parentheses() {
let data = json!({"a": 2, "b": 3, "c": 4});
assert_eq!(evaluate_expression("(a + b) * c", &data), "20");
}
#[test]
fn test_number_literal() {
let data = json!({});
assert_eq!(evaluate_expression("42", &data), "42");
assert_eq!(evaluate_expression("3.14", &data), "3.14");
}
#[test]
fn test_missing_path() {
let data = json!({});
// dexpr returns Null for undefined globals
assert_eq!(evaluate_expression("missing.path", &data), "");
}
#[test]
fn test_numeric_comparison() {
let data = json!({"fatura": {"tutar": 5000}});
assert_eq!(
evaluate_expression(
"if fatura.tutar > 1000 then \"Yuksek\" else \"Dusuk\" end",
&data
),
"Yuksek"
);
}
#[test]
fn test_format_currency() {
assert_eq!(apply_format("18880", Some("currency")), "18.880,00 ₺");
assert_eq!(apply_format("1000.5", Some("currency")), "1.000,50 ₺");
}
#[test]
fn test_format_percentage() {
assert_eq!(apply_format("20", Some("percentage")), "%20.00");
}
#[test]
fn test_negative_result() {
let data = json!({"a": 10, "b": 20});
assert_eq!(evaluate_expression("a - b", &data), "-10");
}
#[test]
fn test_empty_expression() {
let data = json!({});
assert_eq!(evaluate_expression("", &data), "");
}
// dexpr-specific features
#[test]
fn test_string_methods() {
let data = json!({"name": "Acme Teknoloji"});
assert_eq!(evaluate_expression("name.upper()", &data), "ACME TEKNOLOJI");
assert_eq!(evaluate_expression("name.length()", &data), "14");
}
#[test]
fn test_modulo_and_power() {
let data = json!({});
assert_eq!(evaluate_expression("10 % 3", &data), "1");
assert_eq!(evaluate_expression("2 ** 10", &data), "1024");
}
#[test]
fn test_logical_operators() {
let data = json!({"a": true, "b": false});
assert_eq!(evaluate_expression("a && b", &data), "false");
assert_eq!(evaluate_expression("a || b", &data), "true");
}
#[test]
fn test_compound_expression() {
let data = json!({"toplamlar": {"araToplam": 16000, "kdvOran": 18}});
assert_eq!(
evaluate_expression("toplamlar.araToplam * toplamlar.kdvOran / 100", &data),
"2880"
);
}
#[test]
fn test_currency_format_basic() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("1500", &config), "1.500,00 ₺");
assert_eq!(format_currency("0", &config), "0,00 ₺");
}
#[test]
fn test_currency_format_fractional() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("19.99", &config), "19,99 ₺");
assert_eq!(format_currency("100.50", &config), "100,50 ₺");
}
#[test]
fn test_currency_format_rounding_edge_case() {
// 1.005 should round to 1.01, not 1.00
let config = dreport_core::models::FormatConfig::default();
let result = format_currency("1.005", &config);
assert_eq!(result, "1,01 ₺");
}
#[test]
fn test_currency_format_negative() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("-250.75", &config), "-250,75 ₺");
}
#[test]
fn test_currency_format_large_number() {
let config = dreport_core::models::FormatConfig::default();
assert_eq!(format_currency("1234567.89", &config), "1.234.567,89 ₺");
}
#[test]
fn test_currency_format_prefix_position() {
let config = dreport_core::models::FormatConfig {
currency_symbol: "$".to_string(),
currency_position: "prefix".to_string(),
thousands_separator: ",".to_string(),
decimal_separator: ".".to_string(),
};
assert_eq!(format_currency("1500.25", &config), "$1,500.25");
}
#[test]
fn test_array_field_sum() {
let data = json!({
"kalemler": [
{"adi": "A", "tutar": 100},
{"adi": "B", "tutar": 200},
{"adi": "C", "tutar": 50}
]
});
assert_eq!(evaluate_expression("kalemler.tutar.sum()", &data), "350");
}
#[test]
fn test_array_field_sum_in_arithmetic() {
let data = json!({
"kalemler": [
{"tutar": 1000},
{"tutar": 2000}
],
"toplamlar": {"kdvOrani": 20}
});
assert_eq!(
evaluate_expression("kalemler.tutar.sum() * toplamlar.kdvOrani / 100", &data),
"600"
);
}
}