fix: add depth limit to formula evaluation, propagate errors
Circular or self-referencing formulas now return CellValue::Error instead of stack overflowing. eval_expr uses Result<f64, String> internally so errors (circular refs, div/0, missing refs) propagate immediately through the expression tree via ?. The depth limit (16) is checked per evaluate_depth call — normal 1-2 level chains are unaffected. Also adds CellValue::Error variant for displaying ERR:reason in the grid, and handles it in format, persistence, and search. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@ -806,6 +806,7 @@ impl Cmd for SearchNavigate {
|
|||||||
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
let s = match ctx.model.evaluate_aggregated(&key, ctx.none_cats()) {
|
||||||
Some(CellValue::Number(n)) => format!("{n}"),
|
Some(CellValue::Number(n)) => format!("{n}"),
|
||||||
Some(CellValue::Text(t)) => t,
|
Some(CellValue::Text(t)) => t,
|
||||||
|
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
};
|
};
|
||||||
s.to_lowercase().contains(&query)
|
s.to_lowercase().contains(&query)
|
||||||
|
|||||||
@ -5,6 +5,7 @@ pub fn format_value(v: Option<&CellValue>, comma: bool, decimals: u8) -> String
|
|||||||
match v {
|
match v {
|
||||||
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
Some(CellValue::Number(n)) => format_f64(*n, comma, decimals),
|
||||||
Some(CellValue::Text(s)) => s.clone(),
|
Some(CellValue::Text(s)) => s.clone(),
|
||||||
|
Some(CellValue::Error(e)) => format!("ERR:{e}"),
|
||||||
None => String::new(),
|
None => String::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,15 +62,21 @@ impl std::fmt::Display for CellKey {
|
|||||||
pub enum CellValue {
|
pub enum CellValue {
|
||||||
Number(f64),
|
Number(f64),
|
||||||
Text(String),
|
Text(String),
|
||||||
|
/// Evaluation error (circular reference, depth overflow, etc.)
|
||||||
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CellValue {
|
impl CellValue {
|
||||||
pub fn as_f64(&self) -> Option<f64> {
|
pub fn as_f64(&self) -> Option<f64> {
|
||||||
match self {
|
match self {
|
||||||
CellValue::Number(n) => Some(*n),
|
CellValue::Number(n) => Some(*n),
|
||||||
CellValue::Text(_) => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn is_error(&self) -> bool {
|
||||||
|
matches!(self, CellValue::Error(_))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Display for CellValue {
|
impl std::fmt::Display for CellValue {
|
||||||
@ -84,6 +90,7 @@ impl std::fmt::Display for CellValue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
CellValue::Text(s) => write!(f, "{s}"),
|
CellValue::Text(s) => write!(f, "{s}"),
|
||||||
|
CellValue::Error(msg) => write!(f, "ERR:{msg}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -261,11 +261,22 @@ impl Model {
|
|||||||
|
|
||||||
/// Evaluate a computed value at a given key, considering formulas.
|
/// Evaluate a computed value at a given key, considering formulas.
|
||||||
/// Returns None when the cell is empty (no stored value, no applicable formula).
|
/// Returns None when the cell is empty (no stored value, no applicable formula).
|
||||||
|
/// Maximum formula evaluation depth. Circular references return None
|
||||||
|
/// instead of stack-overflowing.
|
||||||
|
const MAX_EVAL_DEPTH: u8 = 16;
|
||||||
|
|
||||||
pub fn evaluate(&self, key: &CellKey) -> Option<CellValue> {
|
pub fn evaluate(&self, key: &CellKey) -> Option<CellValue> {
|
||||||
|
self.evaluate_depth(key, Self::MAX_EVAL_DEPTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn evaluate_depth(&self, key: &CellKey, depth: u8) -> Option<CellValue> {
|
||||||
|
if depth == 0 {
|
||||||
|
return Some(CellValue::Error("circular".into()));
|
||||||
|
}
|
||||||
for formula in &self.formulas {
|
for formula in &self.formulas {
|
||||||
if let Some(item_val) = key.get(&formula.target_category) {
|
if let Some(item_val) = key.get(&formula.target_category) {
|
||||||
if item_val == formula.target {
|
if item_val == formula.target {
|
||||||
return self.eval_formula(formula, key);
|
return self.eval_formula_depth(formula, key, depth - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -326,6 +337,15 @@ impl Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
fn eval_formula(&self, formula: &Formula, context: &CellKey) -> Option<CellValue> {
|
||||||
|
self.eval_formula_depth(formula, context, Self::MAX_EVAL_DEPTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn eval_formula_depth(
|
||||||
|
&self,
|
||||||
|
formula: &Formula,
|
||||||
|
context: &CellKey,
|
||||||
|
depth: u8,
|
||||||
|
) -> Option<CellValue> {
|
||||||
use crate::formula::{AggFunc, Expr};
|
use crate::formula::{AggFunc, Expr};
|
||||||
|
|
||||||
// Check WHERE filter first
|
// Check WHERE filter first
|
||||||
@ -348,42 +368,50 @@ impl Model {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Evaluate an expression, returning Ok(f64) or Err(reason).
|
||||||
|
/// Errors propagate immediately — a circular reference in any
|
||||||
|
/// sub-expression short-circuits the entire formula.
|
||||||
fn eval_expr(
|
fn eval_expr(
|
||||||
expr: &Expr,
|
expr: &Expr,
|
||||||
context: &CellKey,
|
context: &CellKey,
|
||||||
model: &Model,
|
model: &Model,
|
||||||
target_category: &str,
|
target_category: &str,
|
||||||
) -> Option<f64> {
|
depth: u8,
|
||||||
|
) -> Result<f64, String> {
|
||||||
match expr {
|
match expr {
|
||||||
Expr::Number(n) => Some(*n),
|
Expr::Number(n) => Ok(*n),
|
||||||
Expr::Ref(name) => {
|
Expr::Ref(name) => {
|
||||||
let cat = find_item_category(model, name)?;
|
let cat = find_item_category(model, name)
|
||||||
|
.ok_or_else(|| format!("ref:{name}"))?;
|
||||||
let new_key = context.clone().with(cat, name);
|
let new_key = context.clone().with(cat, name);
|
||||||
model.evaluate(&new_key).and_then(|v| v.as_f64())
|
match model.evaluate_depth(&new_key, depth) {
|
||||||
|
Some(CellValue::Number(n)) => Ok(n),
|
||||||
|
Some(CellValue::Error(e)) => Err(e),
|
||||||
|
_ => Err(format!("ref:{name}")),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Expr::BinOp(op, l, r) => {
|
Expr::BinOp(op, l, r) => {
|
||||||
use crate::formula::BinOp;
|
use crate::formula::BinOp;
|
||||||
let lv = eval_expr(l, context, model, target_category)?;
|
let lv = eval_expr(l, context, model, target_category, depth)?;
|
||||||
let rv = eval_expr(r, context, model, target_category)?;
|
let rv = eval_expr(r, context, model, target_category, depth)?;
|
||||||
Some(match op {
|
match op {
|
||||||
BinOp::Add => lv + rv,
|
BinOp::Add => Ok(lv + rv),
|
||||||
BinOp::Sub => lv - rv,
|
BinOp::Sub => Ok(lv - rv),
|
||||||
BinOp::Mul => lv * rv,
|
BinOp::Mul => Ok(lv * rv),
|
||||||
BinOp::Div => {
|
BinOp::Div => {
|
||||||
if rv == 0.0 {
|
if rv == 0.0 {
|
||||||
return None;
|
Err("div/0".into())
|
||||||
|
} else {
|
||||||
|
Ok(lv / rv)
|
||||||
}
|
}
|
||||||
lv / rv
|
|
||||||
}
|
}
|
||||||
BinOp::Pow => lv.powf(rv),
|
BinOp::Pow => Ok(lv.powf(rv)),
|
||||||
// Comparison operators are handled by eval_bool; reaching
|
|
||||||
// here means a comparison was used where a number is expected.
|
|
||||||
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
BinOp::Eq | BinOp::Ne | BinOp::Lt | BinOp::Gt | BinOp::Le | BinOp::Ge => {
|
||||||
return None
|
Err("type".into())
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
Expr::UnaryMinus(e) => Some(-eval_expr(e, context, model, target_category)?),
|
}
|
||||||
|
Expr::UnaryMinus(e) => Ok(-eval_expr(e, context, model, target_category, depth)?),
|
||||||
Expr::Agg(func, inner, agg_filter) => {
|
Expr::Agg(func, inner, agg_filter) => {
|
||||||
let mut partial = context.without(target_category);
|
let mut partial = context.without(target_category);
|
||||||
if let Expr::Ref(item_name) = inner.as_ref() {
|
if let Expr::Ref(item_name) = inner.as_ref() {
|
||||||
@ -401,25 +429,25 @@ impl Model {
|
|||||||
.filter_map(|v| v.as_f64())
|
.filter_map(|v| v.as_f64())
|
||||||
.collect();
|
.collect();
|
||||||
match func {
|
match func {
|
||||||
AggFunc::Sum => Some(values.iter().sum()),
|
AggFunc::Sum => Ok(values.iter().sum()),
|
||||||
AggFunc::Avg => {
|
AggFunc::Avg => {
|
||||||
if values.is_empty() {
|
if values.is_empty() {
|
||||||
None
|
Err("empty".into())
|
||||||
} else {
|
} else {
|
||||||
Some(values.iter().sum::<f64>() / values.len() as f64)
|
Ok(values.iter().sum::<f64>() / values.len() as f64)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AggFunc::Min => values.iter().cloned().reduce(f64::min),
|
AggFunc::Min => values.iter().cloned().reduce(f64::min).ok_or_else(|| "empty".into()),
|
||||||
AggFunc::Max => values.iter().cloned().reduce(f64::max),
|
AggFunc::Max => values.iter().cloned().reduce(f64::max).ok_or_else(|| "empty".into()),
|
||||||
AggFunc::Count => Some(values.len() as f64),
|
AggFunc::Count => Ok(values.len() as f64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Expr::If(cond, then, else_) => {
|
Expr::If(cond, then, else_) => {
|
||||||
let cv = eval_bool(cond, context, model, target_category)?;
|
let cv = eval_bool(cond, context, model, target_category, depth)?;
|
||||||
if cv {
|
if cv {
|
||||||
eval_expr(then, context, model, target_category)
|
eval_expr(then, context, model, target_category, depth)
|
||||||
} else {
|
} else {
|
||||||
eval_expr(else_, context, model, target_category)
|
eval_expr(else_, context, model, target_category, depth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,30 +458,33 @@ impl Model {
|
|||||||
context: &CellKey,
|
context: &CellKey,
|
||||||
model: &Model,
|
model: &Model,
|
||||||
target_category: &str,
|
target_category: &str,
|
||||||
) -> Option<bool> {
|
depth: u8,
|
||||||
|
) -> Result<bool, String> {
|
||||||
use crate::formula::BinOp;
|
use crate::formula::BinOp;
|
||||||
match expr {
|
match expr {
|
||||||
Expr::BinOp(op, l, r) => {
|
Expr::BinOp(op, l, r) => {
|
||||||
let lv = eval_expr(l, context, model, target_category)?;
|
let lv = eval_expr(l, context, model, target_category, depth)?;
|
||||||
let rv = eval_expr(r, context, model, target_category)?;
|
let rv = eval_expr(r, context, model, target_category, depth)?;
|
||||||
Some(match op {
|
match op {
|
||||||
BinOp::Eq => (lv - rv).abs() < 1e-10,
|
BinOp::Eq => Ok((lv - rv).abs() < 1e-10),
|
||||||
BinOp::Ne => (lv - rv).abs() >= 1e-10,
|
BinOp::Ne => Ok((lv - rv).abs() >= 1e-10),
|
||||||
BinOp::Lt => lv < rv,
|
BinOp::Lt => Ok(lv < rv),
|
||||||
BinOp::Gt => lv > rv,
|
BinOp::Gt => Ok(lv > rv),
|
||||||
BinOp::Le => lv <= rv,
|
BinOp::Le => Ok(lv <= rv),
|
||||||
BinOp::Ge => lv >= rv,
|
BinOp::Ge => Ok(lv >= rv),
|
||||||
// Arithmetic operators are not comparisons
|
|
||||||
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => {
|
BinOp::Add | BinOp::Sub | BinOp::Mul | BinOp::Div | BinOp::Pow => {
|
||||||
return None
|
Err("type".into())
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
_ => None,
|
}
|
||||||
|
_ => Err("type".into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
eval_expr(&formula.expr, context, self, &formula.target_category).map(CellValue::Number)
|
match eval_expr(&formula.expr, context, self, &formula.target_category, depth) {
|
||||||
|
Ok(n) => Some(CellValue::Number(n)),
|
||||||
|
Err(e) => Some(CellValue::Error(e)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -862,10 +893,10 @@ mod formula_tests {
|
|||||||
CellValue::Number(0.0),
|
CellValue::Number(0.0),
|
||||||
);
|
);
|
||||||
m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap());
|
m.add_formula(parse_formula("Result = Revenue / Zero", "Measure").unwrap());
|
||||||
// Division by zero must yield Empty, not 0, so the user sees a blank not a misleading zero.
|
// Division by zero yields an error, not a blank or misleading zero.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])),
|
m.evaluate(&coord(&[("Measure", "Result"), ("Region", "East")])),
|
||||||
None
|
Some(CellValue::Error("div/0".into()))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -904,7 +935,47 @@ mod formula_tests {
|
|||||||
cat.add_item("Ghost");
|
cat.add_item("Ghost");
|
||||||
}
|
}
|
||||||
let k = coord(&[("Measure", "Ghost"), ("Region", "East")]);
|
let k = coord(&[("Measure", "Ghost"), ("Region", "East")]);
|
||||||
assert_eq!(m.evaluate(&k), None);
|
assert!(
|
||||||
|
matches!(m.evaluate(&k), Some(CellValue::Error(_))),
|
||||||
|
"missing ref should produce an error, got: {:?}",
|
||||||
|
m.evaluate(&k)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Circular formula references must produce an error, not stack overflow.
|
||||||
|
#[test]
|
||||||
|
fn circular_formula_returns_error() {
|
||||||
|
let mut m = Model::new("Test");
|
||||||
|
m.add_category("Measure").unwrap();
|
||||||
|
if let Some(cat) = m.category_mut("Measure") {
|
||||||
|
cat.add_item("A");
|
||||||
|
cat.add_item("B");
|
||||||
|
}
|
||||||
|
m.add_formula(parse_formula("A = B + 1", "Measure").unwrap());
|
||||||
|
m.add_formula(parse_formula("B = A + 1", "Measure").unwrap());
|
||||||
|
let result = m.evaluate(&coord(&[("Measure", "A")]));
|
||||||
|
assert!(
|
||||||
|
matches!(result, Some(CellValue::Error(_))),
|
||||||
|
"circular reference should produce an error, got: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Self-referencing formula must produce an error.
|
||||||
|
#[test]
|
||||||
|
fn self_referencing_formula_returns_error() {
|
||||||
|
let mut m = Model::new("Test");
|
||||||
|
m.add_category("Measure").unwrap();
|
||||||
|
if let Some(cat) = m.category_mut("Measure") {
|
||||||
|
cat.add_item("X");
|
||||||
|
}
|
||||||
|
m.add_formula(parse_formula("X = X + 1", "Measure").unwrap());
|
||||||
|
let result = m.evaluate(&coord(&[("Measure", "X")]));
|
||||||
|
assert!(
|
||||||
|
matches!(result, Some(CellValue::Error(_))),
|
||||||
|
"self-reference should produce an error, got: {:?}",
|
||||||
|
result
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user