1use std::collections::{HashMap, HashSet};
127
128use petgraph::Direction;
129use petgraph::graph::{DiGraph, NodeIndex};
130use petgraph::visit::EdgeRef;
131
132pub mod provenance;
133
134mod builder;
135mod reachability;
136
137pub use provenance::{BindingEdge, BindingSite, Provenance, StructuralEdge};
138pub use reachability::{UnusedObject, UsedBy};
139
140use crate::identity::ObjectId;
141use crate::model::TabularDatabase;
142use crate::report::ReportModel;
143
144#[derive(Debug)]
151pub struct DependencyGraph {
152 graph: DiGraph<ObjectId, Provenance>,
154 nodes: HashMap<ObjectId, NodeIndex>,
156 roots: Vec<(ObjectId, Provenance)>,
159}
160
161impl DependencyGraph {
162 #[must_use]
167 pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
168 builder::build(db, reports)
169 }
170
171 pub(super) fn assemble(
173 graph: DiGraph<ObjectId, Provenance>,
174 nodes: HashMap<ObjectId, NodeIndex>,
175 roots: Vec<(ObjectId, Provenance)>,
176 ) -> Self {
177 Self {
178 graph,
179 nodes,
180 roots,
181 }
182 }
183
184 pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
187 self.graph.node_indices().map(|index| &self.graph[index])
188 }
189
190 pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
195 self.neighbors(id, Direction::Incoming)
196 }
197
198 pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
200 self.neighbors(id, Direction::Outgoing)
201 }
202
203 pub fn roots(&self) -> &[(ObjectId, Provenance)] {
206 &self.roots
207 }
208
209 pub fn roots_of(&self, id: &ObjectId) -> Vec<&Provenance> {
211 self.roots
212 .iter()
213 .filter(|(target, _)| target == id)
214 .map(|(_, provenance)| provenance)
215 .collect()
216 }
217
218 pub fn unused_objects(&self) -> Vec<UnusedObject> {
223 let reach = reachability::Reachability::compute(self);
224 let mut out: Vec<UnusedObject> = self
225 .graph
226 .node_indices()
227 .filter(|index| !reach.is_live(&self.graph[*index]))
228 .map(|index| {
229 let id = self.graph[index].clone();
230 let mut used_by: Vec<UsedBy> = self
231 .graph
232 .edges_directed(index, Direction::Incoming)
233 .map(|edge| UsedBy {
234 id: self.graph[edge.source()].clone(),
235 provenance: edge.weight().clone(),
236 also_unused: !reach.is_live(&self.graph[edge.source()]),
237 })
238 .collect();
239 used_by.sort_by(|a, b| a.id.cmp(&b.id));
240 UnusedObject { id, used_by }
241 })
242 .collect();
243 out.sort_by(|a, b| a.id.cmp(&b.id));
244 out
245 }
246
247 fn neighbors(&self, id: &ObjectId, direction: Direction) -> Vec<(ObjectId, Provenance)> {
248 let Some(&index) = self.nodes.get(id) else {
249 return Vec::new();
250 };
251 self.graph
252 .edges_directed(index, direction)
253 .map(|edge| {
254 let other = match direction {
255 Direction::Incoming => edge.source(),
256 Direction::Outgoing => edge.target(),
257 };
258 (self.graph[other].clone(), edge.weight().clone())
259 })
260 .collect()
261 }
262
263 pub(super) fn seed_indices(&self) -> Vec<NodeIndex> {
266 let mut seeds: Vec<NodeIndex> = self
267 .roots
268 .iter()
269 .filter_map(|(id, _)| self.nodes.get(id).copied())
270 .collect();
271 seeds.extend(
272 self.nodes
273 .iter()
274 .filter(|(id, _)| matches!(id, ObjectId::Role { .. }))
275 .map(|(_, &index)| index),
276 );
277 seeds
278 }
279
280 pub(super) fn reach(
282 &self,
283 seeds: impl IntoIterator<Item = NodeIndex>,
284 allowed: fn(&Provenance) -> bool,
285 ) -> HashSet<NodeIndex> {
286 let mut seen: HashSet<NodeIndex> = seeds.into_iter().collect();
287 let mut queue: Vec<NodeIndex> = seen.iter().copied().collect();
288 while let Some(index) = queue.pop() {
289 for edge in self.graph.edges_directed(index, Direction::Outgoing) {
290 if !allowed(edge.weight()) {
291 continue;
292 }
293 if seen.insert(edge.target()) {
294 queue.push(edge.target());
295 }
296 }
297 }
298 seen
299 }
300
301 pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
303 &self.graph[index]
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::identity::NameKey;
311 use crate::model::{
312 Column, ColumnKind, DaxExpressionKind, Function, Measure, Partition, PartitionSource,
313 Relationship, Role, SharedExpression, Table, TablePermission,
314 };
315 use crate::report::{
316 Bookmark, BookmarkSection, BookmarkVisual, FieldTarget, FieldWell, Filter, Page,
317 Projection, Visual,
318 };
319
320 fn column(name: &str) -> Column {
321 Column {
322 name: name.to_string(),
323 ..Default::default()
324 }
325 }
326
327 fn measure(name: &str, expression: &str) -> Measure {
328 Measure {
329 name: name.to_string(),
330 expression: expression.to_string(),
331 ..Default::default()
332 }
333 }
334
335 fn m_partition(name: &str, expression: &str) -> Partition {
336 Partition {
337 name: name.to_string(),
338 source: PartitionSource::M {
339 expression: expression.to_string(),
340 },
341 }
342 }
343
344 fn table(name: &str) -> Table {
345 Table {
346 name: name.to_string(),
347 ..Default::default()
348 }
349 }
350
351 fn table_id(name: &str) -> ObjectId {
352 ObjectId::Table {
353 table: NameKey::new(name),
354 }
355 }
356
357 fn column_id(table: &str, column: &str) -> ObjectId {
358 ObjectId::Column {
359 table: NameKey::new(table),
360 column: NameKey::new(column),
361 }
362 }
363
364 fn measure_id(table: &str, measure: &str) -> ObjectId {
365 ObjectId::Measure {
366 table: NameKey::new(table),
367 measure: NameKey::new(measure),
368 }
369 }
370
371 fn report_measure_id(name: &str) -> ObjectId {
372 ObjectId::ReportMeasure {
373 measure: NameKey::new(name),
374 }
375 }
376
377 fn visual_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
379 ReportModel {
380 name: Some("Mini".to_string()),
381 pages: vec![Page {
382 name: NameKey::new(page),
383 display_name: None,
384 is_hidden: false,
385 filters: Vec::new(),
386 binding: None,
387 visuals: vec![Visual {
388 name: NameKey::new(visual),
389 visual_type: "card".to_string(),
390 wells: vec![FieldWell {
391 role: "Values".to_string(),
392 projections: targets
393 .iter()
394 .map(|target| Projection {
395 target: target.clone(),
396 query_ref: None,
397 active: true,
398 })
399 .collect(),
400 }],
401 filters: Vec::new(),
402 sorts: Vec::new(),
403 conditional_formatting: Vec::new(),
404 alt_text: Vec::new(),
405 tooltip_page: None,
406 }],
407 }],
408 ..Default::default()
409 }
410 }
411
412 fn measure_target(table: &str, name: &str) -> FieldTarget {
413 FieldTarget::Measure {
414 home_table: Some(NameKey::new(table)),
415 measure: NameKey::new(name),
416 }
417 }
418
419 fn column_target(table: &str, column: &str) -> FieldTarget {
420 FieldTarget::Column {
421 table: NameKey::new(table),
422 column: NameKey::new(column),
423 }
424 }
425
426 fn find<'a>(unused: &'a [UnusedObject], id: &ObjectId) -> &'a UnusedObject {
428 unused
429 .iter()
430 .find(|finding| &finding.id == id)
431 .unwrap_or_else(|| panic!("{id} expected in the unused set"))
432 }
433
434 fn not_unused(unused: &[UnusedObject], id: &ObjectId) {
435 assert!(
436 !unused.iter().any(|finding| &finding.id == id),
437 "{id} must be live"
438 );
439 }
440
441 mod construction {
442 use super::*;
443
444 #[test]
445 fn every_model_object_gets_a_node_even_when_isolated() {
446 let db = TabularDatabase {
447 tables: vec![Table {
448 name: "Sales".to_string(),
449 columns: vec![column("Amount")],
450 ..Default::default()
451 }],
452 functions: vec![Function {
453 name: "MyFunc".to_string(),
454 expression: "1".to_string(),
455 is_hidden: false,
456 }],
457 ..Default::default()
458 };
459
460 let graph = DependencyGraph::build(&db, &[]);
461
462 let ids: Vec<_> = graph.object_ids().cloned().collect();
463 assert!(ids.contains(&table_id("Sales")));
464 assert!(ids.contains(&column_id("Sales", "Amount")));
465 assert!(ids.contains(&ObjectId::Function {
466 name: NameKey::new("MyFunc")
467 }));
468 }
469
470 #[test]
471 fn identical_edges_are_deduped_but_distinct_provenance_is_kept() {
472 let db = TabularDatabase {
473 tables: vec![Table {
474 name: "Sales".to_string(),
475 columns: vec![column("Amount")],
476 measures: vec![measure(
477 "Total",
478 "SUM('Sales'[Amount]) + SUM('Sales'[Amount])",
479 )],
480 ..Default::default()
481 }],
482 ..Default::default()
483 };
484
485 let graph = DependencyGraph::build(&db, &[]);
486
487 let producers = graph.producers_of(&measure_id("Sales", "Total"));
491 assert_eq!(producers.len(), 2);
492 assert_eq!(
493 producers
494 .iter()
495 .filter(|(id, _)| *id == column_id("Sales", "Amount"))
496 .count(),
497 1,
498 "identical (from, to, provenance) triples dedupe"
499 );
500 let consumers = graph.consumers_of(&column_id("Sales", "Amount"));
503 assert_eq!(consumers.len(), 1);
504 assert!(matches!(
505 consumers[0].1,
506 Provenance::Dax {
507 kind: DaxExpressionKind::Measure
508 }
509 ));
510 assert_eq!(consumers[0].0, measure_id("Sales", "Total"));
511 assert!(
512 graph
513 .consumers_of(&table_id("Sales"))
514 .iter()
515 .any(|(id, p)| *id == column_id("Sales", "Amount")
516 && matches!(
517 p,
518 Provenance::Structural {
519 role: StructuralEdge::TableMember
520 }
521 ))
522 );
523 }
524
525 #[test]
528 fn self_references_are_dropped() {
529 let db = TabularDatabase {
530 expressions: vec![SharedExpression {
531 name: "Recursive".to_string(),
532 expression: "Recursive + 1".to_string(),
533 }],
534 ..Default::default()
535 };
536
537 let graph = DependencyGraph::build(&db, &[]);
538 let id = ObjectId::Expression {
539 name: NameKey::new("Recursive"),
540 };
541
542 assert!(graph.producers_of(&id).is_empty());
543 assert!(graph.consumers_of(&id).is_empty());
544 }
545 }
546
547 mod liveness {
548 use super::*;
549
550 #[test]
554 fn a_relationship_does_not_keep_its_far_table_alive() {
555 let db = TabularDatabase {
556 tables: vec![
557 Table {
558 name: "Sales".to_string(),
559 columns: vec![column("Key")],
560 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
561 ..Default::default()
562 },
563 Table {
564 name: "DimOld".to_string(),
565 columns: vec![column("Key"), column("Notes")],
566 partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
567 ..Default::default()
568 },
569 ],
570 relationships: vec![Relationship {
571 name: None,
572 from_table: "Sales".to_string(),
573 from_column: "Key".to_string(),
574 to_table: "DimOld".to_string(),
575 to_column: "Key".to_string(),
576 is_active: true,
577 }],
578 ..Default::default()
579 };
580 let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
581 let graph = DependencyGraph::build(&db, &[&report]);
582 let unused = graph.unused_objects();
583
584 not_unused(&unused, &table_id("Sales"));
586 not_unused(&unused, &column_id("Sales", "Key"));
587 not_unused(
588 &unused,
589 &ObjectId::Relationship {
590 from_table: NameKey::new("Sales"),
591 from_column: NameKey::new("Key"),
592 to_table: NameKey::new("DimOld"),
593 to_column: NameKey::new("Key"),
594 },
595 );
596
597 let dim_old = find(&unused, &table_id("DimOld"));
599 assert_eq!(dim_old.used_by.len(), 2, "its two columns contain it");
600 let by_key = dim_old
601 .used_by
602 .iter()
603 .find(|used| used.id == column_id("DimOld", "Key"))
604 .expect("the key column references its table");
605 assert!(
606 !by_key.also_unused,
607 "the key column is live, kept by the relationship endpoint"
608 );
609 assert!(matches!(
610 by_key.provenance,
611 Provenance::Structural {
612 role: StructuralEdge::TableMember
613 }
614 ));
615
616 let notes = find(&unused, &column_id("DimOld", "Notes"));
618 assert!(notes.used_by.is_empty(), "an orphan has no consumers");
619 let partition = find(
620 &unused,
621 &ObjectId::Partition {
622 table: NameKey::new("DimOld"),
623 partition: NameKey::new("DimOld"),
624 },
625 );
626 assert_eq!(partition.used_by.len(), 1);
627 assert!(partition.used_by[0].also_unused);
628 assert_eq!(partition.used_by[0].id, table_id("DimOld"));
629 }
630
631 #[test]
634 fn an_rls_filter_keeps_its_column_and_table_alive() {
635 let db = TabularDatabase {
636 tables: vec![Table {
637 name: "Sales".to_string(),
638 columns: vec![column("Region")],
639 ..Default::default()
640 }],
641 roles: vec![Role {
642 name: "Reader".to_string(),
643 table_permissions: vec![TablePermission {
644 table: "Sales".to_string(),
645 filter_expression: Some("'Sales'[Region] = \"West\"".to_string()),
646 }],
647 }],
648 ..Default::default()
649 };
650
651 let graph = DependencyGraph::build(&db, &[]);
652 let unused = graph.unused_objects();
653
654 assert!(
655 unused.is_empty(),
656 "the role seeds the filter, the filter keeps the column, the column keeps the table"
657 );
658 let consumers = graph.consumers_of(&column_id("Sales", "Region"));
659 assert_eq!(consumers.len(), 1);
660 assert_eq!(
661 consumers[0].0,
662 ObjectId::Role {
663 role: NameKey::new("Reader")
664 }
665 );
666 assert!(matches!(
667 consumers[0].1,
668 Provenance::Dax {
669 kind: DaxExpressionKind::RlsFilter
670 }
671 ));
672 }
673
674 #[test]
676 fn a_metadata_only_permission_keeps_its_table_alive() {
677 let db = TabularDatabase {
678 tables: vec![table("Sales")],
679 roles: vec![Role {
680 name: "Reader".to_string(),
681 table_permissions: vec![TablePermission {
682 table: "Sales".to_string(),
683 filter_expression: None,
684 }],
685 }],
686 ..Default::default()
687 };
688
689 let graph = DependencyGraph::build(&db, &[]);
690
691 assert!(graph.unused_objects().is_empty());
692 }
693
694 #[test]
697 fn a_model_with_no_roots_reports_everything_unused() {
698 let db = TabularDatabase {
699 tables: vec![Table {
700 name: "Sales".to_string(),
701 columns: vec![column("Amount")],
702 partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
703 ..Default::default()
704 }],
705 ..Default::default()
706 };
707
708 let graph = DependencyGraph::build(&db, &[]);
709
710 assert_eq!(graph.unused_objects().len(), 3);
711 assert!(graph.roots().is_empty());
712 }
713
714 #[test]
717 fn an_unused_report_measure_is_dead_and_annotates_its_chain() {
718 let db = TabularDatabase {
719 tables: vec![Table {
720 name: "Sales".to_string(),
721 columns: vec![column("Amount"), column("Old")],
722 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
723 ..Default::default()
724 }],
725 ..Default::default()
726 };
727 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
728 report.measures.push(crate::report::ReportMeasure {
729 name: NameKey::new("Local"),
730 expression: "SUM('Sales'[Old])".to_string(),
731 format_string: None,
732 });
733
734 let graph = DependencyGraph::build(&db, &[&report]);
735 let unused = graph.unused_objects();
736
737 let local = find(&unused, &report_measure_id("Local"));
738 assert!(local.used_by.is_empty(), "no visual binds it");
739 let old = find(&unused, &column_id("Sales", "Old"));
740 assert_eq!(old.used_by.len(), 1);
741 assert_eq!(old.used_by[0].id, report_measure_id("Local"));
742 assert!(old.used_by[0].also_unused);
743 not_unused(&unused, &column_id("Sales", "Amount"));
744 }
745
746 #[test]
750 fn a_visual_binding_resolves_to_the_shadowing_report_measure() {
751 let db = TabularDatabase {
752 tables: vec![Table {
753 name: "Sales".to_string(),
754 measures: vec![measure("Total", "0")],
755 ..Default::default()
756 }],
757 ..Default::default()
758 };
759 let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
760 report.measures.push(crate::report::ReportMeasure {
761 name: NameKey::new("Total"),
762 expression: "[Model Total]".to_string(),
763 format_string: None,
764 });
765
766 let graph = DependencyGraph::build(&db, &[&report]);
767
768 assert_eq!(graph.roots_of(&report_measure_id("Total")).len(), 1);
770 assert!(graph.roots_of(&measure_id("Sales", "Total")).is_empty());
771 let unused = graph.unused_objects();
772 not_unused(&unused, &report_measure_id("Total"));
773 let shadowed = find(&unused, &measure_id("Sales", "Total"));
774 assert!(shadowed.used_by.is_empty());
775 }
776
777 #[test]
780 fn a_sort_by_chain_is_annotated() {
781 let db = TabularDatabase {
782 tables: vec![Table {
783 name: "Date".to_string(),
784 columns: vec![
785 Column {
786 name: "Month Name".to_string(),
787 sort_by_column: Some("Month Num".to_string()),
788 ..Default::default()
789 },
790 column("Month Num"),
791 ],
792 ..Default::default()
793 }],
794 ..Default::default()
795 };
796
797 let graph = DependencyGraph::build(&db, &[]);
798 let unused = graph.unused_objects();
799
800 let month_name = find(&unused, &column_id("Date", "Month Name"));
801 assert!(month_name.used_by.is_empty());
802 let month_num = find(&unused, &column_id("Date", "Month Num"));
803 assert_eq!(month_num.used_by.len(), 1);
804 assert_eq!(month_num.used_by[0].id, column_id("Date", "Month Name"));
805 assert!(month_num.used_by[0].also_unused);
806 assert!(matches!(
807 month_num.used_by[0].provenance,
808 Provenance::Structural {
809 role: StructuralEdge::SortByColumn
810 }
811 ));
812 }
813
814 #[test]
817 fn a_group_by_chain_is_annotated() {
818 let db = TabularDatabase {
819 tables: vec![Table {
820 name: "Sales".to_string(),
821 columns: vec![
822 Column {
823 name: "Amount".to_string(),
824 group_by_columns: vec!["Bucket".to_string()],
825 ..Default::default()
826 },
827 column("Bucket"),
828 ],
829 ..Default::default()
830 }],
831 ..Default::default()
832 };
833
834 let graph = DependencyGraph::build(&db, &[]);
835 let unused = graph.unused_objects();
836
837 let amount = find(&unused, &column_id("Sales", "Amount"));
838 assert!(amount.used_by.is_empty());
839 let bucket = find(&unused, &column_id("Sales", "Bucket"));
840 assert_eq!(bucket.used_by.len(), 1);
841 assert_eq!(bucket.used_by[0].id, column_id("Sales", "Amount"));
842 assert!(bucket.used_by[0].also_unused);
843 assert!(matches!(
844 bucket.used_by[0].provenance,
845 Provenance::Structural {
846 role: StructuralEdge::GroupByColumn
847 }
848 ));
849 }
850
851 #[test]
855 fn a_used_column_keeps_its_group_by_column_alive() {
856 let db = TabularDatabase {
857 tables: vec![Table {
858 name: "Sales".to_string(),
859 columns: vec![
860 Column {
861 name: "Amount".to_string(),
862 group_by_columns: vec!["Bucket".to_string()],
863 ..Default::default()
864 },
865 column("Bucket"),
866 ],
867 ..Default::default()
868 }],
869 ..Default::default()
870 };
871 let report = visual_page("P1", "V1", &[column_target("Sales", "Amount")]);
872
873 let graph = DependencyGraph::build(&db, &[&report]);
874
875 assert!(graph.unused_objects().is_empty());
876 }
877
878 #[test]
881 fn a_dead_hierarchy_annotates_its_level_columns() {
882 let db = TabularDatabase {
883 tables: vec![Table {
884 name: "Date".to_string(),
885 columns: vec![column("Year")],
886 hierarchies: vec![crate::model::Hierarchy {
887 name: "Calendar".to_string(),
888 levels: vec![crate::model::HierarchyLevel {
889 name: "Year".to_string(),
890 column: "Year".to_string(),
891 }],
892 is_hidden: false,
893 }],
894 ..Default::default()
895 }],
896 ..Default::default()
897 };
898
899 let graph = DependencyGraph::build(&db, &[]);
900 let unused = graph.unused_objects();
901
902 let hierarchy = find(
903 &unused,
904 &ObjectId::Hierarchy {
905 table: NameKey::new("Date"),
906 hierarchy: NameKey::new("Calendar"),
907 },
908 );
909 assert!(hierarchy.used_by.is_empty());
910 let year = find(&unused, &column_id("Date", "Year"));
911 assert_eq!(year.used_by.len(), 1);
912 assert!(matches!(
913 year.used_by[0].provenance,
914 Provenance::Structural {
915 role: StructuralEdge::HierarchyLevel
916 }
917 ));
918 assert!(year.used_by[0].also_unused);
919 }
920
921 #[test]
924 fn dax_keeps_a_referenced_hierarchy_alive() {
925 let db = TabularDatabase {
926 tables: vec![Table {
927 name: "Date".to_string(),
928 columns: vec![column("Year")],
929 hierarchies: vec![crate::model::Hierarchy {
930 name: "Calendar".to_string(),
931 levels: vec![crate::model::HierarchyLevel {
932 name: "Year".to_string(),
933 column: "Year".to_string(),
934 }],
935 is_hidden: false,
936 }],
937 measures: vec![measure("In Scope", "ISINSCOPE('Date'[Calendar])")],
938 ..Default::default()
939 }],
940 ..Default::default()
941 };
942 let report = visual_page("P1", "V1", &[measure_target("Date", "In Scope")]);
943
944 let graph = DependencyGraph::build(&db, &[&report]);
945
946 assert!(graph.unused_objects().is_empty());
947 }
948
949 #[test]
955 fn a_binding_on_a_calculation_group_column_keeps_its_items_alive() {
956 let db = TabularDatabase {
957 tables: vec![
958 Table {
959 name: "Sales".to_string(),
960 columns: vec![column("Amount")],
961 measures: vec![measure("Total", "SUM('Sales'[Amount])")],
962 ..Default::default()
963 },
964 Table {
965 name: "Date Role".to_string(),
966 columns: vec![column("Date Role")],
967 calculation_group: Some(crate::model::CalculationGroup {
968 items: vec![
969 crate::model::CalculationItem {
970 name: "By Ship Date".to_string(),
971 expression: "SELECTEDMEASURE()".to_string(),
972 format_string_expression: None,
973 },
974 crate::model::CalculationItem {
975 name: "By Due Date".to_string(),
976 expression: "SELECTEDMEASURE()".to_string(),
977 format_string_expression: None,
978 },
979 ],
980 ..Default::default()
981 }),
982 ..Default::default()
983 },
984 ],
985 ..Default::default()
986 };
987 let report = visual_page(
988 "P1",
989 "Slicer",
990 &[
991 measure_target("Sales", "Total"),
992 column_target("Date Role", "Date Role"),
993 ],
994 );
995
996 let graph = DependencyGraph::build(&db, &[&report]);
997
998 assert!(
999 graph.unused_objects().is_empty(),
1000 "the bound column keeps the group, the group's items, and the model alive"
1001 );
1002 let consumers = graph.consumers_of(&ObjectId::CalculationItem {
1003 table: NameKey::new("Date Role"),
1004 item: NameKey::new("By Ship Date"),
1005 });
1006 assert!(
1007 consumers.iter().any(|(id, provenance)| {
1008 *id == column_id("Date Role", "Date Role")
1009 && matches!(provenance, Provenance::Binding(_))
1010 }),
1011 "the column's binding edge names the item, with the binding site as provenance"
1012 );
1013 }
1014
1015 #[test]
1018 fn dax_keeps_a_referenced_calculation_item_alive() {
1019 let db = TabularDatabase {
1020 tables: vec![
1021 Table {
1022 name: "Sales".to_string(),
1023 measures: vec![measure(
1024 "YTD Sales",
1025 "CALCULATE(SUM('Sales'[Amount]), 'Time Intelligence'[YTD])",
1026 )],
1027 ..Default::default()
1028 },
1029 Table {
1030 name: "Time Intelligence".to_string(),
1031 calculation_group: Some(crate::model::CalculationGroup {
1032 items: vec![
1033 crate::model::CalculationItem {
1034 name: "YTD".to_string(),
1035 expression: "SELECTEDMEASURE()".to_string(),
1036 format_string_expression: None,
1037 },
1038 crate::model::CalculationItem {
1039 name: "MTD".to_string(),
1040 expression: "SELECTEDMEASURE()".to_string(),
1041 format_string_expression: None,
1042 },
1043 ],
1044 ..Default::default()
1045 }),
1046 ..Default::default()
1047 },
1048 ],
1049 ..Default::default()
1050 };
1051 let report = visual_page("P1", "V1", &[measure_target("Sales", "YTD Sales")]);
1052
1053 let graph = DependencyGraph::build(&db, &[&report]);
1054 let unused = graph.unused_objects();
1055 let unused_ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1056
1057 assert_eq!(
1058 unused_ids,
1059 [&ObjectId::CalculationItem {
1060 table: NameKey::new("Time Intelligence"),
1061 item: NameKey::new("MTD"),
1062 }],
1063 "only the unselected calculation item is unused"
1064 );
1065 }
1066
1067 #[test]
1070 fn an_unresolved_qualified_reference_keeps_its_table_alive() {
1071 let db = TabularDatabase {
1072 tables: vec![
1073 Table {
1074 name: "Sales".to_string(),
1075 measures: vec![measure("M", "'Ghost'[Nope]")],
1076 ..Default::default()
1077 },
1078 table("Ghost"),
1079 ],
1080 ..Default::default()
1081 };
1082 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1083
1084 let graph = DependencyGraph::build(&db, &[&report]);
1085
1086 assert!(graph.unused_objects().is_empty(), "Ghost stays alive");
1087 }
1088
1089 #[test]
1091 fn an_unresolved_reference_without_a_resolvable_part_keeps_nothing_alive() {
1092 let db = TabularDatabase {
1093 tables: vec![Table {
1094 name: "Sales".to_string(),
1095 measures: vec![measure("M", "'Ghost'[Nope] + [Also Nope]")],
1096 ..Default::default()
1097 }],
1098 ..Default::default()
1099 };
1100 let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1101
1102 let graph = DependencyGraph::build(&db, &[&report]);
1103
1104 assert_eq!(graph.unused_objects().len(), 0, "only Sales and M exist");
1105 }
1106
1107 #[test]
1110 fn m_references_keep_shared_expressions_alive() {
1111 let db = TabularDatabase {
1112 tables: vec![
1113 Table {
1114 name: "Sales".to_string(),
1115 partitions: vec![m_partition(
1116 "Sales",
1117 "let Source = Sql.Database(ServerName) in Source",
1118 )],
1119 ..Default::default()
1120 },
1121 Table {
1122 name: "DimOld".to_string(),
1123 partitions: vec![m_partition(
1124 "DimOld",
1125 "let Source = LegacyParam in Source",
1126 )],
1127 ..Default::default()
1128 },
1129 ],
1130 expressions: vec![
1131 SharedExpression {
1132 name: "ServerName".to_string(),
1133 expression: "\"localhost\"".to_string(),
1134 },
1135 SharedExpression {
1136 name: "LegacyParam".to_string(),
1137 expression: "5".to_string(),
1138 },
1139 ],
1140 ..Default::default()
1141 };
1142 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1145
1146 let graph = DependencyGraph::build(&db, &[&report]);
1147 let unused = graph.unused_objects();
1148
1149 not_unused(
1150 &unused,
1151 &ObjectId::Expression {
1152 name: NameKey::new("ServerName"),
1153 },
1154 );
1155 let legacy = find(
1156 &unused,
1157 &ObjectId::Expression {
1158 name: NameKey::new("LegacyParam"),
1159 },
1160 );
1161 assert_eq!(legacy.used_by.len(), 1);
1162 assert_eq!(
1163 legacy.used_by[0].id,
1164 ObjectId::Partition {
1165 table: NameKey::new("DimOld"),
1166 partition: NameKey::new("DimOld"),
1167 }
1168 );
1169 assert!(legacy.used_by[0].also_unused);
1170 assert!(matches!(legacy.used_by[0].provenance, Provenance::M));
1171 }
1172
1173 #[test]
1177 fn an_m_chain_keeps_shared_expressions_alive() {
1178 let db = TabularDatabase {
1179 tables: vec![Table {
1180 name: "Sales".to_string(),
1181 partitions: vec![m_partition(
1182 "Sales",
1183 "let Source = Sql.Database(#\"Staging Query\") in Source",
1184 )],
1185 ..Default::default()
1186 }],
1187 expressions: vec![
1188 SharedExpression {
1189 name: "Staging Query".to_string(),
1190 expression: "ServerName".to_string(),
1191 },
1192 SharedExpression {
1193 name: "ServerName".to_string(),
1194 expression: "\"localhost\"".to_string(),
1195 },
1196 ],
1197 ..Default::default()
1198 };
1199 let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1200
1201 let graph = DependencyGraph::build(&db, &[&report]);
1202 let unused = graph.unused_objects();
1203
1204 not_unused(
1205 &unused,
1206 &ObjectId::Expression {
1207 name: NameKey::new("Staging Query"),
1208 },
1209 );
1210 not_unused(
1211 &unused,
1212 &ObjectId::Expression {
1213 name: NameKey::new("ServerName"),
1214 },
1215 );
1216
1217 assert_eq!(
1220 graph.consumers_of(&ObjectId::Expression {
1221 name: NameKey::new("ServerName"),
1222 }),
1223 [(
1224 ObjectId::Expression {
1225 name: NameKey::new("Staging Query"),
1226 },
1227 Provenance::M
1228 )]
1229 );
1230 }
1231
1232 #[test]
1234 fn a_bookmark_saved_filter_is_a_root() {
1235 let db = TabularDatabase {
1236 tables: vec![Table {
1237 name: "Sales".to_string(),
1238 columns: vec![column("Region")],
1239 ..Default::default()
1240 }],
1241 ..Default::default()
1242 };
1243 let report = ReportModel {
1244 bookmarks: vec![Bookmark {
1245 name: NameKey::new("B1"),
1246 display_name: None,
1247 filters: Vec::new(),
1248 sections: vec![BookmarkSection {
1249 page: NameKey::new("P1"),
1250 filters: Vec::new(),
1251 visuals: vec![BookmarkVisual {
1252 visual: NameKey::new("V1"),
1253 wells: Vec::new(),
1254 filters: vec![Filter {
1255 target: Some(column_target("Sales", "Region")),
1256 ..Default::default()
1257 }],
1258 }],
1259 }],
1260 }],
1261 ..Default::default()
1262 };
1263
1264 let graph = DependencyGraph::build(&db, &[&report]);
1265
1266 assert!(graph.unused_objects().is_empty());
1267 let roots = graph.roots();
1268 assert_eq!(roots.len(), 1);
1269 assert!(matches!(
1270 &roots[0].1,
1271 Provenance::Binding(edge) if edge.bookmark.is_some()
1272 ));
1273 }
1274
1275 #[test]
1278 fn calculated_table_columns_stay_with_their_table() {
1279 let db = TabularDatabase {
1280 tables: vec![Table {
1281 name: "Top Products".to_string(),
1282 columns: vec![Column {
1283 name: "Product".to_string(),
1284 kind: ColumnKind::CalculatedTableColumn,
1285 ..Default::default()
1286 }],
1287 partitions: vec![Partition {
1288 name: "Top Products".to_string(),
1289 source: PartitionSource::Calculated {
1290 expression: "TOPN(10, 'Product')".to_string(),
1291 },
1292 }],
1293 ..Default::default()
1294 }],
1295 ..Default::default()
1296 };
1297 let report = visual_page("P1", "V1", &[column_target("Top Products", "Product")]);
1298
1299 let graph = DependencyGraph::build(&db, &[&report]);
1300
1301 assert!(graph.unused_objects().is_empty());
1302 }
1303
1304 #[test]
1308 fn calendar_columns_stay_with_their_table() {
1309 let db = TabularDatabase {
1310 tables: vec![Table {
1311 name: "Date".to_string(),
1312 columns: vec![column("Day")],
1313 calendars: vec![crate::model::Calendar {
1314 name: "Fiscal Calendar".to_string(),
1315 columns: vec!["Day".to_string()],
1316 }],
1317 measures: vec![measure("Rows", "COUNTROWS('Date')")],
1318 ..Default::default()
1319 }],
1320 ..Default::default()
1321 };
1322 let report = visual_page("P1", "V1", &[measure_target("Date", "Rows")]);
1323
1324 let graph = DependencyGraph::build(&db, &[&report]);
1325
1326 assert!(graph.unused_objects().is_empty());
1327 }
1328
1329 #[test]
1332 fn a_dead_table_annotates_its_calendar_columns() {
1333 let db = TabularDatabase {
1334 tables: vec![Table {
1335 name: "Date".to_string(),
1336 columns: vec![column("Day")],
1337 calendars: vec![crate::model::Calendar {
1338 name: "Fiscal Calendar".to_string(),
1339 columns: vec!["Day".to_string()],
1340 }],
1341 ..Default::default()
1342 }],
1343 ..Default::default()
1344 };
1345
1346 let graph = DependencyGraph::build(&db, &[]);
1347 let unused = graph.unused_objects();
1348
1349 let day = find(&unused, &column_id("Date", "Day"));
1350 assert_eq!(day.used_by.len(), 1);
1351 assert_eq!(day.used_by[0].id, table_id("Date"));
1352 assert!(day.used_by[0].also_unused);
1353 assert!(matches!(
1354 day.used_by[0].provenance,
1355 Provenance::Structural {
1356 role: StructuralEdge::EngineManaged
1357 }
1358 ));
1359 }
1360 }
1361
1362 mod queries {
1363 use super::*;
1364
1365 #[test]
1366 fn queries_on_an_unknown_object_are_empty() {
1367 let graph = DependencyGraph::build(&TabularDatabase::default(), &[]);
1368
1369 assert!(graph.consumers_of(&table_id("Nope")).is_empty());
1370 assert!(graph.producers_of(&table_id("Nope")).is_empty());
1371 assert!(graph.roots_of(&table_id("Nope")).is_empty());
1372 }
1373
1374 #[test]
1375 fn unused_objects_are_sorted_by_identity() {
1376 let db = TabularDatabase {
1377 tables: vec![Table {
1378 name: "Sales".to_string(),
1379 columns: vec![column("B"), column("A")],
1380 ..Default::default()
1381 }],
1382 ..Default::default()
1383 };
1384
1385 let graph = DependencyGraph::build(&db, &[]);
1386 let unused = graph.unused_objects();
1387 let ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1388 let mut sorted = ids.clone();
1389 sorted.sort();
1390
1391 assert_eq!(ids, sorted);
1392 }
1393
1394 #[test]
1395 fn the_root_carries_the_full_binding_provenance() {
1396 let db = TabularDatabase {
1397 tables: vec![Table {
1398 name: "Sales".to_string(),
1399 measures: vec![measure("Total", "0")],
1400 ..Default::default()
1401 }],
1402 ..Default::default()
1403 };
1404 let report = visual_page("P2", "Card", &[measure_target("Sales", "Total")]);
1405
1406 let graph = DependencyGraph::build(&db, &[&report]);
1407 let roots = graph.roots();
1408
1409 assert_eq!(roots.len(), 1);
1410 assert_eq!(roots[0].0, measure_id("Sales", "Total"));
1411 let Provenance::Binding(edge) = &roots[0].1 else {
1412 panic!("a root carries binding provenance");
1413 };
1414 let BindingEdge {
1415 kind,
1416 report: report_name,
1417 page,
1418 visual,
1419 bookmark,
1420 } = edge.as_ref();
1421 assert!(matches!(kind, BindingSite::FieldWell { role } if role == "Values"));
1422 assert_eq!(report_name.as_ref().map(NameKey::as_str), Some("Mini"));
1423 assert_eq!(page.as_ref().map(NameKey::as_str), Some("P2"));
1424 assert_eq!(visual.as_ref().map(NameKey::as_str), Some("Card"));
1425 assert!(bookmark.is_none());
1426 }
1427 }
1428}