1use std::collections::{HashMap, HashSet};
143
144use petgraph::Direction;
145use petgraph::graph::{DiGraph, NodeIndex};
146use petgraph::visit::EdgeRef;
147
148pub mod provenance;
149
150mod builder;
151mod reachability;
152
153pub use provenance::{BindingEdge, BindingSite, Provenance, StructuralEdge};
154pub use reachability::{UnusedObject, UsedBy};
155
156use crate::identity::{NameKey, ObjectId, fold_name};
157use crate::model::TabularDatabase;
158use crate::report::ReportModel;
159
160#[derive(Debug)]
167pub struct DependencyGraph {
168 graph: DiGraph<ObjectId, Provenance>,
170 nodes: HashMap<ObjectId, NodeIndex>,
172 roots: Vec<(ObjectId, Provenance)>,
175 m_named: HashMap<ObjectId, Vec<ObjectId>>,
180}
181
182impl DependencyGraph {
183 #[must_use]
188 pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
189 builder::build(db, reports)
190 }
191
192 pub(super) fn assemble(
194 graph: DiGraph<ObjectId, Provenance>,
195 nodes: HashMap<ObjectId, NodeIndex>,
196 roots: Vec<(ObjectId, Provenance)>,
197 m_named: HashMap<ObjectId, Vec<ObjectId>>,
198 ) -> Self {
199 Self {
200 graph,
201 nodes,
202 roots,
203 m_named,
204 }
205 }
206
207 pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
210 self.graph.node_indices().map(|index| &self.graph[index])
211 }
212
213 pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
218 self.neighbors(id, Direction::Incoming)
219 }
220
221 pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
223 self.neighbors(id, Direction::Outgoing)
224 }
225
226 pub fn roots(&self) -> &[(ObjectId, Provenance)] {
229 &self.roots
230 }
231
232 pub fn roots_of(&self, id: &ObjectId) -> Vec<&Provenance> {
234 self.roots
235 .iter()
236 .filter(|(target, _)| target == id)
237 .map(|(_, provenance)| provenance)
238 .collect()
239 }
240
241 pub fn named_by_m(&self, id: &ObjectId) -> &[ObjectId] {
249 self.m_named.get(id).map(Vec::as_slice).unwrap_or_default()
250 }
251
252 pub fn unused_objects(&self) -> Vec<UnusedObject> {
259 let reach = reachability::Reachability::compute(self);
260 let mut out: Vec<UnusedObject> = self
261 .graph
262 .node_indices()
263 .filter(|index| !reach.is_live(&self.graph[*index]))
264 .map(|index| {
265 let id = self.graph[index].clone();
266 let mut used_by: Vec<UsedBy> = self
267 .graph
268 .edges_directed(index, Direction::Incoming)
269 .map(|edge| UsedBy {
270 id: self.graph[edge.source()].clone(),
271 provenance: edge.weight().clone(),
272 also_unused: !reach.is_live(&self.graph[edge.source()]),
273 })
274 .collect();
275 used_by.sort_by(|a, b| a.id.cmp(&b.id));
276 let named_by_m = self.named_by_m(&id).to_vec();
277 UnusedObject {
278 id,
279 used_by,
280 named_by_m,
281 }
282 })
283 .collect();
284 out.sort_by(|a, b| a.id.cmp(&b.id));
285 out
286 }
287
288 pub fn auto_date_time_tables(&self, db: &TabularDatabase) -> Vec<AutoDateTimeVerdict> {
301 let reach = reachability::Reachability::compute(self);
302 let mut out: Vec<AutoDateTimeVerdict> = db
303 .tables
304 .iter()
305 .filter(|table| table.is_local_date_table || table.is_template_date_table)
306 .map(|table| {
307 let id = ObjectId::Table {
308 table: NameKey::new(&table.name),
309 };
310 let verdict = if self.bound_with_reports(&id) {
311 AutoDateTimeStatus::InUse
312 } else if !reach.is_live(&id) {
313 AutoDateTimeStatus::Dead
314 } else {
315 AutoDateTimeStatus::UnusedByReports
316 };
317 AutoDateTimeVerdict {
318 id,
319 verdict,
320 source_column: variation_source_column(db, &table.name),
321 }
322 })
323 .collect();
324 out.sort_by(|a, b| a.id.cmp(&b.id));
325 out
326 }
327
328 fn bound_with_reports(&self, table: &ObjectId) -> bool {
335 let ObjectId::Table { table: name } = table else {
336 return false;
337 };
338 let is_member = |id: &ObjectId| match id {
339 ObjectId::Column { table, .. }
340 | ObjectId::Measure { table, .. }
341 | ObjectId::Hierarchy { table, .. }
342 | ObjectId::Partition { table, .. }
343 | ObjectId::CalculationItem { table, .. } => table == name,
344 _ => false,
345 };
346 let is_binding = |provenance: &Provenance| matches!(provenance, Provenance::Binding(_));
347 self.roots.iter().any(|(target, provenance)| {
348 is_binding(provenance) && (target == table || is_member(target))
349 }) || self
350 .consumers_of(table)
351 .iter()
352 .any(|(_, provenance)| is_binding(provenance))
353 }
354
355 fn neighbors(&self, id: &ObjectId, direction: Direction) -> Vec<(ObjectId, Provenance)> {
356 let Some(&index) = self.nodes.get(id) else {
357 return Vec::new();
358 };
359 self.graph
360 .edges_directed(index, direction)
361 .map(|edge| {
362 let other = match direction {
363 Direction::Incoming => edge.source(),
364 Direction::Outgoing => edge.target(),
365 };
366 (self.graph[other].clone(), edge.weight().clone())
367 })
368 .collect()
369 }
370
371 pub(super) fn seed_indices(&self) -> Vec<NodeIndex> {
374 let mut seeds: Vec<NodeIndex> = self
375 .roots
376 .iter()
377 .filter_map(|(id, _)| self.nodes.get(id).copied())
378 .collect();
379 seeds.extend(
380 self.nodes
381 .iter()
382 .filter(|(id, _)| matches!(id, ObjectId::Role { .. }))
383 .map(|(_, &index)| index),
384 );
385 seeds
386 }
387
388 pub(super) fn reach(
390 &self,
391 seeds: impl IntoIterator<Item = NodeIndex>,
392 allowed: fn(&Provenance) -> bool,
393 ) -> HashSet<NodeIndex> {
394 let mut seen: HashSet<NodeIndex> = seeds.into_iter().collect();
395 let mut queue: Vec<NodeIndex> = seen.iter().copied().collect();
396 while let Some(index) = queue.pop() {
397 for edge in self.graph.edges_directed(index, Direction::Outgoing) {
398 if !allowed(edge.weight()) {
399 continue;
400 }
401 if seen.insert(edge.target()) {
402 queue.push(edge.target());
403 }
404 }
405 }
406 seen
407 }
408
409 pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
411 &self.graph[index]
412 }
413}
414
415#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct AutoDateTimeVerdict {
420 pub id: ObjectId,
422 pub verdict: AutoDateTimeStatus,
424 pub source_column: Option<ObjectId>,
429}
430
431#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
434pub enum AutoDateTimeStatus {
435 InUse,
439 UnusedByReports,
443 Dead,
445}
446
447fn variation_source_column(db: &TabularDatabase, table: &str) -> Option<ObjectId> {
450 let target = fold_name(table);
451 for t in &db.tables {
452 for column in &t.columns {
453 for variation in &column.variations {
454 let via_hierarchy = variation
455 .default_hierarchy
456 .as_ref()
457 .is_some_and(|reference| fold_name(&reference.table) == target);
458 let via_relationship = variation
459 .relationship
460 .as_ref()
461 .and_then(|name| {
462 db.relationships
463 .iter()
464 .find(|rel| rel.name.as_deref() == Some(name.as_str()))
465 })
466 .is_some_and(|rel| {
467 fold_name(&rel.from_table) == target || fold_name(&rel.to_table) == target
468 });
469 if via_hierarchy || via_relationship {
470 return Some(ObjectId::Column {
471 table: NameKey::new(&t.name),
472 column: NameKey::new(&column.name),
473 });
474 }
475 }
476 }
477 }
478 None
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484 use crate::identity::NameKey;
485 use crate::model::{
486 Column, ColumnKind, DaxExpressionKind, Function, Hierarchy, HierarchyLevel, HierarchyRef,
487 Measure, Partition, PartitionSource, Relationship, Role, SharedExpression, Table,
488 TablePermission, Variation,
489 };
490 use crate::report::{
491 Bookmark, BookmarkSection, BookmarkVisual, FieldTarget, FieldWell, Filter, Page,
492 Projection, Visual,
493 };
494
495 fn column(name: &str) -> Column {
496 Column {
497 name: name.to_string(),
498 ..Default::default()
499 }
500 }
501
502 fn measure(name: &str, expression: &str) -> Measure {
503 Measure {
504 name: name.to_string(),
505 expression: expression.to_string(),
506 ..Default::default()
507 }
508 }
509
510 fn m_partition(name: &str, expression: &str) -> Partition {
511 Partition {
512 name: name.to_string(),
513 source: PartitionSource::M {
514 expression: expression.to_string(),
515 },
516 }
517 }
518
519 fn table(name: &str) -> Table {
520 Table {
521 name: name.to_string(),
522 ..Default::default()
523 }
524 }
525
526 fn table_id(name: &str) -> ObjectId {
527 ObjectId::Table {
528 table: NameKey::new(name),
529 }
530 }
531
532 fn column_id(table: &str, column: &str) -> ObjectId {
533 ObjectId::Column {
534 table: NameKey::new(table),
535 column: NameKey::new(column),
536 }
537 }
538
539 fn measure_id(table: &str, measure: &str) -> ObjectId {
540 ObjectId::Measure {
541 table: NameKey::new(table),
542 measure: NameKey::new(measure),
543 }
544 }
545
546 fn report_measure_id(name: &str) -> ObjectId {
547 ObjectId::ReportMeasure {
548 measure: NameKey::new(name),
549 }
550 }
551
552 fn visual_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
554 ReportModel {
555 name: Some("Mini".to_string()),
556 pages: vec![Page {
557 name: NameKey::new(page),
558 display_name: None,
559 is_hidden: false,
560 filters: Vec::new(),
561 binding: None,
562 visuals: vec![Visual {
563 name: NameKey::new(visual),
564 visual_type: "card".to_string(),
565 wells: vec![FieldWell {
566 role: "Values".to_string(),
567 projections: targets
568 .iter()
569 .map(|target| Projection {
570 target: target.clone(),
571 query_ref: None,
572 active: true,
573 })
574 .collect(),
575 }],
576 filters: Vec::new(),
577 sorts: Vec::new(),
578 conditional_formatting: Vec::new(),
579 alt_text: Vec::new(),
580 tooltip_page: None,
581 }],
582 }],
583 ..Default::default()
584 }
585 }
586
587 fn measure_target(table: &str, name: &str) -> FieldTarget {
588 FieldTarget::Measure {
589 home_table: Some(NameKey::new(table)),
590 measure: NameKey::new(name),
591 }
592 }
593
594 fn column_target(table: &str, column: &str) -> FieldTarget {
595 FieldTarget::Column {
596 table: NameKey::new(table),
597 column: NameKey::new(column),
598 }
599 }
600
601 fn find<'a>(unused: &'a [UnusedObject], id: &ObjectId) -> &'a UnusedObject {
603 unused
604 .iter()
605 .find(|finding| &finding.id == id)
606 .unwrap_or_else(|| panic!("{id} expected in the unused set"))
607 }
608
609 fn not_unused(unused: &[UnusedObject], id: &ObjectId) {
610 assert!(
611 !unused.iter().any(|finding| &finding.id == id),
612 "{id} must be live"
613 );
614 }
615
616 mod construction {
617 use super::*;
618
619 #[test]
620 fn every_model_object_gets_a_node_even_when_isolated() {
621 let db = TabularDatabase {
622 tables: vec![Table {
623 name: "Sales".to_string(),
624 columns: vec![column("Amount")],
625 ..Default::default()
626 }],
627 functions: vec![Function {
628 name: "MyFunc".to_string(),
629 expression: "1".to_string(),
630 is_hidden: false,
631 }],
632 ..Default::default()
633 };
634
635 let graph = DependencyGraph::build(&db, &[]);
636
637 let ids: Vec<_> = graph.object_ids().cloned().collect();
638 assert!(ids.contains(&table_id("Sales")));
639 assert!(ids.contains(&column_id("Sales", "Amount")));
640 assert!(ids.contains(&ObjectId::Function {
641 name: NameKey::new("MyFunc")
642 }));
643 }
644
645 #[test]
646 fn identical_edges_are_deduped_but_distinct_provenance_is_kept() {
647 let db = TabularDatabase {
648 tables: vec![Table {
649 name: "Sales".to_string(),
650 columns: vec![column("Amount")],
651 measures: vec![measure(
652 "Total",
653 "SUM('Sales'[Amount]) + SUM('Sales'[Amount])",
654 )],
655 ..Default::default()
656 }],
657 ..Default::default()
658 };
659
660 let graph = DependencyGraph::build(&db, &[]);
661
662 let producers = graph.producers_of(&measure_id("Sales", "Total"));
666 assert_eq!(producers.len(), 2);
667 assert_eq!(
668 producers
669 .iter()
670 .filter(|(id, _)| *id == column_id("Sales", "Amount"))
671 .count(),
672 1,
673 "identical (from, to, provenance) triples dedupe"
674 );
675 let consumers = graph.consumers_of(&column_id("Sales", "Amount"));
678 assert_eq!(consumers.len(), 1);
679 assert!(matches!(
680 consumers[0].1,
681 Provenance::Dax {
682 kind: DaxExpressionKind::Measure
683 }
684 ));
685 assert_eq!(consumers[0].0, measure_id("Sales", "Total"));
686 assert!(
687 graph
688 .consumers_of(&table_id("Sales"))
689 .iter()
690 .any(|(id, p)| *id == column_id("Sales", "Amount")
691 && matches!(
692 p,
693 Provenance::Structural {
694 role: StructuralEdge::TableMember
695 }
696 ))
697 );
698 }
699
700 #[test]
703 fn self_references_are_dropped() {
704 let db = TabularDatabase {
705 expressions: vec![SharedExpression {
706 name: "Recursive".to_string(),
707 expression: "Recursive + 1".to_string(),
708 }],
709 ..Default::default()
710 };
711
712 let graph = DependencyGraph::build(&db, &[]);
713 let id = ObjectId::Expression {
714 name: NameKey::new("Recursive"),
715 };
716
717 assert!(graph.producers_of(&id).is_empty());
718 assert!(graph.consumers_of(&id).is_empty());
719 }
720 }
721
722 mod liveness {
723 use super::*;
724
725 #[test]
729 fn a_relationship_does_not_keep_its_far_table_alive() {
730 let db = TabularDatabase {
731 tables: vec![
732 Table {
733 name: "Sales".to_string(),
734 columns: vec![column("Key")],
735 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
736 ..Default::default()
737 },
738 Table {
739 name: "DimOld".to_string(),
740 columns: vec![column("Key"), column("Notes")],
741 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
742 ..Default::default()
743 },
744 ],
745 relationships: vec![Relationship {
746 name: None,
747 from_table: "Sales".to_string(),
748 from_column: "Key".to_string(),
749 to_table: "DimOld".to_string(),
750 to_column: "Key".to_string(),
751 is_active: true,
752 }],
753 ..Default::default()
754 };
755 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
756 let graph = DependencyGraph::build(&db, &[&report]);
757 let unused = graph.unused_objects();
758
759 not_unused(&unused, &table_id("Sales"));
761 not_unused(&unused, &column_id("Sales", "Key"));
762 not_unused(
763 &unused,
764 &ObjectId::Relationship {
765 from_table: NameKey::new("Sales"),
766 from_column: NameKey::new("Key"),
767 to_table: NameKey::new("DimOld"),
768 to_column: NameKey::new("Key"),
769 },
770 );
771
772 let dim_old = find(&unused, &table_id("DimOld"));
774 assert_eq!(dim_old.used_by.len(), 2, "its two columns contain it");
775 let by_key = dim_old
776 .used_by
777 .iter()
778 .find(|used| used.id == column_id("DimOld", "Key"))
779 .expect("the key column references its table");
780 assert!(
781 !by_key.also_unused,
782 "the key column is live, kept by the relationship endpoint"
783 );
784 assert!(matches!(
785 by_key.provenance,
786 Provenance::Structural {
787 role: StructuralEdge::TableMember
788 }
789 ));
790
791 let notes = find(&unused, &column_id("DimOld", "Notes"));
793 assert!(notes.used_by.is_empty(), "an orphan has no consumers");
794 let partition = find(
795 &unused,
796 &ObjectId::Partition {
797 table: NameKey::new("DimOld"),
798 partition: NameKey::new("DimOld"),
799 },
800 );
801 assert_eq!(partition.used_by.len(), 1);
802 assert!(partition.used_by[0].also_unused);
803 assert_eq!(partition.used_by[0].id, table_id("DimOld"));
804 }
805
806 #[test]
811 fn an_unactivated_inactive_relationship_is_a_finding_with_its_keys() {
812 let relationship_id = ObjectId::Relationship {
813 from_table: NameKey::new("Sales"),
814 from_column: NameKey::new("Key"),
815 to_table: NameKey::new("DimOld"),
816 to_column: NameKey::new("Key"),
817 };
818 let db = TabularDatabase {
819 tables: vec![
820 Table {
821 name: "Sales".to_string(),
822 columns: vec![column("Amt"), column("Key")],
823 measures: vec![measure("Total", "SUM('Sales'[Amt])")],
824 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
825 ..Default::default()
826 },
827 Table {
828 name: "DimOld".to_string(),
829 columns: vec![column("Key"), column("Notes")],
830 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
831 ..Default::default()
832 },
833 ],
834 relationships: vec![Relationship {
835 name: None,
836 from_table: "Sales".to_string(),
837 from_column: "Key".to_string(),
838 to_table: "DimOld".to_string(),
839 to_column: "Key".to_string(),
840 is_active: false,
841 }],
842 ..Default::default()
843 };
844 let report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
847 let graph = DependencyGraph::build(&db, &[&report]);
848 let unused = graph.unused_objects();
849
850 not_unused(&unused, &table_id("Sales"));
851
852 let relationship = find(&unused, &relationship_id);
856 assert_eq!(relationship.used_by.len(), 2);
857 assert!(relationship.used_by.iter().all(|used| matches!(
858 &used.provenance,
859 Provenance::Structural {
860 role: StructuralEdge::InactiveRelationship
861 }
862 )));
863 let sales_side = relationship
864 .used_by
865 .iter()
866 .find(|used| used.id == table_id("Sales"))
867 .expect("the from table references the relationship");
868 assert!(!sales_side.also_unused);
869
870 for (table_name, column_name) in [("Sales", "Key"), ("DimOld", "Key")] {
873 let finding = find(&unused, &column_id(table_name, column_name));
874 assert_eq!(
875 finding.used_by.len(),
876 1,
877 "the inactive relationship is the only reference"
878 );
879 assert!(finding.used_by[0].also_unused);
880 assert_eq!(finding.used_by[0].id, relationship_id);
881 assert!(matches!(
882 &finding.used_by[0].provenance,
883 Provenance::Structural {
884 role: StructuralEdge::InactiveRelationshipEndpoint
885 }
886 ));
887 }
888 find(&unused, &table_id("DimOld"));
891 }
892
893 #[test]
897 fn a_live_userelationship_measure_keeps_inactive_keys_alive() {
898 let db = TabularDatabase {
899 tables: vec![
900 Table {
901 name: "Sales".to_string(),
902 columns: vec![column("Amt"), column("Key")],
903 measures: vec![measure(
904 "Old Total",
905 "CALCULATE(SUM('Sales'[Amt]), USERELATIONSHIP('Sales'[Key], 'DimOld'[Key]))",
906 )],
907 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
908 ..Default::default()
909 },
910 Table {
911 name: "DimOld".to_string(),
912 columns: vec![column("Key"), column("Notes")],
913 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
914 ..Default::default()
915 },
916 ],
917 relationships: vec![Relationship {
918 name: None,
919 from_table: "Sales".to_string(),
920 from_column: "Key".to_string(),
921 to_table: "DimOld".to_string(),
922 to_column: "Key".to_string(),
923 is_active: false,
924 }],
925 ..Default::default()
926 };
927 let report = visual_page("P1", "V1", &[measure_target("Sales", "Old Total")]);
928 let graph = DependencyGraph::build(&db, &[&report]);
929 let unused = graph.unused_objects();
930
931 not_unused(&unused, &column_id("Sales", "Key"));
932 not_unused(&unused, &column_id("DimOld", "Key"));
933 not_unused(
936 &unused,
937 &ObjectId::Relationship {
938 from_table: NameKey::new("Sales"),
939 from_column: NameKey::new("Key"),
940 to_table: NameKey::new("DimOld"),
941 to_column: NameKey::new("Key"),
942 },
943 );
944 not_unused(&unused, &table_id("DimOld"));
948 let notes = find(&unused, &column_id("DimOld", "Notes"));
949 assert!(notes.used_by.is_empty());
950 }
951
952 #[test]
955 fn an_rls_filter_keeps_its_column_and_table_alive() {
956 let db = TabularDatabase {
957 tables: vec![Table {
958 name: "Sales".to_string(),
959 columns: vec![column("Region")],
960 ..Default::default()
961 }],
962 roles: vec![Role {
963 name: "Reader".to_string(),
964 table_permissions: vec![TablePermission {
965 table: "Sales".to_string(),
966 filter_expression: Some("'Sales'[Region] = \"West\"".to_string()),
967 }],
968 }],
969 ..Default::default()
970 };
971
972 let graph = DependencyGraph::build(&db, &[]);
973 let unused = graph.unused_objects();
974
975 assert!(
976 unused.is_empty(),
977 "the role seeds the filter, the filter keeps the column, the column keeps the table"
978 );
979 let consumers = graph.consumers_of(&column_id("Sales", "Region"));
980 assert_eq!(consumers.len(), 1);
981 assert_eq!(
982 consumers[0].0,
983 ObjectId::Role {
984 role: NameKey::new("Reader")
985 }
986 );
987 assert!(matches!(
988 consumers[0].1,
989 Provenance::Dax {
990 kind: DaxExpressionKind::RlsFilter
991 }
992 ));
993 }
994
995 #[test]
997 fn a_metadata_only_permission_keeps_its_table_alive() {
998 let db = TabularDatabase {
999 tables: vec![table("Sales")],
1000 roles: vec![Role {
1001 name: "Reader".to_string(),
1002 table_permissions: vec![TablePermission {
1003 table: "Sales".to_string(),
1004 filter_expression: None,
1005 }],
1006 }],
1007 ..Default::default()
1008 };
1009
1010 let graph = DependencyGraph::build(&db, &[]);
1011
1012 assert!(graph.unused_objects().is_empty());
1013 }
1014
1015 #[test]
1018 fn a_model_with_no_roots_reports_everything_unused() {
1019 let db = TabularDatabase {
1020 tables: vec![Table {
1021 name: "Sales".to_string(),
1022 columns: vec![column("Amount")],
1023 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
1024 ..Default::default()
1025 }],
1026 ..Default::default()
1027 };
1028
1029 let graph = DependencyGraph::build(&db, &[]);
1030
1031 assert_eq!(graph.unused_objects().len(), 3);
1032 assert!(graph.roots().is_empty());
1033 }
1034
1035 #[test]
1038 fn an_unused_report_measure_is_dead_and_annotates_its_chain() {
1039 let db = TabularDatabase {
1040 tables: vec![Table {
1041 name: "Sales".to_string(),
1042 columns: vec![column("Amount"), column("Old")],
1043 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
1044 ..Default::default()
1045 }],
1046 ..Default::default()
1047 };
1048 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
1049 report.measures.push(crate::report::ReportMeasure {
1050 name: NameKey::new("Local"),
1051 expression: "SUM('Sales'[Old])".to_string(),
1052 format_string: None,
1053 });
1054
1055 let graph = DependencyGraph::build(&db, &[&report]);
1056 let unused = graph.unused_objects();
1057
1058 let local = find(&unused, &report_measure_id("Local"));
1059 assert!(local.used_by.is_empty(), "no visual binds it");
1060 let old = find(&unused, &column_id("Sales", "Old"));
1061 assert_eq!(old.used_by.len(), 1);
1062 assert_eq!(old.used_by[0].id, report_measure_id("Local"));
1063 assert!(old.used_by[0].also_unused);
1064 not_unused(&unused, &column_id("Sales", "Amount"));
1065 }
1066
1067 #[test]
1071 fn a_visual_binding_resolves_to_the_shadowing_report_measure() {
1072 let db = TabularDatabase {
1073 tables: vec![Table {
1074 name: "Sales".to_string(),
1075 measures: vec![measure("Total", "0")],
1076 ..Default::default()
1077 }],
1078 ..Default::default()
1079 };
1080 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
1081 report.measures.push(crate::report::ReportMeasure {
1082 name: NameKey::new("Total"),
1083 expression: "[Model Total]".to_string(),
1084 format_string: None,
1085 });
1086
1087 let graph = DependencyGraph::build(&db, &[&report]);
1088
1089 assert_eq!(graph.roots_of(&report_measure_id("Total")).len(), 1);
1091 assert!(graph.roots_of(&measure_id("Sales", "Total")).is_empty());
1092 let unused = graph.unused_objects();
1093 not_unused(&unused, &report_measure_id("Total"));
1094 let shadowed = find(&unused, &measure_id("Sales", "Total"));
1095 assert!(shadowed.used_by.is_empty());
1096 }
1097
1098 #[test]
1101 fn a_sort_by_chain_is_annotated() {
1102 let db = TabularDatabase {
1103 tables: vec![Table {
1104 name: "Date".to_string(),
1105 columns: vec![
1106 Column {
1107 name: "Month Name".to_string(),
1108 sort_by_column: Some("Month Num".to_string()),
1109 ..Default::default()
1110 },
1111 column("Month Num"),
1112 ],
1113 ..Default::default()
1114 }],
1115 ..Default::default()
1116 };
1117
1118 let graph = DependencyGraph::build(&db, &[]);
1119 let unused = graph.unused_objects();
1120
1121 let month_name = find(&unused, &column_id("Date", "Month Name"));
1122 assert!(month_name.used_by.is_empty());
1123 let month_num = find(&unused, &column_id("Date", "Month Num"));
1124 assert_eq!(month_num.used_by.len(), 1);
1125 assert_eq!(month_num.used_by[0].id, column_id("Date", "Month Name"));
1126 assert!(month_num.used_by[0].also_unused);
1127 assert!(matches!(
1128 month_num.used_by[0].provenance,
1129 Provenance::Structural {
1130 role: StructuralEdge::SortByColumn
1131 }
1132 ));
1133 }
1134
1135 #[test]
1138 fn a_group_by_chain_is_annotated() {
1139 let db = TabularDatabase {
1140 tables: vec![Table {
1141 name: "Sales".to_string(),
1142 columns: vec![
1143 Column {
1144 name: "Amount".to_string(),
1145 group_by_columns: vec!["Bucket".to_string()],
1146 ..Default::default()
1147 },
1148 column("Bucket"),
1149 ],
1150 ..Default::default()
1151 }],
1152 ..Default::default()
1153 };
1154
1155 let graph = DependencyGraph::build(&db, &[]);
1156 let unused = graph.unused_objects();
1157
1158 let amount = find(&unused, &column_id("Sales", "Amount"));
1159 assert!(amount.used_by.is_empty());
1160 let bucket = find(&unused, &column_id("Sales", "Bucket"));
1161 assert_eq!(bucket.used_by.len(), 1);
1162 assert_eq!(bucket.used_by[0].id, column_id("Sales", "Amount"));
1163 assert!(bucket.used_by[0].also_unused);
1164 assert!(matches!(
1165 bucket.used_by[0].provenance,
1166 Provenance::Structural {
1167 role: StructuralEdge::GroupByColumn
1168 }
1169 ));
1170 }
1171
1172 #[test]
1176 fn a_used_column_keeps_its_group_by_column_alive() {
1177 let db = TabularDatabase {
1178 tables: vec![Table {
1179 name: "Sales".to_string(),
1180 columns: vec![
1181 Column {
1182 name: "Amount".to_string(),
1183 group_by_columns: vec!["Bucket".to_string()],
1184 ..Default::default()
1185 },
1186 column("Bucket"),
1187 ],
1188 ..Default::default()
1189 }],
1190 ..Default::default()
1191 };
1192 let report = visual_page("P1", "V1", &[column_target("Sales", "Amount")]);
1193
1194 let graph = DependencyGraph::build(&db, &[&report]);
1195
1196 assert!(graph.unused_objects().is_empty());
1197 }
1198
1199 #[test]
1202 fn a_dead_hierarchy_annotates_its_level_columns() {
1203 let db = TabularDatabase {
1204 tables: vec![Table {
1205 name: "Date".to_string(),
1206 columns: vec![column("Year")],
1207 hierarchies: vec![crate::model::Hierarchy {
1208 name: "Calendar".to_string(),
1209 levels: vec![crate::model::HierarchyLevel {
1210 name: "Year".to_string(),
1211 column: "Year".to_string(),
1212 }],
1213 is_hidden: false,
1214 }],
1215 ..Default::default()
1216 }],
1217 ..Default::default()
1218 };
1219
1220 let graph = DependencyGraph::build(&db, &[]);
1221 let unused = graph.unused_objects();
1222
1223 let hierarchy = find(
1224 &unused,
1225 &ObjectId::Hierarchy {
1226 table: NameKey::new("Date"),
1227 hierarchy: NameKey::new("Calendar"),
1228 },
1229 );
1230 assert!(hierarchy.used_by.is_empty());
1231 let year = find(&unused, &column_id("Date", "Year"));
1232 assert_eq!(year.used_by.len(), 1);
1233 assert!(matches!(
1234 year.used_by[0].provenance,
1235 Provenance::Structural {
1236 role: StructuralEdge::HierarchyLevel
1237 }
1238 ));
1239 assert!(year.used_by[0].also_unused);
1240 }
1241
1242 #[test]
1245 fn dax_keeps_a_referenced_hierarchy_alive() {
1246 let db = TabularDatabase {
1247 tables: vec![Table {
1248 name: "Date".to_string(),
1249 columns: vec![column("Year")],
1250 hierarchies: vec![crate::model::Hierarchy {
1251 name: "Calendar".to_string(),
1252 levels: vec![crate::model::HierarchyLevel {
1253 name: "Year".to_string(),
1254 column: "Year".to_string(),
1255 }],
1256 is_hidden: false,
1257 }],
1258 measures: vec![measure("In Scope", "ISINSCOPE('Date'[Calendar])")],
1259 ..Default::default()
1260 }],
1261 ..Default::default()
1262 };
1263 let report = visual_page("P1", "V1", &[measure_target("Date", "In Scope")]);
1264
1265 let graph = DependencyGraph::build(&db, &[&report]);
1266
1267 assert!(graph.unused_objects().is_empty());
1268 }
1269
1270 #[test]
1276 fn a_binding_on_a_calculation_group_column_keeps_its_items_alive() {
1277 let db = TabularDatabase {
1278 tables: vec![
1279 Table {
1280 name: "Sales".to_string(),
1281 columns: vec![column("Amount")],
1282 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
1283 ..Default::default()
1284 },
1285 Table {
1286 name: "Date Role".to_string(),
1287 columns: vec![column("Date Role")],
1288 calculation_group: Some(crate::model::CalculationGroup {
1289 items: vec![
1290 crate::model::CalculationItem {
1291 name: "By Ship Date".to_string(),
1292 expression: "SELECTEDMEASURE()".to_string(),
1293 format_string_expression: None,
1294 },
1295 crate::model::CalculationItem {
1296 name: "By Due Date".to_string(),
1297 expression: "SELECTEDMEASURE()".to_string(),
1298 format_string_expression: None,
1299 },
1300 ],
1301 ..Default::default()
1302 }),
1303 ..Default::default()
1304 },
1305 ],
1306 ..Default::default()
1307 };
1308 let report = visual_page(
1309 "P1",
1310 "Slicer",
1311 &[
1312 measure_target("Sales", "Total"),
1313 column_target("Date Role", "Date Role"),
1314 ],
1315 );
1316
1317 let graph = DependencyGraph::build(&db, &[&report]);
1318
1319 assert!(
1320 graph.unused_objects().is_empty(),
1321 "the bound column keeps the group, the group's items, and the model alive"
1322 );
1323 let consumers = graph.consumers_of(&ObjectId::CalculationItem {
1324 table: NameKey::new("Date Role"),
1325 item: NameKey::new("By Ship Date"),
1326 });
1327 assert!(
1328 consumers.iter().any(|(id, provenance)| {
1329 *id == column_id("Date Role", "Date Role")
1330 && matches!(provenance, Provenance::Binding(_))
1331 }),
1332 "the column's binding edge names the item, with the binding site as provenance"
1333 );
1334 }
1335
1336 #[test]
1339 fn dax_keeps_a_referenced_calculation_item_alive() {
1340 let db = TabularDatabase {
1341 tables: vec![
1342 Table {
1343 name: "Sales".to_string(),
1344 measures: vec![measure(
1345 "YTD Sales",
1346 "CALCULATE(SUM('Sales'[Amount]), 'Time Intelligence'[YTD])",
1347 )],
1348 ..Default::default()
1349 },
1350 Table {
1351 name: "Time Intelligence".to_string(),
1352 calculation_group: Some(crate::model::CalculationGroup {
1353 items: vec![
1354 crate::model::CalculationItem {
1355 name: "YTD".to_string(),
1356 expression: "SELECTEDMEASURE()".to_string(),
1357 format_string_expression: None,
1358 },
1359 crate::model::CalculationItem {
1360 name: "MTD".to_string(),
1361 expression: "SELECTEDMEASURE()".to_string(),
1362 format_string_expression: None,
1363 },
1364 ],
1365 ..Default::default()
1366 }),
1367 ..Default::default()
1368 },
1369 ],
1370 ..Default::default()
1371 };
1372 let report = visual_page("P1", "V1", &[measure_target("Sales", "YTD Sales")]);
1373
1374 let graph = DependencyGraph::build(&db, &[&report]);
1375 let unused = graph.unused_objects();
1376 let unused_ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1377
1378 assert_eq!(
1379 unused_ids,
1380 [&ObjectId::CalculationItem {
1381 table: NameKey::new("Time Intelligence"),
1382 item: NameKey::new("MTD"),
1383 }],
1384 "only the unselected calculation item is unused"
1385 );
1386 }
1387
1388 #[test]
1391 fn an_unresolved_qualified_reference_keeps_its_table_alive() {
1392 let db = TabularDatabase {
1393 tables: vec![
1394 Table {
1395 name: "Sales".to_string(),
1396 measures: vec![measure("M", "'Ghost'[Nope]")],
1397 ..Default::default()
1398 },
1399 table("Ghost"),
1400 ],
1401 ..Default::default()
1402 };
1403 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1404
1405 let graph = DependencyGraph::build(&db, &[&report]);
1406
1407 assert!(graph.unused_objects().is_empty(), "Ghost stays alive");
1408 }
1409
1410 #[test]
1412 fn an_unresolved_reference_without_a_resolvable_part_keeps_nothing_alive() {
1413 let db = TabularDatabase {
1414 tables: vec![Table {
1415 name: "Sales".to_string(),
1416 measures: vec![measure("M", "'Ghost'[Nope] + [Also Nope]")],
1417 ..Default::default()
1418 }],
1419 ..Default::default()
1420 };
1421 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1422
1423 let graph = DependencyGraph::build(&db, &[&report]);
1424
1425 assert_eq!(graph.unused_objects().len(), 0, "only Sales and M exist");
1426 }
1427
1428 #[test]
1431 fn m_references_keep_shared_expressions_alive() {
1432 let db = TabularDatabase {
1433 tables: vec![
1434 Table {
1435 name: "Sales".to_string(),
1436 partitions: vec![m_partition(
1437 "Sales",
1438 "let Source = Sql.Database(ServerName) in Source",
1439 )],
1440 ..Default::default()
1441 },
1442 Table {
1443 name: "DimOld".to_string(),
1444 partitions: vec![m_partition(
1445 "DimOld",
1446 "let Source = LegacyParam in Source",
1447 )],
1448 ..Default::default()
1449 },
1450 ],
1451 expressions: vec![
1452 SharedExpression {
1453 name: "ServerName".to_string(),
1454 expression: "\"localhost\"".to_string(),
1455 },
1456 SharedExpression {
1457 name: "LegacyParam".to_string(),
1458 expression: "5".to_string(),
1459 },
1460 ],
1461 ..Default::default()
1462 };
1463 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1466
1467 let graph = DependencyGraph::build(&db, &[&report]);
1468 let unused = graph.unused_objects();
1469
1470 not_unused(
1471 &unused,
1472 &ObjectId::Expression {
1473 name: NameKey::new("ServerName"),
1474 },
1475 );
1476 let legacy = find(
1477 &unused,
1478 &ObjectId::Expression {
1479 name: NameKey::new("LegacyParam"),
1480 },
1481 );
1482 assert_eq!(legacy.used_by.len(), 1);
1483 assert_eq!(
1484 legacy.used_by[0].id,
1485 ObjectId::Partition {
1486 table: NameKey::new("DimOld"),
1487 partition: NameKey::new("DimOld"),
1488 }
1489 );
1490 assert!(legacy.used_by[0].also_unused);
1491 assert!(matches!(legacy.used_by[0].provenance, Provenance::M));
1492 }
1493
1494 #[test]
1498 fn an_m_chain_keeps_shared_expressions_alive() {
1499 let db = TabularDatabase {
1500 tables: vec![Table {
1501 name: "Sales".to_string(),
1502 partitions: vec![m_partition(
1503 "Sales",
1504 "let Source = Sql.Database(#\"Staging Query\") in Source",
1505 )],
1506 ..Default::default()
1507 }],
1508 expressions: vec![
1509 SharedExpression {
1510 name: "Staging Query".to_string(),
1511 expression: "ServerName".to_string(),
1512 },
1513 SharedExpression {
1514 name: "ServerName".to_string(),
1515 expression: "\"localhost\"".to_string(),
1516 },
1517 ],
1518 ..Default::default()
1519 };
1520 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1521
1522 let graph = DependencyGraph::build(&db, &[&report]);
1523 let unused = graph.unused_objects();
1524
1525 not_unused(
1526 &unused,
1527 &ObjectId::Expression {
1528 name: NameKey::new("Staging Query"),
1529 },
1530 );
1531 not_unused(
1532 &unused,
1533 &ObjectId::Expression {
1534 name: NameKey::new("ServerName"),
1535 },
1536 );
1537
1538 assert_eq!(
1541 graph.consumers_of(&ObjectId::Expression {
1542 name: NameKey::new("ServerName"),
1543 }),
1544 [(
1545 ObjectId::Expression {
1546 name: NameKey::new("Staging Query"),
1547 },
1548 Provenance::M
1549 )]
1550 );
1551 }
1552
1553 #[test]
1561 fn an_m_partition_names_its_columns_without_keeping_them_alive() {
1562 let db = TabularDatabase {
1563 tables: vec![Table {
1564 name: "Sales".to_string(),
1565 columns: vec![
1566 column("Pk"),
1567 column("Amount"),
1568 column("Region"),
1569 column("Orphaned"),
1570 ],
1571 partitions: vec![m_partition(
1572 "Sales",
1573 concat!(
1574 "let\n",
1575 " Source = Sql.Database(ServerName, \"db\"),\n",
1576 " Typed = Table.TransformColumnTypes(Source, {{\"Amount\", type text}}),\n",
1577 " Expanded = Table.ExpandTableColumn(Typed, \"Detail\", {\"Region\"}),\n",
1578 " Filtered = Table.SelectRows(Expanded, each [Orphaned] = \"West\")\n",
1579 "in\n",
1580 " Filtered",
1581 ),
1582 )],
1583 ..Default::default()
1584 }],
1585 expressions: vec![SharedExpression {
1586 name: "ServerName".to_string(),
1587 expression: "\"localhost\"".to_string(),
1588 }],
1589 ..Default::default()
1590 };
1591 let report = visual_page("P1", "V1", &[column_target("Sales", "Pk")]);
1595
1596 let graph = DependencyGraph::build(&db, &[&report]);
1597 let unused = graph.unused_objects();
1598
1599 let partition = ObjectId::Partition {
1600 table: NameKey::new("Sales"),
1601 partition: NameKey::new("Sales"),
1602 };
1603 let expected_named = [partition];
1604 for name in ["Amount", "Region", "Orphaned"] {
1605 let finding = find(&unused, &column_id("Sales", name));
1606 assert!(
1607 finding.used_by.is_empty(),
1608 "M names are not consumers: no edge points at the column"
1609 );
1610 assert_eq!(finding.named_by_m, expected_named);
1611 }
1612 not_unused(
1615 &unused,
1616 &ObjectId::Expression {
1617 name: NameKey::new("ServerName"),
1618 },
1619 );
1620 }
1621
1622 #[test]
1626 fn a_dead_tables_partition_keeps_nothing_alive() {
1627 let db = TabularDatabase {
1628 tables: vec![Table {
1629 name: "DimOld".to_string(),
1630 columns: vec![column("Key")],
1631 partitions: vec![m_partition(
1632 "DimOld",
1633 "let Source = Table.SelectRows(#\"DimOld\", each [Key] <> null) in Source",
1634 )],
1635 ..Default::default()
1636 }],
1637 ..Default::default()
1638 };
1639 let graph = DependencyGraph::build(&db, &[]);
1640 let unused = graph.unused_objects();
1641
1642 find(&unused, &column_id("DimOld", "Key"));
1643 find(&unused, &table_id("DimOld"));
1647 }
1648
1649 #[test]
1654 fn an_m_merge_source_keeps_the_joined_table_alive() {
1655 let db = TabularDatabase {
1656 tables: vec![
1657 Table {
1658 name: "Sales".to_string(),
1659 columns: vec![column("Key")],
1660 partitions: vec![m_partition(
1661 "Sales",
1662 concat!(
1663 "let\n",
1664 " Source = Sql.Database(ServerName, \"db\"),\n",
1665 " Joined = Table.NestedJoin(Source, {\"Key\"}, #\"DimOld\", {\"Key\"}, \"Dim\")\n",
1666 "in\n",
1667 " Joined",
1668 ),
1669 )],
1670 ..Default::default()
1671 },
1672 Table {
1673 name: "DimOld".to_string(),
1674 columns: vec![column("Key")],
1675 partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
1676 ..Default::default()
1677 },
1678 ],
1679 expressions: vec![SharedExpression {
1680 name: "ServerName".to_string(),
1681 expression: "\"localhost\"".to_string(),
1682 }],
1683 ..Default::default()
1684 };
1685 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
1688
1689 let graph = DependencyGraph::build(&db, &[&report]);
1690 let unused = graph.unused_objects();
1691
1692 not_unused(&unused, &table_id("DimOld"));
1693 let finding = find(&unused, &column_id("DimOld", "Key"));
1696 assert_eq!(
1697 finding.named_by_m,
1698 [ObjectId::Partition {
1699 table: NameKey::new("Sales"),
1700 partition: NameKey::new("Sales"),
1701 }]
1702 );
1703 }
1704
1705 #[test]
1709 fn a_qualified_m_field_access_keeps_the_named_table_alive() {
1710 let db = TabularDatabase {
1711 tables: vec![
1712 Table {
1713 name: "Sales".to_string(),
1714 columns: vec![column("Key")],
1715 partitions: vec![m_partition(
1716 "Sales",
1717 "let Source = #\"DimOld\"[Key] in Source",
1718 )],
1719 ..Default::default()
1720 },
1721 Table {
1722 name: "DimOld".to_string(),
1723 columns: vec![column("Key")],
1724 partitions: vec![m_partition("DimOld", "let Source = DimOld in Source")],
1725 ..Default::default()
1726 },
1727 ],
1728 ..Default::default()
1729 };
1730 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
1731
1732 let graph = DependencyGraph::build(&db, &[&report]);
1733 let unused = graph.unused_objects();
1734
1735 not_unused(&unused, &table_id("DimOld"));
1736 }
1737
1738 #[test]
1742 fn a_name_inside_an_m_comment_or_string_keeps_nothing_alive() {
1743 let db = TabularDatabase {
1744 tables: vec![Table {
1745 name: "Sales".to_string(),
1746 partitions: vec![m_partition(
1747 "Sales",
1748 concat!(
1749 "let\n",
1750 " // ServerName was renamed; this step is retired.\n",
1751 " Text = \"ServerName is mentioned here as data\",\n",
1752 " Source = 1\n",
1753 "in\n",
1754 " Source",
1755 ),
1756 )],
1757 ..Default::default()
1758 }],
1759 expressions: vec![SharedExpression {
1760 name: "ServerName".to_string(),
1761 expression: "\"localhost\"".to_string(),
1762 }],
1763 ..Default::default()
1764 };
1765 let graph = DependencyGraph::build(&db, &[]);
1766 let unused = graph.unused_objects();
1767
1768 find(
1769 &unused,
1770 &ObjectId::Expression {
1771 name: NameKey::new("ServerName"),
1772 },
1773 );
1774 }
1775
1776 #[test]
1778 fn a_bookmark_saved_filter_is_a_root() {
1779 let db = TabularDatabase {
1780 tables: vec![Table {
1781 name: "Sales".to_string(),
1782 columns: vec![column("Region")],
1783 ..Default::default()
1784 }],
1785 ..Default::default()
1786 };
1787 let report = ReportModel {
1788 bookmarks: vec![Bookmark {
1789 name: NameKey::new("B1"),
1790 display_name: None,
1791 filters: Vec::new(),
1792 sections: vec![BookmarkSection {
1793 page: NameKey::new("P1"),
1794 filters: Vec::new(),
1795 visuals: vec![BookmarkVisual {
1796 visual: NameKey::new("V1"),
1797 wells: Vec::new(),
1798 filters: vec![Filter {
1799 target: Some(column_target("Sales", "Region")),
1800 ..Default::default()
1801 }],
1802 }],
1803 }],
1804 }],
1805 ..Default::default()
1806 };
1807
1808 let graph = DependencyGraph::build(&db, &[&report]);
1809
1810 assert!(graph.unused_objects().is_empty());
1811 let roots = graph.roots();
1812 assert_eq!(roots.len(), 1);
1813 assert!(matches!(
1814 &roots[0].1,
1815 Provenance::Binding(edge) if edge.bookmark.is_some()
1816 ));
1817 }
1818
1819 #[test]
1822 fn calculated_table_columns_stay_with_their_table() {
1823 let db = TabularDatabase {
1824 tables: vec![Table {
1825 name: "Top Products".to_string(),
1826 columns: vec![Column {
1827 name: "Product".to_string(),
1828 kind: ColumnKind::CalculatedTableColumn,
1829 ..Default::default()
1830 }],
1831 partitions: vec![Partition {
1832 name: "Top Products".to_string(),
1833 source: PartitionSource::Calculated {
1834 expression: "TOPN(10, 'Product')".to_string(),
1835 },
1836 }],
1837 ..Default::default()
1838 }],
1839 ..Default::default()
1840 };
1841 let report = visual_page("P1", "V1", &[column_target("Top Products", "Product")]);
1842
1843 let graph = DependencyGraph::build(&db, &[&report]);
1844
1845 assert!(graph.unused_objects().is_empty());
1846 }
1847
1848 #[test]
1852 fn calendar_columns_stay_with_their_table() {
1853 let db = TabularDatabase {
1854 tables: vec![Table {
1855 name: "Date".to_string(),
1856 columns: vec![column("Day")],
1857 calendars: vec![crate::model::Calendar {
1858 name: "Fiscal Calendar".to_string(),
1859 columns: vec!["Day".to_string()],
1860 }],
1861 measures: vec![measure("Rows", "COUNTROWS('Date')")],
1862 ..Default::default()
1863 }],
1864 ..Default::default()
1865 };
1866 let report = visual_page("P1", "V1", &[measure_target("Date", "Rows")]);
1867
1868 let graph = DependencyGraph::build(&db, &[&report]);
1869
1870 assert!(graph.unused_objects().is_empty());
1871 }
1872
1873 #[test]
1876 fn a_dead_table_annotates_its_calendar_columns() {
1877 let db = TabularDatabase {
1878 tables: vec![Table {
1879 name: "Date".to_string(),
1880 columns: vec![column("Day")],
1881 calendars: vec![crate::model::Calendar {
1882 name: "Fiscal Calendar".to_string(),
1883 columns: vec!["Day".to_string()],
1884 }],
1885 ..Default::default()
1886 }],
1887 ..Default::default()
1888 };
1889
1890 let graph = DependencyGraph::build(&db, &[]);
1891 let unused = graph.unused_objects();
1892
1893 let day = find(&unused, &column_id("Date", "Day"));
1894 assert_eq!(day.used_by.len(), 1);
1895 assert_eq!(day.used_by[0].id, table_id("Date"));
1896 assert!(day.used_by[0].also_unused);
1897 assert!(matches!(
1898 day.used_by[0].provenance,
1899 Provenance::Structural {
1900 role: StructuralEdge::EngineManaged
1901 }
1902 ));
1903 }
1904 }
1905
1906 mod queries {
1907 use super::*;
1908
1909 #[test]
1910 fn queries_on_an_unknown_object_are_empty() {
1911 let graph = DependencyGraph::build(&TabularDatabase::default(), &[]);
1912
1913 assert!(graph.consumers_of(&table_id("Nope")).is_empty());
1914 assert!(graph.producers_of(&table_id("Nope")).is_empty());
1915 assert!(graph.roots_of(&table_id("Nope")).is_empty());
1916 }
1917
1918 #[test]
1919 fn unused_objects_are_sorted_by_identity() {
1920 let db = TabularDatabase {
1921 tables: vec![Table {
1922 name: "Sales".to_string(),
1923 columns: vec![column("B"), column("A")],
1924 ..Default::default()
1925 }],
1926 ..Default::default()
1927 };
1928
1929 let graph = DependencyGraph::build(&db, &[]);
1930 let unused = graph.unused_objects();
1931 let ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1932 let mut sorted = ids.clone();
1933 sorted.sort();
1934
1935 assert_eq!(ids, sorted);
1936 }
1937
1938 #[test]
1939 fn the_root_carries_the_full_binding_provenance() {
1940 let db = TabularDatabase {
1941 tables: vec![Table {
1942 name: "Sales".to_string(),
1943 measures: vec![measure("Total", "0")],
1944 ..Default::default()
1945 }],
1946 ..Default::default()
1947 };
1948 let report = visual_page("P2", "Card", &[measure_target("Sales", "Total")]);
1949
1950 let graph = DependencyGraph::build(&db, &[&report]);
1951 let roots = graph.roots();
1952
1953 assert_eq!(roots.len(), 1);
1954 assert_eq!(roots[0].0, measure_id("Sales", "Total"));
1955 let Provenance::Binding(edge) = &roots[0].1 else {
1956 panic!("a root carries binding provenance");
1957 };
1958 let BindingEdge {
1959 kind,
1960 report: report_name,
1961 page,
1962 visual,
1963 bookmark,
1964 } = edge.as_ref();
1965 assert!(matches!(kind, BindingSite::FieldWell { role } if role == "Values"));
1966 assert_eq!(report_name.as_ref().map(NameKey::as_str), Some("Mini"));
1967 assert_eq!(page.as_ref().map(NameKey::as_str), Some("P2"));
1968 assert_eq!(visual.as_ref().map(NameKey::as_str), Some("Card"));
1969 assert!(bookmark.is_none());
1970 }
1971 }
1972
1973 mod auto_date_time {
1977 use super::*;
1978
1979 fn hierarchy_level_target(
1980 table: &str,
1981 hierarchy: &str,
1982 level: &str,
1983 via_column: Option<&str>,
1984 via_variation: Option<&str>,
1985 ) -> FieldTarget {
1986 FieldTarget::HierarchyLevel {
1987 table: NameKey::new(table),
1988 hierarchy: NameKey::new(hierarchy),
1989 level: NameKey::new(level),
1990 via_column: via_column.map(NameKey::new),
1991 via_variation: via_variation.map(NameKey::new),
1992 }
1993 }
1994
1995 fn varied_model(variation: Option<Variation>) -> TabularDatabase {
1998 let local_date_table = Table {
1999 name: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2000 is_local_date_table: true,
2001 is_hidden: true,
2002 columns: vec![column("Date"), column("Year"), column("Month")],
2003 hierarchies: vec![Hierarchy {
2004 name: "Date Hierarchy".to_string(),
2005 levels: vec![
2006 HierarchyLevel {
2007 name: "Year".to_string(),
2008 column: "Year".to_string(),
2009 },
2010 HierarchyLevel {
2011 name: "Month".to_string(),
2012 column: "Month".to_string(),
2013 },
2014 ],
2015 ..Default::default()
2016 }],
2017 ..Default::default()
2018 };
2019 let mut date = column("Date");
2020 date.variations = variation.into_iter().collect();
2021 TabularDatabase {
2022 tables: vec![
2023 Table {
2024 name: "Sales".to_string(),
2025 columns: vec![date, column("Amount")],
2026 ..Default::default()
2027 },
2028 local_date_table,
2029 ],
2030 relationships: vec![Relationship {
2031 from_table: "Sales".to_string(),
2032 from_column: "Date".to_string(),
2033 to_table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2034 to_column: "Date".to_string(),
2035 ..Default::default()
2036 }],
2037 ..Default::default()
2038 }
2039 }
2040
2041 fn declared_variation() -> Variation {
2042 Variation {
2043 name: "Variation".to_string(),
2044 is_default: true,
2045 relationship: Some("b10a0bfa-b7fe-4437-8b2d-85624b0f085f".to_string()),
2046 default_hierarchy: Some(HierarchyRef {
2047 table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
2048 hierarchy: "Date Hierarchy".to_string(),
2049 }),
2050 }
2051 }
2052
2053 fn local_table_id() -> ObjectId {
2054 table_id("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228")
2055 }
2056
2057 fn hierarchy_id() -> ObjectId {
2058 ObjectId::Hierarchy {
2059 table: NameKey::new("LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228"),
2060 hierarchy: NameKey::new("Date Hierarchy"),
2061 }
2062 }
2063
2064 #[test]
2068 fn a_variation_bound_date_hierarchy_keeps_the_machinery_alive() {
2069 let db = varied_model(Some(declared_variation()));
2070 let report = visual_page(
2071 "P1",
2072 "V1",
2073 &[hierarchy_level_target(
2074 "Sales",
2075 "Date Hierarchy",
2076 "Year",
2077 Some("Date"),
2078 Some("Variation"),
2079 )],
2080 );
2081
2082 let graph = DependencyGraph::build(&db, &[&report]);
2083 let unused = graph.unused_objects();
2084
2085 assert_eq!(
2086 graph.roots_of(&hierarchy_id()).len(),
2087 1,
2088 "the binding lands on the date table's hierarchy"
2089 );
2090 not_unused(&unused, &hierarchy_id());
2091 not_unused(&unused, &local_table_id());
2092 not_unused(
2093 &unused,
2094 &column_id(
2095 "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
2096 "Year",
2097 ),
2098 );
2099 let verdicts = graph.auto_date_time_tables(&db);
2102 assert_eq!(verdicts.len(), 1);
2103 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::InUse);
2104 assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
2105 }
2106
2107 #[test]
2111 fn the_relationship_fallback_resolves_without_the_declaration() {
2112 let db = varied_model(None);
2113 let report = visual_page(
2114 "P1",
2115 "V1",
2116 &[hierarchy_level_target(
2117 "Sales",
2118 "Date Hierarchy",
2119 "Month",
2120 Some("Date"),
2121 None,
2122 )],
2123 );
2124
2125 let graph = DependencyGraph::build(&db, &[&report]);
2126
2127 assert_eq!(graph.roots_of(&hierarchy_id()).len(), 1);
2128 let unused = graph.unused_objects();
2129 not_unused(&unused, &local_table_id());
2130 not_unused(
2131 &unused,
2132 &column_id(
2133 "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228",
2134 "Month",
2135 ),
2136 );
2137 }
2138
2139 #[test]
2142 fn a_related_table_that_is_not_date_machinery_does_not_resolve() {
2143 let mut db = varied_model(None);
2144 db.tables[1].is_local_date_table = false;
2145 let report = visual_page(
2146 "P1",
2147 "V1",
2148 &[hierarchy_level_target(
2149 "Sales",
2150 "Date Hierarchy",
2151 "Year",
2152 Some("Date"),
2153 None,
2154 )],
2155 );
2156
2157 let graph = DependencyGraph::build(&db, &[&report]);
2158
2159 assert!(graph.roots_of(&hierarchy_id()).is_empty());
2160 assert_eq!(graph.roots_of(&table_id("Sales")).len(), 1);
2162 }
2163
2164 #[test]
2168 fn machinery_alive_only_through_dax_is_unused_by_reports() {
2169 let db = TabularDatabase {
2170 tables: vec![
2171 Table {
2172 name: "Sales".to_string(),
2173 measures: vec![measure("Years", "COUNTROWS('LocalDateTable_x')")],
2174 ..Default::default()
2175 },
2176 Table {
2177 name: "LocalDateTable_x".to_string(),
2178 is_local_date_table: true,
2179 columns: vec![column("Year")],
2180 ..Default::default()
2181 },
2182 ],
2183 ..Default::default()
2184 };
2185 let report = visual_page("P1", "V1", &[measure_target("Sales", "Years")]);
2186
2187 let graph = DependencyGraph::build(&db, &[&report]);
2188 let unused = graph.unused_objects();
2189
2190 not_unused(&unused, &local_table_id());
2191 let verdicts = graph.auto_date_time_tables(&db);
2192 assert_eq!(verdicts.len(), 1);
2193 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::UnusedByReports);
2194 assert_eq!(verdicts[0].source_column, None);
2195 }
2196
2197 #[test]
2200 fn unbound_unreferenced_machinery_is_dead() {
2201 let db = varied_model(Some(declared_variation()));
2202
2203 let graph = DependencyGraph::build(&db, &[]);
2204 let unused = graph.unused_objects();
2205
2206 let dead = find(&unused, &local_table_id());
2207 assert!(dead.used_by.iter().all(|used| used.also_unused));
2208 let verdicts = graph.auto_date_time_tables(&db);
2209 assert_eq!(verdicts[0].verdict, AutoDateTimeStatus::Dead);
2210 assert_eq!(verdicts[0].source_column, Some(column_id("Sales", "Date")));
2211 }
2212 }
2213}