From aa953e1eceec405e55c919cc34018537c6f3e4ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Hatcher?= Date: Wed, 12 Nov 2025 17:46:39 +0100 Subject: [PATCH] UPDATE: Add some DATABASE functions DAVERAGE DCOUNT DGET DMAX DMIN DSUM --- .../src/expressions/parser/static_analysis.rs | 14 + base/src/functions/database.rs | 568 ++++++++++++++++++ base/src/functions/mod.rs | 36 +- xlsx/tests/calc_tests/DAVERAGE.xlsx | Bin 0 -> 9494 bytes 4 files changed, 617 insertions(+), 1 deletion(-) create mode 100644 base/src/functions/database.rs create mode 100644 xlsx/tests/calc_tests/DAVERAGE.xlsx diff --git a/base/src/expressions/parser/static_analysis.rs b/base/src/expressions/parser/static_analysis.rs index 6253d82..9dd2fb9 100644 --- a/base/src/expressions/parser/static_analysis.rs +++ b/base/src/expressions/parser/static_analysis.rs @@ -876,6 +876,13 @@ fn get_function_args_signature(kind: &Function, arg_count: usize) -> Vec args_signature_scalars(arg_count, 0, 1), Function::Cell => args_signature_scalars(arg_count, 1, 1), Function::Info => args_signature_scalars(arg_count, 1, 1), + + Function::Daverage => vec![Signature::Vector, Signature::Scalar, Signature::Vector], + Function::Dcount => vec![Signature::Vector, Signature::Scalar, Signature::Vector], + Function::Dget => vec![Signature::Vector, Signature::Scalar, Signature::Vector], + Function::Dmax => vec![Signature::Vector, Signature::Scalar, Signature::Vector], + Function::Dmin => vec![Signature::Vector, Signature::Scalar, Signature::Vector], + Function::Dsum => vec![Signature::Vector, Signature::Scalar, Signature::Vector], } } @@ -1139,5 +1146,12 @@ fn static_analysis_on_function(kind: &Function, args: &[Node]) -> StaticResult { Function::Sheets => scalar_arguments(args), Function::Cell => scalar_arguments(args), Function::Info => scalar_arguments(args), + + Function::Dget => not_implemented(args), + Function::Dmax => not_implemented(args), + Function::Dmin => not_implemented(args), + Function::Dcount => not_implemented(args), + Function::Daverage => not_implemented(args), + Function::Dsum => not_implemented(args), } } diff --git a/base/src/functions/database.rs b/base/src/functions/database.rs new file mode 100644 index 0000000..3e47854 --- /dev/null +++ b/base/src/functions/database.rs @@ -0,0 +1,568 @@ +use crate::{ + calc_result::CalcResult, + expressions::{parser::Node, token::Error, types::CellReferenceIndex}, + Model, +}; + +use super::util::{compare_values, from_wildcard_to_regex, result_matches_regex}; + +impl Model { + // =DAVERAGE(database, field, criteria) + pub(crate) fn fn_daverage(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let (db_left, db_right) = match self.get_reference(&args[0], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) { + Ok(c) => c, + Err(e) => return e, + }; + + let criteria = match self.get_reference(&args[2], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let mut sum = 0.0f64; + let mut count = 0usize; + + let mut row = db_left.row + 1; // skip header + while row <= db_right.row { + if self.db_row_matches_criteria(db_left, db_right, row, criteria) { + let v = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: field_col, + }); + if let CalcResult::Number(n) = v { + if n.is_finite() { + sum += n; + count += 1; + } + } + } + row += 1; + } + + if count == 0 { + return CalcResult::Error { + error: Error::DIV, + origin: cell, + message: "No numeric values matched criteria".to_string(), + }; + } + + CalcResult::Number(sum / count as f64) + } + + // =DCOUNT(database, field, criteria) + // Counts numeric entries in the field for rows that match criteria + pub(crate) fn fn_dcount(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let (db_left, db_right) = match self.get_reference(&args[0], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) { + Ok(c) => c, + Err(e) => return e, + }; + + let criteria = match self.get_reference(&args[2], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let mut count = 0usize; + let mut row = db_left.row + 1; // skip header + while row <= db_right.row { + if self.db_row_matches_criteria(db_left, db_right, row, criteria) { + let v = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: field_col, + }); + if matches!(v, CalcResult::Number(_)) { + count += 1; + } + } + row += 1; + } + + CalcResult::Number(count as f64) + } + + // =DGET(database, field, criteria) + // Returns the (single) field value for the unique matching row + pub(crate) fn fn_dget(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let (db_left, db_right) = match self.get_reference(&args[0], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) { + Ok(c) => c, + Err(e) => return e, + }; + + let criteria = match self.get_reference(&args[2], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let mut result: Option = None; + let mut matches = 0usize; + + let mut row = db_left.row + 1; // skip header + while row <= db_right.row { + if self.db_row_matches_criteria(db_left, db_right, row, criteria) { + matches += 1; + if matches > 1 { + return CalcResult::Error { + error: Error::NUM, + origin: cell, + message: "More than one matching record".to_string(), + }; + } + result = Some(self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: field_col, + })); + } + row += 1; + } + + match (matches, result) { + (0, _) | (_, None) => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "No matching record".to_string(), + }, + (_, Some(v)) => v, + } + } + + // =DMAX(database, field, criteria) + pub(crate) fn fn_dmax(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.db_extreme(args, cell, true) + } + + // =DMIN(database, field, criteria) + pub(crate) fn fn_dmin(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + self.db_extreme(args, cell, false) + } + + // =DSUM(database, field, criteria) + pub(crate) fn fn_dsum(&mut self, args: &[Node], cell: CellReferenceIndex) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let (db_left, db_right) = match self.get_reference(&args[0], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) { + Ok(c) => c, + Err(e) => return e, + }; + + let criteria = match self.get_reference(&args[2], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let mut sum = 0.0f64; + + // skip header + let mut row = db_left.row + 1; + while row <= db_right.row { + if self.db_row_matches_criteria(db_left, db_right, row, criteria) { + let v = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: field_col, + }); + if let CalcResult::Number(n) = v { + if n.is_finite() { + sum += n; + } + } + } + row += 1; + } + + CalcResult::Number(sum) + } + + /// Resolve the "field" (2nd arg) to an absolute column index (i32) within the sheet. + /// Field can be a number (1-based index) or a header name (case-insensitive). + /// Returns the absolute column index, not a 1-based offset within the database range. + fn resolve_db_field_column( + &mut self, + db_left: CellReferenceIndex, + db_right: CellReferenceIndex, + field_arg: &Node, + cell: CellReferenceIndex, + ) -> Result { + // If numeric -> index + if let Ok(n) = self.get_number(field_arg, cell) { + let idx = if n < 1.0 { + n.ceil() as i32 + } else { + n.floor() as i32 + }; + if idx < 1 || db_left.column + idx - 1 > db_right.column { + return Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Field index out of range".to_string(), + }); + } + return Ok(db_left.column + idx - 1); + } + + // Otherwise treat as header name + let wanted = match self.get_string(field_arg, cell) { + Ok(s) => s.to_lowercase(), + Err(e) => return Err(e), + }; + + let mut col = db_left.column; + while col <= db_right.column { + let v = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row: db_left.row, + column: col, + }); + if let CalcResult::String(s) = v { + if s.to_lowercase() == wanted { + return Ok(col); + } + } + col += 1; + } + + Err(CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "Field header not found".to_string(), + }) + } + + /// Check whether a database row matches the criteria range. + /// Criteria logic: OR across criteria rows; AND across columns within a row. + fn db_row_matches_criteria( + &mut self, + db_left: CellReferenceIndex, + db_right: CellReferenceIndex, + row: i32, + criteria: (CellReferenceIndex, CellReferenceIndex), + ) -> bool { + let (c_left, c_right) = criteria; + + // Read criteria headers (first row of criteria range) + // Map header name (lowercased) -> db column (if exists) + let mut crit_cols: Vec> = Vec::new(); + let mut col = c_left.column; + while col <= c_right.column { + let h = self.evaluate_cell(CellReferenceIndex { + sheet: c_left.sheet, + row: c_left.row, + column: col, + }); + let db_col = if let CalcResult::String(s) = h { + let wanted = s.to_lowercase(); + // Find corresponding DB column + let mut db_c = db_left.column; + let mut found: Option = None; + while db_c <= db_right.column { + let hdr = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row: db_left.row, + column: db_c, + }); + if let CalcResult::String(hs) = hdr { + if hs.to_lowercase() == wanted { + found = Some(db_c); + break; + } + } + db_c += 1; + } + found + } else { + None + }; + crit_cols.push(db_col); + col += 1; + } + + // If no criteria rows (only headers), everything matches + if c_right.row <= c_left.row { + return true; + } + + // Evaluate each criteria row (OR) + let mut r = c_left.row + 1; + while r <= c_right.row { + // AND across columns for this criteria row + let mut and_ok = true; + + for (offset, maybe_db_col) in crit_cols.iter().enumerate() { + // Criteria cell + let ccell = self.evaluate_cell(CellReferenceIndex { + sheet: c_left.sheet, + row: r, + column: c_left.column + offset as i32, + }); + + // Empty criteria cell -> ignored + if matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) { + continue; + } + + // Header without mapping -> ignore this criteria column (Excel ignores unknown headers) + let db_col = match maybe_db_col { + Some(c) => *c, + None => continue, + }; + + // Database value for this row/column + let db_val = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: db_col, + }); + + if !self.criteria_cell_matches(&db_val, &ccell) { + and_ok = false; + break; + } + } + + if and_ok { + // This criteria row satisfied (OR) + return true; + } + + r += 1; + } + + // none matched + false + } + + /// Implements Excel-like criteria matching for a single value. + /// Supports prefixes: <>, >=, <=, >, <, = ; wildcards * and ? for string equals. + fn criteria_cell_matches(&self, db_val: &CalcResult, crit_cell: &CalcResult) -> bool { + // Convert the criteria cell to a string for operator parsing if possible, + // otherwise fall back to equality via compare_values. + let crit_str_opt = match crit_cell { + CalcResult::String(s) => Some(s.clone()), + CalcResult::Number(n) => Some(n.to_string()), + CalcResult::Boolean(b) => Some(if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + }), + CalcResult::EmptyCell | CalcResult::EmptyArg => return true, + CalcResult::Error { .. } => return false, + CalcResult::Range { .. } | CalcResult::Array(_) => return false, + }; + + if crit_str_opt.is_none() { + return compare_values(db_val, crit_cell) == 0; + } + let mut crit = match crit_str_opt { + Some(s) => s.trim().to_string(), + None => return false, + }; + + // Detect operator prefix + let mut op = "="; // default equality (with wildcard semantics for strings) + let prefixes = ["<>", ">=", "<=", ">", "<", "="]; + for p in prefixes.iter() { + if crit.starts_with(p) { + op = p; + crit = crit[p.len()..].trim().to_string(); + break; + } + } + + // Try to parse numeric RHS + let rhs_num = crit.parse::().ok(); + + match op { + ">" | ">=" | "<" | "<=" => { + if let Some(t) = rhs_num { + // numeric comparison + if let CalcResult::Number(n) = db_val { + match op { + ">" => *n > t, + ">=" => *n >= t, + "<" => *n < t, + "<=" => *n <= t, + _ => false, + } + } else { + // For non-numbers, use compare_values with a number token to emulate Excel ordering + let rhs = CalcResult::Number(t); + let c = compare_values(db_val, &rhs); + match op { + ">" => c > 0, + ">=" => c >= 0, + "<" => c < 0, + "<=" => c <= 0, + _ => false, + } + } + } else { + // string comparison (case-insensitive) using compare_values semantics + let rhs = CalcResult::String(crit.to_lowercase()); + let lhs = match db_val { + CalcResult::String(s) => CalcResult::String(s.to_lowercase()), + x => x.clone(), + }; + let c = compare_values(&lhs, &rhs); + match op { + ">" => c > 0, + ">=" => c >= 0, + "<" => c < 0, + "<=" => c <= 0, + _ => false, + } + } + } + "<>" => { + // not equal (with wildcard semantics for strings) + // If rhs has wildcards and db_val is string, do regex; else use compare_values != 0 + if let CalcResult::String(s) = db_val { + if crit.contains('*') || crit.contains('?') { + if let Ok(re) = from_wildcard_to_regex(&crit.to_lowercase(), true) { + return !result_matches_regex( + &CalcResult::String(s.to_lowercase()), + &re, + ); + } + } + } + let rhs = if let Some(n) = rhs_num { + CalcResult::Number(n) + } else { + CalcResult::String(crit.to_lowercase()) + }; + let lhs = match db_val { + CalcResult::String(s) => CalcResult::String(s.to_lowercase()), + x => x.clone(), + }; + compare_values(&lhs, &rhs) != 0 + } + _ => { + // equality. For strings, support wildcards (*, ?) + if let Some(n) = rhs_num { + // numeric equals + if let CalcResult::Number(m) = db_val { + (*m - n).abs() <= f64::EPSILON + } else { + compare_values(db_val, &CalcResult::Number(n)) == 0 + } + } else { + // textual/boolean equals (case-insensitive), wildcard-enabled for strings + if let CalcResult::String(s) = db_val { + if crit.contains('*') || crit.contains('?') { + if let Ok(re) = from_wildcard_to_regex(&crit.to_lowercase(), true) { + return result_matches_regex( + &CalcResult::String(s.to_lowercase()), + &re, + ); + } + } + return s.to_lowercase() == crit.to_lowercase(); + } + // Fallback: compare_values equality + compare_values(db_val, &CalcResult::String(crit.to_lowercase())) == 0 + } + } + } + } + + /// Shared implementation for DMAX/DMIN + fn db_extreme( + &mut self, + args: &[Node], + cell: CellReferenceIndex, + want_max: bool, + ) -> CalcResult { + if args.len() != 3 { + return CalcResult::new_args_number_error(cell); + } + + let (db_left, db_right) = match self.get_reference(&args[0], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let field_col = match self.resolve_db_field_column(db_left, db_right, &args[1], cell) { + Ok(c) => c, + Err(e) => return e, + }; + + let criteria = match self.get_reference(&args[2], cell) { + Ok(r) => (r.left, r.right), + Err(e) => return e, + }; + + let mut best: Option = None; + + let mut row = db_left.row + 1; // skip header + while row <= db_right.row { + if self.db_row_matches_criteria(db_left, db_right, row, criteria) { + let v = self.evaluate_cell(CellReferenceIndex { + sheet: db_left.sheet, + row, + column: field_col, + }); + if let CalcResult::Number(n) = v { + if n.is_finite() { + best = Some(match best { + None => n, + Some(cur) => { + if want_max { + n.max(cur) + } else { + n.min(cur) + } + } + }); + } + } + } + row += 1; + } + + match best { + Some(v) => CalcResult::Number(v), + None => CalcResult::Error { + error: Error::VALUE, + origin: cell, + message: "No numeric values matched criteria".to_string(), + }, + } + } +} diff --git a/base/src/functions/mod.rs b/base/src/functions/mod.rs index a62fe45..b3b321b 100644 --- a/base/src/functions/mod.rs +++ b/base/src/functions/mod.rs @@ -8,6 +8,7 @@ use crate::{ }; pub(crate) mod binary_search; +mod database; mod date_and_time; mod engineering; mod financial; @@ -310,10 +311,18 @@ pub enum Function { Delta, Gestep, Subtotal, + + // Database + Daverage, + Dcount, + Dget, + Dmax, + Dmin, + Dsum, } impl Function { - pub fn into_iter() -> IntoIter { + pub fn into_iter() -> IntoIter { [ Function::And, Function::False, @@ -571,6 +580,12 @@ impl Function { Function::Cell, Function::Info, Function::Sheets, + Function::Daverage, + Function::Dcount, + Function::Dget, + Function::Dmax, + Function::Dmin, + Function::Dsum, ] .into_iter() } @@ -917,6 +932,13 @@ impl Function { "INFO" => Some(Function::Info), "SHEETS" | "_XLFN.SHEETS" => Some(Function::Sheets), + "DAVERAGE" => Some(Function::Daverage), + "DCOUNT" => Some(Function::Dcount), + "DGET" => Some(Function::Dget), + "DMAX" => Some(Function::Dmax), + "DMIN" => Some(Function::Dmin), + "DSUM" => Some(Function::Dsum), + _ => None, } } @@ -1182,6 +1204,12 @@ impl fmt::Display for Function { Function::Cell => write!(f, "CELL"), Function::Info => write!(f, "INFO"), Function::Sheets => write!(f, "SHEETS"), + Function::Daverage => write!(f, "DAVERAGE"), + Function::Dcount => write!(f, "DCOUNT"), + Function::Dget => write!(f, "DGET"), + Function::Dmax => write!(f, "DMAX"), + Function::Dmin => write!(f, "DMIN"), + Function::Dsum => write!(f, "DSUM"), } } } @@ -1466,6 +1494,12 @@ impl Model { Function::Cell => self.fn_cell(args, cell), Function::Info => self.fn_info(args, cell), Function::Sheets => self.fn_sheets(args, cell), + Function::Daverage => self.fn_daverage(args, cell), + Function::Dcount => self.fn_dcount(args, cell), + Function::Dget => self.fn_dget(args, cell), + Function::Dmax => self.fn_dmax(args, cell), + Function::Dmin => self.fn_dmin(args, cell), + Function::Dsum => self.fn_dsum(args, cell), } } } diff --git a/xlsx/tests/calc_tests/DAVERAGE.xlsx b/xlsx/tests/calc_tests/DAVERAGE.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..4f02cf71dc328ef2eae70298fd90c272351a9d75 GIT binary patch literal 9494 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 literal 0 HcmV?d00001