diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 2f1bb08..7ce2483 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -88,6 +88,8 @@ pub struct Evaluator<'h> { struct FnCtx<'a, 'h> { ev: &'a mut Evaluator<'h>, + /// The criteria argument of a database function, when it is a reference. + criteria: Option, } impl Ctx for FnCtx<'_, '_> { @@ -100,6 +102,28 @@ impl Ctx for FnCtx<'_, '_> { fn random(&mut self) -> f64 { self.ev.host.random() } + /// Excel's "formula as criteria": the formula is written for the first record, so its relative + /// references move down to `record`'s row, and the database's labels name its fields. + /// Known gap: the shifted references are not dependencies of the calling cell. One that lands + /// outside both the database and the criteria cell's own precedents (say `=G2 Option { + let area = self.criteria?; + let at = area.range.start.offset(row as i64, col as i64).filter(|c| area.range.contains(*c))?; + let expr = self.ev.host.workbook().sheet(area.sheet)?.cell(at)?.formula.as_ref()?.expr_arc()?; + let shifted = gridcraft_formula::adjust::shift_relative((*expr).clone(), record as i64 - 1, 0); + let env = (0..db.cols) + .filter_map(|c| match db.get(0, c) { + Some(Value::Text(t)) if !t.trim().is_empty() => { + Some((t.trim().to_string(), Ev::V(db.get(record, c).cloned().unwrap_or(Value::Empty)))) + } + _ => None, + }) + .collect(); + let mut ev = Evaluator { host: &mut *self.ev.host, sheet: area.sheet, at, env, depth: self.ev.depth }; + Some(matches!(ev.value(&shifted).scalar().to_bool(), Ok(true))) + } } impl<'h> Evaluator<'h> { @@ -592,7 +616,7 @@ impl<'h> Evaluator<'h> { let value = self.value(a); evaluated.push(Arg { value, from_ref: false }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V); } let i = match index.to_number() { @@ -789,8 +813,17 @@ impl<'h> Evaluator<'h> { let mut evaluated = Vec::with_capacity(args.len()); // SUMIF/AVERAGEIF: the size of the criteria range (argument 1), which the sum range takes. let mut criteria_shape = None; + // Database functions: the criteria range (argument 2), whose formulas are computed criteria. + let mut criteria = None; for (i, a) in args.iter().enumerate() { let mut ev = self.eval(a); + if spec.category == gridcraft_functions::Category::Database + && i == 2 + && let Ev::R(areas) = &ev + && let [area] = areas.as_slice() + { + criteria = Some(*area); + } if resizes_sum_range(name, args.len()) { if i == 0 { criteria_shape = ev_shape(&ev); @@ -818,7 +851,7 @@ impl<'h> Evaluator<'h> { }; evaluated.push(Arg { value, from_ref }); } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria }; gridcraft_functions::call(spec, &evaluated, &mut ctx).pipe(Ev::V) } @@ -872,13 +905,13 @@ impl<'h> Evaluator<'h> { }; let args = [Arg { value, from_ref }]; if kind != Agg::Count { - match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(sum, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.sum += n, other => return Err(Ev::V(other)), } } if kind != Agg::Sum { - match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self }) { + match gridcraft_functions::call(count, &args, &mut FnCtx { ev: self, criteria: None }) { Value::Number(n) => acc.count = acc.count.saturating_add(n as u64), other => return Err(Ev::V(other)), } @@ -1281,7 +1314,7 @@ impl<'h> Evaluator<'h> { } } } - let mut ctx = FnCtx { ev: self }; + let mut ctx = FnCtx { ev: self, criteria: None }; return match gridcraft_functions::call(spec, &xargs, &mut ctx) { Value::Number(p) if p >= 1.0 => { let k = p as u32 - 1; diff --git a/crates/calc/src/tests.rs b/crates/calc/src/tests.rs index 2914734..ee4c4bc 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -1121,6 +1121,56 @@ fn trim_references() { assert_eq!(t.get("K20"), Value::Error(CellError::Calc)); } +/// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 46-49): a criteria cell holding +/// a formula under a label that names no field is evaluated for every record. +#[test] +fn database_functions_with_computed_criteria() { + let mut t = T::new(); + let db = [ + ["name", "age", "size", "headgear", "glasses"], + ["Huey", "6", "45", "cap~", "FALSE"], + ["Dewey", "7", "fourty-six", "cap?", "FALSE"], + ["Louie", "8", "47", "cap*", "FALSE"], + ["Donald", "32", "70", "sailor's hat", "FALSE"], + ["Scrooge", "62", "65", "top hat", "TRUE"], + ]; + for (r, row) in db.iter().enumerate() { + for (c, v) in row.iter().enumerate() { + t.set(&format!("{}{}", ["F", "G", "H", "I", "J"][c], r + 2), v); + } + } + t.set("G45", "7"); + let cases = [ + // Relative references are to the first record's row and move to each record's row; + // `$G$45` stays put. + ("G", "=G2<$G$45", "Dewey"), + // Column labels stand for the record's fields. + ("H", "=glasses=TRUE", "Scrooge"), + ("I", "=age<7", "Huey"), + ("J", "=size/7=10", "Donald"), + // An absolute reference to a cell that is not the first record's. + ("K", "=H3=$L$1", "Donald"), + ]; + t.set("L1", "70"); + for (i, (col, formula, _)) in cases.iter().enumerate() { + t.set(&format!("{col}46"), "Formula"); + t.set(&format!("{col}47"), formula); + t.set(&format!("B{}", 46 + i), &format!("=DGET($F$2:$J$7,\"name\",{col}46:{col}47)")); + } + for (i, (_, formula, want)) in cases.iter().enumerate() { + assert_eq!(t.get(&format!("B{}", 46 + i)), Value::text(*want), "{formula}"); + } + // Every database function takes them, ANDed with the other criteria of their row. + // Text sorts after numbers, so Dewey's "fourty-six" is > 40. + t.set("L47", "=size>40"); + t.set("M46", "age"); + t.set("M47", ">6"); + t.set("B51", "=DCOUNT($F$2:$J$7,\"age\",I46:I47)"); + t.set("B52", "=DSUM($F$2:$J$7,\"age\",G46:G47)"); + t.set("B53", "=DCOUNTA($F$2:$J$7,\"name\",L46:M47)"); + assert_eq!(t.num("B51"), 1.0); + assert_eq!(t.num("B52"), 7.0); + assert_eq!(t.num("B53"), 4.0); #[test] fn offset_extreme_arguments_return_ref() { let wb = Workbook::new(); diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e3729..67132e9 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,9 @@ //! The database and the criteria are arrays whose first row holds column labels. Each criteria //! row is a set of conditions that must all hold (AND); a record matches when any criteria row //! holds (OR). An empty criteria cell matches anything. Plain text conditions match values that -//! begin with the text, as in Excel's advanced filter. +//! begin with the text, as in Excel's advanced filter, so an empty string matches any text. A +//! formula under a label that names no field is a computed criterion, which the host evaluates +//! for each record ([`Ctx::computed_criterion`]). use std::borrow::Cow; @@ -49,7 +51,6 @@ fn field_index(db: &Array, a: &[Arg]) -> R> { fn cell_criterion(v: &Value) -> Option { match v { Value::Empty => None, - Value::Text(t) if t.is_empty() => None, Value::Text(t) => { let s: &str = t; let has_op = s.starts_with(['=', '<', '>']); @@ -65,9 +66,10 @@ fn cell_criterion(v: &Value) -> Option { } /// Rows (1-based within the database, excluding the header) of matching records. -fn matching_records(db: &Array, crit: &Array) -> Vec { - // Per criteria row: list of (database column or None when the label is unknown, criterion). - let mut rows: Vec, Criterion)>> = Vec::new(); +fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec { + // Per criteria row: its index and a list of (criteria column, database column or None when + // the label is unknown, criterion). + let mut rows: Vec<(usize, Vec<(usize, Option, Criterion)>)> = Vec::new(); for r in 1..crit.rows { let mut conds = Vec::new(); for c in 0..crit.cols { @@ -78,18 +80,20 @@ fn matching_records(db: &Array, crit: &Array) -> Vec { Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)), _ => None, }; - conds.push((col, cr)); + conds.push((c, col, cr)); } - rows.push(conds); + rows.push((r, conds)); } let all_match = rows.is_empty(); (1..db.rows) - .filter(|&r| { + .filter(|&rec| { all_match - || rows.iter().any(|conds| { - conds.iter().all(|(col, cr)| match col { - Some(c) => cr.matches(db.get(r, *c).unwrap_or(&Value::Empty)), - None => false, + || rows.iter().any(|(r, conds)| { + conds.iter().all(|(c, col, cr)| match col { + Some(f) => cr.matches(db.get(rec, *f).unwrap_or(&Value::Empty)), + // Under a label that names no field, only a formula (a computed + // criterion) can hold. + None => ctx.computed_criterion(*r, *c, db, rec).unwrap_or(false), }) }) }) @@ -102,7 +106,7 @@ struct Selection<'a> { records: Vec, } -fn select(a: &[Arg]) -> R> { +fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R> { let dbv = &a.first().ok_or(CellError::Value)?.value; let critv = &a.get(2).ok_or(CellError::Value)?.value; if let Value::Error(e) = dbv { @@ -114,31 +118,31 @@ fn select(a: &[Arg]) -> R> { let db = as_array(dbv); let crit = as_array(critv); let field = field_index(&db, a)?; - let records = matching_records(&db, &crit); + let records = matching_records(&db, &crit, ctx); Ok(Selection { db, field, records }) } /// Numbers of the field among matching records (field required). -fn field_numbers(a: &[Arg]) -> R> { - let s = select(a)?; +fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R> { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; Ok(s.records.iter().filter_map(|&r| s.db.get(r, f).and_then(Value::as_f64)).collect()) } -fn dsum(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(field_numbers(a)?.iter().sum()) +fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(field_numbers(a, c)?.iter().sum()) } -fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return Err(CellError::Div0); } num_val(v.iter().sum::() / v.len() as f64) } -fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| matches!(s.db.get(r, f), Some(Value::Number(_)))).count(), None => s.records.len(), @@ -146,8 +150,8 @@ fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let n = match s.field { Some(f) => s.records.iter().filter(|&&r| !matches!(s.db.get(r, f), None | Some(Value::Empty))).count(), None => s.records.len(), @@ -155,28 +159,31 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { num_val(n as f64) } -fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { - let s = select(a)?; +fn dget(a: &[Arg], c: &mut dyn Ctx) -> R { + let s = select(a, c)?; let f = s.field.ok_or(CellError::Value)?; - match s.records.as_slice() { - [] => Err(CellError::Value), - [r] => Ok(s.db.get(*r, f).cloned().unwrap_or(Value::Empty)), + // Records whose field is blank (or "") don't count, as in Excel. + let mut found = + s.records.iter().filter_map(|&r| s.db.get(r, f)).filter(|v| !matches!(v, Value::Empty) && !matches!(v, Value::Text(t) if t.is_empty())); + match (found.next(), found.next()) { + (None, _) => Err(CellError::Value), + (Some(v), None) => Ok(v.clone()), _ => Err(CellError::Num), } } -fn dmax(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::max).unwrap_or(0.0)) } -fn dmin(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; num_val(v.iter().copied().reduce(f64::min).unwrap_or(0.0)) } -fn dproduct(a: &[Arg], _c: &mut dyn Ctx) -> R { - let v = field_numbers(a)?; +fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R { + let v = field_numbers(a, c)?; if v.is_empty() { return num_val(0.0); } @@ -194,20 +201,20 @@ fn variance(v: &[f64], sample: bool) -> R { Ok(ss / if sample { n - 1.0 } else { n }) } -fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?) +fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?) } -fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?) +fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?) } -fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, true)?.sqrt()) +fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, true)?.sqrt()) } -fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R { - num_val(variance(&field_numbers(a)?, false)?.sqrt()) +fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R { + num_val(variance(&field_numbers(a, c)?, false)?.sqrt()) } pub(crate) fn specs() -> Vec { @@ -371,4 +378,69 @@ mod tests { is_err(ev("DSUM", vec![e(CellError::Ref), t("Yield"), rf(c("=Cherry"))]), CellError::Ref); close(ev("DCOUNTA", vec![rf(db()), empty(), rf(c("=Pear"))]), 2.0); } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 74-86). + #[test] + fn empty_string_criterion() { + let db = arr(vec![ + vec![tv("Index"), tv("Value")], + vec![tv("a"), tv("=")], + vec![tv("b"), Value::Empty], + vec![tv("c"), tv("")], + vec![tv("d"), tv(" ")], + vec![tv("f"), Value::Error(CellError::Value)], + vec![tv("h"), tv("abc")], + vec![tv("i"), nv(0.0)], + ]); + let get = |index: &str| { + let crit = arr(vec![vec![tv("Index"), tv("Value")], vec![tv(index), tv("")]]); + ev("DGET", vec![rf(db.clone()), t("Index"), rf(crit)]) + }; + // Like any plain text, "" is a "begins with" pattern: it matches every text value. + is_text(get("c"), "c"); + is_text(get("h"), "h"); + is_err(get("b"), CellError::Value); + is_err(get("i"), CellError::Value); + is_err(get("f"), CellError::Value); + let all = arr(vec![vec![tv("Value")], vec![tv("")]]); + close(ev("DCOUNTA", vec![rf(db.clone()), t("Index"), rf(all)]), 4.0); + + // Under a label that names no field, "" is a condition that never holds. + let db = arr(vec![vec![tv("One"), tv("Two"), tv("Three")], vec![tv("a"), tv("c"), tv("x")], vec![tv("b"), tv("d"), tv("y")]]); + let get = |label: Value, value: Value| { + let crit = arr(vec![vec![tv("One"), label, tv("Three")], vec![tv("a"), value, tv("x")]]); + ev("DGET", vec![rf(db.clone()), t("One"), rf(crit)]) + }; + is_text(get(Value::Empty, Value::Empty), "a"); + is_text(get(tv(""), Value::Empty), "a"); + is_err(get(Value::Empty, tv("")), CellError::Value); + is_err(get(tv(""), tv("")), CellError::Value); + } + + /// Expected results are Excel's, from Apache POI's `DGet.xls` (rows 108-119). + #[test] + fn dget_skips_empty_fields() { + let db = arr(vec![ + vec![tv("One"), tv("Two")], + vec![tv("a"), Value::Empty], + vec![tv("b"), tv(" ")], + vec![tv("c"), tv("")], + vec![tv("d"), Value::Error(CellError::NA)], + vec![tv("j"), Value::Empty], + vec![tv("j"), tv("Text")], + vec![tv("j"), Value::Empty], + vec![tv("k"), Value::Empty], + vec![tv("k"), tv("")], + ]); + let get = |one: &str| { + let crit = arr(vec![vec![tv("One")], vec![tv(one)]]); + ev("DGET", vec![rf(db.clone()), t("Two"), rf(crit)]) + }; + is_err(get("a"), CellError::Value); + is_text(get("b"), " "); + is_err(get("c"), CellError::Value); + is_err(get("d"), CellError::NA); + is_text(get("j"), "Text"); + is_err(get("k"), CellError::Value); + } } diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 65612b4..148d682 100644 --- a/crates/functions/src/lib.rs +++ b/crates/functions/src/lib.rs @@ -15,7 +15,7 @@ use std::collections::HashMap; use std::sync::OnceLock; -use gridcraft_core::{CellError, DateSystem, Value}; +use gridcraft_core::{Array, CellError, DateSystem, Value}; /// Builds a [`FnSpec`] whose implementation returns `Result`. /// @@ -105,6 +105,12 @@ pub trait Ctx { fn now_serial(&self) -> f64; /// Uniform in [0, 1). fn random(&mut self) -> f64; + /// For the database functions: whether the formula in the criteria cell at (`row`, `col`) holds + /// for `record` (a row of `db` below its labels). `None` when that cell holds no formula or the + /// criteria didn't come from a reference. + fn computed_criterion(&mut self, _row: usize, _col: usize, _db: &Array, _record: usize) -> Option { + None + } } pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;