use std::collections::{HashMap, HashSet};
use crate::dax::{self, RawRef, unescape_name};
use crate::identity::{NameKey, ObjectId, fold_name};
use crate::model::index::ModelIndex;
use crate::model::{PartitionSource, Table, TabularDatabase};
use crate::report::{FieldTarget, ReportModel};
use super::builder::table_struct;
use super::provenance::BindingEdge;
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum BrokenReason {
TableNotFound,
FieldNotFound,
MeasureNotFound,
HierarchyNotFound,
LevelNotFound,
BoundArtifactBroken {
artifact: ObjectId,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BrokenBinding {
pub edge: BindingEdge,
pub target: FieldTarget,
pub reason: BrokenReason,
}
type SortKey<'a> = (
Option<&'a NameKey>,
Option<&'a NameKey>,
Option<&'a NameKey>,
Option<&'a NameKey>,
bool,
String,
&'a BrokenReason,
);
impl BrokenBinding {
pub(super) fn sort_key(&self) -> SortKey<'_> {
(
self.edge.report.as_ref(),
self.edge.page.as_ref(),
self.edge.visual.as_ref(),
self.edge.bookmark.as_ref(),
self.edge.mobile,
self.target.to_string(),
&self.reason,
)
}
}
pub(super) fn broken_artifacts(
db: &TabularDatabase,
reports: &[&ReportModel],
index: &ModelIndex,
) -> HashMap<ObjectId, Vec<String>> {
let mut out: HashMap<ObjectId, Vec<String>> = HashMap::new();
let mut scan = |text: &str,
home_table: Option<&str>,
owner: &ObjectId,
report_measures: &HashSet<String>| {
let mut query_time: HashSet<String> = dax::quoted_names(text)
.into_iter()
.map(|name| fold_name(name.as_ref()))
.collect();
query_time.extend(constructors::COLUMN_NAMES.map(str::to_string));
for raw in dax::references(text) {
if matches!(raw, RawRef::Field { .. })
&& !field_resolves(db, index, home_table, &raw, report_measures, &query_time)
{
let written = raw
.to_field_ref()
.expect("a field reference materializes")
.to_string();
out.entry(owner.clone()).or_default().push(written);
}
}
};
for expression in db.dax_expressions() {
let owner = expression.owner.to_object_id();
scan(
expression.text,
expression.home_table,
&owner,
&HashSet::new(),
);
}
for report in reports {
let report_measures: HashSet<String> = report
.measures
.iter()
.map(|measure| fold_name(measure.name.as_str()))
.collect();
for expression in report.dax_expressions() {
let owner = expression.owner.to_object_id();
scan(
expression.text,
expression.home_table,
&owner,
&report_measures,
);
}
}
for refs in out.values_mut() {
refs.sort();
refs.dedup();
}
out
}
fn field_resolves(
db: &TabularDatabase,
index: &ModelIndex,
home_table: Option<&str>,
raw: &RawRef<'_>,
report_measures: &HashSet<String>,
query_time: &HashSet<String>,
) -> bool {
if !dax::bind(db, index, home_table, raw.clone()).is_unresolved() {
return true;
}
let RawRef::Field { table, name, .. } = raw else {
return false;
};
let folded = fold_name(unescape_name(name).as_ref());
match table {
None => {
name.starts_with('@')
|| report_measures.contains(&folded)
|| query_time.contains(&folded)
}
Some(table) => {
let table = &unescape_name(table);
named_hierarchy_or_item(db, index, table, &folded)
|| calculated_table_field_resolves(db, index, table, &folded)
}
}
}
mod constructors {
pub const COLUMN_NAMES: [&str; 11] = [
"value", "value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8",
"value9", "value10",
];
}
pub(super) fn named_hierarchy_or_item(
db: &TabularDatabase,
index: &ModelIndex,
table: &str,
folded: &str,
) -> bool {
let Some(t): Option<&Table> = table_struct(db, index, table) else {
return false;
};
if t.hierarchies.iter().any(|h| fold_name(&h.name) == folded) {
return true;
}
t.calculation_group.as_ref().is_some_and(|group| {
group
.items
.iter()
.any(|item| fold_name(&item.name) == folded)
})
}
pub(super) fn calculated_table_field_resolves(
db: &TabularDatabase,
index: &ModelIndex,
table: &str,
folded: &str,
) -> bool {
let Some(candidates) = calculated_table_candidates(db, index, table) else {
return false;
};
candidates.contains(folded)
}
fn calculated_table_candidates(
db: &TabularDatabase,
index: &ModelIndex,
table: &str,
) -> Option<HashSet<String>> {
let t = table_struct(db, index, table)?;
let expression = t
.partitions
.iter()
.find_map(|partition| match &partition.source {
PartitionSource::Calculated { expression } => Some(expression.as_str()),
_ => None,
})?;
let mut candidates: HashSet<String> = dax::quoted_names(expression)
.into_iter()
.map(|name| fold_name(name.as_ref()))
.collect();
candidates.extend(constructors::COLUMN_NAMES.map(str::to_string));
for raw in dax::references(expression) {
match &raw {
RawRef::Field { table, name, .. } => {
if let Some(qualifier) = table {
extend_table_columns(db, index, &unescape_name(qualifier), &mut candidates);
} else if !dax::bind(db, index, None, raw.clone()).is_unresolved() {
candidates.insert(fold_name(unescape_name(name).as_ref()));
}
}
RawRef::Table { name, .. } => {
extend_table_columns(db, index, &unescape_name(name), &mut candidates);
}
RawRef::Function { .. } => {}
}
}
Some(candidates)
}
fn extend_table_columns(
db: &TabularDatabase,
index: &ModelIndex,
table: &str,
candidates: &mut HashSet<String>,
) {
if let Some(t) = table_struct(db, index, table) {
candidates.extend(t.columns.iter().map(|column| fold_name(&column.name)));
}
}
pub(super) fn kpi_variant_base(name: &str) -> Option<&str> {
const SUFFIXES: [&str; 4] = ["goal", "status", "trend", "value"];
let folded = fold_name(name);
let suffix = SUFFIXES
.into_iter()
.filter_map(|suffix| folded.strip_suffix(suffix))
.max_by_key(|rest| rest.len())?;
let base = name[..suffix.len()].trim_end();
(!base.is_empty()).then_some(base)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Column, Measure, Table};
fn db() -> TabularDatabase {
TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![
Column {
name: "Amount".to_string(),
..Default::default()
},
Column {
name: "Region".to_string(),
..Default::default()
},
],
measures: vec![Measure {
name: "Total".to_string(),
expression: "SUM('Sales'[Amount])".to_string(),
..Default::default()
}],
..Default::default()
}],
..Default::default()
}
}
mod kpi_variants {
use super::*;
#[test]
fn the_engine_suffixes_strip_to_the_base_measure() {
for (name, base) in [
("Total Goal", "Total"),
("Total Status", "Total"),
("Total Trend", "Total"),
("Total Value", "Total"),
("total goal", "total"),
] {
assert_eq!(kpi_variant_base(name), Some(base), "{name}");
}
}
#[test]
fn names_without_a_suffix_and_bare_suffixes_do_not_strip() {
assert_eq!(kpi_variant_base("Total"), None);
assert_eq!(kpi_variant_base("Goal"), None, "no base left");
assert_eq!(kpi_variant_base("Value"), None);
assert_eq!(kpi_variant_base("Sales Value Growth"), None);
}
#[test]
fn the_longest_suffix_wins_when_names_stack() {
assert_eq!(kpi_variant_base("Sales Goal Value"), Some("Sales Goal"));
}
}
mod artifact_pass {
use super::*;
#[test]
fn a_measure_referencing_a_missing_column_is_broken() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Broken".to_string(),
expression: "SUM('Sales'[Nope]) + [Total]".to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
let broken = broken_artifacts(&model, &[], &index);
let refs = &broken[&ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Broken"),
}];
assert_eq!(refs, &["'Sales'[Nope]".to_string()]);
}
#[test]
fn builtins_tables_and_resolving_refs_are_not_breakage() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Fine".to_string(),
expression: "COUNTROWS(Missing) + SUM('Sales'[Amount])".to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
assert!(broken_artifacts(&model, &[], &index).is_empty());
}
#[test]
fn a_hierarchy_reference_resolves() {
let model = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![Column {
name: "Year".to_string(),
..Default::default()
}],
hierarchies: vec![crate::model::Hierarchy {
name: "Calendar".to_string(),
levels: vec![crate::model::HierarchyLevel {
name: "Year".to_string(),
column: "Year".to_string(),
}],
is_hidden: false,
}],
measures: vec![Measure {
name: "In Scope".to_string(),
expression: "ISINSCOPE('Date'[Calendar])".to_string(),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let index = ModelIndex::build(&model);
assert!(broken_artifacts(&model, &[], &index).is_empty());
}
#[test]
fn a_missing_field_on_a_live_table_is_breakage() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Stale".to_string(),
expression: "SUM('Sales'[Color])".to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
let broken = broken_artifacts(&model, &[], &index);
assert_eq!(broken.len(), 1);
}
#[test]
fn a_report_measure_body_is_scanned_too() {
let model = db();
let index = ModelIndex::build(&model);
let mut report = ReportModel::default();
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Local"),
expression: "[Total] + [Gone]".to_string(),
format_string: None,
});
let broken = broken_artifacts(&model, &[&report], &index);
let refs = &broken[&ObjectId::ReportMeasure {
measure: NameKey::new("Local"),
}];
assert_eq!(refs, &["[Gone]".to_string()]);
}
#[test]
fn a_report_measure_referencing_a_sibling_resolves() {
let model = db();
let index = ModelIndex::build(&model);
let mut report = ReportModel::default();
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Outer"),
expression: "DIVIDE([Inner], [Base], 0)".to_string(),
format_string: None,
});
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Inner"),
expression: "1".to_string(),
format_string: None,
});
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Base"),
expression: "2".to_string(),
format_string: None,
});
let broken = broken_artifacts(&model, &[&report], &index);
assert!(
!broken.contains_key(&ObjectId::ReportMeasure {
measure: NameKey::new("Outer"),
}),
"the siblings resolve: {broken:?}"
);
}
#[test]
fn an_extension_column_reference_resolves() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Kvalificerede".to_string(),
expression: "COUNTROWS(FILTER(ADDCOLUMNS(VALUES('Sales'[Amount]), \"@Krav\", [Total]), [@Krav] > 0)) + SUM('Sales'[Nope])"
.to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
let broken = broken_artifacts(&model, &[], &index);
let refs = &broken[&ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Kvalificerede"),
}];
assert_eq!(refs, &["'Sales'[Nope]".to_string()]);
}
#[test]
fn a_constructor_value_column_resolves() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Dele".to_string(),
expression: "VAR Dele = { \"a\", \"b\" } RETURN CONCATENATEX(FILTER(Dele, NOT ISBLANK([Value])), [Value], \" | \")"
.to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
let broken = broken_artifacts(&model, &[], &index);
assert!(
!broken.contains_key(&ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Dele"),
}),
"the constructor column resolves: {broken:?}"
);
}
#[test]
fn a_string_named_extension_column_resolves() {
let mut model = db();
model.tables[0].measures.push(Measure {
name: "Ordninger".to_string(),
expression: "COUNTROWS(FILTER(SELECTCOLUMNS('Sales', \"Ordning\", 'Sales'[Amount]), [Ordning] > 0))".to_string(),
..Default::default()
});
let index = ModelIndex::build(&model);
assert!(broken_artifacts(&model, &[], &index).is_empty());
}
#[test]
fn a_measure_on_a_calculated_table_reading_a_datatable_header_resolves() {
let model = calculated_colaxis("Group");
let index = ModelIndex::build(&model);
assert!(broken_artifacts(&model, &[], &index).is_empty());
}
#[test]
fn a_measure_reading_a_renamed_datatable_header_still_flags() {
let model = calculated_colaxis("Gruppe");
let index = ModelIndex::build(&model);
let broken = broken_artifacts(&model, &[], &index);
let refs = &broken[&ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Label"),
}];
assert_eq!(
refs,
&["'ColAxis'[Group]".to_string()],
"the old header name is no longer visible"
);
}
fn calculated_colaxis(header: &str) -> TabularDatabase {
TabularDatabase {
tables: vec![
Table {
name: "ColAxis".to_string(),
partitions: vec![crate::model::Partition {
name: "ColAxis".to_string(),
source: crate::model::PartitionSource::Calculated {
expression: format!(
"DATATABLE(\"{header}\", STRING, {{\"Outlook\"}})"
),
},
}],
..Default::default()
},
Table {
name: "Sales".to_string(),
columns: vec![Column {
name: "Amount".to_string(),
..Default::default()
}],
measures: vec![Measure {
name: "Label".to_string(),
expression: "CONCATENATEX('ColAxis', 'ColAxis'[Group], \" | \")"
.to_string(),
..Default::default()
}],
..Default::default()
},
],
..Default::default()
}
}
}
#[test]
fn named_hierarchy_or_item_answers_like_the_builder() {
let model = db();
let index = ModelIndex::build(&model);
assert!(
!named_hierarchy_or_item(&model, &index, "Sales", "amount"),
"a column is the binder's candidate, not an extended one"
);
assert!(!named_hierarchy_or_item(&model, &index, "Sales", "nope"));
assert!(!named_hierarchy_or_item(&model, &index, "Ghost", "x"));
}
}