447 lines
17 KiB
Rust
447 lines
17 KiB
Rust
//! Database functions (DSUM, DCOUNT, DGET…): aggregate one field of the records of a table
|
|
//! that satisfy a criteria table.
|
|
//!
|
|
//! 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. 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;
|
|
|
|
use gridcraft_core::{Array, CellError, Value, compare_text};
|
|
|
|
use crate::criteria::Criterion;
|
|
use crate::util::{A, R, as_array, has, num_val};
|
|
use crate::{Arg, Ctx, FnSpec};
|
|
|
|
/// Column index of a label in the header row.
|
|
fn find_column(db: &Array, label: &str) -> Option<usize> {
|
|
let label = label.trim();
|
|
(0..db.cols).find(|&c| match db.get(0, c) {
|
|
Some(Value::Text(t)) => compare_text(t.trim(), label).is_eq(),
|
|
Some(Value::Number(n)) => gridcraft_core::number_to_text(*n) == label,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
/// Resolves the field argument: a label or a 1-based column number. `None` when omitted.
|
|
fn field_index(db: &Array, a: &[Arg]) -> R<Option<usize>> {
|
|
if !has(a, 1) {
|
|
return Ok(None);
|
|
}
|
|
let v = a.get(1).map(|x| x.value.scalar()).unwrap_or(Value::Empty);
|
|
match v {
|
|
Value::Number(n) => {
|
|
let i = n.trunc();
|
|
if i < 1.0 || i > db.cols as f64 {
|
|
return Err(CellError::Value);
|
|
}
|
|
Ok(Some(i as usize - 1))
|
|
}
|
|
Value::Text(t) => find_column(db, &t).map(Some).ok_or(CellError::Value),
|
|
Value::Bool(_) => Err(CellError::Value),
|
|
Value::Error(e) => Err(e),
|
|
_ => Ok(None),
|
|
}
|
|
}
|
|
|
|
/// One criteria cell as a matcher. Plain text becomes a "begins with" pattern.
|
|
fn cell_criterion(v: &Value) -> Option<Criterion> {
|
|
match v {
|
|
Value::Empty => None,
|
|
Value::Text(t) => {
|
|
let s: &str = t;
|
|
let has_op = s.starts_with(['=', '<', '>']);
|
|
let plain_text = !has_op
|
|
&& gridcraft_core::parse::parse_number_text(s).is_none()
|
|
&& !s.eq_ignore_ascii_case("TRUE")
|
|
&& !s.eq_ignore_ascii_case("FALSE")
|
|
&& CellError::parse(s).is_none();
|
|
if plain_text && !s.ends_with('*') { Some(Criterion::parse(&Value::from(format!("{s}*")))) } else { Some(Criterion::parse(v)) }
|
|
}
|
|
other => Some(Criterion::parse(other)),
|
|
}
|
|
}
|
|
|
|
/// Rows (1-based within the database, excluding the header) of matching records.
|
|
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 {
|
|
let Some(cell) = crit.get(r, c) else { continue };
|
|
let Some(cr) = cell_criterion(cell) else { continue };
|
|
let col = match crit.get(0, c) {
|
|
Some(Value::Text(t)) => find_column(db, t),
|
|
Some(Value::Number(n)) => find_column(db, &gridcraft_core::number_to_text(*n)),
|
|
_ => None,
|
|
};
|
|
conds.push((c, col, cr));
|
|
}
|
|
rows.push((r, conds));
|
|
}
|
|
let all_match = rows.is_empty();
|
|
(1..db.rows)
|
|
.filter(|&rec| {
|
|
all_match
|
|
|| 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),
|
|
})
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
struct Selection<'a> {
|
|
db: Cow<'a, Array>,
|
|
field: Option<usize>,
|
|
records: Vec<usize>,
|
|
}
|
|
|
|
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 {
|
|
return Err(*e);
|
|
}
|
|
if let Value::Error(e) = critv {
|
|
return Err(*e);
|
|
}
|
|
let db = as_array(dbv);
|
|
let crit = as_array(critv);
|
|
let field = field_index(&db, a)?;
|
|
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], 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, c)?.iter().sum())
|
|
}
|
|
|
|
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, 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(),
|
|
};
|
|
num_val(n as f64)
|
|
}
|
|
|
|
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(),
|
|
};
|
|
num_val(n as f64)
|
|
}
|
|
|
|
fn dget(a: &[Arg], c: &mut dyn Ctx) -> R<Value> {
|
|
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 =
|
|
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, 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, 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, c)?;
|
|
if v.is_empty() {
|
|
return num_val(0.0);
|
|
}
|
|
num_val(v.iter().product())
|
|
}
|
|
|
|
fn variance(v: &[f64], sample: bool) -> R<f64> {
|
|
let n = v.len() as f64;
|
|
let min = if sample { 2.0 } else { 1.0 };
|
|
if n < min {
|
|
return Err(CellError::Div0);
|
|
}
|
|
let mean = v.iter().sum::<f64>() / n;
|
|
let ss: f64 = v.iter().map(|x| (x - mean) * (x - mean)).sum();
|
|
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, c)?, true)?)
|
|
}
|
|
|
|
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, c)?, true)?.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> {
|
|
vec![
|
|
f!("DSUM", 3, 3, Database, A, "DSUM(database, field, criteria)", "Adds the numbers in a field of the records that meet the criteria.", dsum),
|
|
f!(
|
|
"DAVERAGE",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DAVERAGE(database, field, criteria)",
|
|
"Averages the numbers in a field of the records that meet the criteria.",
|
|
daverage
|
|
),
|
|
f!(
|
|
"DCOUNT",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DCOUNT(database, [field], criteria)",
|
|
"Counts numeric cells in a field of the matching records (or the matching records).",
|
|
dcount
|
|
),
|
|
f!(
|
|
"DCOUNTA",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DCOUNTA(database, [field], criteria)",
|
|
"Counts non-blank cells in a field of the matching records (or the matching records).",
|
|
dcounta
|
|
),
|
|
f!(
|
|
"DGET",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DGET(database, field, criteria)",
|
|
"Returns the field value of the single record that meets the criteria.",
|
|
dget
|
|
),
|
|
f!("DMAX", 3, 3, Database, A, "DMAX(database, field, criteria)", "Largest number in a field of the matching records.", dmax),
|
|
f!("DMIN", 3, 3, Database, A, "DMIN(database, field, criteria)", "Smallest number in a field of the matching records.", dmin),
|
|
f!(
|
|
"DPRODUCT",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DPRODUCT(database, field, criteria)",
|
|
"Multiplies the numbers in a field of the matching records.",
|
|
dproduct
|
|
),
|
|
f!(
|
|
"DSTDEV",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DSTDEV(database, field, criteria)",
|
|
"Sample standard deviation of a field over the matching records.",
|
|
dstdev
|
|
),
|
|
f!(
|
|
"DSTDEVP",
|
|
3,
|
|
3,
|
|
Database,
|
|
A,
|
|
"DSTDEVP(database, field, criteria)",
|
|
"Population standard deviation of a field over the matching records.",
|
|
dstdevp
|
|
),
|
|
f!("DVAR", 3, 3, Database, A, "DVAR(database, field, criteria)", "Sample variance of a field over the matching records.", dvar),
|
|
f!("DVARP", 3, 3, Database, A, "DVARP(database, field, criteria)", "Population variance of a field over the matching records.", dvarp),
|
|
]
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::util::testutil::*;
|
|
use gridcraft_core::{CellError, Value};
|
|
|
|
/// The classic orchard table.
|
|
fn db() -> Value {
|
|
arr(vec![
|
|
vec![tv("Tree"), tv("Height"), tv("Age"), tv("Yield"), tv("Profit")],
|
|
vec![tv("Apple"), nv(18.0), nv(20.0), nv(14.0), nv(105.0)],
|
|
vec![tv("Pear"), nv(12.0), nv(12.0), nv(10.0), nv(96.0)],
|
|
vec![tv("Cherry"), nv(13.0), nv(14.0), nv(9.0), nv(105.0)],
|
|
vec![tv("Apple"), nv(14.0), nv(15.0), nv(10.0), nv(75.0)],
|
|
vec![tv("Pear"), nv(9.0), nv(8.0), nv(8.0), nv(76.8)],
|
|
vec![tv("Apple"), nv(8.0), nv(9.0), nv(6.0), nv(45.0)],
|
|
])
|
|
}
|
|
|
|
/// Apple with height > 10, OR any pear.
|
|
fn crit() -> Value {
|
|
arr(vec![
|
|
vec![tv("Tree"), tv("Height"), tv("Age"), tv("Yield"), tv("Profit"), tv("Height")],
|
|
vec![tv("=Apple"), tv(">10"), Value::Empty, Value::Empty, Value::Empty, tv("<16")],
|
|
vec![tv("=Pear"), Value::Empty, Value::Empty, Value::Empty, Value::Empty, Value::Empty],
|
|
])
|
|
}
|
|
|
|
fn d(f: &str, field: crate::Arg) -> Value {
|
|
ev(f, vec![rf(db()), field, rf(crit())])
|
|
}
|
|
|
|
#[test]
|
|
fn orchard() {
|
|
close(d("DCOUNT", t("Age")), 3.0);
|
|
close(d("DCOUNTA", t("Profit")), 3.0);
|
|
close(d("DMAX", t("Profit")), 96.0);
|
|
close(d("DMIN", t("Profit")), 75.0);
|
|
close(d("DSUM", t("Profit")), 247.8);
|
|
close(d("DSUM", n(5.0)), 247.8);
|
|
close(d("DPRODUCT", t("Yield")), 800.0);
|
|
close(d("DAVERAGE", t("Yield")), 28.0 / 3.0);
|
|
close_tol(d("DSTDEV", t("Yield")), 1.154700538, 1e-9);
|
|
close_tol(d("DSTDEVP", t("Yield")), 0.942809042, 1e-9);
|
|
close_tol(d("DVAR", t("Yield")), 1.333333333, 1e-9);
|
|
close_tol(d("DVARP", t("Yield")), 0.888888889, 1e-9);
|
|
close(d("DCOUNT", empty()), 3.0);
|
|
is_err(d("DSUM", t("Nope")), CellError::Value);
|
|
is_err(d("DSUM", n(9.0)), CellError::Value);
|
|
}
|
|
|
|
#[test]
|
|
fn simple_criteria() {
|
|
let c = |cells: Vec<Value>| arr(vec![vec![tv("Tree"), tv("Height")], cells]);
|
|
close(ev("DSUM", vec![rf(db()), t("Profit"), rf(c(vec![tv("Apple"), Value::Empty]))]), 225.0);
|
|
// Plain text matches values that begin with it.
|
|
close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(c(vec![tv("P"), Value::Empty]))]), 2.0);
|
|
close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(c(vec![tv("=P"), Value::Empty]))]), 0.0);
|
|
close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, tv(">=13")]))]), 33.0);
|
|
close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, nv(18.0)]))]), 14.0);
|
|
close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![Value::Empty, Value::Empty]))]), 57.0);
|
|
close(ev("DSUM", vec![rf(db()), t("Yield"), rf(c(vec![tv("*e*"), tv("<10")]))]), 14.0);
|
|
// Header only: every record matches.
|
|
close(ev("DCOUNTA", vec![rf(db()), t("tree"), rf(arr(vec![vec![tv("Tree")]]))]), 6.0);
|
|
// Unknown criteria label never matches.
|
|
close(ev("DCOUNT", vec![rf(db()), t("Age"), rf(arr(vec![vec![tv("Color")], vec![tv("red")]]))]), 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn dget_and_empty() {
|
|
let c = |tree: &str| arr(vec![vec![tv("Tree")], vec![tv(tree)]]);
|
|
close(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), 9.0);
|
|
is_err(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Apple"))]), CellError::Num);
|
|
is_err(ev("DGET", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), CellError::Value);
|
|
is_err(ev("DAVERAGE", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), CellError::Div0);
|
|
close(ev("DMAX", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), 0.0);
|
|
close(ev("DPRODUCT", vec![rf(db()), t("Yield"), rf(c("=Plum"))]), 0.0);
|
|
is_err(ev("DSTDEV", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), CellError::Div0);
|
|
close(ev("DSTDEVP", vec![rf(db()), t("Yield"), rf(c("=Cherry"))]), 0.0);
|
|
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);
|
|
}
|
|
}
|