Merge pull request #751 from marcoarnulfo:fix/dget-empty-criteria-results

This commit is contained in:
Brandon Thomas 2026-10-13 00:08:07 -04:00
commit 8b75c7c29f
4 changed files with 210 additions and 49 deletions

View File

@ -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<Area>,
}
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<H2` with column H
/// outside the database) is read here but not tracked, so editing that cell doesn't recalculate
/// the D-function until something else does.
fn computed_criterion(&mut self, row: usize, col: usize, db: &Array, record: usize) -> Option<bool> {
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;

View File

@ -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();

View File

@ -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<Option<usize>> {
fn cell_criterion(v: &Value) -> Option<Criterion> {
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<Criterion> {
}
/// Rows (1-based within the database, excluding the header) of matching records.
fn matching_records(db: &Array, crit: &Array) -> Vec<usize> {
// Per criteria row: list of (database column or None when the label is unknown, criterion).
let mut rows: Vec<Vec<(Option<usize>, Criterion)>> = Vec::new();
fn matching_records(db: &Array, crit: &Array, ctx: &mut dyn Ctx) -> Vec<usize> {
// 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<usize>, 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<usize> {
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<usize>,
}
fn select(a: &[Arg]) -> R<Selection<'_>> {
fn select<'a>(a: &'a [Arg], ctx: &mut dyn Ctx) -> R<Selection<'a>> {
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<Selection<'_>> {
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<Vec<f64>> {
let s = select(a)?;
fn field_numbers(a: &[Arg], c: &mut dyn Ctx) -> R<Vec<f64>> {
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<Value> {
num_val(field_numbers(a)?.iter().sum())
fn dsum(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(field_numbers(a, c)?.iter().sum())
}
fn daverage(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a)?;
fn daverage(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
let v = field_numbers(a, c)?;
if v.is_empty() {
return Err(CellError::Div0);
}
num_val(v.iter().sum::<f64>() / v.len() as f64)
}
fn dcount(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dcount(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
num_val(n as f64)
}
fn dcounta(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dcounta(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
num_val(n as f64)
}
fn dget(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
let s = select(a)?;
fn dget(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let v = field_numbers(a)?;
fn dmax(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let v = field_numbers(a)?;
fn dmin(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<Value> {
let v = field_numbers(a)?;
fn dproduct(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
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<f64> {
Ok(ss / if sample { n - 1.0 } else { n })
}
fn dvar(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, true)?)
fn dvar(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, true)?)
}
fn dvarp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, false)?)
fn dvarp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, false)?)
}
fn dstdev(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, true)?.sqrt())
fn dstdev(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, true)?.sqrt())
}
fn dstdevp(a: &[Arg], _c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a)?, false)?.sqrt())
fn dstdevp(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
num_val(variance(&field_numbers(a, c)?, false)?.sqrt())
}
pub(crate) fn specs() -> Vec<FnSpec> {
@ -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);
}
}

View File

@ -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<Value, CellError>`.
///
@ -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<bool> {
None
}
}
pub type FnImpl = fn(&[Arg], &mut dyn Ctx) -> Value;