1use std::collections::{HashMap, HashSet};
143
144use petgraph::Direction;
145use petgraph::graph::{DiGraph, NodeIndex};
146use petgraph::visit::EdgeRef;
147
148pub mod broken;
149pub mod provenance;
150
151mod builder;
152mod reachability;
153
154pub use broken::{BrokenBinding, BrokenReason};
155pub use provenance::{BindingEdge, BindingSite, Provenance, StructuralEdge};
156pub use reachability::{UnusedObject, UsedBy};
157
158use crate::identity::{NameKey, ObjectId, fold_name};
159use crate::model::TabularDatabase;
160use crate::report::ReportModel;
161
162#[derive(Debug)]
169pub struct DependencyGraph {
170 graph: DiGraph<ObjectId, Provenance>,
172 nodes: HashMap<ObjectId, NodeIndex>,
174 roots: Vec<(ObjectId, Provenance)>,
177 m_named: HashMap<ObjectId, Vec<ObjectId>>,
182 broken: Vec<BrokenBinding>,
187}
188
189impl DependencyGraph {
190 #[must_use]
195 pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
196 builder::build(db, reports)
197 }
198
199 pub(super) fn assemble(
201 graph: DiGraph<ObjectId, Provenance>,
202 nodes: HashMap<ObjectId, NodeIndex>,
203 roots: Vec<(ObjectId, Provenance)>,
204 m_named: HashMap<ObjectId, Vec<ObjectId>>,
205 broken: Vec<BrokenBinding>,
206 ) -> Self {
207 Self {
208 graph,
209 nodes,
210 roots,
211 m_named,
212 broken,
213 }
214 }
215
216 pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
219 self.graph.node_indices().map(|index| &self.graph[index])
220 }
221
222 pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
227 self.neighbors(id, Direction::Incoming)
228 }
229
230 pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
232 self.neighbors(id, Direction::Outgoing)
233 }
234
235 pub fn roots(&self) -> &[(ObjectId, Provenance)] {
238 &self.roots
239 }
240
241 pub fn roots_of(&self, id: &ObjectId) -> Vec<&Provenance> {
243 self.roots
244 .iter()
245 .filter(|(target, _)| target == id)
246 .map(|(_, provenance)| provenance)
247 .collect()
248 }
249
250 pub fn broken_bindings(&self) -> &[BrokenBinding] {
262 &self.broken
263 }
264
265 pub fn named_by_m(&self, id: &ObjectId) -> &[ObjectId] {
273 self.m_named.get(id).map(Vec::as_slice).unwrap_or_default()
274 }
275
276 pub fn unused_objects(&self) -> Vec<UnusedObject> {
283 let reach = reachability::Reachability::compute(self);
284 let mut out: Vec<UnusedObject> = self
285 .graph
286 .node_indices()
287 .filter(|index| !reach.is_live(&self.graph[*index]))
288 .map(|index| {
289 let id = self.graph[index].clone();
290 let mut used_by: Vec<UsedBy> = self
291 .graph
292 .edges_directed(index, Direction::Incoming)
293 .map(|edge| UsedBy {
294 id: self.graph[edge.source()].clone(),
295 provenance: edge.weight().clone(),
296 also_unused: !reach.is_live(&self.graph[edge.source()]),
297 })
298 .collect();
299 used_by.sort_by(|a, b| a.id.cmp(&b.id));
300 let named_by_m = self.named_by_m(&id).to_vec();
301 UnusedObject {
302 id,
303 used_by,
304 named_by_m,
305 }
306 })
307 .collect();
308 out.sort_by(|a, b| a.id.cmp(&b.id));
309 out
310 }
311
312 pub fn auto_date_time_tables(&self, db: &TabularDatabase) -> Vec<AutoDateTimeVerdict> {
325 let reach = reachability::Reachability::compute(self);
326 let mut out: Vec<AutoDateTimeVerdict> = db
327 .tables
328 .iter()
329 .filter(|table| table.is_local_date_table || table.is_template_date_table)
330 .map(|table| {
331 let id = ObjectId::Table {
332 table: NameKey::new(&table.name),
333 };
334 let verdict = if self.bound_with_reports(&id) {
335 AutoDateTimeStatus::InUse
336 } else if !reach.is_live(&id) {
337 AutoDateTimeStatus::Dead
338 } else {
339 AutoDateTimeStatus::UnusedByReports
340 };
341 AutoDateTimeVerdict {
342 id,
343 verdict,
344 source_column: variation_source_column(db, &table.name),
345 }
346 })
347 .collect();
348 out.sort_by(|a, b| a.id.cmp(&b.id));
349 out
350 }
351
352 fn bound_with_reports(&self, table: &ObjectId) -> bool {
359 let ObjectId::Table { table: name } = table else {
360 return false;
361 };
362 let is_member = |id: &ObjectId| match id {
363 ObjectId::Column { table, .. }
364 | ObjectId::Measure { table, .. }
365 | ObjectId::Hierarchy { table, .. }
366 | ObjectId::Partition { table, .. }
367 | ObjectId::CalculationItem { table, .. } => table == name,
368 _ => false,
369 };
370 let is_binding = |provenance: &Provenance| matches!(provenance, Provenance::Binding(_));
371 self.roots.iter().any(|(target, provenance)| {
372 is_binding(provenance) && (target == table || is_member(target))
373 }) || self
374 .consumers_of(table)
375 .iter()
376 .any(|(_, provenance)| is_binding(provenance))
377 }
378
379 fn neighbors(&self, id: &ObjectId, direction: Direction) -> Vec<(ObjectId, Provenance)> {
380 let Some(&index) = self.nodes.get(id) else {
381 return Vec::new();
382 };
383 self.graph
384 .edges_directed(index, direction)
385 .map(|edge| {
386 let other = match direction {
387 Direction::Incoming => edge.source(),
388 Direction::Outgoing => edge.target(),
389 };
390 (self.graph[other].clone(), edge.weight().clone())
391 })
392 .collect()
393 }
394
395 pub(super) fn seed_indices(&self) -> Vec<NodeIndex> {
398 let mut seeds: Vec<NodeIndex> = self
399 .roots
400 .iter()
401 .filter_map(|(id, _)| self.nodes.get(id).copied())
402 .collect();
403 seeds.extend(
404 self.nodes
405 .iter()
406 .filter(|(id, _)| matches!(id, ObjectId::Role { .. }))
407 .map(|(_, &index)| index),
408 );
409 seeds
410 }
411
412 pub(super) fn reach(
414 &self,
415 seeds: impl IntoIterator<Item = NodeIndex>,
416 allowed: fn(&Provenance) -> bool,
417 ) -> HashSet<NodeIndex> {
418 let mut seen: HashSet<NodeIndex> = seeds.into_iter().collect();
419 let mut queue: Vec<NodeIndex> = seen.iter().copied().collect();
420 while let Some(index) = queue.pop() {
421 for edge in self.graph.edges_directed(index, Direction::Outgoing) {
422 if !allowed(edge.weight()) {
423 continue;
424 }
425 if seen.insert(edge.target()) {
426 queue.push(edge.target());
427 }
428 }
429 }
430 seen
431 }
432
433 pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
435 &self.graph[index]
436 }
437}
438
439#[derive(Debug, Clone, PartialEq, Eq)]
443pub struct AutoDateTimeVerdict {
444 pub id: ObjectId,
446 pub verdict: AutoDateTimeStatus,
448 pub source_column: Option<ObjectId>,
453}
454
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
458pub enum AutoDateTimeStatus {
459 InUse,
463 UnusedByReports,
467 Dead,
469}
470
471fn variation_source_column(db: &TabularDatabase, table: &str) -> Option<ObjectId> {
474 let target = fold_name(table);
475 for t in &db.tables {
476 for column in &t.columns {
477 for variation in &column.variations {
478 let via_hierarchy = variation
479 .default_hierarchy
480 .as_ref()
481 .is_some_and(|reference| fold_name(&reference.table) == target);
482 let via_relationship = variation
483 .relationship
484 .as_ref()
485 .and_then(|name| {
486 db.relationships
487 .iter()
488 .find(|rel| rel.name.as_deref() == Some(name.as_str()))
489 })
490 .is_some_and(|rel| {
491 fold_name(&rel.from_table) == target || fold_name(&rel.to_table) == target
492 });
493 if via_hierarchy || via_relationship {
494 return Some(ObjectId::Column {
495 table: NameKey::new(&t.name),
496 column: NameKey::new(&column.name),
497 });
498 }
499 }
500 }
501 }
502 None
503}
504
505#[cfg(test)]
506mod tests {
507 use super::*;
508 use crate::identity::NameKey;
509 use crate::model::{
510 Column, ColumnKind, DaxExpressionKind, Function, Hierarchy, HierarchyLevel, HierarchyRef,
511 Measure, ParameterValuesColumn, Partition, PartitionSource, RefreshPolicy, Relationship,
512 Role, SharedExpression, Table, TablePermission, Variation,
513 };
514 use crate::report::{
515 Bookmark, BookmarkSection, BookmarkVisual, FieldTarget, FieldWell, Filter, Page,
516 Projection, Visual,
517 };
518
519 fn column(name: &str) -> Column {
520 Column {
521 name: name.to_string(),
522 ..Default::default()
523 }
524 }
525
526 fn measure(name: &str, expression: &str) -> Measure {
527 Measure {
528 name: name.to_string(),
529 expression: expression.to_string(),
530 ..Default::default()
531 }
532 }
533
534 fn m_partition(name: &str, expression: &str) -> Partition {
535 Partition {
536 name: name.to_string(),
537 source: PartitionSource::M {
538 expression: expression.to_string(),
539 },
540 }
541 }
542
543 fn table(name: &str) -> Table {
544 Table {
545 name: name.to_string(),
546 ..Default::default()
547 }
548 }
549
550 fn table_id(name: &str) -> ObjectId {
551 ObjectId::Table {
552 table: NameKey::new(name),
553 }
554 }
555
556 fn column_id(table: &str, column: &str) -> ObjectId {
557 ObjectId::Column {
558 table: NameKey::new(table),
559 column: NameKey::new(column),
560 }
561 }
562
563 fn measure_id(table: &str, measure: &str) -> ObjectId {
564 ObjectId::Measure {
565 table: NameKey::new(table),
566 measure: NameKey::new(measure),
567 }
568 }
569
570 fn report_measure_id(name: &str) -> ObjectId {
571 ObjectId::ReportMeasure {
572 measure: NameKey::new(name),
573 }
574 }
575
576 fn visual_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
578 ReportModel {
579 name: Some("Mini".to_string()),
580 pages: vec![Page {
581 name: NameKey::new(page),
582 display_name: None,
583 is_hidden: false,
584 filters: Vec::new(),
585 binding: None,
586 visuals: vec![Visual {
587 name: NameKey::new(visual),
588 visual_type: "card".to_string(),
589 wells: vec![FieldWell {
590 role: "Values".to_string(),
591 projections: targets
592 .iter()
593 .map(|target| Projection {
594 target: target.clone(),
595 query_ref: None,
596 active: true,
597 })
598 .collect(),
599 }],
600 filters: Vec::new(),
601 sorts: Vec::new(),
602 conditional_formatting: Vec::new(),
603 alt_text: Vec::new(),
604 tooltip_page: None,
605 }],
606 }],
607 ..Default::default()
608 }
609 }
610
611 fn measure_target(table: &str, name: &str) -> FieldTarget {
612 FieldTarget::Measure {
613 home_table: Some(NameKey::new(table)),
614 measure: NameKey::new(name),
615 }
616 }
617
618 fn column_target(table: &str, column: &str) -> FieldTarget {
619 FieldTarget::Column {
620 table: NameKey::new(table),
621 column: NameKey::new(column),
622 }
623 }
624
625 fn visual_mobile_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
628 let mut report = visual_page(page, visual, targets);
629 report.mobile_pages = std::mem::take(&mut report.pages);
630 report
631 }
632
633 fn find<'a>(unused: &'a [UnusedObject], id: &ObjectId) -> &'a UnusedObject {
635 unused
636 .iter()
637 .find(|finding| &finding.id == id)
638 .unwrap_or_else(|| panic!("{id} expected in the unused set"))
639 }
640
641 fn not_unused(unused: &[UnusedObject], id: &ObjectId) {
642 assert!(
643 !unused.iter().any(|finding| &finding.id == id),
644 "{id} must be live"
645 );
646 }
647
648 mod construction {
649 use super::*;
650
651 #[test]
652 fn every_model_object_gets_a_node_even_when_isolated() {
653 let db = TabularDatabase {
654 tables: vec![Table {
655 name: "Sales".to_string(),
656 columns: vec![column("Amount")],
657 ..Default::default()
658 }],
659 functions: vec![Function {
660 name: "MyFunc".to_string(),
661 expression: "1".to_string(),
662 is_hidden: false,
663 }],
664 ..Default::default()
665 };
666
667 let graph = DependencyGraph::build(&db, &[]);
668
669 let ids: Vec<_> = graph.object_ids().cloned().collect();
670 assert!(ids.contains(&table_id("Sales")));
671 assert!(ids.contains(&column_id("Sales", "Amount")));
672 assert!(ids.contains(&ObjectId::Function {
673 name: NameKey::new("MyFunc")
674 }));
675 }
676
677 #[test]
678 fn identical_edges_are_deduped_but_distinct_provenance_is_kept() {
679 let db = TabularDatabase {
680 tables: vec![Table {
681 name: "Sales".to_string(),
682 columns: vec![column("Amount")],
683 measures: vec![measure(
684 "Total",
685 "SUM('Sales'[Amount]) + SUM('Sales'[Amount])",
686 )],
687 ..Default::default()
688 }],
689 ..Default::default()
690 };
691
692 let graph = DependencyGraph::build(&db, &[]);
693
694 let producers = graph.producers_of(&measure_id("Sales", "Total"));
698 assert_eq!(producers.len(), 2);
699 assert_eq!(
700 producers
701 .iter()
702 .filter(|(id, _)| *id == column_id("Sales", "Amount"))
703 .count(),
704 1,
705 "identical (from, to, provenance) triples dedupe"
706 );
707 let consumers = graph.consumers_of(&column_id("Sales", "Amount"));
710 assert_eq!(consumers.len(), 1);
711 assert!(matches!(
712 consumers[0].1,
713 Provenance::Dax {
714 kind: DaxExpressionKind::Measure
715 }
716 ));
717 assert_eq!(consumers[0].0, measure_id("Sales", "Total"));
718 assert!(
719 graph
720 .consumers_of(&table_id("Sales"))
721 .iter()
722 .any(|(id, p)| *id == column_id("Sales", "Amount")
723 && matches!(
724 p,
725 Provenance::Structural {
726 role: StructuralEdge::TableMember
727 }
728 ))
729 );
730 }
731
732 #[test]
735 fn self_references_are_dropped() {
736 let db = TabularDatabase {
737 expressions: vec![SharedExpression {
738 name: "Recursive".to_string(),
739 expression: "Recursive + 1".to_string(),
740 ..Default::default()
741 }],
742 ..Default::default()
743 };
744
745 let graph = DependencyGraph::build(&db, &[]);
746 let id = ObjectId::Expression {
747 name: NameKey::new("Recursive"),
748 };
749
750 assert!(graph.producers_of(&id).is_empty());
751 assert!(graph.consumers_of(&id).is_empty());
752 }
753 }
754
755 mod liveness {
756 use super::*;
757
758 #[test]
762 fn a_mobile_only_binding_keeps_its_target_alive() {
763 let db = TabularDatabase {
764 tables: vec![Table {
765 name: "Sales".to_string(),
766 columns: vec![column("Units")],
767 measures: vec![measure("Total", "SUM('Sales'[Units])")],
768 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
769 ..Default::default()
770 }],
771 ..Default::default()
772 };
773 let report = visual_mobile_page("P1", "VM", &[measure_target("Sales", "Total")]);
774 let graph = DependencyGraph::build(&db, &[&report]);
775 let unused = graph.unused_objects();
776
777 not_unused(&unused, &measure_id("Sales", "Total"));
778 not_unused(&unused, &table_id("Sales"));
779
780 let roots = graph.roots();
783 assert_eq!(roots.len(), 1);
784 assert_eq!(roots[0].0, measure_id("Sales", "Total"));
785 let Provenance::Binding(edge) = &roots[0].1 else {
786 panic!("a root carries binding provenance");
787 };
788 assert!(edge.mobile);
789 }
790
791 #[test]
795 fn a_relationship_does_not_keep_its_far_table_alive() {
796 let db = TabularDatabase {
797 tables: vec![
798 Table {
799 name: "Sales".to_string(),
800 columns: vec![column("Key")],
801 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
802 ..Default::default()
803 },
804 Table {
805 name: "DimOld".to_string(),
806 columns: vec![column("Key"), column("Notes")],
807 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
808 ..Default::default()
809 },
810 ],
811 relationships: vec![Relationship {
812 name: None,
813 from_table: "Sales".to_string(),
814 from_column: "Key".to_string(),
815 to_table: "DimOld".to_string(),
816 to_column: "Key".to_string(),
817 is_active: true,
818 }],
819 ..Default::default()
820 };
821 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
822 let graph = DependencyGraph::build(&db, &[&report]);
823 let unused = graph.unused_objects();
824
825 not_unused(&unused, &table_id("Sales"));
827 not_unused(&unused, &column_id("Sales", "Key"));
828 not_unused(
829 &unused,
830 &ObjectId::Relationship {
831 from_table: NameKey::new("Sales"),
832 from_column: NameKey::new("Key"),
833 to_table: NameKey::new("DimOld"),
834 to_column: NameKey::new("Key"),
835 },
836 );
837
838 let dim_old = find(&unused, &table_id("DimOld"));
840 assert_eq!(dim_old.used_by.len(), 2, "its two columns contain it");
841 let by_key = dim_old
842 .used_by
843 .iter()
844 .find(|used| used.id == column_id("DimOld", "Key"))
845 .expect("the key column references its table");
846 assert!(
847 !by_key.also_unused,
848 "the key column is live, kept by the relationship endpoint"
849 );
850 assert!(matches!(
851 by_key.provenance,
852 Provenance::Structural {
853 role: StructuralEdge::TableMember
854 }
855 ));
856
857 let notes = find(&unused, &column_id("DimOld", "Notes"));
859 assert!(notes.used_by.is_empty(), "an orphan has no consumers");
860 let partition = find(
861 &unused,
862 &ObjectId::Partition {
863 table: NameKey::new("DimOld"),
864 partition: NameKey::new("DimOld"),
865 },
866 );
867 assert_eq!(partition.used_by.len(), 1);
868 assert!(partition.used_by[0].also_unused);
869 assert_eq!(partition.used_by[0].id, table_id("DimOld"));
870 }
871
872 #[test]
877 fn an_unactivated_inactive_relationship_is_a_finding_with_its_keys() {
878 let relationship_id = ObjectId::Relationship {
879 from_table: NameKey::new("Sales"),
880 from_column: NameKey::new("Key"),
881 to_table: NameKey::new("DimOld"),
882 to_column: NameKey::new("Key"),
883 };
884 let db = TabularDatabase {
885 tables: vec![
886 Table {
887 name: "Sales".to_string(),
888 columns: vec![column("Amt"), column("Key")],
889 measures: vec![measure("Total", "SUM('Sales'[Amt])")],
890 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
891 ..Default::default()
892 },
893 Table {
894 name: "DimOld".to_string(),
895 columns: vec![column("Key"), column("Notes")],
896 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
897 ..Default::default()
898 },
899 ],
900 relationships: vec![Relationship {
901 name: None,
902 from_table: "Sales".to_string(),
903 from_column: "Key".to_string(),
904 to_table: "DimOld".to_string(),
905 to_column: "Key".to_string(),
906 is_active: false,
907 }],
908 ..Default::default()
909 };
910 let report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
913 let graph = DependencyGraph::build(&db, &[&report]);
914 let unused = graph.unused_objects();
915
916 not_unused(&unused, &table_id("Sales"));
917
918 let relationship = find(&unused, &relationship_id);
922 assert_eq!(relationship.used_by.len(), 2);
923 assert!(relationship.used_by.iter().all(|used| matches!(
924 &used.provenance,
925 Provenance::Structural {
926 role: StructuralEdge::InactiveRelationship
927 }
928 )));
929 let sales_side = relationship
930 .used_by
931 .iter()
932 .find(|used| used.id == table_id("Sales"))
933 .expect("the from table references the relationship");
934 assert!(!sales_side.also_unused);
935
936 for (table_name, column_name) in [("Sales", "Key"), ("DimOld", "Key")] {
939 let finding = find(&unused, &column_id(table_name, column_name));
940 assert_eq!(
941 finding.used_by.len(),
942 1,
943 "the inactive relationship is the only reference"
944 );
945 assert!(finding.used_by[0].also_unused);
946 assert_eq!(finding.used_by[0].id, relationship_id);
947 assert!(matches!(
948 &finding.used_by[0].provenance,
949 Provenance::Structural {
950 role: StructuralEdge::InactiveRelationshipEndpoint
951 }
952 ));
953 }
954 find(&unused, &table_id("DimOld"));
957 }
958
959 #[test]
963 fn a_live_userelationship_measure_keeps_inactive_keys_alive() {
964 let db = TabularDatabase {
965 tables: vec![
966 Table {
967 name: "Sales".to_string(),
968 columns: vec![column("Amt"), column("Key")],
969 measures: vec![measure(
970 "Old Total",
971 "CALCULATE(SUM('Sales'[Amt]), USERELATIONSHIP('Sales'[Key], 'DimOld'[Key]))",
972 )],
973 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
974 ..Default::default()
975 },
976 Table {
977 name: "DimOld".to_string(),
978 columns: vec![column("Key"), column("Notes")],
979 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
980 ..Default::default()
981 },
982 ],
983 relationships: vec![Relationship {
984 name: None,
985 from_table: "Sales".to_string(),
986 from_column: "Key".to_string(),
987 to_table: "DimOld".to_string(),
988 to_column: "Key".to_string(),
989 is_active: false,
990 }],
991 ..Default::default()
992 };
993 let report = visual_page("P1", "V1", &[measure_target("Sales", "Old Total")]);
994 let graph = DependencyGraph::build(&db, &[&report]);
995 let unused = graph.unused_objects();
996
997 not_unused(&unused, &column_id("Sales", "Key"));
998 not_unused(&unused, &column_id("DimOld", "Key"));
999 not_unused(
1002 &unused,
1003 &ObjectId::Relationship {
1004 from_table: NameKey::new("Sales"),
1005 from_column: NameKey::new("Key"),
1006 to_table: NameKey::new("DimOld"),
1007 to_column: NameKey::new("Key"),
1008 },
1009 );
1010 not_unused(&unused, &table_id("DimOld"));
1014 let notes = find(&unused, &column_id("DimOld", "Notes"));
1015 assert!(notes.used_by.is_empty());
1016 }
1017
1018 #[test]
1021 fn an_rls_filter_keeps_its_column_and_table_alive() {
1022 let db = TabularDatabase {
1023 tables: vec![Table {
1024 name: "Sales".to_string(),
1025 columns: vec![column("Region")],
1026 ..Default::default()
1027 }],
1028 roles: vec![Role {
1029 name: "Reader".to_string(),
1030 table_permissions: vec![TablePermission {
1031 table: "Sales".to_string(),
1032 filter_expression: Some("'Sales'[Region] = \"West\"".to_string()),
1033 }],
1034 }],
1035 ..Default::default()
1036 };
1037
1038 let graph = DependencyGraph::build(&db, &[]);
1039 let unused = graph.unused_objects();
1040
1041 assert!(
1042 unused.is_empty(),
1043 "the role seeds the filter, the filter keeps the column, the column keeps the table"
1044 );
1045 let consumers = graph.consumers_of(&column_id("Sales", "Region"));
1046 assert_eq!(consumers.len(), 1);
1047 assert_eq!(
1048 consumers[0].0,
1049 ObjectId::Role {
1050 role: NameKey::new("Reader")
1051 }
1052 );
1053 assert!(matches!(
1054 consumers[0].1,
1055 Provenance::Dax {
1056 kind: DaxExpressionKind::RlsFilter
1057 }
1058 ));
1059 }
1060
1061 #[test]
1063 fn a_metadata_only_permission_keeps_its_table_alive() {
1064 let db = TabularDatabase {
1065 tables: vec![table("Sales")],
1066 roles: vec![Role {
1067 name: "Reader".to_string(),
1068 table_permissions: vec![TablePermission {
1069 table: "Sales".to_string(),
1070 filter_expression: None,
1071 }],
1072 }],
1073 ..Default::default()
1074 };
1075
1076 let graph = DependencyGraph::build(&db, &[]);
1077
1078 assert!(graph.unused_objects().is_empty());
1079 }
1080
1081 #[test]
1084 fn a_model_with_no_roots_reports_everything_unused() {
1085 let db = TabularDatabase {
1086 tables: vec![Table {
1087 name: "Sales".to_string(),
1088 columns: vec![column("Amount")],
1089 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
1090 ..Default::default()
1091 }],
1092 ..Default::default()
1093 };
1094
1095 let graph = DependencyGraph::build(&db, &[]);
1096
1097 assert_eq!(graph.unused_objects().len(), 3);
1098 assert!(graph.roots().is_empty());
1099 }
1100
1101 #[test]
1104 fn an_unused_report_measure_is_dead_and_annotates_its_chain() {
1105 let db = TabularDatabase {
1106 tables: vec![Table {
1107 name: "Sales".to_string(),
1108 columns: vec![column("Amount"), column("Old")],
1109 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
1110 ..Default::default()
1111 }],
1112 ..Default::default()
1113 };
1114 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
1115 report.measures.push(crate::report::ReportMeasure {
1116 name: NameKey::new("Local"),
1117 expression: "SUM('Sales'[Old])".to_string(),
1118 format_string: None,
1119 });
1120
1121 let graph = DependencyGraph::build(&db, &[&report]);
1122 let unused = graph.unused_objects();
1123
1124 let local = find(&unused, &report_measure_id("Local"));
1125 assert!(local.used_by.is_empty(), "no visual binds it");
1126 let old = find(&unused, &column_id("Sales", "Old"));
1127 assert_eq!(old.used_by.len(), 1);
1128 assert_eq!(old.used_by[0].id, report_measure_id("Local"));
1129 assert!(old.used_by[0].also_unused);
1130 not_unused(&unused, &column_id("Sales", "Amount"));
1131 }
1132
1133 #[test]
1137 fn a_visual_binding_resolves_to_the_shadowing_report_measure() {
1138 let db = TabularDatabase {
1139 tables: vec![Table {
1140 name: "Sales".to_string(),
1141 measures: vec![measure("Total", "0")],
1142 ..Default::default()
1143 }],
1144 ..Default::default()
1145 };
1146 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
1147 report.measures.push(crate::report::ReportMeasure {
1148 name: NameKey::new("Total"),
1149 expression: "[Model Total]".to_string(),
1150 format_string: None,
1151 });
1152
1153 let graph = DependencyGraph::build(&db, &[&report]);
1154
1155 assert_eq!(graph.roots_of(&report_measure_id("Total")).len(), 1);
1157 assert!(graph.roots_of(&measure_id("Sales", "Total")).is_empty());
1158 let unused = graph.unused_objects();
1159 not_unused(&unused, &report_measure_id("Total"));
1160 let shadowed = find(&unused, &measure_id("Sales", "Total"));
1161 assert!(shadowed.used_by.is_empty());
1162 }
1163
1164 #[test]
1167 fn a_sort_by_chain_is_annotated() {
1168 let db = TabularDatabase {
1169 tables: vec![Table {
1170 name: "Date".to_string(),
1171 columns: vec![
1172 Column {
1173 name: "Month Name".to_string(),
1174 sort_by_column: Some("Month Num".to_string()),
1175 ..Default::default()
1176 },
1177 column("Month Num"),
1178 ],
1179 ..Default::default()
1180 }],
1181 ..Default::default()
1182 };
1183
1184 let graph = DependencyGraph::build(&db, &[]);
1185 let unused = graph.unused_objects();
1186
1187 let month_name = find(&unused, &column_id("Date", "Month Name"));
1188 assert!(month_name.used_by.is_empty());
1189 let month_num = find(&unused, &column_id("Date", "Month Num"));
1190 assert_eq!(month_num.used_by.len(), 1);
1191 assert_eq!(month_num.used_by[0].id, column_id("Date", "Month Name"));
1192 assert!(month_num.used_by[0].also_unused);
1193 assert!(matches!(
1194 month_num.used_by[0].provenance,
1195 Provenance::Structural {
1196 role: StructuralEdge::SortByColumn
1197 }
1198 ));
1199 }
1200
1201 #[test]
1204 fn a_group_by_chain_is_annotated() {
1205 let db = TabularDatabase {
1206 tables: vec![Table {
1207 name: "Sales".to_string(),
1208 columns: vec![
1209 Column {
1210 name: "Amount".to_string(),
1211 group_by_columns: vec!["Bucket".to_string()],
1212 ..Default::default()
1213 },
1214 column("Bucket"),
1215 ],
1216 ..Default::default()
1217 }],
1218 ..Default::default()
1219 };
1220
1221 let graph = DependencyGraph::build(&db, &[]);
1222 let unused = graph.unused_objects();
1223
1224 let amount = find(&unused, &column_id("Sales", "Amount"));
1225 assert!(amount.used_by.is_empty());
1226 let bucket = find(&unused, &column_id("Sales", "Bucket"));
1227 assert_eq!(bucket.used_by.len(), 1);
1228 assert_eq!(bucket.used_by[0].id, column_id("Sales", "Amount"));
1229 assert!(bucket.used_by[0].also_unused);
1230 assert!(matches!(
1231 bucket.used_by[0].provenance,
1232 Provenance::Structural {
1233 role: StructuralEdge::GroupByColumn
1234 }
1235 ));
1236 }
1237
1238 #[test]
1242 fn a_used_column_keeps_its_group_by_column_alive() {
1243 let db = TabularDatabase {
1244 tables: vec![Table {
1245 name: "Sales".to_string(),
1246 columns: vec![
1247 Column {
1248 name: "Amount".to_string(),
1249 group_by_columns: vec!["Bucket".to_string()],
1250 ..Default::default()
1251 },
1252 column("Bucket"),
1253 ],
1254 ..Default::default()
1255 }],
1256 ..Default::default()
1257 };
1258 let report = visual_page("P1", "V1", &[column_target("Sales", "Amount")]);
1259
1260 let graph = DependencyGraph::build(&db, &[&report]);
1261
1262 assert!(graph.unused_objects().is_empty());
1263 }
1264
1265 #[test]
1268 fn a_dead_hierarchy_annotates_its_level_columns() {
1269 let db = TabularDatabase {
1270 tables: vec![Table {
1271 name: "Date".to_string(),
1272 columns: vec![column("Year")],
1273 hierarchies: vec![crate::model::Hierarchy {
1274 name: "Calendar".to_string(),
1275 levels: vec![crate::model::HierarchyLevel {
1276 name: "Year".to_string(),
1277 column: "Year".to_string(),
1278 }],
1279 is_hidden: false,
1280 }],
1281 ..Default::default()
1282 }],
1283 ..Default::default()
1284 };
1285
1286 let graph = DependencyGraph::build(&db, &[]);
1287 let unused = graph.unused_objects();
1288
1289 let hierarchy = find(
1290 &unused,
1291 &ObjectId::Hierarchy {
1292 table: NameKey::new("Date"),
1293 hierarchy: NameKey::new("Calendar"),
1294 },
1295 );
1296 assert!(hierarchy.used_by.is_empty());
1297 let year = find(&unused, &column_id("Date", "Year"));
1298 assert_eq!(year.used_by.len(), 1);
1299 assert!(matches!(
1300 year.used_by[0].provenance,
1301 Provenance::Structural {
1302 role: StructuralEdge::HierarchyLevel
1303 }
1304 ));
1305 assert!(year.used_by[0].also_unused);
1306 }
1307
1308 #[test]
1311 fn dax_keeps_a_referenced_hierarchy_alive() {
1312 let db = TabularDatabase {
1313 tables: vec![Table {
1314 name: "Date".to_string(),
1315 columns: vec![column("Year")],
1316 hierarchies: vec![crate::model::Hierarchy {
1317 name: "Calendar".to_string(),
1318 levels: vec![crate::model::HierarchyLevel {
1319 name: "Year".to_string(),
1320 column: "Year".to_string(),
1321 }],
1322 is_hidden: false,
1323 }],
1324 measures: vec![measure("In Scope", "ISINSCOPE('Date'[Calendar])")],
1325 ..Default::default()
1326 }],
1327 ..Default::default()
1328 };
1329 let report = visual_page("P1", "V1", &[measure_target("Date", "In Scope")]);
1330
1331 let graph = DependencyGraph::build(&db, &[&report]);
1332
1333 assert!(graph.unused_objects().is_empty());
1334 }
1335
1336 #[test]
1342 fn a_binding_on_a_calculation_group_column_keeps_its_items_alive() {
1343 let db = TabularDatabase {
1344 tables: vec![
1345 Table {
1346 name: "Sales".to_string(),
1347 columns: vec![column("Amount")],
1348 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
1349 ..Default::default()
1350 },
1351 Table {
1352 name: "Date Role".to_string(),
1353 columns: vec![column("Date Role")],
1354 calculation_group: Some(crate::model::CalculationGroup {
1355 items: vec![
1356 crate::model::CalculationItem {
1357 name: "By Ship Date".to_string(),
1358 expression: "SELECTEDMEASURE()".to_string(),
1359 format_string_expression: None,
1360 },
1361 crate::model::CalculationItem {
1362 name: "By Due Date".to_string(),
1363 expression: "SELECTEDMEASURE()".to_string(),
1364 format_string_expression: None,
1365 },
1366 ],
1367 ..Default::default()
1368 }),
1369 ..Default::default()
1370 },
1371 ],
1372 ..Default::default()
1373 };
1374 let report = visual_page(
1375 "P1",
1376 "Slicer",
1377 &[
1378 measure_target("Sales", "Total"),
1379 column_target("Date Role", "Date Role"),
1380 ],
1381 );
1382
1383 let graph = DependencyGraph::build(&db, &[&report]);
1384
1385 assert!(
1386 graph.unused_objects().is_empty(),
1387 "the bound column keeps the group, the group's items, and the model alive"
1388 );
1389 let consumers = graph.consumers_of(&ObjectId::CalculationItem {
1390 table: NameKey::new("Date Role"),
1391 item: NameKey::new("By Ship Date"),
1392 });
1393 assert!(
1394 consumers.iter().any(|(id, provenance)| {
1395 *id == column_id("Date Role", "Date Role")
1396 && matches!(provenance, Provenance::Binding(_))
1397 }),
1398 "the column's binding edge names the item, with the binding site as provenance"
1399 );
1400 }
1401
1402 #[test]
1405 fn dax_keeps_a_referenced_calculation_item_alive() {
1406 let db = TabularDatabase {
1407 tables: vec![
1408 Table {
1409 name: "Sales".to_string(),
1410 measures: vec![measure(
1411 "YTD Sales",
1412 "CALCULATE(SUM('Sales'[Amount]), 'Time Intelligence'[YTD])",
1413 )],
1414 ..Default::default()
1415 },
1416 Table {
1417 name: "Time Intelligence".to_string(),
1418 calculation_group: Some(crate::model::CalculationGroup {
1419 items: vec![
1420 crate::model::CalculationItem {
1421 name: "YTD".to_string(),
1422 expression: "SELECTEDMEASURE()".to_string(),
1423 format_string_expression: None,
1424 },
1425 crate::model::CalculationItem {
1426 name: "MTD".to_string(),
1427 expression: "SELECTEDMEASURE()".to_string(),
1428 format_string_expression: None,
1429 },
1430 ],
1431 ..Default::default()
1432 }),
1433 ..Default::default()
1434 },
1435 ],
1436 ..Default::default()
1437 };
1438 let report = visual_page("P1", "V1", &[measure_target("Sales", "YTD Sales")]);
1439
1440 let graph = DependencyGraph::build(&db, &[&report]);
1441 let unused = graph.unused_objects();
1442 let unused_ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1443
1444 assert_eq!(
1445 unused_ids,
1446 [&ObjectId::CalculationItem {
1447 table: NameKey::new("Time Intelligence"),
1448 item: NameKey::new("MTD"),
1449 }],
1450 "only the unselected calculation item is unused"
1451 );
1452 }
1453
1454 #[test]
1457 fn an_unresolved_qualified_reference_keeps_its_table_alive() {
1458 let db = TabularDatabase {
1459 tables: vec![
1460 Table {
1461 name: "Sales".to_string(),
1462 measures: vec![measure("M", "'Ghost'[Nope]")],
1463 ..Default::default()
1464 },
1465 table("Ghost"),
1466 ],
1467 ..Default::default()
1468 };
1469 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1470
1471 let graph = DependencyGraph::build(&db, &[&report]);
1472
1473 assert!(graph.unused_objects().is_empty(), "Ghost stays alive");
1474 }
1475
1476 #[test]
1478 fn an_unresolved_reference_without_a_resolvable_part_keeps_nothing_alive() {
1479 let db = TabularDatabase {
1480 tables: vec![Table {
1481 name: "Sales".to_string(),
1482 measures: vec![measure("M", "'Ghost'[Nope] + [Also Nope]")],
1483 ..Default::default()
1484 }],
1485 ..Default::default()
1486 };
1487 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1488
1489 let graph = DependencyGraph::build(&db, &[&report]);
1490
1491 assert_eq!(graph.unused_objects().len(), 0, "only Sales and M exist");
1492 }
1493
1494 #[test]
1497 fn m_references_keep_shared_expressions_alive() {
1498 let db = TabularDatabase {
1499 tables: vec![
1500 Table {
1501 name: "Sales".to_string(),
1502 partitions: vec![m_partition(
1503 "Sales",
1504 "let Source = Sql.Database(ServerName) in Source",
1505 )],
1506 ..Default::default()
1507 },
1508 Table {
1509 name: "DimOld".to_string(),
1510 partitions: vec![m_partition(
1511 "DimOld",
1512 "let Source = LegacyParam in Source",
1513 )],
1514 ..Default::default()
1515 },
1516 ],
1517 expressions: vec![
1518 SharedExpression {
1519 name: "ServerName".to_string(),
1520 expression: "\"localhost\"".to_string(),
1521 ..Default::default()
1522 },
1523 SharedExpression {
1524 name: "LegacyParam".to_string(),
1525 expression: "5".to_string(),
1526 ..Default::default()
1527 },
1528 ],
1529 ..Default::default()
1530 };
1531 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1534
1535 let graph = DependencyGraph::build(&db, &[&report]);
1536 let unused = graph.unused_objects();
1537
1538 not_unused(
1539 &unused,
1540 &ObjectId::Expression {
1541 name: NameKey::new("ServerName"),
1542 },
1543 );
1544 let legacy = find(
1545 &unused,
1546 &ObjectId::Expression {
1547 name: NameKey::new("LegacyParam"),
1548 },
1549 );
1550 assert_eq!(legacy.used_by.len(), 1);
1551 assert_eq!(
1552 legacy.used_by[0].id,
1553 ObjectId::Partition {
1554 table: NameKey::new("DimOld"),
1555 partition: NameKey::new("DimOld"),
1556 }
1557 );
1558 assert!(legacy.used_by[0].also_unused);
1559 assert!(matches!(legacy.used_by[0].provenance, Provenance::M));
1560 }
1561
1562 #[test]
1568 fn a_consumed_parameter_keeps_its_bound_column_alive() {
1569 let db = TabularDatabase {
1570 tables: vec![
1571 Table {
1572 name: "DaysList".to_string(),
1573 columns: vec![column("Days")],
1574 ..Default::default()
1575 },
1576 Table {
1577 name: "SampleData".to_string(),
1578 columns: vec![column("Days")],
1579 partitions: vec![m_partition(
1580 "SampleData",
1581 "let Source = Sql.Database(\"s\", \"db\") in Table.SelectRows(Source, each [Days] >= MinDays)",
1582 )],
1583 ..Default::default()
1584 },
1585 ],
1586 expressions: vec![SharedExpression {
1587 name: "MinDays".to_string(),
1588 expression: "15".to_string(),
1589 parameter_values_column: Some(ParameterValuesColumn {
1590 table: "DaysList".to_string(),
1591 column: "Days".to_string(),
1592 }),
1593 }],
1594 ..Default::default()
1595 };
1596 let report = visual_page("P1", "V1", &[column_target("SampleData", "Days")]);
1597
1598 let graph = DependencyGraph::build(&db, &[&report]);
1599 let unused = graph.unused_objects();
1600
1601 assert!(
1602 unused.is_empty(),
1603 "the whole chain is live, bound column included: {unused:?}"
1604 );
1605 assert_eq!(
1608 graph.consumers_of(&column_id("DaysList", "Days")),
1609 [(
1610 ObjectId::Expression {
1611 name: NameKey::new("MinDays"),
1612 },
1613 Provenance::Structural {
1614 role: StructuralEdge::MParameterBinding,
1615 }
1616 )]
1617 );
1618 }
1619
1620 #[test]
1625 fn an_unconsumed_or_dangling_binding_keeps_nothing_alive() {
1626 let db = TabularDatabase {
1627 tables: vec![
1628 Table {
1629 name: "DaysList".to_string(),
1630 columns: vec![column("Days")],
1631 ..Default::default()
1632 },
1633 Table {
1634 name: "SampleData".to_string(),
1635 columns: vec![column("Days")],
1636 partitions: vec![m_partition(
1637 "SampleData",
1638 "let Source = Sql.Database(\"s\", \"db\") in Source",
1639 )],
1640 ..Default::default()
1641 },
1642 ],
1643 expressions: vec![
1644 SharedExpression {
1645 name: "Unconsumed".to_string(),
1646 expression: "15".to_string(),
1647 parameter_values_column: Some(ParameterValuesColumn {
1648 table: "DaysList".to_string(),
1649 column: "Days".to_string(),
1650 }),
1651 },
1652 SharedExpression {
1653 name: "Dangling".to_string(),
1654 expression: "1".to_string(),
1655 parameter_values_column: Some(ParameterValuesColumn {
1656 table: "Ghost".to_string(),
1657 column: "Nope".to_string(),
1658 }),
1659 },
1660 ],
1661 ..Default::default()
1662 };
1663 let report = visual_page("P1", "V1", &[column_target("SampleData", "Days")]);
1664
1665 let graph = DependencyGraph::build(&db, &[&report]);
1666 let unused = graph.unused_objects();
1667
1668 let unconsumed = find(
1669 &unused,
1670 &ObjectId::Expression {
1671 name: NameKey::new("Unconsumed"),
1672 },
1673 );
1674 assert!(unconsumed.used_by.is_empty(), "no partition names it");
1675
1676 let bound = find(&unused, &column_id("DaysList", "Days"));
1679 assert_eq!(bound.used_by.len(), 1);
1680 assert_eq!(
1681 bound.used_by[0].id,
1682 ObjectId::Expression {
1683 name: NameKey::new("Unconsumed"),
1684 }
1685 );
1686 assert!(matches!(
1687 bound.used_by[0].provenance,
1688 Provenance::Structural {
1689 role: StructuralEdge::MParameterBinding
1690 }
1691 ));
1692 assert!(bound.used_by[0].also_unused);
1693
1694 let dangling = find(
1697 &unused,
1698 &ObjectId::Expression {
1699 name: NameKey::new("Dangling"),
1700 },
1701 );
1702 assert!(dangling.used_by.is_empty());
1703 }
1704
1705 #[test]
1709 fn an_m_chain_keeps_shared_expressions_alive() {
1710 let db = TabularDatabase {
1711 tables: vec![Table {
1712 name: "Sales".to_string(),
1713 partitions: vec![m_partition(
1714 "Sales",
1715 "let Source = Sql.Database(#\"Staging Query\") in Source",
1716 )],
1717 ..Default::default()
1718 }],
1719 expressions: vec![
1720 SharedExpression {
1721 name: "Staging Query".to_string(),
1722 expression: "ServerName".to_string(),
1723 ..Default::default()
1724 },
1725 SharedExpression {
1726 name: "ServerName".to_string(),
1727 expression: "\"localhost\"".to_string(),
1728 ..Default::default()
1729 },
1730 ],
1731 ..Default::default()
1732 };
1733 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1734
1735 let graph = DependencyGraph::build(&db, &[&report]);
1736 let unused = graph.unused_objects();
1737
1738 not_unused(
1739 &unused,
1740 &ObjectId::Expression {
1741 name: NameKey::new("Staging Query"),
1742 },
1743 );
1744 not_unused(
1745 &unused,
1746 &ObjectId::Expression {
1747 name: NameKey::new("ServerName"),
1748 },
1749 );
1750
1751 assert_eq!(
1754 graph.consumers_of(&ObjectId::Expression {
1755 name: NameKey::new("ServerName"),
1756 }),
1757 [(
1758 ObjectId::Expression {
1759 name: NameKey::new("Staging Query"),
1760 },
1761 Provenance::M
1762 )]
1763 );
1764 }
1765
1766 #[test]
1774 fn an_m_partition_names_its_columns_without_keeping_them_alive() {
1775 let db = TabularDatabase {
1776 tables: vec![Table {
1777 name: "Sales".to_string(),
1778 columns: vec![
1779 column("Pk"),
1780 column("Amount"),
1781 column("Region"),
1782 column("Orphaned"),
1783 ],
1784 partitions: vec![m_partition(
1785 "Sales",
1786 concat!(
1787 "let\n",
1788 " Source = Sql.Database(ServerName, \"db\"),\n",
1789 " Typed = Table.TransformColumnTypes(Source, {{\"Amount\", type text}}),\n",
1790 " Expanded = Table.ExpandTableColumn(Typed, \"Detail\", {\"Region\"}),\n",
1791 " Filtered = Table.SelectRows(Expanded, each [Orphaned] = \"West\")\n",
1792 "in\n",
1793 " Filtered",
1794 ),
1795 )],
1796 ..Default::default()
1797 }],
1798 expressions: vec![SharedExpression {
1799 name: "ServerName".to_string(),
1800 expression: "\"localhost\"".to_string(),
1801 ..Default::default()
1802 }],
1803 ..Default::default()
1804 };
1805 let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
1809
1810 let graph = DependencyGraph::build(&db, &[&report]);
1811 let unused = graph.unused_objects();
1812
1813 let partition = ObjectId::Partition {
1814 table: NameKey::new("Sales"),
1815 partition: NameKey::new("Sales"),
1816 };
1817 let expected_named = [partition];
1818 for name in ["Amount", "Region", "Orphaned"] {
1819 let finding = find(&unused, &column_id("Sales", name));
1820 assert!(
1821 finding.used_by.is_empty(),
1822 "M names are not consumers: no edge points at the column"
1823 );
1824 assert_eq!(finding.named_by_m, expected_named);
1825 }
1826 not_unused(
1829 &unused,
1830 &ObjectId::Expression {
1831 name: NameKey::new("ServerName"),
1832 },
1833 );
1834 }
1835
1836 #[test]
1840 fn a_dead_tables_partition_keeps_nothing_alive() {
1841 let db = TabularDatabase {
1842 tables: vec![Table {
1843 name: "DimOld".to_string(),
1844 columns: vec![column("Key")],
1845 partitions: vec![m_partition(
1846 "DimOld",
1847 "let Source = Table.SelectRows(#\"DimOld\", each [Key] <> null) in Source",
1848 )],
1849 ..Default::default()
1850 }],
1851 ..Default::default()
1852 };
1853 let graph = DependencyGraph::build(&db, &[]);
1854 let unused = graph.unused_objects();
1855
1856 find(&unused, &column_id("DimOld", "Key"));
1857 find(&unused, &table_id("DimOld"));
1861 }
1862
1863 #[test]
1867 fn a_change_detection_measure_is_live_while_its_table_is_live() {
1868 let db = TabularDatabase {
1869 tables: vec![Table {
1870 name: "Sales".to_string(),
1871 columns: vec![column("Pk")],
1872 measures: vec![measure("Change Detector", "COUNTROWS('Sales')")],
1873 partitions: vec![m_partition(
1874 "Sales",
1875 "let Source = Sql.Database(\"s\", \"db\") in Source",
1876 )],
1877 refresh_policy: Some(RefreshPolicy {
1878 policy_type: Some("basicRefreshPolicy".to_string()),
1879 change_detection: Some(
1880 "EVALUATE ROW(\"Bookmark\", 'Sales'[Change Detector])".to_string(),
1881 ),
1882 ..Default::default()
1883 }),
1884 ..Default::default()
1885 }],
1886 ..Default::default()
1887 };
1888 let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
1891
1892 let graph = DependencyGraph::build(&db, &[&report]);
1893 let unused = graph.unused_objects();
1894
1895 not_unused(&unused, &measure_id("Sales", "Change Detector"));
1896 }
1897
1898 #[test]
1902 fn a_dead_tables_policy_flags_its_measure_with_policy_provenance() {
1903 let db = TabularDatabase {
1904 tables: vec![Table {
1905 name: "DimOld".to_string(),
1906 measures: vec![measure("Change Detector", "COUNTROWS('DimOld')")],
1907 partitions: vec![m_partition(
1908 "DimOld",
1909 "let Source = Sql.Database(\"s\", \"db\") in Source",
1910 )],
1911 refresh_policy: Some(RefreshPolicy {
1912 change_detection: Some(
1913 "EVALUATE ROW(\"Bookmark\", 'DimOld'[Change Detector])".to_string(),
1914 ),
1915 ..Default::default()
1916 }),
1917 ..Default::default()
1918 }],
1919 ..Default::default()
1920 };
1921 let graph = DependencyGraph::build(&db, &[]);
1922 let unused = graph.unused_objects();
1923
1924 let finding = find(&unused, &measure_id("DimOld", "Change Detector"));
1925 assert_eq!(
1926 finding.used_by,
1927 [UsedBy {
1928 id: ObjectId::Partition {
1929 table: NameKey::new("DimOld"),
1930 partition: NameKey::new("DimOld"),
1931 },
1932 provenance: Provenance::Dax {
1933 kind: DaxExpressionKind::ChangeDetection,
1934 },
1935 also_unused: true,
1936 }],
1937 "the partition references the measure through its policy"
1938 );
1939 }
1940
1941 #[test]
1946 fn a_policy_source_expression_keeps_the_parameters_it_names_alive() {
1947 let db = TabularDatabase {
1948 tables: vec![Table {
1949 name: "Sales".to_string(),
1950 columns: vec![column("Pk"), column("Modified")],
1951 partitions: vec![m_partition(
1952 "Sales",
1953 "let Source = Sql.Database(\"s\", \"db\") in Source",
1954 )],
1955 refresh_policy: Some(RefreshPolicy {
1956 source_expression: Some(concat!(
1957 "let\n",
1958 " Source = Sql.Database(\"s\", \"db\"),\n",
1959 " Filtered = Table.SelectRows(Source, each [Modified] >= RangeStart ",
1960 "and [Modified] < RangeEnd)\n",
1961 "in\n",
1962 " Filtered",
1963 )
1964 .to_string()),
1965 ..Default::default()
1966 }),
1967 ..Default::default()
1968 }],
1969 expressions: vec![
1970 SharedExpression {
1971 name: "RangeStart".to_string(),
1972 expression: "#datetime(2024, 1, 1, 0, 0, 0)".to_string(),
1973 ..Default::default()
1974 },
1975 SharedExpression {
1976 name: "RangeEnd".to_string(),
1977 expression: "#datetime(2024, 12, 31, 0, 0, 0)".to_string(),
1978 ..Default::default()
1979 },
1980 ],
1981 ..Default::default()
1982 };
1983 let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
1984
1985 let graph = DependencyGraph::build(&db, &[&report]);
1986 let unused = graph.unused_objects();
1987
1988 not_unused(
1989 &unused,
1990 &ObjectId::Expression {
1991 name: NameKey::new("RangeStart"),
1992 },
1993 );
1994 not_unused(
1995 &unused,
1996 &ObjectId::Expression {
1997 name: NameKey::new("RangeEnd"),
1998 },
1999 );
2000 }
2001
2002 #[test]
2006 fn change_detection_polling_by_shared_query_name_keeps_it_alive() {
2007 let db = TabularDatabase {
2008 tables: vec![Table {
2009 name: "Sales".to_string(),
2010 columns: vec![column("Pk")],
2011 partitions: vec![m_partition(
2012 "Sales",
2013 "let Source = Sql.Database(\"s\", \"db\") in Source",
2014 )],
2015 refresh_policy: Some(RefreshPolicy {
2016 change_detection: Some("DetectDataChangesQuery".to_string()),
2017 ..Default::default()
2018 }),
2019 ..Default::default()
2020 }],
2021 expressions: vec![SharedExpression {
2022 name: "DetectDataChangesQuery".to_string(),
2023 expression: "let Source = Sql.Database(\"s\", \"db\") in Source".to_string(),
2024 ..Default::default()
2025 }],
2026 ..Default::default()
2027 };
2028 let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
2029
2030 let graph = DependencyGraph::build(&db, &[&report]);
2031 let unused = graph.unused_objects();
2032
2033 not_unused(
2034 &unused,
2035 &ObjectId::Expression {
2036 name: NameKey::new("DetectDataChangesQuery"),
2037 },
2038 );
2039 }
2040
2041 #[test]
2046 fn an_m_merge_source_keeps_the_joined_table_alive() {
2047 let db = TabularDatabase {
2048 tables: vec![
2049 Table {
2050 name: "Sales".to_string(),
2051 columns: vec![column("Key")],
2052 partitions: vec![m_partition(
2053 "Sales",
2054 concat!(
2055 "let\n",
2056 " Source = Sql.Database(ServerName, \"db\"),\n",
2057 " Joined = Table.NestedJoin(Source, {\"Key\"}, #\"DimOld\", {\"Key\"}, \"Dim\")\n",
2058 "in\n",
2059 " Joined",
2060 ),
2061 )],
2062 ..Default::default()
2063 },
2064 Table {
2065 name: "DimOld".to_string(),
2066 columns: vec![column("Key")],
2067 partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
2068 ..Default::default()
2069 },
2070 ],
2071 expressions: vec![SharedExpression {
2072 name: "ServerName".to_string(),
2073 expression: "\"localhost\"".to_string(),
2074 ..Default::default()
2075 }],
2076 ..Default::default()
2077 };
2078 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
2081
2082 let graph = DependencyGraph::build(&db, &[&report]);
2083 let unused = graph.unused_objects();
2084
2085 not_unused(&unused, &table_id("DimOld"));
2086 let finding = find(&unused, &column_id("DimOld", "Key"));
2089 assert_eq!(
2090 finding.named_by_m,
2091 [ObjectId::Partition {
2092 table: NameKey::new("Sales"),
2093 partition: NameKey::new("Sales"),
2094 }]
2095 );
2096 }
2097
2098 #[test]
2102 fn a_qualified_m_field_access_keeps_the_named_table_alive() {
2103 let db = TabularDatabase {
2104 tables: vec![
2105 Table {
2106 name: "Sales".to_string(),
2107 columns: vec![column("Key")],
2108 partitions: vec![m_partition(
2109 "Sales",
2110 "let Source = #\"DimOld\"[Key] in Source",
2111 )],
2112 ..Default::default()
2113 },
2114 Table {
2115 name: "DimOld".to_string(),
2116 columns: vec![column("Key")],
2117 partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
2118 ..Default::default()
2119 },
2120 ],
2121 ..Default::default()
2122 };
2123 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
2124
2125 let graph = DependencyGraph::build(&db, &[&report]);
2126 let unused = graph.unused_objects();
2127
2128 not_unused(&unused, &table_id("DimOld"));
2129 }
2130
2131 #[test]
2135 fn a_name_inside_an_m_comment_or_string_keeps_nothing_alive() {
2136 let db = TabularDatabase {
2137 tables: vec![Table {
2138 name: "Sales".to_string(),
2139 partitions: vec![m_partition(
2140 "Sales",
2141 concat!(
2142 "let\n",
2143 " // ServerName was renamed; this step is retired.\n",
2144 " Text = \"ServerName is mentioned here as data\",\n",
2145 " Source = 1\n",
2146 "in\n",
2147 " Source",
2148 ),
2149 )],
2150 ..Default::default()
2151 }],
2152 expressions: vec![SharedExpression {
2153 name: "ServerName".to_string(),
2154 expression: "\"localhost\"".to_string(),
2155 ..Default::default()
2156 }],
2157 ..Default::default()
2158 };
2159 let graph = DependencyGraph::build(&db, &[]);
2160 let unused = graph.unused_objects();
2161
2162 find(
2163 &unused,
2164 &ObjectId::Expression {
2165 name: NameKey::new("ServerName"),
2166 },
2167 );
2168 }
2169
2170 #[test]
2172 fn a_bookmark_saved_filter_is_a_root() {
2173 let db = TabularDatabase {
2174 tables: vec![Table {
2175 name: "Sales".to_string(),
2176 columns: vec![column("Region")],
2177 ..Default::default()
2178 }],
2179 ..Default::default()
2180 };
2181 let report = ReportModel {
2182 bookmarks: vec![Bookmark {
2183 name: NameKey::new("B1"),
2184 display_name: None,
2185 filters: Vec::new(),
2186 sections: vec![BookmarkSection {
2187 page: NameKey::new("P1"),
2188 filters: Vec::new(),
2189 visuals: vec![BookmarkVisual {
2190 visual: NameKey::new("V1"),
2191 wells: Vec::new(),
2192 filters: vec![Filter {
2193 target: Some(column_target("Sales", "Region")),
2194 ..Default::default()
2195 }],
2196 }],
2197 }],
2198 }],
2199 ..Default::default()
2200 };
2201
2202 let graph = DependencyGraph::build(&db, &[&report]);
2203
2204 assert!(graph.unused_objects().is_empty());
2205 let roots = graph.roots();
2206 assert_eq!(roots.len(), 1);
2207 assert!(matches!(
2208 &roots[0].1,
2209 Provenance::Binding(edge) if edge.bookmark.is_some()
2210 ));
2211 }
2212
2213 #[test]
2216 fn calculated_table_columns_stay_with_their_table() {
2217 let db = TabularDatabase {
2218 tables: vec![Table {
2219 name: "Top Products".to_string(),
2220 columns: vec![Column {
2221 name: "Product".to_string(),
2222 kind: ColumnKind::CalculatedTableColumn,
2223 ..Default::default()
2224 }],
2225 partitions: vec![Partition {
2226 name: "Top Products".to_string(),
2227 source: PartitionSource::Calculated {
2228 expression: "TOPN(10, 'Product')".to_string(),
2229 },
2230 }],
2231 ..Default::default()
2232 }],
2233 ..Default::default()
2234 };
2235 let report = visual_page("P1", "V1", &[column_target("Top Products", "Product")]);
2236
2237 let graph = DependencyGraph::build(&db, &[&report]);
2238
2239 assert!(graph.unused_objects().is_empty());
2240 }
2241
2242 #[test]
2246 fn calendar_columns_stay_with_their_table() {
2247 let db = TabularDatabase {
2248 tables: vec![Table {
2249 name: "Date".to_string(),
2250 columns: vec![column("Day")],
2251 calendars: vec![crate::model::Calendar {
2252 name: "Fiscal Calendar".to_string(),
2253 columns: vec!["Day".to_string()],
2254 }],
2255 measures: vec![measure("Rows", "COUNTROWS('Date')")],
2256 ..Default::default()
2257 }],
2258 ..Default::default()
2259 };
2260 let report = visual_page("P1", "V1", &[measure_target("Date", "Rows")]);
2261
2262 let graph = DependencyGraph::build(&db, &[&report]);
2263
2264 assert!(graph.unused_objects().is_empty());
2265 }
2266
2267 #[test]
2270 fn a_dead_table_annotates_its_calendar_columns() {
2271 let db = TabularDatabase {
2272 tables: vec![Table {
2273 name: "Date".to_string(),
2274 columns: vec![column("Day")],
2275 calendars: vec![crate::model::Calendar {
2276 name: "Fiscal Calendar".to_string(),
2277 columns: vec!["Day".to_string()],
2278 }],
2279 ..Default::default()
2280 }],
2281 ..Default::default()
2282 };
2283
2284 let graph = DependencyGraph::build(&db, &[]);
2285 let unused = graph.unused_objects();
2286
2287 let day = find(&unused, &column_id("Date", "Day"));
2288 assert_eq!(day.used_by.len(), 1);
2289 assert_eq!(day.used_by[0].id, table_id("Date"));
2290 assert!(day.used_by[0].also_unused);
2291 assert!(matches!(
2292 day.used_by[0].provenance,
2293 Provenance::Structural {
2294 role: StructuralEdge::EngineManaged
2295 }
2296 ));
2297 }
2298 }
2299
2300 mod queries {
2301 use super::*;
2302
2303 #[test]
2304 fn queries_on_an_unknown_object_are_empty() {
2305 let graph = DependencyGraph::build(&TabularDatabase::default(), &[]);
2306
2307 assert!(graph.consumers_of(&table_id("Nope")).is_empty());
2308 assert!(graph.producers_of(&table_id("Nope")).is_empty());
2309 assert!(graph.roots_of(&table_id("Nope")).is_empty());
2310 }
2311
2312 #[test]
2313 fn unused_objects_are_sorted_by_identity() {
2314 let db = TabularDatabase {
2315 tables: vec![Table {
2316 name: "Sales".to_string(),
2317 columns: vec![column("B"), column("A")],
2318 ..Default::default()
2319 }],
2320 ..Default::default()
2321 };
2322
2323 let graph = DependencyGraph::build(&db, &[]);
2324 let unused = graph.unused_objects();
2325 let ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
2326 let mut sorted = ids.clone();
2327 sorted.sort();
2328
2329 assert_eq!(ids, sorted);
2330 }
2331
2332 #[test]
2333 fn the_root_carries_the_full_binding_provenance() {
2334 let db = TabularDatabase {
2335 tables: vec![Table {
2336 name: "Sales".to_string(),
2337 measures: vec![measure("Total", "0")],
2338 ..Default::default()
2339 }],
2340 ..Default::default()
2341 };
2342 let report = visual_page("P2", "Card", &[measure_target("Sales", "Total")]);
2343
2344 let graph = DependencyGraph::build(&db, &[&report]);
2345 let roots = graph.roots();
2346
2347 assert_eq!(roots.len(), 1);
2348 assert_eq!(roots[0].0, measure_id("Sales", "Total"));
2349 let Provenance::Binding(edge) = &roots[0].1 else {
2350 panic!("a root carries binding provenance");
2351 };
2352 let BindingEdge {
2353 kind,
2354 report: report_name,
2355 page,
2356 visual,
2357 bookmark,
2358 mobile,
2359 } = edge.as_ref();
2360 assert!(matches!(kind, BindingSite::FieldWell { role } if role == "Values"));
2361 assert_eq!(report_name.as_ref().map(NameKey::as_str), Some("Mini"));
2362 assert_eq!(page.as_ref().map(NameKey::as_str), Some("P2"));
2363 assert_eq!(visual.as_ref().map(NameKey::as_str), Some("Card"));
2364 assert!(bookmark.is_none());
2365 assert!(!mobile);
2366 }
2367 }
2368
2369 mod auto_date_time {
2373 use super::*;
2374
2375 fn hierarchy_level_target(
2376 table: &str,
2377 hierarchy: &str,
2378 level: &str,
2379 via_column: Option<&str>,
2380 via_variation: Option<&str>,
2381 ) -> FieldTarget {
2382 FieldTarget::HierarchyLevel {
2383 table: NameKey::new(table),
2384 hierarchy: NameKey::new(hierarchy),
2385 level: NameKey::new(level),
2386 via_column: via_column.map(NameKey::new),
2387 via_variation: via_variation.map(NameKey::new),
2388 }
2389 }
2390
2391 fn varied_model(variation: Option<Variation>) -> TabularDatabase {
2394 let local_date_table = Table {
2395 name: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2396 is_local_date_table: true,
2397 is_hidden: true,
2398 columns: vec![column("Date"), column("Year"), column("Month")],
2399 hierarchies: vec![Hierarchy {
2400 name: "Date Hierarchy".to_string(),
2401 levels: vec![
2402 HierarchyLevel {
2403 name: "Year".to_string(),
2404 column: "Year".to_string(),
2405 },
2406 HierarchyLevel {
2407 name: "Month".to_string(),
2408 column: "Month".to_string(),
2409 },
2410 ],
2411 ..Default::default()
2412 }],
2413 ..Default::default()
2414 };
2415 let mut date = column("Date");
2416 date.variations = variation.into_iter().collect();
2417 TabularDatabase {
2418 tables: vec![
2419 Table {
2420 name: "Sales".to_string(),
2421 columns: vec![date, column("Amount")],
2422 ..Default::default()
2423 },
2424 local_date_table,
2425 ],
2426 relationships: vec![Relationship {
2427 from_table: "Sales".to_string(),
2428 from_column: "Date".to_string(),
2429 to_table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2430 to_column: "Date".to_string(),
2431 ..Default::default()
2432 }],
2433 ..Default::default()
2434 }
2435 }
2436
2437 fn declared_variation() -> Variation {
2438 Variation {
2439 name: "Variation".to_string(),
2440 is_default: true,
2441 relationship: Some("b10a0bfa-b7fe-4437-8b2d-85624b0f085f".to_string()),
2442 default_hierarchy: Some(HierarchyRef {
2443 table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2444 hierarchy: "Date Hierarchy".to_string(),
2445 }),
2446 }
2447 }
2448
2449 fn local_table_id() -> ObjectId {
2450 table_id("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228")
2451 }
2452
2453 fn hierarchy_id() -> ObjectId {
2454 ObjectId::Hierarchy {
2455 table: NameKey::new("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228"),
2456 hierarchy: NameKey::new("Date Hierarchy"),
2457 }
2458 }
2459
2460 #[test]
2464 fn a_variation_bound_date_hierarchy_keeps_the_machinery_alive() {
2465 let db = varied_model(Some(declared_variation()));
2466 let report = visual_page(
2467 "P1",
2468 "V1",
2469 &[hierarchy_level_target(
2470 "Sales",
2471 "Date Hierarchy",
2472 "Year",
2473 Some("Date"),
2474 Some("Variation"),
2475 )],
2476 );
2477
2478 let graph = DependencyGraph::build(&db, &[&report]);
2479 let unused = graph.unused_objects();
2480
2481 assert_eq!(
2482 graph.roots_of(&hierarchy_id()).len(),
2483 1,
2484 "the binding lands on the date table's hierarchy"
2485 );
2486 not_unused(&unused, &hierarchy_id());
2487 not_unused(&unused, &local_table_id());
2488 not_unused(
2489 &unused,
2490 &column_id(
2491 "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
2492 "Year",
2493 ),
2494 );
2495 let verdicts = graph.auto_date_time_tables(&db);
2498 assert_eq!(verdicts.len(), 1);
2499 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::InUse);
2500 assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
2501 }
2502
2503 #[test]
2507 fn the_relationship_fallback_resolves_without_the_declaration() {
2508 let db = varied_model(None);
2509 let report = visual_page(
2510 "P1",
2511 "V1",
2512 &[hierarchy_level_target(
2513 "Sales",
2514 "Date Hierarchy",
2515 "Month",
2516 Some("Date"),
2517 None,
2518 )],
2519 );
2520
2521 let graph = DependencyGraph::build(&db, &[&report]);
2522
2523 assert_eq!(graph.roots_of(&hierarchy_id()).len(), 1);
2524 let unused = graph.unused_objects();
2525 not_unused(&unused, &local_table_id());
2526 not_unused(
2527 &unused,
2528 &column_id(
2529 "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
2530 "Month",
2531 ),
2532 );
2533 }
2534
2535 #[test]
2538 fn a_related_table_that_is_not_date_machinery_does_not_resolve() {
2539 let mut db = varied_model(None);
2540 db.tables[1].is_local_date_table = false;
2541 let report = visual_page(
2542 "P1",
2543 "V1",
2544 &[hierarchy_level_target(
2545 "Sales",
2546 "Date Hierarchy",
2547 "Year",
2548 Some("Date"),
2549 None,
2550 )],
2551 );
2552
2553 let graph = DependencyGraph::build(&db, &[&report]);
2554
2555 assert!(graph.roots_of(&hierarchy_id()).is_empty());
2556 assert_eq!(graph.roots_of(&table_id("Sales")).len(), 1);
2558 }
2559
2560 #[test]
2564 fn machinery_alive_only_through_dax_is_unused_by_reports() {
2565 let db = TabularDatabase {
2566 tables: vec![
2567 Table {
2568 name: "Sales".to_string(),
2569 measures: vec![measure("Years", "COUNTROWS('LocalDateTable_x')")],
2570 ..Default::default()
2571 },
2572 Table {
2573 name: "LocalDateTable_x".to_string(),
2574 is_local_date_table: true,
2575 columns: vec![column("Year")],
2576 ..Default::default()
2577 },
2578 ],
2579 ..Default::default()
2580 };
2581 let report = visual_page("P1", "V1", &[measure_target("Sales", "Years")]);
2582
2583 let graph = DependencyGraph::build(&db, &[&report]);
2584 let unused = graph.unused_objects();
2585
2586 not_unused(&unused, &local_table_id());
2587 let verdicts = graph.auto_date_time_tables(&db);
2588 assert_eq!(verdicts.len(), 1);
2589 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::UnusedByReports);
2590 assert_eq!(verdicts[0].source_column, None);
2591 }
2592
2593 #[test]
2596 fn unbound_unreferenced_machinery_is_dead() {
2597 let db = varied_model(Some(declared_variation()));
2598
2599 let graph = DependencyGraph::build(&db, &[]);
2600 let unused = graph.unused_objects();
2601
2602 let dead = find(&unused, &local_table_id());
2603 assert!(dead.used_by.iter().all(|used| used.also_unused));
2604 let verdicts = graph.auto_date_time_tables(&db);
2605 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::Dead);
2606 assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
2607 }
2608 }
2609
2610 mod broken {
2615 use super::*;
2616 use crate::graph::{BrokenBinding, BrokenReason};
2617
2618 fn hierarchy_level_target(
2619 table: &str,
2620 hierarchy: &str,
2621 level: &str,
2622 via_column: Option<&str>,
2623 ) -> FieldTarget {
2624 FieldTarget::HierarchyLevel {
2625 table: NameKey::new(table),
2626 hierarchy: NameKey::new(hierarchy),
2627 level: NameKey::new(level),
2628 via_column: via_column.map(NameKey::new),
2629 via_variation: None,
2630 }
2631 }
2632
2633 fn written_target(table: Option<&str>, name: &str) -> FieldTarget {
2634 FieldTarget::Written(crate::identity::FieldRef {
2635 table: table.map(NameKey::new),
2636 name: NameKey::new(name),
2637 })
2638 }
2639
2640 fn broken_of<'a>(graph: &'a DependencyGraph, target: &FieldTarget) -> &'a BrokenBinding {
2641 graph
2642 .broken_bindings()
2643 .iter()
2644 .find(|binding| &binding.target == target)
2645 .unwrap_or_else(|| panic!("{target} expected among the broken bindings"))
2646 }
2647
2648 fn not_broken(graph: &DependencyGraph, target: &FieldTarget) {
2649 assert!(
2650 !graph
2651 .broken_bindings()
2652 .iter()
2653 .any(|binding| &binding.target == target),
2654 "{target} must not be broken"
2655 );
2656 }
2657
2658 #[test]
2662 fn a_missing_column_on_a_live_table_is_broken() {
2663 let db = TabularDatabase {
2664 tables: vec![Table {
2665 name: "Sales".to_string(),
2666 columns: vec![column("Amount")],
2667 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
2668 ..Default::default()
2669 }],
2670 ..Default::default()
2671 };
2672 let report = visual_page(
2673 "P1",
2674 "V1",
2675 &[
2676 measure_target("Sales", "Total"),
2677 column_target("Sales", "Color"),
2678 ],
2679 );
2680
2681 let graph = DependencyGraph::build(&db, &[&report]);
2682
2683 let broken = broken_of(&graph, &column_target("Sales", "Color"));
2684 assert_eq!(broken.reason, BrokenReason::FieldNotFound);
2685 assert_eq!(
2686 broken.target.to_string(),
2687 "'Sales'[Color]",
2688 "the written form is the display id"
2689 );
2690 assert_eq!(broken.edge.visual.as_ref().map(NameKey::as_str), Some("V1"));
2691 assert_eq!(broken.edge.page.as_ref().map(NameKey::as_str), Some("P1"));
2692 assert_eq!(
2693 broken.edge.report.as_ref().map(NameKey::as_str),
2694 Some("Mini")
2695 );
2696 not_unused(&graph.unused_objects(), &table_id("Sales"));
2698 }
2699
2700 #[test]
2701 fn a_binding_whose_table_is_gone_is_broken() {
2702 let db = TabularDatabase {
2703 tables: vec![Table {
2704 name: "Sales".to_string(),
2705 measures: vec![measure("Total", "0")],
2706 ..Default::default()
2707 }],
2708 ..Default::default()
2709 };
2710 let report = visual_page("P1", "V1", &[column_target("Ghost", "X")]);
2711
2712 let graph = DependencyGraph::build(&db, &[&report]);
2713
2714 assert_eq!(
2715 broken_of(&graph, &column_target("Ghost", "X")).reason,
2716 BrokenReason::TableNotFound
2717 );
2718 }
2719
2720 #[test]
2721 fn a_binding_naming_a_missing_measure_is_broken() {
2722 let db = TabularDatabase {
2723 tables: vec![table("Sales")],
2724 ..Default::default()
2725 };
2726 let report = visual_page("P1", "V1", &[measure_target("Sales", "Gone")]);
2727
2728 let graph = DependencyGraph::build(&db, &[&report]);
2729
2730 assert_eq!(
2731 broken_of(&graph, &measure_target("Sales", "Gone")).reason,
2732 BrokenReason::MeasureNotFound
2733 );
2734 }
2735
2736 #[test]
2740 fn a_kpi_variant_binding_resolves_instead_of_flagging() {
2741 let db = TabularDatabase {
2742 tables: vec![Table {
2743 name: "Sales".to_string(),
2744 measures: vec![measure("Total", "0")],
2745 ..Default::default()
2746 }],
2747 ..Default::default()
2748 };
2749 let report = visual_page("P1", "V1", &[measure_target("Sales", "Total Goal")]);
2750
2751 let graph = DependencyGraph::build(&db, &[&report]);
2752
2753 assert!(graph.broken_bindings().is_empty());
2754 }
2755
2756 #[test]
2759 fn a_kpi_variant_over_a_missing_base_measure_still_flags() {
2760 let db = TabularDatabase {
2761 tables: vec![table("Sales")],
2762 ..Default::default()
2763 };
2764 let report = visual_page("P1", "V1", &[measure_target("Sales", "Gone Status")]);
2765
2766 let graph = DependencyGraph::build(&db, &[&report]);
2767
2768 assert_eq!(
2769 broken_of(&graph, &measure_target("Sales", "Gone Status")).reason,
2770 BrokenReason::MeasureNotFound
2771 );
2772 }
2773
2774 #[test]
2779 fn a_plain_missing_hierarchy_flags_but_a_variation_one_does_not() {
2780 let db = TabularDatabase {
2781 tables: vec![Table {
2782 name: "Sales".to_string(),
2783 columns: vec![column("Date")],
2784 ..Default::default()
2785 }],
2786 ..Default::default()
2787 };
2788 let report = visual_page(
2789 "P1",
2790 "V1",
2791 &[
2792 hierarchy_level_target("Sales", "Calendar", "Year", None),
2793 hierarchy_level_target("Sales", "Fiscal", "Year", Some("Date")),
2794 ],
2795 );
2796
2797 let graph = DependencyGraph::build(&db, &[&report]);
2798
2799 assert_eq!(
2800 broken_of(
2801 &graph,
2802 &hierarchy_level_target("Sales", "Calendar", "Year", None)
2803 )
2804 .reason,
2805 BrokenReason::HierarchyNotFound
2806 );
2807 not_broken(
2808 &graph,
2809 &hierarchy_level_target("Sales", "Fiscal", "Year", Some("Date")),
2810 );
2811 }
2812
2813 #[test]
2816 fn a_missing_level_in_a_live_hierarchy_is_broken() {
2817 let db = TabularDatabase {
2818 tables: vec![Table {
2819 name: "Date".to_string(),
2820 columns: vec![column("Year")],
2821 hierarchies: vec![Hierarchy {
2822 name: "Calendar".to_string(),
2823 levels: vec![HierarchyLevel {
2824 name: "Year".to_string(),
2825 column: "Year".to_string(),
2826 }],
2827 is_hidden: false,
2828 }],
2829 ..Default::default()
2830 }],
2831 ..Default::default()
2832 };
2833 let report = visual_page(
2834 "P1",
2835 "V1",
2836 &[hierarchy_level_target("Date", "Calendar", "Quarter", None)],
2837 );
2838
2839 let graph = DependencyGraph::build(&db, &[&report]);
2840
2841 assert_eq!(
2842 broken_of(
2843 &graph,
2844 &hierarchy_level_target("Date", "Calendar", "Quarter", None)
2845 )
2846 .reason,
2847 BrokenReason::LevelNotFound
2848 );
2849 not_unused(
2850 &graph.unused_objects(),
2851 &ObjectId::Hierarchy {
2852 table: NameKey::new("Date"),
2853 hierarchy: NameKey::new("Calendar"),
2854 },
2855 );
2856 }
2857
2858 #[test]
2861 fn a_written_miss_is_never_broken() {
2862 let db = TabularDatabase {
2863 tables: vec![table("Sales")],
2864 ..Default::default()
2865 };
2866 let report = visual_page("P1", "V1", &[written_target(Some("Ghost"), "X")]);
2867
2868 let graph = DependencyGraph::build(&db, &[&report]);
2869
2870 assert!(graph.broken_bindings().is_empty());
2871 }
2872
2873 #[test]
2877 fn a_binding_on_a_broken_measure_inherits_the_breakage() {
2878 let db = TabularDatabase {
2879 tables: vec![Table {
2880 name: "Sales".to_string(),
2881 columns: vec![column("Amount")],
2882 measures: vec![
2883 measure("Total", "SUM('Sales'[Amount])"),
2884 measure("Broken", "SUM('Sales'[Nope])"),
2885 ],
2886 ..Default::default()
2887 }],
2888 ..Default::default()
2889 };
2890 let report = visual_page("P1", "V1", &[measure_target("Sales", "Broken")]);
2891
2892 let graph = DependencyGraph::build(&db, &[&report]);
2893
2894 let broken = broken_of(&graph, &measure_target("Sales", "Broken"));
2895 assert_eq!(
2896 broken.reason,
2897 BrokenReason::BoundArtifactBroken {
2898 artifact: measure_id("Sales", "Broken"),
2899 }
2900 );
2901 not_unused(&graph.unused_objects(), &measure_id("Sales", "Broken"));
2902 }
2903
2904 #[test]
2908 fn an_unbound_broken_measure_produces_no_binding_record() {
2909 let db = TabularDatabase {
2910 tables: vec![Table {
2911 name: "Sales".to_string(),
2912 columns: vec![column("Amount")],
2913 measures: vec![
2914 measure("Total", "SUM('Sales'[Amount])"),
2915 measure("Dead Broken", "SUM('Sales'[Nope])"),
2916 ],
2917 ..Default::default()
2918 }],
2919 ..Default::default()
2920 };
2921 let report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
2922
2923 let graph = DependencyGraph::build(&db, &[&report]);
2924
2925 assert!(graph.broken_bindings().is_empty());
2926 let unused = graph.unused_objects();
2927 find(&unused, &measure_id("Sales", "Dead Broken"));
2928 }
2929
2930 #[test]
2933 fn the_records_sort_by_binding_site_then_target() {
2934 let db = TabularDatabase {
2935 tables: vec![table("Sales")],
2936 ..Default::default()
2937 };
2938 let mut report = visual_page(
2939 "P2",
2940 "V2",
2941 &[column_target("Sales", "B"), column_target("Sales", "A")],
2942 );
2943 report.pages.insert(
2944 0,
2945 Page {
2946 name: NameKey::new("P1"),
2947 display_name: None,
2948 is_hidden: false,
2949 filters: Vec::new(),
2950 binding: None,
2951 visuals: vec![Visual {
2952 name: NameKey::new("V1"),
2953 visual_type: "card".to_string(),
2954 wells: vec![FieldWell {
2955 role: "Values".to_string(),
2956 projections: vec![Projection {
2957 target: column_target("Sales", "C"),
2958 query_ref: None,
2959 active: true,
2960 }],
2961 }],
2962 filters: Vec::new(),
2963 sorts: Vec::new(),
2964 conditional_formatting: Vec::new(),
2965 alt_text: Vec::new(),
2966 tooltip_page: None,
2967 }],
2968 },
2969 );
2970
2971 let graph = DependencyGraph::build(&db, &[&report]);
2972
2973 let targets: Vec<String> = graph
2974 .broken_bindings()
2975 .iter()
2976 .map(|binding| binding.target.to_string())
2977 .collect();
2978 assert_eq!(
2979 targets,
2980 ["'Sales'[C]", "'Sales'[A]", "'Sales'[B]"],
2981 "page P1 before P2, then the written targets in order"
2982 );
2983 }
2984
2985 #[test]
2990 fn a_binding_on_a_calculated_tables_datatable_header_resolves() {
2991 let db = TabularDatabase {
2992 tables: vec![Table {
2993 name: "ColAxis (Outlook Bosteder)".to_string(),
2994 partitions: vec![Partition {
2995 name: "ColAxisOutlook".to_string(),
2996 source: PartitionSource::Calculated {
2997 expression: "DATATABLE(\"Ordinal\", INTEGER, \"Group\", STRING, \"MonthNum\", INTEGER, \"StaticLabel\", STRING, {\"Outlook\", \"Jan\", 1, \"Jan\"})"
2998 .to_string(),
2999 },
3000 }],
3001 ..Default::default()
3002 }],
3003 ..Default::default()
3004 };
3005 let report = visual_page(
3006 "P1",
3007 "V1",
3008 &[
3009 column_target("ColAxis (Outlook Bosteder)", "Group"),
3010 column_target("ColAxis (Outlook Bosteder)", "StaticLabel"),
3011 ],
3012 );
3013
3014 let graph = DependencyGraph::build(&db, &[&report]);
3015
3016 assert!(
3017 graph.broken_bindings().is_empty(),
3018 "the DATATABLE headers resolve: {:?}",
3019 graph.broken_bindings()
3020 );
3021 not_unused(
3022 &graph.unused_objects(),
3023 &table_id("ColAxis (Outlook Bosteder)"),
3024 );
3025 }
3026
3027 #[test]
3032 fn a_binding_off_the_calculated_tables_visible_names_still_flags() {
3033 let db = TabularDatabase {
3034 tables: vec![Table {
3035 name: "ColAxis".to_string(),
3036 partitions: vec![Partition {
3037 name: "ColAxis".to_string(),
3038 source: PartitionSource::Calculated {
3039 expression: "DATATABLE(\"Gruppe\", STRING, {\"Outlook\"})".to_string(),
3040 },
3041 }],
3042 ..Default::default()
3043 }],
3044 ..Default::default()
3045 };
3046 let report = visual_page("P1", "V1", &[column_target("ColAxis", "Group")]);
3047
3048 let graph = DependencyGraph::build(&db, &[&report]);
3049
3050 let broken = broken_of(&graph, &column_target("ColAxis", "Group"));
3051 assert_eq!(broken.reason, BrokenReason::FieldNotFound);
3052 not_unused(&graph.unused_objects(), &table_id("ColAxis"));
3053 }
3054
3055 #[test]
3059 fn a_binding_on_a_calculated_tables_wrapped_table_columns_resolves() {
3060 let db = TabularDatabase {
3061 tables: vec![
3062 Table {
3063 name: "Sales".to_string(),
3064 columns: vec![column("Color"), column("Amount")],
3065 ..Default::default()
3066 },
3067 Table {
3068 name: "Top Sales".to_string(),
3069 partitions: vec![Partition {
3070 name: "Top Sales".to_string(),
3071 source: PartitionSource::Calculated {
3072 expression: "CALCULATETABLE(VALUES('Sales'))".to_string(),
3073 },
3074 }],
3075 ..Default::default()
3076 },
3077 ],
3078 ..Default::default()
3079 };
3080 let report = visual_page("P1", "V1", &[column_target("Top Sales", "Color")]);
3081
3082 let graph = DependencyGraph::build(&db, &[&report]);
3083
3084 not_broken(&graph, &column_target("Top Sales", "Color"));
3085 not_unused(&graph.unused_objects(), &table_id("Top Sales"));
3087 }
3088
3089 #[test]
3090 fn a_foreign_column_of_a_calculated_table_still_flags() {
3091 let db = TabularDatabase {
3092 tables: vec![
3093 Table {
3094 name: "Sales".to_string(),
3095 columns: vec![column("Color"), column("Amount")],
3096 ..Default::default()
3097 },
3098 Table {
3099 name: "Top Sales".to_string(),
3100 partitions: vec![Partition {
3101 name: "Top Sales".to_string(),
3102 source: PartitionSource::Calculated {
3103 expression: "CALCULATETABLE(VALUES('Sales'))".to_string(),
3104 },
3105 }],
3106 ..Default::default()
3107 },
3108 ],
3109 ..Default::default()
3110 };
3111 let report = visual_page("P1", "V1", &[column_target("Top Sales", "Region")]);
3112
3113 let graph = DependencyGraph::build(&db, &[&report]);
3114
3115 assert_eq!(
3116 broken_of(&graph, &column_target("Top Sales", "Region")).reason,
3117 BrokenReason::FieldNotFound
3118 );
3119 }
3120 }
3121}