use std::collections::{HashMap, HashSet};
use petgraph::Direction;
use petgraph::graph::{DiGraph, NodeIndex};
use petgraph::visit::EdgeRef;
pub mod broken;
pub mod provenance;
mod builder;
mod reachability;
pub use broken::{BrokenBinding, BrokenReason};
pub use provenance::{BindingEdge, BindingSite, Provenance, StructuralEdge};
pub use reachability::{UnusedObject, UsedBy};
use crate::identity::{NameKey, ObjectId, fold_name};
use crate::model::TabularDatabase;
use crate::report::ReportModel;
#[derive(Debug)]
pub struct DependencyGraph {
graph: DiGraph<ObjectId, Provenance>,
nodes: HashMap<ObjectId, NodeIndex>,
roots: Vec<(ObjectId, Provenance)>,
m_named: HashMap<ObjectId, Vec<ObjectId>>,
broken: Vec<BrokenBinding>,
}
impl DependencyGraph {
#[must_use]
pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
builder::build(db, reports)
}
pub(super) fn assemble(
graph: DiGraph<ObjectId, Provenance>,
nodes: HashMap<ObjectId, NodeIndex>,
roots: Vec<(ObjectId, Provenance)>,
m_named: HashMap<ObjectId, Vec<ObjectId>>,
broken: Vec<BrokenBinding>,
) -> Self {
Self {
graph,
nodes,
roots,
m_named,
broken,
}
}
pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
self.graph.node_indices().map(|index| &self.graph[index])
}
pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
self.neighbors(id, Direction::Incoming)
}
pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
self.neighbors(id, Direction::Outgoing)
}
pub fn roots(&self) -> &[(ObjectId, Provenance)] {
&self.roots
}
pub fn roots_of(&self, id: &ObjectId) -> Vec<&Provenance> {
self.roots
.iter()
.filter(|(target, _)| target == id)
.map(|(_, provenance)| provenance)
.collect()
}
pub fn broken_bindings(&self) -> &[BrokenBinding] {
&self.broken
}
pub fn named_by_m(&self, id: &ObjectId) -> &[ObjectId] {
self.m_named.get(id).map(Vec::as_slice).unwrap_or_default()
}
pub fn unused_objects(&self) -> Vec<UnusedObject> {
let reach = reachability::Reachability::compute(self);
let mut out: Vec<UnusedObject> = self
.graph
.node_indices()
.filter(|index| !reach.is_live(&self.graph[*index]))
.map(|index| {
let id = self.graph[index].clone();
let mut used_by: Vec<UsedBy> = self
.graph
.edges_directed(index, Direction::Incoming)
.map(|edge| UsedBy {
id: self.graph[edge.source()].clone(),
provenance: edge.weight().clone(),
also_unused: !reach.is_live(&self.graph[edge.source()]),
})
.collect();
used_by.sort_by(|a, b| a.id.cmp(&b.id));
let named_by_m = self.named_by_m(&id).to_vec();
UnusedObject {
id,
used_by,
named_by_m,
}
})
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
pub fn auto_date_time_tables(&self, db: &TabularDatabase) -> Vec<AutoDateTimeVerdict> {
let reach = reachability::Reachability::compute(self);
let mut out: Vec<AutoDateTimeVerdict> = db
.tables
.iter()
.filter(|table| table.is_local_date_table || table.is_template_date_table)
.map(|table| {
let id = ObjectId::Table {
table: NameKey::new(&table.name),
};
let verdict = if self.bound_with_reports(&id) {
AutoDateTimeStatus::InUse
} else if !reach.is_live(&id) {
AutoDateTimeStatus::Dead
} else {
AutoDateTimeStatus::UnusedByReports
};
AutoDateTimeVerdict {
id,
verdict,
source_column: variation_source_column(db, &table.name),
}
})
.collect();
out.sort_by(|a, b| a.id.cmp(&b.id));
out
}
fn bound_with_reports(&self, table: &ObjectId) -> bool {
let ObjectId::Table { table: name } = table else {
return false;
};
let is_member = |id: &ObjectId| match id {
ObjectId::Column { table, .. }
| ObjectId::Measure { table, .. }
| ObjectId::Hierarchy { table, .. }
| ObjectId::Partition { table, .. }
| ObjectId::CalculationItem { table, .. } => table == name,
_ => false,
};
let is_binding = |provenance: &Provenance| matches!(provenance, Provenance::Binding(_));
self.roots.iter().any(|(target, provenance)| {
is_binding(provenance) && (target == table || is_member(target))
}) || self
.consumers_of(table)
.iter()
.any(|(_, provenance)| is_binding(provenance))
}
fn neighbors(&self, id: &ObjectId, direction: Direction) -> Vec<(ObjectId, Provenance)> {
let Some(&index) = self.nodes.get(id) else {
return Vec::new();
};
self.graph
.edges_directed(index, direction)
.map(|edge| {
let other = match direction {
Direction::Incoming => edge.source(),
Direction::Outgoing => edge.target(),
};
(self.graph[other].clone(), edge.weight().clone())
})
.collect()
}
pub(super) fn seed_indices(&self) -> Vec<NodeIndex> {
let mut seeds: Vec<NodeIndex> = self
.roots
.iter()
.filter_map(|(id, _)| self.nodes.get(id).copied())
.collect();
seeds.extend(
self.nodes
.iter()
.filter(|(id, _)| matches!(id, ObjectId::Role { .. }))
.map(|(_, &index)| index),
);
seeds
}
pub(super) fn reach(
&self,
seeds: impl IntoIterator<Item = NodeIndex>,
allowed: fn(&Provenance) -> bool,
) -> HashSet<NodeIndex> {
let mut seen: HashSet<NodeIndex> = seeds.into_iter().collect();
let mut queue: Vec<NodeIndex> = seen.iter().copied().collect();
while let Some(index) = queue.pop() {
for edge in self.graph.edges_directed(index, Direction::Outgoing) {
if !allowed(edge.weight()) {
continue;
}
if seen.insert(edge.target()) {
queue.push(edge.target());
}
}
}
seen
}
pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
&self.graph[index]
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AutoDateTimeVerdict {
pub id: ObjectId,
pub verdict: AutoDateTimeStatus,
pub source_column: Option<ObjectId>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AutoDateTimeStatus {
InUse,
UnusedByReports,
Dead,
}
fn variation_source_column(db: &TabularDatabase, table: &str) -> Option<ObjectId> {
let target = fold_name(table);
for t in &db.tables {
for column in &t.columns {
for variation in &column.variations {
let via_hierarchy = variation
.default_hierarchy
.as_ref()
.is_some_and(|reference| fold_name(&reference.table) == target);
let via_relationship = variation
.relationship
.as_ref()
.and_then(|name| {
db.relationships
.iter()
.find(|rel| rel.name.as_deref() == Some(name.as_str()))
})
.is_some_and(|rel| {
fold_name(&rel.from_table) == target || fold_name(&rel.to_table) == target
});
if via_hierarchy || via_relationship {
return Some(ObjectId::Column {
table: NameKey::new(&t.name),
column: NameKey::new(&column.name),
});
}
}
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::NameKey;
use crate::model::{
Column, ColumnKind, DaxExpressionKind, Function, Hierarchy, HierarchyLevel, HierarchyRef,
Measure, ParameterValuesColumn, Partition, PartitionSource, RefreshPolicy, Relationship,
Role, SharedExpression, Table, TablePermission, Variation,
};
use crate::report::{
Bookmark, BookmarkSection, BookmarkVisual, FieldTarget, FieldWell, Filter, Page,
Projection, Visual,
};
fn column(name: &str) -> Column {
Column {
name: name.to_string(),
..Default::default()
}
}
fn measure(name: &str, expression: &str) -> Measure {
Measure {
name: name.to_string(),
expression: expression.to_string(),
..Default::default()
}
}
fn m_partition(name: &str, expression: &str) -> Partition {
Partition {
name: name.to_string(),
source: PartitionSource::M {
expression: expression.to_string(),
},
}
}
fn table(name: &str) -> Table {
Table {
name: name.to_string(),
..Default::default()
}
}
fn table_id(name: &str) -> ObjectId {
ObjectId::Table {
table: NameKey::new(name),
}
}
fn column_id(table: &str, column: &str) -> ObjectId {
ObjectId::Column {
table: NameKey::new(table),
column: NameKey::new(column),
}
}
fn measure_id(table: &str, measure: &str) -> ObjectId {
ObjectId::Measure {
table: NameKey::new(table),
measure: NameKey::new(measure),
}
}
fn report_measure_id(name: &str) -> ObjectId {
ObjectId::ReportMeasure {
measure: NameKey::new(name),
}
}
fn visual_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
ReportModel {
name: Some("Mini".to_string()),
pages: vec![Page {
name: NameKey::new(page),
display_name: None,
is_hidden: false,
filters: Vec::new(),
binding: None,
visuals: vec![Visual {
name: NameKey::new(visual),
visual_type: "card".to_string(),
wells: vec![FieldWell {
role: "Values".to_string(),
projections: targets
.iter()
.map(|target| Projection {
target: target.clone(),
query_ref: None,
active: true,
})
.collect(),
}],
filters: Vec::new(),
sorts: Vec::new(),
conditional_formatting: Vec::new(),
alt_text: Vec::new(),
tooltip_page: None,
}],
}],
..Default::default()
}
}
fn measure_target(table: &str, name: &str) -> FieldTarget {
FieldTarget::Measure {
home_table: Some(NameKey::new(table)),
measure: NameKey::new(name),
}
}
fn column_target(table: &str, column: &str) -> FieldTarget {
FieldTarget::Column {
table: NameKey::new(table),
column: NameKey::new(column),
}
}
fn visual_mobile_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
let mut report = visual_page(page, visual, targets);
report.mobile_pages = std::mem::take(&mut report.pages);
report
}
fn find<'a>(unused: &'a [UnusedObject], id: &ObjectId) -> &'a UnusedObject {
unused
.iter()
.find(|finding| &finding.id == id)
.unwrap_or_else(|| panic!("{id} expected in the unused set"))
}
fn not_unused(unused: &[UnusedObject], id: &ObjectId) {
assert!(
!unused.iter().any(|finding| &finding.id == id),
"{id} must be live"
);
}
mod construction {
use super::*;
#[test]
fn every_model_object_gets_a_node_even_when_isolated() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
..Default::default()
}],
functions: vec![Function {
name: "MyFunc".to_string(),
expression: "1".to_string(),
is_hidden: false,
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let ids: Vec<_> = graph.object_ids().cloned().collect();
assert!(ids.contains(&table_id("Sales")));
assert!(ids.contains(&column_id("Sales", "Amount")));
assert!(ids.contains(&ObjectId::Function {
name: NameKey::new("MyFunc")
}));
}
#[test]
fn identical_edges_are_deduped_but_distinct_provenance_is_kept() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
measures: vec![measure(
"Total",
"SUM('Sales'[Amount]) + SUM('Sales'[Amount])",
)],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let producers = graph.producers_of(&measure_id("Sales", "Total"));
assert_eq!(producers.len(), 2);
assert_eq!(
producers
.iter()
.filter(|(id, _)| *id == column_id("Sales", "Amount"))
.count(),
1,
"identical (from, to, provenance) triples dedupe"
);
let consumers = graph.consumers_of(&column_id("Sales", "Amount"));
assert_eq!(consumers.len(), 1);
assert!(matches!(
consumers[0].1,
Provenance::Dax {
kind: DaxExpressionKind::Measure
}
));
assert_eq!(consumers[0].0, measure_id("Sales", "Total"));
assert!(
graph
.consumers_of(&table_id("Sales"))
.iter()
.any(|(id, p)| *id == column_id("Sales", "Amount")
&& matches!(
p,
Provenance::Structural {
role: StructuralEdge::TableMember
}
))
);
}
#[test]
fn self_references_are_dropped() {
let db = TabularDatabase {
expressions: vec![SharedExpression {
name: "Recursive".to_string(),
expression: "Recursive + 1".to_string(),
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let id = ObjectId::Expression {
name: NameKey::new("Recursive"),
};
assert!(graph.producers_of(&id).is_empty());
assert!(graph.consumers_of(&id).is_empty());
}
}
mod liveness {
use super::*;
#[test]
fn a_mobile_only_binding_keeps_its_target_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Units")],
measures: vec![measure("Total", "SUM('Sales'[Units])")],
partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
..Default::default()
}],
..Default::default()
};
let report = visual_mobile_page("P1", "VM", &[measure_target("Sales", "Total")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &measure_id("Sales", "Total"));
not_unused(&unused, &table_id("Sales"));
let roots = graph.roots();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, measure_id("Sales", "Total"));
let Provenance::Binding(edge) = &roots[0].1 else {
panic!("a root carries binding provenance");
};
assert!(edge.mobile);
}
#[test]
fn a_relationship_does_not_keep_its_far_table_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
..Default::default()
},
Table {
name: "DimOld".to_string(),
columns: vec![column("Key"), column("Notes")],
partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
..Default::default()
},
],
relationships: vec![Relationship {
name: None,
from_table: "Sales".to_string(),
from_column: "Key".to_string(),
to_table: "DimOld".to_string(),
to_column: "Key".to_string(),
is_active: true,
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &table_id("Sales"));
not_unused(&unused, &column_id("Sales", "Key"));
not_unused(
&unused,
&ObjectId::Relationship {
from_table: NameKey::new("Sales"),
from_column: NameKey::new("Key"),
to_table: NameKey::new("DimOld"),
to_column: NameKey::new("Key"),
},
);
let dim_old = find(&unused, &table_id("DimOld"));
assert_eq!(dim_old.used_by.len(), 2, "its two columns contain it");
let by_key = dim_old
.used_by
.iter()
.find(|used| used.id == column_id("DimOld", "Key"))
.expect("the key column references its table");
assert!(
!by_key.also_unused,
"the key column is live, kept by the relationship endpoint"
);
assert!(matches!(
by_key.provenance,
Provenance::Structural {
role: StructuralEdge::TableMember
}
));
let notes = find(&unused, &column_id("DimOld", "Notes"));
assert!(notes.used_by.is_empty(), "an orphan has no consumers");
let partition = find(
&unused,
&ObjectId::Partition {
table: NameKey::new("DimOld"),
partition: NameKey::new("DimOld"),
},
);
assert_eq!(partition.used_by.len(), 1);
assert!(partition.used_by[0].also_unused);
assert_eq!(partition.used_by[0].id, table_id("DimOld"));
}
#[test]
fn an_unactivated_inactive_relationship_is_a_finding_with_its_keys() {
let relationship_id = ObjectId::Relationship {
from_table: NameKey::new("Sales"),
from_column: NameKey::new("Key"),
to_table: NameKey::new("DimOld"),
to_column: NameKey::new("Key"),
};
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Amt"), column("Key")],
measures: vec![measure("Total", "SUM('Sales'[Amt])")],
partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
..Default::default()
},
Table {
name: "DimOld".to_string(),
columns: vec![column("Key"), column("Notes")],
partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
..Default::default()
},
],
relationships: vec![Relationship {
name: None,
from_table: "Sales".to_string(),
from_column: "Key".to_string(),
to_table: "DimOld".to_string(),
to_column: "Key".to_string(),
is_active: false,
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &table_id("Sales"));
let relationship = find(&unused, &relationship_id);
assert_eq!(relationship.used_by.len(), 2);
assert!(relationship.used_by.iter().all(|used| matches!(
&used.provenance,
Provenance::Structural {
role: StructuralEdge::InactiveRelationship
}
)));
let sales_side = relationship
.used_by
.iter()
.find(|used| used.id == table_id("Sales"))
.expect("the from table references the relationship");
assert!(!sales_side.also_unused);
for (table_name, column_name) in [("Sales", "Key"), ("DimOld", "Key")] {
let finding = find(&unused, &column_id(table_name, column_name));
assert_eq!(
finding.used_by.len(),
1,
"the inactive relationship is the only reference"
);
assert!(finding.used_by[0].also_unused);
assert_eq!(finding.used_by[0].id, relationship_id);
assert!(matches!(
&finding.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::InactiveRelationshipEndpoint
}
));
}
find(&unused, &table_id("DimOld"));
}
#[test]
fn a_live_userelationship_measure_keeps_inactive_keys_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Amt"), column("Key")],
measures: vec![measure(
"Old Total",
"CALCULATE(SUM('Sales'[Amt]), USERELATIONSHIP('Sales'[Key], 'DimOld'[Key]))",
)],
partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
..Default::default()
},
Table {
name: "DimOld".to_string(),
columns: vec![column("Key"), column("Notes")],
partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
..Default::default()
},
],
relationships: vec![Relationship {
name: None,
from_table: "Sales".to_string(),
from_column: "Key".to_string(),
to_table: "DimOld".to_string(),
to_column: "Key".to_string(),
is_active: false,
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Old Total")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &column_id("Sales", "Key"));
not_unused(&unused, &column_id("DimOld", "Key"));
not_unused(
&unused,
&ObjectId::Relationship {
from_table: NameKey::new("Sales"),
from_column: NameKey::new("Key"),
to_table: NameKey::new("DimOld"),
to_column: NameKey::new("Key"),
},
);
not_unused(&unused, &table_id("DimOld"));
let notes = find(&unused, &column_id("DimOld", "Notes"));
assert!(notes.used_by.is_empty());
}
#[test]
fn an_rls_filter_keeps_its_column_and_table_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Region")],
..Default::default()
}],
roles: vec![Role {
name: "Reader".to_string(),
table_permissions: vec![TablePermission {
table: "Sales".to_string(),
filter_expression: Some("'Sales'[Region] = \"West\"".to_string()),
}],
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
assert!(
unused.is_empty(),
"the role seeds the filter, the filter keeps the column, the column keeps the table"
);
let consumers = graph.consumers_of(&column_id("Sales", "Region"));
assert_eq!(consumers.len(), 1);
assert_eq!(
consumers[0].0,
ObjectId::Role {
role: NameKey::new("Reader")
}
);
assert!(matches!(
consumers[0].1,
Provenance::Dax {
kind: DaxExpressionKind::RlsFilter
}
));
}
#[test]
fn a_metadata_only_permission_keeps_its_table_alive() {
let db = TabularDatabase {
tables: vec![table("Sales")],
roles: vec![Role {
name: "Reader".to_string(),
table_permissions: vec![TablePermission {
table: "Sales".to_string(),
filter_expression: None,
}],
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
assert!(graph.unused_objects().is_empty());
}
#[test]
fn a_model_with_no_roots_reports_everything_unused() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
assert_eq!(graph.unused_objects().len(), 3);
assert!(graph.roots().is_empty());
}
#[test]
fn an_unused_report_measure_is_dead_and_annotates_its_chain() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount"), column("Old")],
measures: vec![measure("Total", "SUM('Sales'[Amount])")],
..Default::default()
}],
..Default::default()
};
let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Local"),
expression: "SUM('Sales'[Old])".to_string(),
format_string: None,
});
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
let local = find(&unused, &report_measure_id("Local"));
assert!(local.used_by.is_empty(), "no visual binds it");
let old = find(&unused, &column_id("Sales", "Old"));
assert_eq!(old.used_by.len(), 1);
assert_eq!(old.used_by[0].id, report_measure_id("Local"));
assert!(old.used_by[0].also_unused);
not_unused(&unused, &column_id("Sales", "Amount"));
}
#[test]
fn a_visual_binding_resolves_to_the_shadowing_report_measure() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
measures: vec![measure("Total", "0")],
..Default::default()
}],
..Default::default()
};
let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
report.measures.push(crate::report::ReportMeasure {
name: NameKey::new("Total"),
expression: "[Model Total]".to_string(),
format_string: None,
});
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(graph.roots_of(&report_measure_id("Total")).len(), 1);
assert!(graph.roots_of(&measure_id("Sales", "Total")).is_empty());
let unused = graph.unused_objects();
not_unused(&unused, &report_measure_id("Total"));
let shadowed = find(&unused, &measure_id("Sales", "Total"));
assert!(shadowed.used_by.is_empty());
}
#[test]
fn a_sort_by_chain_is_annotated() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![
Column {
name: "Month Name".to_string(),
sort_by_column: Some("Month Num".to_string()),
..Default::default()
},
column("Month Num"),
],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let month_name = find(&unused, &column_id("Date", "Month Name"));
assert!(month_name.used_by.is_empty());
let month_num = find(&unused, &column_id("Date", "Month Num"));
assert_eq!(month_num.used_by.len(), 1);
assert_eq!(month_num.used_by[0].id, column_id("Date", "Month Name"));
assert!(month_num.used_by[0].also_unused);
assert!(matches!(
month_num.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::SortByColumn
}
));
}
#[test]
fn a_group_by_chain_is_annotated() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![
Column {
name: "Amount".to_string(),
group_by_columns: vec!["Bucket".to_string()],
..Default::default()
},
column("Bucket"),
],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let amount = find(&unused, &column_id("Sales", "Amount"));
assert!(amount.used_by.is_empty());
let bucket = find(&unused, &column_id("Sales", "Bucket"));
assert_eq!(bucket.used_by.len(), 1);
assert_eq!(bucket.used_by[0].id, column_id("Sales", "Amount"));
assert!(bucket.used_by[0].also_unused);
assert!(matches!(
bucket.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::GroupByColumn
}
));
}
#[test]
fn a_used_column_keeps_its_group_by_column_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![
Column {
name: "Amount".to_string(),
group_by_columns: vec!["Bucket".to_string()],
..Default::default()
},
column("Bucket"),
],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Amount")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty());
}
#[test]
fn a_dead_hierarchy_annotates_its_level_columns() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![column("Year")],
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,
}],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let hierarchy = find(
&unused,
&ObjectId::Hierarchy {
table: NameKey::new("Date"),
hierarchy: NameKey::new("Calendar"),
},
);
assert!(hierarchy.used_by.is_empty());
let year = find(&unused, &column_id("Date", "Year"));
assert_eq!(year.used_by.len(), 1);
assert!(matches!(
year.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::HierarchyLevel
}
));
assert!(year.used_by[0].also_unused);
}
#[test]
fn dax_keeps_a_referenced_hierarchy_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![column("Year")],
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("In Scope", "ISINSCOPE('Date'[Calendar])")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Date", "In Scope")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty());
}
#[test]
fn a_binding_on_a_calculation_group_column_keeps_its_items_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
measures: vec![measure("Total", "SUM('Sales'[Amount])")],
..Default::default()
},
Table {
name: "Date Role".to_string(),
columns: vec![column("Date Role")],
calculation_group: Some(crate::model::CalculationGroup {
items: vec![
crate::model::CalculationItem {
name: "By Ship Date".to_string(),
expression: "SELECTEDMEASURE()".to_string(),
format_string_expression: None,
},
crate::model::CalculationItem {
name: "By Due Date".to_string(),
expression: "SELECTEDMEASURE()".to_string(),
format_string_expression: None,
},
],
..Default::default()
}),
..Default::default()
},
],
..Default::default()
};
let report = visual_page(
"P1",
"Slicer",
&[
measure_target("Sales", "Total"),
column_target("Date Role", "Date Role"),
],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(
graph.unused_objects().is_empty(),
"the bound column keeps the group, the group's items, and the model alive"
);
let consumers = graph.consumers_of(&ObjectId::CalculationItem {
table: NameKey::new("Date Role"),
item: NameKey::new("By Ship Date"),
});
assert!(
consumers.iter().any(|(id, provenance)| {
*id == column_id("Date Role", "Date Role")
&& matches!(provenance, Provenance::Binding(_))
}),
"the column's binding edge names the item, with the binding site as provenance"
);
}
#[test]
fn dax_keeps_a_referenced_calculation_item_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
measures: vec![measure(
"YTD Sales",
"CALCULATE(SUM('Sales'[Amount]), 'Time Intelligence'[YTD])",
)],
..Default::default()
},
Table {
name: "Time Intelligence".to_string(),
calculation_group: Some(crate::model::CalculationGroup {
items: vec![
crate::model::CalculationItem {
name: "YTD".to_string(),
expression: "SELECTEDMEASURE()".to_string(),
format_string_expression: None,
},
crate::model::CalculationItem {
name: "MTD".to_string(),
expression: "SELECTEDMEASURE()".to_string(),
format_string_expression: None,
},
],
..Default::default()
}),
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "YTD Sales")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
let unused_ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
assert_eq!(
unused_ids,
[&ObjectId::CalculationItem {
table: NameKey::new("Time Intelligence"),
item: NameKey::new("MTD"),
}],
"only the unselected calculation item is unused"
);
}
#[test]
fn an_unresolved_qualified_reference_keeps_its_table_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
measures: vec![measure("M", "'Ghost'[Nope]")],
..Default::default()
},
table("Ghost"),
],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty(), "Ghost stays alive");
}
#[test]
fn an_unresolved_reference_without_a_resolvable_part_keeps_nothing_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
measures: vec![measure("M", "'Ghost'[Nope] + [Also Nope]")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(graph.unused_objects().len(), 0, "only Sales and M exist");
}
#[test]
fn m_references_keep_shared_expressions_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
partitions: vec![m_partition(
"Sales",
"let Source = Sql.Database(ServerName) in Source",
)],
..Default::default()
},
Table {
name: "DimOld".to_string(),
partitions: vec![m_partition(
"DimOld",
"let Source = LegacyParam in Source",
)],
..Default::default()
},
],
expressions: vec![
SharedExpression {
name: "ServerName".to_string(),
expression: "\"localhost\"".to_string(),
..Default::default()
},
SharedExpression {
name: "LegacyParam".to_string(),
expression: "5".to_string(),
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("ServerName"),
},
);
let legacy = find(
&unused,
&ObjectId::Expression {
name: NameKey::new("LegacyParam"),
},
);
assert_eq!(legacy.used_by.len(), 1);
assert_eq!(
legacy.used_by[0].id,
ObjectId::Partition {
table: NameKey::new("DimOld"),
partition: NameKey::new("DimOld"),
}
);
assert!(legacy.used_by[0].also_unused);
assert!(matches!(legacy.used_by[0].provenance, Provenance::M));
}
#[test]
fn a_consumed_parameter_keeps_its_bound_column_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "DaysList".to_string(),
columns: vec![column("Days")],
..Default::default()
},
Table {
name: "SampleData".to_string(),
columns: vec![column("Days")],
partitions: vec![m_partition(
"SampleData",
"let Source = Sql.Database(\"s\", \"db\") in Table.SelectRows(Source, each [Days] >= MinDays)",
)],
..Default::default()
},
],
expressions: vec![SharedExpression {
name: "MinDays".to_string(),
expression: "15".to_string(),
parameter_values_column: Some(ParameterValuesColumn {
table: "DaysList".to_string(),
column: "Days".to_string(),
}),
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("SampleData", "Days")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
assert!(
unused.is_empty(),
"the whole chain is live, bound column included: {unused:?}"
);
assert_eq!(
graph.consumers_of(&column_id("DaysList", "Days")),
[(
ObjectId::Expression {
name: NameKey::new("MinDays"),
},
Provenance::Structural {
role: StructuralEdge::MParameterBinding,
}
)]
);
}
#[test]
fn an_unconsumed_or_dangling_binding_keeps_nothing_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "DaysList".to_string(),
columns: vec![column("Days")],
..Default::default()
},
Table {
name: "SampleData".to_string(),
columns: vec![column("Days")],
partitions: vec![m_partition(
"SampleData",
"let Source = Sql.Database(\"s\", \"db\") in Source",
)],
..Default::default()
},
],
expressions: vec![
SharedExpression {
name: "Unconsumed".to_string(),
expression: "15".to_string(),
parameter_values_column: Some(ParameterValuesColumn {
table: "DaysList".to_string(),
column: "Days".to_string(),
}),
},
SharedExpression {
name: "Dangling".to_string(),
expression: "1".to_string(),
parameter_values_column: Some(ParameterValuesColumn {
table: "Ghost".to_string(),
column: "Nope".to_string(),
}),
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("SampleData", "Days")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
let unconsumed = find(
&unused,
&ObjectId::Expression {
name: NameKey::new("Unconsumed"),
},
);
assert!(unconsumed.used_by.is_empty(), "no partition names it");
let bound = find(&unused, &column_id("DaysList", "Days"));
assert_eq!(bound.used_by.len(), 1);
assert_eq!(
bound.used_by[0].id,
ObjectId::Expression {
name: NameKey::new("Unconsumed"),
}
);
assert!(matches!(
bound.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::MParameterBinding
}
));
assert!(bound.used_by[0].also_unused);
let dangling = find(
&unused,
&ObjectId::Expression {
name: NameKey::new("Dangling"),
},
);
assert!(dangling.used_by.is_empty());
}
#[test]
fn an_m_chain_keeps_shared_expressions_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
partitions: vec![m_partition(
"Sales",
"let Source = Sql.Database(#\"Staging Query\") in Source",
)],
..Default::default()
}],
expressions: vec![
SharedExpression {
name: "Staging Query".to_string(),
expression: "ServerName".to_string(),
..Default::default()
},
SharedExpression {
name: "ServerName".to_string(),
expression: "\"localhost\"".to_string(),
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("Staging Query"),
},
);
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("ServerName"),
},
);
assert_eq!(
graph.consumers_of(&ObjectId::Expression {
name: NameKey::new("ServerName"),
}),
[(
ObjectId::Expression {
name: NameKey::new("Staging Query"),
},
Provenance::M
)]
);
}
#[test]
fn an_m_partition_names_its_columns_without_keeping_them_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![
column("Pk"),
column("Amount"),
column("Region"),
column("Orphaned"),
],
partitions: vec![m_partition(
"Sales",
concat!(
"let\n",
" Source = Sql.Database(ServerName, \"db\"),\n",
" Typed = Table.TransformColumnTypes(Source, {{\"Amount\", type text}}),\n",
" Expanded = Table.ExpandTableColumn(Typed, \"Detail\", {\"Region\"}),\n",
" Filtered = Table.SelectRows(Expanded, each [Orphaned] = \"West\")\n",
"in\n",
" Filtered",
),
)],
..Default::default()
}],
expressions: vec![SharedExpression {
name: "ServerName".to_string(),
expression: "\"localhost\"".to_string(),
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
let partition = ObjectId::Partition {
table: NameKey::new("Sales"),
partition: NameKey::new("Sales"),
};
let expected_named = [partition];
for name in ["Amount", "Region", "Orphaned"] {
let finding = find(&unused, &column_id("Sales", name));
assert!(
finding.used_by.is_empty(),
"M names are not consumers: no edge points at the column"
);
assert_eq!(finding.named_by_m, expected_named);
}
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("ServerName"),
},
);
}
#[test]
fn a_dead_tables_partition_keeps_nothing_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "DimOld".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition(
"DimOld",
"let Source = Table.SelectRows(#\"DimOld\", each [Key] <> null) in Source",
)],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
find(&unused, &column_id("DimOld", "Key"));
find(&unused, &table_id("DimOld"));
}
#[test]
fn a_change_detection_measure_is_live_while_its_table_is_live() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Pk")],
measures: vec![measure("Change Detector", "COUNTROWS('Sales')")],
partitions: vec![m_partition(
"Sales",
"let Source = Sql.Database(\"s\", \"db\") in Source",
)],
refresh_policy: Some(RefreshPolicy {
policy_type: Some("basicRefreshPolicy".to_string()),
change_detection: Some(
"EVALUATE ROW(\"Bookmark\", 'Sales'[Change Detector])".to_string(),
),
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &measure_id("Sales", "Change Detector"));
}
#[test]
fn a_dead_tables_policy_flags_its_measure_with_policy_provenance() {
let db = TabularDatabase {
tables: vec![Table {
name: "DimOld".to_string(),
measures: vec![measure("Change Detector", "COUNTROWS('DimOld')")],
partitions: vec![m_partition(
"DimOld",
"let Source = Sql.Database(\"s\", \"db\") in Source",
)],
refresh_policy: Some(RefreshPolicy {
change_detection: Some(
"EVALUATE ROW(\"Bookmark\", 'DimOld'[Change Detector])".to_string(),
),
..Default::default()
}),
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let finding = find(&unused, &measure_id("DimOld", "Change Detector"));
assert_eq!(
finding.used_by,
[UsedBy {
id: ObjectId::Partition {
table: NameKey::new("DimOld"),
partition: NameKey::new("DimOld"),
},
provenance: Provenance::Dax {
kind: DaxExpressionKind::ChangeDetection,
},
also_unused: true,
}],
"the partition references the measure through its policy"
);
}
#[test]
fn a_policy_source_expression_keeps_the_parameters_it_names_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Pk"), column("Modified")],
partitions: vec![m_partition(
"Sales",
"let Source = Sql.Database(\"s\", \"db\") in Source",
)],
refresh_policy: Some(RefreshPolicy {
source_expression: Some(concat!(
"let\n",
" Source = Sql.Database(\"s\", \"db\"),\n",
" Filtered = Table.SelectRows(Source, each [Modified] >= RangeStart ",
"and [Modified] < RangeEnd)\n",
"in\n",
" Filtered",
)
.to_string()),
..Default::default()
}),
..Default::default()
}],
expressions: vec![
SharedExpression {
name: "RangeStart".to_string(),
expression: "#datetime(2024, 1, 1, 0, 0, 0)".to_string(),
..Default::default()
},
SharedExpression {
name: "RangeEnd".to_string(),
expression: "#datetime(2024, 12, 31, 0, 0, 0)".to_string(),
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("RangeStart"),
},
);
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("RangeEnd"),
},
);
}
#[test]
fn change_detection_polling_by_shared_query_name_keeps_it_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Pk")],
partitions: vec![m_partition(
"Sales",
"let Source = Sql.Database(\"s\", \"db\") in Source",
)],
refresh_policy: Some(RefreshPolicy {
change_detection: Some("DetectDataChangesQuery".to_string()),
..Default::default()
}),
..Default::default()
}],
expressions: vec![SharedExpression {
name: "DetectDataChangesQuery".to_string(),
expression: "let Source = Sql.Database(\"s\", \"db\") in Source".to_string(),
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(
&unused,
&ObjectId::Expression {
name: NameKey::new("DetectDataChangesQuery"),
},
);
}
#[test]
fn an_m_merge_source_keeps_the_joined_table_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition(
"Sales",
concat!(
"let\n",
" Source = Sql.Database(ServerName, \"db\"),\n",
" Joined = Table.NestedJoin(Source, {\"Key\"}, #\"DimOld\", {\"Key\"}, \"Dim\")\n",
"in\n",
" Joined",
),
)],
..Default::default()
},
Table {
name: "DimOld".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
..Default::default()
},
],
expressions: vec![SharedExpression {
name: "ServerName".to_string(),
expression: "\"localhost\"".to_string(),
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &table_id("DimOld"));
let finding = find(&unused, &column_id("DimOld", "Key"));
assert_eq!(
finding.named_by_m,
[ObjectId::Partition {
table: NameKey::new("Sales"),
partition: NameKey::new("Sales"),
}]
);
}
#[test]
fn a_qualified_m_field_access_keeps_the_named_table_alive() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition(
"Sales",
"let Source = #\"DimOld\"[Key] in Source",
)],
..Default::default()
},
Table {
name: "DimOld".to_string(),
columns: vec![column("Key")],
partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &table_id("DimOld"));
}
#[test]
fn a_name_inside_an_m_comment_or_string_keeps_nothing_alive() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
partitions: vec![m_partition(
"Sales",
concat!(
"let\n",
" // ServerName was renamed; this step is retired.\n",
" Text = \"ServerName is mentioned here as data\",\n",
" Source = 1\n",
"in\n",
" Source",
),
)],
..Default::default()
}],
expressions: vec![SharedExpression {
name: "ServerName".to_string(),
expression: "\"localhost\"".to_string(),
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
find(
&unused,
&ObjectId::Expression {
name: NameKey::new("ServerName"),
},
);
}
#[test]
fn a_bookmark_saved_filter_is_a_root() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Region")],
..Default::default()
}],
..Default::default()
};
let report = ReportModel {
bookmarks: vec![Bookmark {
name: NameKey::new("B1"),
display_name: None,
filters: Vec::new(),
sections: vec![BookmarkSection {
page: NameKey::new("P1"),
filters: Vec::new(),
visuals: vec![BookmarkVisual {
visual: NameKey::new("V1"),
wells: Vec::new(),
filters: vec![Filter {
target: Some(column_target("Sales", "Region")),
..Default::default()
}],
}],
}],
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty());
let roots = graph.roots();
assert_eq!(roots.len(), 1);
assert!(matches!(
&roots[0].1,
Provenance::Binding(edge) if edge.bookmark.is_some()
));
}
#[test]
fn calculated_table_columns_stay_with_their_table() {
let db = TabularDatabase {
tables: vec![Table {
name: "Top Products".to_string(),
columns: vec![Column {
name: "Product".to_string(),
kind: ColumnKind::CalculatedTableColumn,
..Default::default()
}],
partitions: vec![Partition {
name: "Top Products".to_string(),
source: PartitionSource::Calculated {
expression: "TOPN(10, 'Product')".to_string(),
},
}],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Top Products", "Product")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty());
}
#[test]
fn calendar_columns_stay_with_their_table() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![column("Day")],
calendars: vec![crate::model::Calendar {
name: "Fiscal Calendar".to_string(),
columns: vec!["Day".to_string()],
}],
measures: vec![measure("Rows", "COUNTROWS('Date')")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Date", "Rows")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.unused_objects().is_empty());
}
#[test]
fn a_dead_table_annotates_its_calendar_columns() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![column("Day")],
calendars: vec![crate::model::Calendar {
name: "Fiscal Calendar".to_string(),
columns: vec!["Day".to_string()],
}],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let day = find(&unused, &column_id("Date", "Day"));
assert_eq!(day.used_by.len(), 1);
assert_eq!(day.used_by[0].id, table_id("Date"));
assert!(day.used_by[0].also_unused);
assert!(matches!(
day.used_by[0].provenance,
Provenance::Structural {
role: StructuralEdge::EngineManaged
}
));
}
}
mod queries {
use super::*;
#[test]
fn queries_on_an_unknown_object_are_empty() {
let graph = DependencyGraph::build(&TabularDatabase::default(), &[]);
assert!(graph.consumers_of(&table_id("Nope")).is_empty());
assert!(graph.producers_of(&table_id("Nope")).is_empty());
assert!(graph.roots_of(&table_id("Nope")).is_empty());
}
#[test]
fn unused_objects_are_sorted_by_identity() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("B"), column("A")],
..Default::default()
}],
..Default::default()
};
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
let mut sorted = ids.clone();
sorted.sort();
assert_eq!(ids, sorted);
}
#[test]
fn the_root_carries_the_full_binding_provenance() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
measures: vec![measure("Total", "0")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P2", "Card", &[measure_target("Sales", "Total")]);
let graph = DependencyGraph::build(&db, &[&report]);
let roots = graph.roots();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, measure_id("Sales", "Total"));
let Provenance::Binding(edge) = &roots[0].1 else {
panic!("a root carries binding provenance");
};
let BindingEdge {
kind,
report: report_name,
page,
visual,
bookmark,
mobile,
} = edge.as_ref();
assert!(matches!(kind, BindingSite::FieldWell { role } if role == "Values"));
assert_eq!(report_name.as_ref().map(NameKey::as_str), Some("Mini"));
assert_eq!(page.as_ref().map(NameKey::as_str), Some("P2"));
assert_eq!(visual.as_ref().map(NameKey::as_str), Some("Card"));
assert!(bookmark.is_none());
assert!(!mobile);
}
}
mod auto_date_time {
use super::*;
fn hierarchy_level_target(
table: &str,
hierarchy: &str,
level: &str,
via_column: Option<&str>,
via_variation: Option<&str>,
) -> FieldTarget {
FieldTarget::HierarchyLevel {
table: NameKey::new(table),
hierarchy: NameKey::new(hierarchy),
level: NameKey::new(level),
via_column: via_column.map(NameKey::new),
via_variation: via_variation.map(NameKey::new),
}
}
fn varied_model(variation: Option<Variation>) -> TabularDatabase {
let local_date_table = Table {
name: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
is_local_date_table: true,
is_hidden: true,
columns: vec![column("Date"), column("Year"), column("Month")],
hierarchies: vec![Hierarchy {
name: "Date Hierarchy".to_string(),
levels: vec![
HierarchyLevel {
name: "Year".to_string(),
column: "Year".to_string(),
},
HierarchyLevel {
name: "Month".to_string(),
column: "Month".to_string(),
},
],
..Default::default()
}],
..Default::default()
};
let mut date = column("Date");
date.variations = variation.into_iter().collect();
TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![date, column("Amount")],
..Default::default()
},
local_date_table,
],
relationships: vec![Relationship {
from_table: "Sales".to_string(),
from_column: "Date".to_string(),
to_table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
to_column: "Date".to_string(),
..Default::default()
}],
..Default::default()
}
}
fn declared_variation() -> Variation {
Variation {
name: "Variation".to_string(),
is_default: true,
relationship: Some("b10a0bfa-b7fe-4437-8b2d-85624b0f085f".to_string()),
default_hierarchy: Some(HierarchyRef {
table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
hierarchy: "Date Hierarchy".to_string(),
}),
}
}
fn local_table_id() -> ObjectId {
table_id("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228")
}
fn hierarchy_id() -> ObjectId {
ObjectId::Hierarchy {
table: NameKey::new("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228"),
hierarchy: NameKey::new("Date Hierarchy"),
}
}
#[test]
fn a_variation_bound_date_hierarchy_keeps_the_machinery_alive() {
let db = varied_model(Some(declared_variation()));
let report = visual_page(
"P1",
"V1",
&[hierarchy_level_target(
"Sales",
"Date Hierarchy",
"Year",
Some("Date"),
Some("Variation"),
)],
);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
assert_eq!(
graph.roots_of(&hierarchy_id()).len(),
1,
"the binding lands on the date table's hierarchy"
);
not_unused(&unused, &hierarchy_id());
not_unused(&unused, &local_table_id());
not_unused(
&unused,
&column_id(
"LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
"Year",
),
);
let verdicts = graph.auto_date_time_tables(&db);
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::InUse);
assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
}
#[test]
fn the_relationship_fallback_resolves_without_the_declaration() {
let db = varied_model(None);
let report = visual_page(
"P1",
"V1",
&[hierarchy_level_target(
"Sales",
"Date Hierarchy",
"Month",
Some("Date"),
None,
)],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(graph.roots_of(&hierarchy_id()).len(), 1);
let unused = graph.unused_objects();
not_unused(&unused, &local_table_id());
not_unused(
&unused,
&column_id(
"LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
"Month",
),
);
}
#[test]
fn a_related_table_that_is_not_date_machinery_does_not_resolve() {
let mut db = varied_model(None);
db.tables[1].is_local_date_table = false;
let report = visual_page(
"P1",
"V1",
&[hierarchy_level_target(
"Sales",
"Date Hierarchy",
"Year",
Some("Date"),
None,
)],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.roots_of(&hierarchy_id()).is_empty());
assert_eq!(graph.roots_of(&table_id("Sales")).len(), 1);
}
#[test]
fn machinery_alive_only_through_dax_is_unused_by_reports() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
measures: vec![measure("Years", "COUNTROWS('LocalDateTable_x')")],
..Default::default()
},
Table {
name: "LocalDateTable_x".to_string(),
is_local_date_table: true,
columns: vec![column("Year")],
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Years")]);
let graph = DependencyGraph::build(&db, &[&report]);
let unused = graph.unused_objects();
not_unused(&unused, &local_table_id());
let verdicts = graph.auto_date_time_tables(&db);
assert_eq!(verdicts.len(), 1);
assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::UnusedByReports);
assert_eq!(verdicts[0].source_column, None);
}
#[test]
fn unbound_unreferenced_machinery_is_dead() {
let db = varied_model(Some(declared_variation()));
let graph = DependencyGraph::build(&db, &[]);
let unused = graph.unused_objects();
let dead = find(&unused, &local_table_id());
assert!(dead.used_by.iter().all(|used| used.also_unused));
let verdicts = graph.auto_date_time_tables(&db);
assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::Dead);
assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
}
}
mod broken {
use super::*;
use crate::graph::{BrokenBinding, BrokenReason};
fn hierarchy_level_target(
table: &str,
hierarchy: &str,
level: &str,
via_column: Option<&str>,
) -> FieldTarget {
FieldTarget::HierarchyLevel {
table: NameKey::new(table),
hierarchy: NameKey::new(hierarchy),
level: NameKey::new(level),
via_column: via_column.map(NameKey::new),
via_variation: None,
}
}
fn written_target(table: Option<&str>, name: &str) -> FieldTarget {
FieldTarget::Written(crate::identity::FieldRef {
table: table.map(NameKey::new),
name: NameKey::new(name),
})
}
fn broken_of<'a>(graph: &'a DependencyGraph, target: &FieldTarget) -> &'a BrokenBinding {
graph
.broken_bindings()
.iter()
.find(|binding| &binding.target == target)
.unwrap_or_else(|| panic!("{target} expected among the broken bindings"))
}
fn not_broken(graph: &DependencyGraph, target: &FieldTarget) {
assert!(
!graph
.broken_bindings()
.iter()
.any(|binding| &binding.target == target),
"{target} must not be broken"
);
}
#[test]
fn a_missing_column_on_a_live_table_is_broken() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
measures: vec![measure("Total", "SUM('Sales'[Amount])")],
..Default::default()
}],
..Default::default()
};
let report = visual_page(
"P1",
"V1",
&[
measure_target("Sales", "Total"),
column_target("Sales", "Color"),
],
);
let graph = DependencyGraph::build(&db, &[&report]);
let broken = broken_of(&graph, &column_target("Sales", "Color"));
assert_eq!(broken.reason, BrokenReason::FieldNotFound);
assert_eq!(
broken.target.to_string(),
"'Sales'[Color]",
"the written form is the display id"
);
assert_eq!(broken.edge.visual.as_ref().map(NameKey::as_str), Some("V1"));
assert_eq!(broken.edge.page.as_ref().map(NameKey::as_str), Some("P1"));
assert_eq!(
broken.edge.report.as_ref().map(NameKey::as_str),
Some("Mini")
);
not_unused(&graph.unused_objects(), &table_id("Sales"));
}
#[test]
fn a_binding_whose_table_is_gone_is_broken() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
measures: vec![measure("Total", "0")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Ghost", "X")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(&graph, &column_target("Ghost", "X")).reason,
BrokenReason::TableNotFound
);
}
#[test]
fn a_binding_naming_a_missing_measure_is_broken() {
let db = TabularDatabase {
tables: vec![table("Sales")],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Gone")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(&graph, &measure_target("Sales", "Gone")).reason,
BrokenReason::MeasureNotFound
);
}
#[test]
fn a_kpi_variant_binding_resolves_instead_of_flagging() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
measures: vec![measure("Total", "0")],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Total Goal")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.broken_bindings().is_empty());
}
#[test]
fn a_kpi_variant_over_a_missing_base_measure_still_flags() {
let db = TabularDatabase {
tables: vec![table("Sales")],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Gone Status")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(&graph, &measure_target("Sales", "Gone Status")).reason,
BrokenReason::MeasureNotFound
);
}
#[test]
fn a_plain_missing_hierarchy_flags_but_a_variation_one_does_not() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Date")],
..Default::default()
}],
..Default::default()
};
let report = visual_page(
"P1",
"V1",
&[
hierarchy_level_target("Sales", "Calendar", "Year", None),
hierarchy_level_target("Sales", "Fiscal", "Year", Some("Date")),
],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(
&graph,
&hierarchy_level_target("Sales", "Calendar", "Year", None)
)
.reason,
BrokenReason::HierarchyNotFound
);
not_broken(
&graph,
&hierarchy_level_target("Sales", "Fiscal", "Year", Some("Date")),
);
}
#[test]
fn a_missing_level_in_a_live_hierarchy_is_broken() {
let db = TabularDatabase {
tables: vec![Table {
name: "Date".to_string(),
columns: vec![column("Year")],
hierarchies: vec![Hierarchy {
name: "Calendar".to_string(),
levels: vec![HierarchyLevel {
name: "Year".to_string(),
column: "Year".to_string(),
}],
is_hidden: false,
}],
..Default::default()
}],
..Default::default()
};
let report = visual_page(
"P1",
"V1",
&[hierarchy_level_target("Date", "Calendar", "Quarter", None)],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(
&graph,
&hierarchy_level_target("Date", "Calendar", "Quarter", None)
)
.reason,
BrokenReason::LevelNotFound
);
not_unused(
&graph.unused_objects(),
&ObjectId::Hierarchy {
table: NameKey::new("Date"),
hierarchy: NameKey::new("Calendar"),
},
);
}
#[test]
fn a_written_miss_is_never_broken() {
let db = TabularDatabase {
tables: vec![table("Sales")],
..Default::default()
};
let report = visual_page("P1", "V1", &[written_target(Some("Ghost"), "X")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.broken_bindings().is_empty());
}
#[test]
fn a_binding_on_a_broken_measure_inherits_the_breakage() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
measures: vec![
measure("Total", "SUM('Sales'[Amount])"),
measure("Broken", "SUM('Sales'[Nope])"),
],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Broken")]);
let graph = DependencyGraph::build(&db, &[&report]);
let broken = broken_of(&graph, &measure_target("Sales", "Broken"));
assert_eq!(
broken.reason,
BrokenReason::BoundArtifactBroken {
artifact: measure_id("Sales", "Broken"),
}
);
not_unused(&graph.unused_objects(), &measure_id("Sales", "Broken"));
}
#[test]
fn an_unbound_broken_measure_produces_no_binding_record() {
let db = TabularDatabase {
tables: vec![Table {
name: "Sales".to_string(),
columns: vec![column("Amount")],
measures: vec![
measure("Total", "SUM('Sales'[Amount])"),
measure("Dead Broken", "SUM('Sales'[Nope])"),
],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(graph.broken_bindings().is_empty());
let unused = graph.unused_objects();
find(&unused, &measure_id("Sales", "Dead Broken"));
}
#[test]
fn the_records_sort_by_binding_site_then_target() {
let db = TabularDatabase {
tables: vec![table("Sales")],
..Default::default()
};
let mut report = visual_page(
"P2",
"V2",
&[column_target("Sales", "B"), column_target("Sales", "A")],
);
report.pages.insert(
0,
Page {
name: NameKey::new("P1"),
display_name: None,
is_hidden: false,
filters: Vec::new(),
binding: None,
visuals: vec![Visual {
name: NameKey::new("V1"),
visual_type: "card".to_string(),
wells: vec![FieldWell {
role: "Values".to_string(),
projections: vec![Projection {
target: column_target("Sales", "C"),
query_ref: None,
active: true,
}],
}],
filters: Vec::new(),
sorts: Vec::new(),
conditional_formatting: Vec::new(),
alt_text: Vec::new(),
tooltip_page: None,
}],
},
);
let graph = DependencyGraph::build(&db, &[&report]);
let targets: Vec<String> = graph
.broken_bindings()
.iter()
.map(|binding| binding.target.to_string())
.collect();
assert_eq!(
targets,
["'Sales'[C]", "'Sales'[A]", "'Sales'[B]"],
"page P1 before P2, then the written targets in order"
);
}
#[test]
fn a_binding_on_a_calculated_tables_datatable_header_resolves() {
let db = TabularDatabase {
tables: vec![Table {
name: "ColAxis (Outlook Bosteder)".to_string(),
partitions: vec![Partition {
name: "ColAxisOutlook".to_string(),
source: PartitionSource::Calculated {
expression: "DATATABLE(\"Ordinal\", INTEGER, \"Group\", STRING, \"MonthNum\", INTEGER, \"StaticLabel\", STRING, {\"Outlook\", \"Jan\", 1, \"Jan\"})"
.to_string(),
},
}],
..Default::default()
}],
..Default::default()
};
let report = visual_page(
"P1",
"V1",
&[
column_target("ColAxis (Outlook Bosteder)", "Group"),
column_target("ColAxis (Outlook Bosteder)", "StaticLabel"),
],
);
let graph = DependencyGraph::build(&db, &[&report]);
assert!(
graph.broken_bindings().is_empty(),
"the DATATABLE headers resolve: {:?}",
graph.broken_bindings()
);
not_unused(
&graph.unused_objects(),
&table_id("ColAxis (Outlook Bosteder)"),
);
}
#[test]
fn a_binding_off_the_calculated_tables_visible_names_still_flags() {
let db = TabularDatabase {
tables: vec![Table {
name: "ColAxis".to_string(),
partitions: vec![Partition {
name: "ColAxis".to_string(),
source: PartitionSource::Calculated {
expression: "DATATABLE(\"Gruppe\", STRING, {\"Outlook\"})".to_string(),
},
}],
..Default::default()
}],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("ColAxis", "Group")]);
let graph = DependencyGraph::build(&db, &[&report]);
let broken = broken_of(&graph, &column_target("ColAxis", "Group"));
assert_eq!(broken.reason, BrokenReason::FieldNotFound);
not_unused(&graph.unused_objects(), &table_id("ColAxis"));
}
#[test]
fn a_binding_on_a_calculated_tables_wrapped_table_columns_resolves() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Color"), column("Amount")],
..Default::default()
},
Table {
name: "Top Sales".to_string(),
partitions: vec![Partition {
name: "Top Sales".to_string(),
source: PartitionSource::Calculated {
expression: "CALCULATETABLE(VALUES('Sales'))".to_string(),
},
}],
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Top Sales", "Color")]);
let graph = DependencyGraph::build(&db, &[&report]);
not_broken(&graph, &column_target("Top Sales", "Color"));
not_unused(&graph.unused_objects(), &table_id("Top Sales"));
}
#[test]
fn a_foreign_column_of_a_calculated_table_still_flags() {
let db = TabularDatabase {
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Color"), column("Amount")],
..Default::default()
},
Table {
name: "Top Sales".to_string(),
partitions: vec![Partition {
name: "Top Sales".to_string(),
source: PartitionSource::Calculated {
expression: "CALCULATETABLE(VALUES('Sales'))".to_string(),
},
}],
..Default::default()
},
],
..Default::default()
};
let report = visual_page("P1", "V1", &[column_target("Top Sales", "Region")]);
let graph = DependencyGraph::build(&db, &[&report]);
assert_eq!(
broken_of(&graph, &column_target("Top Sales", "Region")).reason,
BrokenReason::FieldNotFound
);
}
}
}