Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

83 changes: 83 additions & 0 deletions crates/engine/src/cmd/pivot/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1094,3 +1094,86 @@ fn legacy_count_numbers_caption_follows_the_function() {
assert_eq!(text(&s, "E3"), after, "{legacy}");
}
}

/// The grand total of the report on the active sheet (column `col` of the Grand Total row).
fn grand_total(s: &Session, col: u32) -> Value {
let gt = find_row(s, "Grand Total").unwrap();
val(s, &CellRef::new(gt, col).a1())
}

#[test]
fn table_source_leaves_out_its_totals_row() {
let mut s = sales();
s.execute("insert.table", json!({"range": "A1:E13", "name": "SalesTable"})).unwrap();
s.execute("table.totalRow", json!({"table": "SalesTable", "on": true})).unwrap();
s.execute("insert.pivotTable", json!({"source": "SalesTable", "rows": ["Region"], "values": ["Sales"]})).unwrap();
// Three regions, no "Total" item, and the totals row's sum is not counted again.
assert_eq!(find_row(&s, "Total"), None);
assert_eq!(grand_total(&s, 1), Value::Number(TOTAL));
assert_eq!(pt(&s).last_range.unwrap().height(), 5);
}

#[test]
fn sheet_scoped_and_chained_names_resolve() {
let mut s = sales();
s.execute("formulas.defineName", json!({"name": "Data", "refersTo": "=Sheet1!$A$1:$E$13", "scope": "Sheet1"})).unwrap();
s.execute("formulas.defineName", json!({"name": "Alias", "refersTo": "=Data"})).unwrap();
for (source, at) in [("Data", "H1"), ("Alias", "H20")] {
s.execute("insert.pivotTable", json!({"source": source, "destination": format!("Sheet1!{at}"), "rows": ["Region"], "values": ["Sales"]}))
.unwrap();
assert_eq!(pt(&s).source, source);
let r = pt(&s).last_range.unwrap();
assert_eq!(val(&s, &CellRef::new(r.end.row, r.start.col + 1).a1()), Value::Number(TOTAL), "{source}");
}
}

#[test]
fn repeated_and_padded_headers_keep_their_fields() {
let mut s = Session::new();
s.new_workbook();
let rows = json!([[" Region ", "Sales", "sales"], ["East", 1, 10], ["West", 2, 20], ["East", 3, 30]]);
s.execute("range.setValues", json!({"range": "A1", "values": rows})).unwrap();
let r =
s.execute("insert.pivotTable", json!({"source": "Sheet1!A1:C4", "rows": ["Region"], "values": ["sales2"], "filters": ["Sales"]})).unwrap();
assert_eq!(r["fields"], json!(["Region", "Sales", "sales2"]));
// The page field on the first `Sales` stays above the report and filters by its own column.
assert_eq!(text(&s, "A1"), "Sales");
s.execute("pivot.filter", json!({"field": "Sales", "selected": ["1", "3"]})).unwrap();
assert_eq!(text(&s, "B1"), "(Multiple Items)");
assert_eq!(grand_total(&s, 1), Value::Number(40.0));
}

#[test]
fn formatted_page_items_are_chosen_by_their_labels() {
let mut s = sales();
s.execute("home.numberFormat", json!({"range": "E2:E13", "code": "$#,##0.00"})).unwrap();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Units"], "filters": ["Sales"]})).unwrap();
s.execute("pivot.filter", json!({"field": "Sales", "selected": ["$120.00"]})).unwrap();
assert_eq!(text(&s, "B1"), "$120.00");
// East Pears (8) and West Apples (12).
assert_eq!(grand_total(&s, 1), Value::Number(20.0));
s.execute("pivot.filter", json!({"field": "Sales", "selected": ["$120.00", "$60.00"]})).unwrap();
assert_eq!(text(&s, "B1"), "(Multiple Items)");
assert_eq!(grand_total(&s, 1), Value::Number(30.0));
}

#[test]
fn headers_read_values_while_formulas_show() {
let mut s = sales();
s.execute("cell.set", json!({"cell": "A1", "input": "=\"Reg\"&\"ion\""})).unwrap();
s.execute("formulas.showFormulas", json!({"on": true})).unwrap();
let r = s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Sales"]})).unwrap();
assert_eq!(r["fields"][0], "Region");
}

#[test]
fn accounting_items_keep_their_labels_without_the_fill() {
let mut s = sales();
s.execute("home.numberFormat", json!({"range": "E2:E13", "code": "_($* #,##0_);_($* (#,##0);_($* \"-\"_);_(@_)"})).unwrap();
s.execute("insert.pivotTable", json!({"rows": ["Region"], "values": ["Units"], "filters": ["Sales"]})).unwrap();
// The page item is labelled without a space where the fill is, and a selection stored by
// that label (as files and earlier versions hold it) still matches.
s.execute("pivot.filter", json!({"field": "Sales", "selected": [" $120 "]})).unwrap();
assert_eq!(text(&s, "B1"), " $120 ");
assert_eq!(grand_total(&s, 1), Value::Number(20.0));
}
12 changes: 1 addition & 11 deletions crates/engine/src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,17 +137,7 @@ pub fn value_text(wb: &Workbook, sheet: &Sheet, c: CellRef) -> String {
/// [`value_text`] for a value and a format code given apart from a cell (print takes the code
/// from the cell's effective look, which a conditional format can override).
pub fn value_text_as(wb: &Workbook, v: &Value, code: &str) -> String {
let f = format(v, code, wb);
match f.fill {
Some((_, pos)) => {
let mut t = f.text;
if pos <= t.len() && t.is_char_boundary(pos) {
t.insert(pos, ' ');
}
t
}
None => f.text,
}
format(v, code, wb).plain()
}

/// Tab-separated text of a range, as the system clipboard gets it.
Expand Down
79 changes: 4 additions & 75 deletions crates/engine/src/pivot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,8 @@
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};

use gridcraft_core::date::datetime_from_serial;
pub use gridcraft_core::quote_sheet;
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value, split_sheet};
use gridcraft_core::{CellError, CellRef, MAX_COLS, MAX_ROWS, RangeRef, Value};
use gridcraft_model::{
BorderLine, BorderStyle, Cell, Chart, Color, Fill, HAlign, NumFmt, PivotDateGroup, PivotField, PivotFilter, PivotFunc, PivotLayout, PivotShowAs,
PivotSort, PivotTable, PivotValue, Series, Style, StyleId, Workbook,
Expand Down Expand Up @@ -42,8 +41,6 @@ pub const MAX_AXIS_FIELDS: usize = 32;
/// Most fields in the Values area.
pub const MAX_VALUE_FIELDS: usize = 256;

const MONTHS: [&str; 12] = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];

// ---------------------------------------------------------------- source

/// The source list: header names, per-column number formats and the records.
Expand Down Expand Up @@ -74,86 +71,18 @@ pub fn range_text(sheet_name: &str, r: RangeRef) -> String {

/// Resolves source text (range, table name or defined name) to (sheet, range including header).
pub fn resolve_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<(usize, RangeRef), String> {
resolve_inner(wb, text, default_sheet, 0)
}

fn resolve_inner(wb: &Workbook, text: &str, default_sheet: usize, depth: u32) -> Result<(usize, RangeRef), String> {
const INVALID: &str = "The reference isn't valid.";
let t = text.trim().trim_start_matches('=').trim();
if t.is_empty() || depth > 4 {
return Err(INVALID.into());
}
if let Some((si, ti)) = wb.table(t)
&& let Some(tb) = wb.sheet(si).and_then(|s| s.tables.get(ti))
{
if !tb.header_row {
return Err("The table needs a header row to be used as a PivotTable source.".into());
}
let end_row = tb.range.end.row.saturating_sub(tb.totals_row as u32).max(tb.range.start.row);
return Ok((si, RangeRef::new(tb.range.start, CellRef::new(end_row, tb.range.end.col))));
}
let (sheet, body) = split_sheet(t);
if let Some(r) = RangeRef::parse(body) {
let si = match sheet {
Some(n) => wb.sheet_index(&n).ok_or_else(|| INVALID.to_string())?,
None => default_sheet,
};
let sh = wb.sheet(si).ok_or_else(|| INVALID.to_string())?;
let r = if r.is_full_cols() || r.is_full_rows() {
let used = sh.used_range().ok_or_else(|| INVALID.to_string())?;
r.intersection(&used).ok_or_else(|| INVALID.to_string())?
} else {
r
};
return Ok((si, r));
}
if sheet.is_none()
&& let Some(n) = wb.name(t, default_sheet)
{
let f = n.formula.clone();
return resolve_inner(wb, &f, default_sheet, depth + 1);
}
Err(INVALID.into())
gridcraft_model::pivot_source::resolve(wb, text, default_sheet)
}

const FIELD_NAME_ERR: &str =
"The PivotTable field name is not valid. To create a PivotTable, you must use data that is organized as a list with labeled columns.";

/// Reads the source list.
pub fn read_source(wb: &Workbook, text: &str, default_sheet: usize) -> Result<Source, String> {
let (si, r) = resolve_source(wb, text, default_sheet)?;
let sh = wb.sheet(si).ok_or("The reference isn't valid.")?;
if r.count() > MAX_SOURCE_CELLS {
return Err("The source range is too large for a PivotTable.".into());
}
let mut headers: Vec<String> = Vec::new();
// Names taken so far (lower-case), and the next suffix to try for each repeated header, so
// that thousands of equal headers don't take cubic time.
let mut taken: HashSet<String> = HashSet::new();
let mut next_suffix: HashMap<String, usize> = HashMap::new();
let mut formats = Vec::new();
for c in r.start.col..=r.end.col {
let h = crate::display::cell_text(wb, sh, CellRef::new(r.start.row, c)).trim().to_string();
if h.is_empty() {
return Err(FIELD_NAME_ERR.into());
}
let key = h.to_ascii_lowercase();
let mut n = h.clone();
let mut k = next_suffix.get(&key).copied().unwrap_or(2);
while taken.contains(&n.to_ascii_lowercase()) {
n = format!("{h}{k}");
k += 1;
}
next_suffix.insert(key, k);
taken.insert(n.to_ascii_lowercase());
headers.push(n);
let fmt = if r.end.row > r.start.row {
wb.styles.get(sh.style_id(CellRef::new(r.start.row + 1, c))).num_fmt.as_str().to_string()
} else {
"General".into()
};
formats.push(fmt);
}
let headers = gridcraft_model::pivot_source::headers(wb, si, r)?;
let formats: Vec<String> = (r.start.col..=r.end.col).map(|c| gridcraft_model::pivot_source::column_format(wb, si, r, c)).collect();
let w = headers.len();
let mut rows = Vec::new();
let mut numeric = vec![true; w];
Expand Down
25 changes: 14 additions & 11 deletions crates/engine/src/pivot/compute.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_label};
use gridcraft_model::pivot_source::{ItemFormat, item_label};

use super::items::{Acc, FieldItems, build_tree, collect_siblings, is_collapsed, item_format};
use super::output::{
AggMap, CellKind, ColKind, ColSpec, Ctx, Grid, Line, LineKind, Output, RowKind, ValSrc, caption, data_cols_exceed_sheet, flatten_cols,
};
Expand Down Expand Up @@ -78,11 +80,12 @@ struct AggregateInput<'a> {
vals: &'a [(ValSrc, &'a PivotValue)],
extra: &'a [usize],
filters: &'a [(usize, &'a PivotFilter, PivotDateGroup, bool)],
filter_fmts: &'a [String],
/// Each filter's, row field's and column field's item format, parsed once for the report.
filter_fmts: &'a [ItemFormat],
/// Each filter's selected labels, ASCII-lowercased, so a record's test is one probe.
filter_sets: &'a [Option<HashSet<String>>],
row_fmts: &'a [String],
col_fmts: &'a [String],
row_fmts: &'a [ItemFormat],
col_fmts: &'a [ItemFormat],
empty: bool,
}

Expand All @@ -109,7 +112,7 @@ fn aggregate_record(
) -> Result<(), String> {
for (((c, _, group, _), fmt), selected) in input.filters.iter().zip(input.filter_fmts).zip(input.filter_sets) {
if let Some(selected) = selected {
let label = item_label(input.wb, rec.get(*c).unwrap_or(&Value::Empty), *group, fmt);
let label = item_label(rec.get(*c).unwrap_or(&Value::Empty), *group, fmt, input.wb.date_system);
if !selected.contains(&label.to_ascii_lowercase()) {
return Ok(());
}
Expand Down Expand Up @@ -442,14 +445,12 @@ fn show_value(definition: &PivotValue, raw: Value, lookup: &ValueLookup<'_>, con
type ReportFilter<'a> = (usize, &'a PivotFilter, PivotDateGroup, bool);

fn report_filters<'a>(pt: &'a PivotTable, src: &Source) -> (Vec<ReportFilter<'a>>, Vec<ReportFilter<'a>>) {
let axis_group =
|name: &str| pt.rows.iter().chain(pt.columns.iter()).find(|field| field.source_col.eq_ignore_ascii_case(name)).map(|field| field.date_group);
let filters: Vec<_> = pt
.filters
.iter()
.filter_map(|filter| {
let column = src.col(&filter.source_col)?;
let group = axis_group(&filter.source_col);
let group = pt.axis_field(&filter.source_col).map(|field| field.date_group);
Some((column, filter, group.unwrap_or_default(), group.is_none()))
})
.collect();
Expand Down Expand Up @@ -532,11 +533,13 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
let row_levels = rows.len() + usize::from(on_rows);

// ---- aggregate
let filter_fmts: Vec<String> = filters.iter().map(|f| fmt_of(f.0)).collect();
let filter_fmts: Vec<ItemFormat> = filters.iter().map(|f| item_format(&fmt_of(f.0))).collect();
let filter_sets: Vec<Option<HashSet<String>>> =
filters.iter().map(|(_, field, _, _)| field.selected.as_ref().map(|s| s.iter().map(|v| v.to_ascii_lowercase()).collect())).collect();
let row_fmts: Vec<String> = rows.iter().map(|(c, _)| fmt_of(*c)).collect();
let col_fmts: Vec<String> = cols.iter().map(|(c, _)| fmt_of(*c)).collect();
let row_item_fmts: Vec<ItemFormat> = row_fmts.iter().map(|f| item_format(f)).collect();
let col_item_fmts: Vec<ItemFormat> = col_fmts.iter().map(|f| item_format(f)).collect();
let aggregate = aggregate_source(
AggregateInput {
wb,
Expand All @@ -549,8 +552,8 @@ pub(super) fn compute_from(wb: &Workbook, pt: &PivotTable, src: &Source, mut pro
filters: &filters,
filter_fmts: &filter_fmts,
filter_sets: &filter_sets,
row_fmts: &row_fmts,
col_fmts: &col_fmts,
row_fmts: &row_item_fmts,
col_fmts: &col_item_fmts,
empty,
},
&mut probe,
Expand Down
Loading