From eb534b5cafb038f8b399154d5fb16a785a4d6ef1 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 18:58:24 +0200 Subject: [PATCH 1/3] Functions: DGET skips empty result fields and "" criteria match any text (part of #737) --- crates/functions/src/database.rs | 77 +++++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 5 deletions(-) diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index 38e3729..bf0c611 100644 --- a/crates/functions/src/database.rs +++ b/crates/functions/src/database.rs @@ -4,7 +4,7 @@ //! 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. use std::borrow::Cow; @@ -49,7 +49,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(['=', '<', '>']); @@ -158,9 +157,12 @@ fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R { fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { let s = select(a)?; 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), } } @@ -371,4 +373,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); + } } From 4d2e8e3455701857caae78162ad623740e53e0ee Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 19:16:00 +0200 Subject: [PATCH 2/3] Functions: database functions evaluate computed criteria (part of #737) A formula under a criteria label that names no field is evaluated for each record: its relative references move from the first record to that record, and the database labels name the record's fields, as in Excel. The calc evaluator passes the criteria reference to the database functions through Ctx::computed_criterion. --- crates/calc/src/eval.rs | 39 +++++++++++++-- crates/calc/src/tests.rs | 52 ++++++++++++++++++++ crates/functions/src/database.rs | 83 +++++++++++++++++--------------- crates/functions/src/lib.rs | 8 ++- 4 files changed, 137 insertions(+), 45 deletions(-) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 92a0a39..98c1179 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,24 @@ 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. + fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> 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> { @@ -579,7 +599,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() { @@ -762,8 +782,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); @@ -791,7 +820,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) } @@ -845,13 +874,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)), } @@ -1221,7 +1250,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 5133409..3eafc41 100644 --- a/crates/calc/src/tests.rs +++ b/crates/calc/src/tests.rs @@ -968,3 +968,55 @@ fn trim_references() { t.set("K20", "=SUM(Q1:.Q9)"); 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); +} diff --git a/crates/functions/src/database.rs b/crates/functions/src/database.rs index bf0c611..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, so an empty string matches any text. +//! 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; @@ -64,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 { @@ -77,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), }) }) }) @@ -101,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 { @@ -113,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(), @@ -145,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(), @@ -154,8 +159,8 @@ 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)?; // Records whose field is blank (or "") don't count, as in Excel. let mut found = @@ -167,18 +172,18 @@ fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R { } } -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); } @@ -196,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 { diff --git a/crates/functions/src/lib.rs b/crates/functions/src/lib.rs index 282b230..0c5ede3 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; From a5f531838ba2341eee61a3ab59573ef3eb032ccf Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Mon, 12 Oct 2026 21:08:20 +0200 Subject: [PATCH 3/3] Functions: note the dependency gap of computed criteria (part of #737) --- crates/calc/src/eval.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/calc/src/eval.rs b/crates/calc/src/eval.rs index 98c1179..b22c48a 100644 --- a/crates/calc/src/eval.rs +++ b/crates/calc/src/eval.rs @@ -104,6 +104,10 @@ impl Ctx for FnCtx<'_, '_> { } /// 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))?;