use logisheets_base::{BlockId, SheetId};
use super::schema_manager::field_type::{AggFunc, FieldAggregate, PivotColumn, PivotSpec};
#[derive(Debug, Clone, Copy)]
pub struct AnalysisTarget<'a> {
pub source: BlockId,
pub pivot: Option<&'a PivotSpec>,
}
fn lit(v: &str) -> String {
v.replace('"', "\"\"")
}
pub fn aggregate_formula(
source_sheet: SheetId,
source_block: BlockId,
aggregate: Option<&FieldAggregate>,
) -> Option<String> {
let agg = aggregate?;
Some(format!(
"{}(BLOCKREFSB({},{},\"*\",\"{}\"))",
agg.func.as_str(),
source_sheet,
source_block,
lit(&agg.source_field)
))
}
pub fn pivot_formula(
source_sheet: SheetId,
source_block: BlockId,
pivot: &PivotSpec,
field_name: &str,
column: Option<&PivotColumn>,
) -> String {
let col = |field: &str| {
format!(
"BLOCKREFSB({},{},\"*\",\"{}\")",
source_sheet,
source_block,
lit(field)
)
};
let (measure, func) = pivot.effective(column);
let counts_records = matches!(func, AggFunc::Count | AggFunc::CountA);
let mut out = if counts_records {
format!("COUNTIFS({},#KEY", col(&pivot.row_dim))
} else {
format!(
"{}IFS({},{},#KEY",
func.as_str(),
col(measure),
col(&pivot.row_dim),
)
};
if let Some(col_dim) = &pivot.col_dim {
let value = match column {
Some(c) => c.col_value.as_deref(),
None => Some(field_name),
};
if let Some(value) = value {
out.push_str(&format!(",{},\"{}\"", col(col_dim), lit(value)));
}
}
for f in &pivot.filters {
out.push_str(&format!(",{},\"{}\"", col(&f.field), lit(&f.criteria)));
}
if matches!(func, AggFunc::CountA) {
out.push_str(&format!(",{},\"<>\"", col(measure)));
}
out.push(')');
out
}
pub fn pivot_key_validation(
source_sheet: SheetId,
source_block: BlockId,
pivot: &PivotSpec,
) -> String {
let col = |field: &str| {
format!(
"BLOCKREFSB({},{},\"*\",\"{}\")",
source_sheet,
source_block,
lit(field)
)
};
let mut out = format!("COUNTIFS({},#PLACEHOLDER", col(&pivot.row_dim));
for f in &pivot.filters {
out.push_str(&format!(",{},\"{}\"", col(&f.field), lit(&f.criteria)));
}
out.push_str(")>0");
out
}
pub fn remove_cascade(
navigator: &crate::navigator::Navigator,
sheet_id: SheetId,
block_id: BlockId,
) -> Vec<BlockId> {
let mut out: Vec<BlockId> = Vec::new();
let mut frontier = vec![block_id];
while let Some(id) = frontier.pop() {
for dependent in navigator.analyzed_by(&sheet_id, id) {
if dependent != block_id && !out.contains(&dependent) {
out.push(dependent);
frontier.push(dependent);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::block_manager::schema_manager::field_type::{AggFunc, PivotFilter};
fn agg(func: AggFunc, field: &str) -> FieldAggregate {
FieldAggregate {
func,
source_field: field.to_string(),
}
}
#[test]
fn a_field_with_no_aggregate_gets_no_formula() {
assert_eq!(aggregate_formula(0, 1, None), None);
}
#[test]
fn each_function_lowers_to_itself_over_the_source_field() {
for (func, name) in [
(AggFunc::Sum, "SUM"),
(AggFunc::Count, "COUNT"),
(AggFunc::CountA, "COUNTA"),
(AggFunc::Average, "AVERAGE"),
(AggFunc::Min, "MIN"),
(AggFunc::Max, "MAX"),
] {
assert_eq!(
aggregate_formula(7, 3, Some(&agg(func, "amt"))).unwrap(),
format!("{name}(BLOCKREFSB(7,3,\"*\",\"amt\"))")
);
}
}
#[test]
fn the_source_is_named_by_id_so_renaming_the_block_cannot_reach_it() {
let f = aggregate_formula(2, 9, Some(&agg(AggFunc::Sum, "amt"))).unwrap();
assert!(f.contains("BLOCKREFSB(2,9,"));
assert!(!f.contains("orders"), "no ref name appears: {f}");
}
fn pivot(col_dim: Option<&str>) -> PivotSpec {
use crate::block_manager::schema_manager::field_type::DimOrder;
PivotSpec {
row_dim: "region".into(),
col_dim: col_dim.map(String::from),
measure: "amt".into(),
func: AggFunc::Sum,
order: DimOrder::Ascending,
order_values: vec![],
filters: vec![],
}
}
#[test]
fn a_pivot_cell_filters_on_its_row_and_on_its_own_column_name() {
assert_eq!(
pivot_formula(7, 3, &pivot(Some("quarter")), "Q1", None),
"SUMIFS(BLOCKREFSB(7,3,\"*\",\"amt\"),\
BLOCKREFSB(7,3,\"*\",\"region\"),#KEY,\
BLOCKREFSB(7,3,\"*\",\"quarter\"),\"Q1\")"
);
}
#[test]
fn a_grouped_pivot_filters_on_the_row_alone() {
assert_eq!(
pivot_formula(7, 3, &pivot(None), "total", None),
"SUMIFS(BLOCKREFSB(7,3,\"*\",\"amt\"),\
BLOCKREFSB(7,3,\"*\",\"region\"),#KEY)"
);
}
#[test]
fn every_function_lowers_to_its_ifs_form() {
for (func, name) in [
(AggFunc::Sum, "SUMIFS"),
(AggFunc::Count, "COUNTIFS"),
(AggFunc::CountA, "COUNTIFS"),
(AggFunc::Average, "AVERAGEIFS"),
(AggFunc::Min, "MINIFS"),
(AggFunc::Max, "MAXIFS"),
] {
let spec = PivotSpec {
func,
..pivot(Some("quarter"))
};
assert!(
pivot_formula(7, 3, &spec, "Q1", None).starts_with(&format!("{name}(")),
"{func:?} should lower to {name}"
);
}
}
#[test]
fn count_omits_the_measure_because_countifs_has_no_value_range() {
let spec = PivotSpec {
func: AggFunc::Count,
..pivot(Some("quarter"))
};
assert_eq!(
pivot_formula(7, 3, &spec, "Q1", None),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#KEY,\
BLOCKREFSB(7,3,\"*\",\"quarter\"),\"Q1\")"
);
let grouped = PivotSpec {
func: AggFunc::Count,
..pivot(None)
};
assert_eq!(
pivot_formula(7, 3, &grouped, "total", None),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#KEY)"
);
}
#[test]
fn counta_counts_records_whose_measure_is_present() {
let spec = PivotSpec {
func: AggFunc::CountA,
..pivot(Some("quarter"))
};
assert_eq!(
pivot_formula(7, 3, &spec, "Q1", None),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#KEY,\
BLOCKREFSB(7,3,\"*\",\"quarter\"),\"Q1\",\
BLOCKREFSB(7,3,\"*\",\"amt\"),\"<>\")"
);
}
#[test]
fn countas_criterion_comes_after_the_recipes_own_filters() {
let spec = PivotSpec {
func: AggFunc::CountA,
filters: vec![PivotFilter {
field: "region".into(),
criteria: "<>North".into(),
}],
..pivot(None)
};
assert_eq!(
pivot_formula(7, 3, &spec, "total", None),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#KEY,\
BLOCKREFSB(7,3,\"*\",\"region\"),\"<>North\",\
BLOCKREFSB(7,3,\"*\",\"amt\"),\"<>\")"
);
}
#[test]
fn a_declared_column_can_counta_its_own_measure() {
let spec = PivotSpec {
func: AggFunc::Sum,
..pivot(Some("quarter"))
};
let column = PivotColumn {
col_value: None,
measure: Some("note".into()),
func: Some(AggFunc::CountA),
};
assert_eq!(
pivot_formula(7, 3, &spec, "filled", Some(&column)),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#KEY,\
BLOCKREFSB(7,3,\"*\",\"note\"),\"<>\")"
);
}
#[test]
fn a_quote_in_a_dimension_value_is_escaped() {
let f = pivot_formula(0, 1, &pivot(Some("quarter")), "say \"hi\"", None);
assert!(
f.contains("\"say \"\"hi\"\"\""),
"expected doubling, got {f}"
);
}
#[test]
fn the_source_is_named_by_id_in_a_pivot_too() {
let f = pivot_formula(2, 9, &pivot(Some("quarter")), "Q1", None);
assert!(f.contains("BLOCKREFSB(2,9,"));
assert!(!f.contains("orders"), "no ref name appears: {f}");
}
#[test]
fn the_key_rule_asks_whether_the_value_occurs_in_the_source() {
assert_eq!(
pivot_key_validation(7, 3, &pivot(Some("quarter"))),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#PLACEHOLDER)>0"
);
}
#[test]
fn the_key_rule_applies_the_pivot_s_own_filters() {
let spec = PivotSpec {
filters: vec![PivotFilter {
field: "region".into(),
criteria: "<>North".into(),
}],
..pivot(Some("quarter"))
};
assert_eq!(
pivot_key_validation(7, 3, &spec),
"COUNTIFS(BLOCKREFSB(7,3,\"*\",\"region\"),#PLACEHOLDER,\
BLOCKREFSB(7,3,\"*\",\"region\"),\"<>North\")>0"
);
}
#[test]
fn a_quote_in_a_filter_criteria_is_escaped_in_the_key_rule() {
let spec = PivotSpec {
filters: vec![PivotFilter {
field: "region".into(),
criteria: "<>we\"ird".into(),
}],
..pivot(None)
};
assert!(pivot_key_validation(0, 1, &spec).contains("\"<>we\"\"ird\""));
}
#[test]
fn a_quote_in_the_row_dimension_name_is_escaped_in_the_key_rule() {
let spec = PivotSpec {
row_dim: "we\"ird".into(),
..pivot(None)
};
assert!(pivot_key_validation(0, 1, &spec).contains("\"we\"\"ird\""));
}
#[test]
fn a_quote_in_a_field_name_is_escaped_rather_than_closing_the_literal() {
let f = aggregate_formula(0, 1, Some(&agg(AggFunc::Sum, "say \"hi\""))).unwrap();
assert!(
f.contains("\"say \"\"hi\"\"\""),
"expected a doubled quote, got {f}"
);
}
}