committed by
Nicolás Hatcher Andrés
parent
129959137d
commit
c52c05aa8e
@@ -1,6 +1,9 @@
|
||||
use chrono::Datelike;
|
||||
|
||||
use crate::{
|
||||
calc_result::CalcResult,
|
||||
expressions::{parser::Node, token::Error, types::CellReferenceIndex},
|
||||
formatter::dates::date_to_serial_number,
|
||||
Model,
|
||||
};
|
||||
|
||||
@@ -28,6 +31,15 @@ impl Model {
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if db_right.row <= db_left.row {
|
||||
// no data rows
|
||||
return CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No data rows in database".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut sum = 0.0f64;
|
||||
let mut count = 0usize;
|
||||
|
||||
@@ -82,6 +94,15 @@ impl Model {
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if db_right.row <= db_left.row {
|
||||
// no data rows
|
||||
return CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No data rows in database".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut count = 0usize;
|
||||
let mut row = db_left.row + 1; // skip header
|
||||
while row <= db_right.row {
|
||||
@@ -123,10 +144,19 @@ impl Model {
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if db_right.row <= db_left.row {
|
||||
// no data rows
|
||||
return CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No data rows in database".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut result: Option<CalcResult> = None;
|
||||
let mut matches = 0usize;
|
||||
|
||||
let mut row = db_left.row + 1; // 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) {
|
||||
matches += 1;
|
||||
@@ -187,7 +217,16 @@ impl Model {
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
let mut sum = 0.0f64;
|
||||
if db_right.row <= db_left.row {
|
||||
// no data rows
|
||||
return CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No data rows in database".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut sum = 0.0;
|
||||
|
||||
// skip header
|
||||
let mut row = db_left.row + 1;
|
||||
@@ -220,42 +259,80 @@ impl Model {
|
||||
field_arg: &Node,
|
||||
cell: CellReferenceIndex,
|
||||
) -> Result<i32, CalcResult> {
|
||||
// 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(),
|
||||
});
|
||||
let field_column_name = match self.evaluate_node_in_context(field_arg, cell) {
|
||||
CalcResult::String(s) => s.to_lowercase(),
|
||||
CalcResult::Number(index) => {
|
||||
let index = index.floor() as i32;
|
||||
if index < 1 || db_left.column + index - 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 + index - 1);
|
||||
}
|
||||
CalcResult::Boolean(b) => {
|
||||
return if b {
|
||||
Ok(1)
|
||||
} else {
|
||||
// Index 0 is out of range
|
||||
Err(CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "Invalid field specifier".to_string(),
|
||||
})
|
||||
};
|
||||
}
|
||||
error @ CalcResult::Error { .. } => {
|
||||
return Err(error);
|
||||
}
|
||||
CalcResult::Range { .. } => {
|
||||
return Err(CalcResult::Error {
|
||||
error: Error::NIMPL,
|
||||
origin: cell,
|
||||
message: "Arrays not supported yet".to_string(),
|
||||
})
|
||||
}
|
||||
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
|
||||
CalcResult::Array(_) => {
|
||||
return Err(CalcResult::Error {
|
||||
error: Error::NIMPL,
|
||||
origin: cell,
|
||||
message: "Arrays not supported yet".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 {
|
||||
// We search in the database a column whose header matches field_column_name
|
||||
for column in db_left.column..=db_right.column {
|
||||
let v = self.evaluate_cell(CellReferenceIndex {
|
||||
sheet: db_left.sheet,
|
||||
row: db_left.row,
|
||||
column: col,
|
||||
column,
|
||||
});
|
||||
if let CalcResult::String(s) = v {
|
||||
if s.to_lowercase() == wanted {
|
||||
return Ok(col);
|
||||
match &v {
|
||||
CalcResult::String(s) => {
|
||||
if s.to_lowercase() == field_column_name {
|
||||
return Ok(column);
|
||||
}
|
||||
}
|
||||
CalcResult::Number(n) => {
|
||||
if field_column_name == n.to_string() {
|
||||
return Ok(column);
|
||||
}
|
||||
}
|
||||
CalcResult::Boolean(b) => {
|
||||
if field_column_name == b.to_string() {
|
||||
return Ok(column);
|
||||
}
|
||||
}
|
||||
CalcResult::Error { .. }
|
||||
| CalcResult::Range { .. }
|
||||
| CalcResult::EmptyCell
|
||||
| CalcResult::EmptyArg
|
||||
| CalcResult::Array(_) => {}
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
|
||||
Err(CalcResult::Error {
|
||||
@@ -278,53 +355,84 @@ impl Model {
|
||||
|
||||
// Read criteria headers (first row of criteria range)
|
||||
// Map header name (lowercased) -> db column (if exists)
|
||||
let mut crit_cols: Vec<Option<i32>> = Vec::new();
|
||||
let mut col = c_left.column;
|
||||
while col <= c_right.column {
|
||||
let h = self.evaluate_cell(CellReferenceIndex {
|
||||
let mut crit_cols: Vec<i32> = Vec::new();
|
||||
let mut header_count = 0;
|
||||
// We cover the criteria table:
|
||||
// headerA | headerB | ...
|
||||
// critA1 | critA2 | ...
|
||||
// critB1 | critB2 | ...
|
||||
// ...
|
||||
for column in c_left.column..=c_right.column {
|
||||
let cell = CellReferenceIndex {
|
||||
sheet: c_left.sheet,
|
||||
row: c_left.row,
|
||||
column: col,
|
||||
});
|
||||
let db_col = if let CalcResult::String(s) = h {
|
||||
column,
|
||||
};
|
||||
let criteria_header = self.evaluate_cell(cell);
|
||||
if let Ok(s) = self.cast_to_string(criteria_header, cell) {
|
||||
// Non-empty string header. If the header is non string we skip it
|
||||
header_count += 1;
|
||||
let wanted = s.to_lowercase();
|
||||
// Find corresponding DB column
|
||||
let mut db_c = db_left.column;
|
||||
let mut found: Option<i32> = None;
|
||||
while db_c <= db_right.column {
|
||||
let hdr = self.evaluate_cell(CellReferenceIndex {
|
||||
|
||||
// Find corresponding Database column
|
||||
let mut found = false;
|
||||
for db_column in db_left.column..=db_right.column {
|
||||
let db_header = self.evaluate_cell(CellReferenceIndex {
|
||||
sheet: db_left.sheet,
|
||||
row: db_left.row,
|
||||
column: db_c,
|
||||
column: db_column,
|
||||
});
|
||||
if let CalcResult::String(hs) = hdr {
|
||||
if let Ok(hs) = self.cast_to_string(db_header, cell) {
|
||||
if hs.to_lowercase() == wanted {
|
||||
found = Some(db_c);
|
||||
crit_cols.push(db_column);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
db_c += 1;
|
||||
}
|
||||
found
|
||||
} else {
|
||||
None
|
||||
if !found {
|
||||
// that means the criteria column has no matching DB column
|
||||
// If the criteria condition is empty then we remove this condition
|
||||
// otherwise this condition can never be satisfied
|
||||
// We evaluate all criteria rows to see if any is non-empty
|
||||
let mut has_non_empty = false;
|
||||
for r in (c_left.row + 1)..=c_right.row {
|
||||
let ccell = self.evaluate_cell(CellReferenceIndex {
|
||||
sheet: c_left.sheet,
|
||||
row: r,
|
||||
column,
|
||||
});
|
||||
if !matches!(ccell, CalcResult::EmptyCell | CalcResult::EmptyArg) {
|
||||
has_non_empty = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if has_non_empty {
|
||||
// This criteria column can never be satisfied
|
||||
header_count -= 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
crit_cols.push(db_col);
|
||||
col += 1;
|
||||
}
|
||||
|
||||
// If no criteria rows (only headers), everything matches
|
||||
if c_right.row <= c_left.row {
|
||||
// If no criteria rows (only headers), everything matches
|
||||
return true;
|
||||
}
|
||||
|
||||
if header_count == 0 {
|
||||
// If there are not "String" headers, nothing matches
|
||||
// NB: There might be String headers that do not match any DB columns,
|
||||
// in that case everything matches.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Evaluate each criteria row (OR)
|
||||
let mut r = c_left.row + 1;
|
||||
while r <= c_right.row {
|
||||
for r in (c_left.row + 1)..=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() {
|
||||
for (offset, db_col) in crit_cols.iter().enumerate() {
|
||||
// Criteria cell
|
||||
let ccell = self.evaluate_cell(CellReferenceIndex {
|
||||
sheet: c_left.sheet,
|
||||
@@ -337,17 +445,11 @@ impl Model {
|
||||
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,
|
||||
column: *db_col,
|
||||
});
|
||||
|
||||
if !self.criteria_cell_matches(&db_val, &ccell) {
|
||||
@@ -360,8 +462,6 @@ impl Model {
|
||||
// This criteria row satisfied (OR)
|
||||
return true;
|
||||
}
|
||||
|
||||
r += 1;
|
||||
}
|
||||
|
||||
// none matched
|
||||
@@ -373,44 +473,67 @@ impl Model {
|
||||
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,
|
||||
|
||||
let mut criteria = match crit_cell {
|
||||
CalcResult::String(s) => s.trim().to_string(),
|
||||
CalcResult::Number(n) => {
|
||||
// treat as equality with number
|
||||
return match db_val {
|
||||
CalcResult::Number(v) => (*v - *n).abs() <= f64::EPSILON,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
CalcResult::Boolean(b) => {
|
||||
// check equality with boolean
|
||||
return match db_val {
|
||||
CalcResult::Boolean(v) => *v == *b,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
CalcResult::EmptyCell | CalcResult::EmptyArg => "".to_string(),
|
||||
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) {
|
||||
if criteria.starts_with(p) {
|
||||
op = p;
|
||||
crit = crit[p.len()..].trim().to_string();
|
||||
criteria = criteria[p.len()..].trim().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse numeric RHS
|
||||
let rhs_num = crit.parse::<f64>().ok();
|
||||
// Is it a number?
|
||||
let rhs_num = criteria.parse::<f64>().ok();
|
||||
|
||||
// Is it a date?
|
||||
// FIXME: We should parse dates according to locale settings
|
||||
let rhs_date = criteria.parse::<chrono::NaiveDate>().ok();
|
||||
|
||||
match op {
|
||||
">" | ">=" | "<" | "<=" => {
|
||||
if let Some(t) = rhs_num {
|
||||
if let Some(d) = rhs_date {
|
||||
// date comparison
|
||||
let serial = match date_to_serial_number(d.day(), d.month(), d.year()) {
|
||||
Ok(sn) => sn as f64,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
if let CalcResult::Number(n) = db_val {
|
||||
match op {
|
||||
">" => *n > serial,
|
||||
">=" => *n >= serial,
|
||||
"<" => *n < serial,
|
||||
"<=" => *n <= serial,
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if let Some(t) = rhs_num {
|
||||
// numeric comparison
|
||||
if let CalcResult::Number(n) = db_val {
|
||||
match op {
|
||||
@@ -421,7 +544,6 @@ impl Model {
|
||||
_ => 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 {
|
||||
@@ -434,7 +556,7 @@ impl Model {
|
||||
}
|
||||
} else {
|
||||
// string comparison (case-insensitive) using compare_values semantics
|
||||
let rhs = CalcResult::String(crit.to_lowercase());
|
||||
let rhs = CalcResult::String(criteria.to_lowercase());
|
||||
let lhs = match db_val {
|
||||
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
|
||||
x => x.clone(),
|
||||
@@ -453,8 +575,8 @@ impl Model {
|
||||
// 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) {
|
||||
if criteria.contains('*') || criteria.contains('?') {
|
||||
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
|
||||
return !result_matches_regex(
|
||||
&CalcResult::String(s.to_lowercase()),
|
||||
&re,
|
||||
@@ -465,7 +587,7 @@ impl Model {
|
||||
let rhs = if let Some(n) = rhs_num {
|
||||
CalcResult::Number(n)
|
||||
} else {
|
||||
CalcResult::String(crit.to_lowercase())
|
||||
CalcResult::String(criteria.to_lowercase())
|
||||
};
|
||||
let lhs = match db_val {
|
||||
CalcResult::String(s) => CalcResult::String(s.to_lowercase()),
|
||||
@@ -485,18 +607,19 @@ impl Model {
|
||||
} 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) {
|
||||
if criteria.contains('*') || criteria.contains('?') {
|
||||
if let Ok(re) = from_wildcard_to_regex(&criteria.to_lowercase(), true) {
|
||||
return result_matches_regex(
|
||||
&CalcResult::String(s.to_lowercase()),
|
||||
&re,
|
||||
);
|
||||
}
|
||||
}
|
||||
return s.to_lowercase() == crit.to_lowercase();
|
||||
// This is weird but we only need to check if "starts with" for equality
|
||||
return s.to_lowercase().starts_with(&criteria.to_lowercase());
|
||||
}
|
||||
// Fallback: compare_values equality
|
||||
compare_values(db_val, &CalcResult::String(crit.to_lowercase())) == 0
|
||||
compare_values(db_val, &CalcResult::String(criteria.to_lowercase())) == 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -528,9 +651,18 @@ impl Model {
|
||||
Err(e) => return e,
|
||||
};
|
||||
|
||||
if db_right.row <= db_left.row {
|
||||
// no data rows
|
||||
return CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No data rows in database".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
let mut best: Option<f64> = None;
|
||||
|
||||
let mut row = db_left.row + 1; // 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 {
|
||||
@@ -538,15 +670,15 @@ impl Model {
|
||||
row,
|
||||
column: field_col,
|
||||
});
|
||||
if let CalcResult::Number(n) = v {
|
||||
if n.is_finite() {
|
||||
if let CalcResult::Number(value) = v {
|
||||
if value.is_finite() {
|
||||
best = Some(match best {
|
||||
None => n,
|
||||
None => value,
|
||||
Some(cur) => {
|
||||
if want_max {
|
||||
n.max(cur)
|
||||
value.max(cur)
|
||||
} else {
|
||||
n.min(cur)
|
||||
value.min(cur)
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -558,11 +690,7 @@ impl Model {
|
||||
|
||||
match best {
|
||||
Some(v) => CalcResult::Number(v),
|
||||
None => CalcResult::Error {
|
||||
error: Error::VALUE,
|
||||
origin: cell,
|
||||
message: "No numeric values matched criteria".to_string(),
|
||||
},
|
||||
None => CalcResult::Number(0.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BIN
xlsx/tests/calc_tests/DMIN_DMAX_DAVERAGE_DSUM_DCOUNT_DGET.xlsx
Normal file
BIN
xlsx/tests/calc_tests/DMIN_DMAX_DAVERAGE_DSUM_DCOUNT_DGET.xlsx
Normal file
Binary file not shown.
Reference in New Issue
Block a user