Skip to main content

ripbi_core/
graph.rs

1//! The dependency graph: one [`petgraph`] DAG over the semantic model and the
2//! reports that share it, plus the reachability analysis that isolates dead
3//! objects.
4//!
5//! # Shape
6//!
7//! Nodes are [`ObjectId`]s — every table, column, measure, partition,
8//! hierarchy, relationship, role, calculation item, shared expression,
9//! user-defined function, and report measure, whether or not anything
10//! references them. Edges point from user to used and carry their
11//! [`Provenance`] as first-class data, so a reverse query
12//! ([`consumers_of`](DependencyGraph::consumers_of)) is a pure read and a
13//! second view over the graph (`ripbi deps`) is pure rendering in the CLI.
14//! Report sites — visuals, pages, bookmarks — are not model objects, so their
15//! bindings live beside the graph as [`roots`](DependencyGraph::roots) with
16//! full provenance.
17//!
18//! # Liveness policy (conservative — what "unused" means)
19//!
20//! Reachability starts from the roots: every
21//! [`ReportModel::bindings`](crate::ReportModel::bindings) target and every
22//! role (roles are security configuration, never dead weight; their filter
23//! edges keep the referenced columns alive). From there, two passes over the
24//! edge catalog decide liveness — the full catalog with the reasoning behind
25//! every rule lives in `docs/graph.md` beside this module:
26//!
27//! - **DAX references** (`dax::bind`, every candidate — an unqualified
28//!   `[Name]` keeps the measure *and* the home-table column alive) and their
29//!   extended candidates (hierarchies, calculation items, and, for a
30//!   reference matching nothing, its qualifying table). A reference that
31//!   matches nothing and has no resolvable part keeps nothing alive.
32//! - **M references** (`m::bind`, same conservatism). The pipeline is M →
33//!   tables/columns → DAX → reports, so M is upstream of everything and
34//!   deletion flows one way. A **table or shared expression** named in M — a
35//!   merge source, a referenced parameter query — keeps alive: deleting it
36//!   deletes the query another partition reads, which breaks refresh. A
37//!   **column** named in M is the column's *supply chain*, not a consumer:
38//!   the query keeps producing it and the model just stops mapping it, so
39//!   unloading cannot break refresh and there is deliberately no edge.
40//!   Only Data columns qualify — an M step can only name a column it
41//!   produces, so a calculated column matching an M name (the auto date/time
42//!   columns named like Desktop's date-template query) is not recorded.
43//!   Instead the naming expressions ride along on the finding
44//!   ([`UnusedObject::named_by_m`]) — the what-a-full-removal-must-edit
45//!   context. Liveness still flows through the owner, so a dead table's
46//!   partition keeps nothing alive.
47//! - **Report bindings** with their provenance, report measures shadowing
48//!   model measures of the same name. An unused report measure is dead like
49//!   any other node — its body's references stay alive only through it.
50//! - **Containment**: a used member (column, measure, hierarchy, calculation
51//!   item) keeps its table alive; a used table keeps its partitions,
52//!   relationships, and engine-managed columns (calculated-table columns,
53//!   calculation-group columns, calendar columns) alive.
54//! - **Relationships**: live if either endpoint table is reachable. An
55//!   **active** relationship keeps both key columns alive — but a key column
56//!   kept alive *only* as a relationship endpoint does **not** keep its table
57//!   alive, so a table referenced by nothing but a relationship is still
58//!   unused. An **inactive** relationship is live only when a live DAX
59//!   reference (`USERELATIONSHIP`) activates it — switching one on at query
60//!   time is DAX's job, and nothing else can. Unactivated, it is a finding
61//!   itself, and its key columns are findings chained under it.
62//!
63//! The conservatism rule from name resolution governs everything: marking an
64//! object used too many is harmless; marking one too few tells a user to
65//! delete live code. A model scanned with no reports and no roles therefore
66//! reports *everything* as unused — callers decide whether that is a finding
67//! or a missing report.
68//!
69//! # Examples
70//!
71//! ```
72//! use ripbi_core::{
73//!     Column, FieldTarget, FieldWell, Measure, NameKey, Page, Projection,
74//!     ReportModel, Table, TabularDatabase, Visual,
75//! };
76//! use ripbi_core::graph::DependencyGraph;
77//!
78//! let db = TabularDatabase {
79//!     tables: vec![Table {
80//!         name: "Sales".to_string(),
81//!         columns: vec![
82//!             Column { name: "Amount".to_string(), ..Default::default() },
83//!             Column { name: "Legacy".to_string(), ..Default::default() },
84//!         ],
85//!         measures: vec![Measure {
86//!             name: "Total".to_string(),
87//!             expression: "SUM('Sales'[Amount])".to_string(),
88//!             ..Default::default()
89//!         }],
90//!         ..Default::default()
91//!     }],
92//!     ..Default::default()
93//! };
94//! // One visual projecting the Total measure keeps it — and its column — alive.
95//! let report = ReportModel {
96//!     pages: vec![Page {
97//!         name: NameKey::new("P1"),
98//!         display_name: None,
99//!         is_hidden: false,
100//!         filters: Vec::new(),
101//!         binding: None,
102//!         visuals: vec![Visual {
103//!             name: NameKey::new("V1"),
104//!             visual_type: "card".to_string(),
105//!             wells: vec![FieldWell {
106//!                 role: "Values".to_string(),
107//!                 projections: vec![Projection {
108//!                     target: FieldTarget::Measure {
109//!                         home_table: Some(NameKey::new("Sales")),
110//!                         measure: NameKey::new("Total"),
111//!                     },
112//!                     query_ref: None,
113//!                     active: true,
114//!                 }],
115//!             }],
116//!             filters: Vec::new(),
117//!             sorts: Vec::new(),
118//!             conditional_formatting: Vec::new(),
119//!             alt_text: Vec::new(),
120//!             tooltip_page: None,
121//!         }],
122//!     }],
123//!     ..Default::default()
124//! };
125//!
126//! let graph = DependencyGraph::build(&db, &[&report]);
127//!
128//! // The visual well is a root with provenance…
129//! assert_eq!(graph.roots().len(), 1);
130//! let total = ripbi_core::ObjectId::Measure {
131//!     table: NameKey::new("Sales"),
132//!     measure: NameKey::new("Total"),
133//! };
134//! assert_eq!(graph.roots_of(&total).len(), 1);
135//! // …and nothing touches `Legacy`, so it is the one unused object.
136//! let unused = graph.unused_objects();
137//! assert_eq!(unused.len(), 1);
138//! assert_eq!(unused[0].id.to_string(), "'Sales'[Legacy]");
139//! assert!(unused[0].used_by.is_empty(), "nothing references it at all");
140//! ```
141
142use 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/// The dependency graph of one semantic model and the reports sharing it.
161///
162/// Build it once with [`DependencyGraph::build`], then query: who uses an
163/// object ([`consumers_of`](DependencyGraph::consumers_of)), what an object
164/// uses ([`producers_of`](DependencyGraph::producers_of)), and what nothing
165/// reaches ([`unused_objects`](DependencyGraph::unused_objects)).
166#[derive(Debug)]
167pub struct DependencyGraph {
168    /// The object-to-object edges, user → used, weighted by provenance.
169    graph: DiGraph<ObjectId, Provenance>,
170    /// Node key → petgraph index. Every model and report object has a node.
171    nodes: HashMap<ObjectId, NodeIndex>,
172    /// The reachability roots: report bindings pointing at model objects, with
173    /// their binding provenance, in report order.
174    roots: Vec<(ObjectId, Provenance)>,
175    /// Data columns named by M expressions: the supply chain that is
176    /// deliberately *not* edges. Key: the column. Value: the naming
177    /// expressions, sorted. Engine-computed columns are excluded — an M step
178    /// can only name a column it produces.
179    m_named: HashMap<ObjectId, Vec<ObjectId>>,
180}
181
182impl DependencyGraph {
183    /// Builds the graph for one model and every report that shares it.
184    ///
185    /// Never fails: resolution misses are data, never errors. Passing no
186    /// reports leaves every model object unused unless a role keeps it alive.
187    #[must_use]
188    pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
189        builder::build(db, reports)
190    }
191
192    /// Assembles a finished graph from its parts. Only the builder calls this.
193    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    /// Every object in the graph, in build order (model order, then
208    /// relationships, roles, shared expressions, functions, report measures).
209    pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
210        self.graph.node_indices().map(|index| &self.graph[index])
211    }
212
213    /// The objects that use `id`, with what kind of use each edge records —
214    /// the query the `ripbi deps` view is built on. Report bindings are not
215    /// object-to-object edges; they are answered by
216    /// [`roots_of`](DependencyGraph::roots_of).
217    pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
218        self.neighbors(id, Direction::Incoming)
219    }
220
221    /// The objects that `id` uses, with what kind of use each edge records.
222    pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
223        self.neighbors(id, Direction::Outgoing)
224    }
225
226    /// Every reachability root: the report bindings, with their targets and
227    /// provenance, in report order. Deterministic for a given set of reports.
228    pub fn roots(&self) -> &[(ObjectId, Provenance)] {
229        &self.roots
230    }
231
232    /// The provenance of every report binding that targets `id`.
233    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    /// The M expressions that name `id` — its Power Query supply chain. A
242    /// name is not a consumer: unloading a column these expressions produce
243    /// cannot break refresh. But removing the column *entirely* — model and
244    /// script — means editing each of them, which is what this answers.
245    /// Non-empty only ever for Data columns: an M step can only name a column
246    /// it produces, so an engine-computed column matching an M name is
247    /// coincidence, not supply chain.
248    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    /// Every object reachability never reached, sorted by object identity:
253    /// the `scan` findings. Each finding names who still references it —
254    /// empty for a true orphan, and every referencing object is either
255    /// itself unused, a key column kept alive only as an active relationship
256    /// endpoint, or the table of an inactive relationship it cannot keep
257    /// alive.
258    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    /// Every auto date/time table — flagged at ingestion or matching the
289    /// engine's `LocalDateTable_` / `DateTableTemplate_` name prefixes — with
290    /// the second verdict over the same graph the reachability findings come
291    /// from, sorted by object identity.
292    ///
293    /// The verdict is deliberately *not* reachability. The engine's own
294    /// relationship to the user's date column keeps the machinery alive for
295    /// as long as that column is used, so "alive" says nothing about whether
296    /// a report binds it; a table counts as used only when a report binding
297    /// ([`Provenance::Binding`]) lands on the table itself or on one of its
298    /// members. This is the same data the reachability findings are computed
299    /// from, read with a different question — not a separate analysis.
300    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    /// Whether any report binding lands on the table itself or on one of its
329    /// members (columns, measures, hierarchies, partitions, calculation
330    /// items). Binding roots are not edges, so both the root list and the
331    /// incoming `Binding` edges (the calculation-item selection case) count.
332    /// A binding on the *varied* (user-side) column does not count: the
333    /// framework relationship is not a consumer.
334    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    /// The petgraph indices reachability starts from: every root target and
372    /// every role.
373    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    /// The set of nodes reachable from `seeds` over the edges `allowed`.
389    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    /// The node key at a petgraph index.
410    pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
411        &self.graph[index]
412    }
413}
414
415/// One auto date/time table with the second, provenance-based verdict: does a
416/// report *bind* the machinery, or is it kept alive only by the engine's own
417/// relationship?
418#[derive(Debug, Clone, PartialEq, Eq)]
419pub struct AutoDateTimeVerdict {
420    /// The auto date/time table.
421    pub id: ObjectId,
422    /// The verdict.
423    pub verdict: AutoDateTimeStatus,
424    /// The user's date column the machinery serves, when a variation
425    /// declaration or the hidden relationship ties the table to one — the
426    /// `for 'Date'[OrderDate]` display. `None` for the template table, which
427    /// relates to nothing.
428    pub source_column: Option<ObjectId>,
429}
430
431/// The provenance-based verdict for one auto date/time table — the three
432/// states of issue #16, none of which plain reachability can produce.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
434pub enum AutoDateTimeStatus {
435    /// A report binding lands on the table or one of its members: the
436    /// machinery is in use, and the advice is to replace it with a real date
437    /// table.
438    InUse,
439    /// No report binding touches it, yet the machinery is still live: the
440    /// framework relationship to a used date column keeps it alive. Pure
441    /// bloat — disable auto date/time.
442    UnusedByReports,
443    /// Reachability never reached it at all: dead with its dead chain.
444    Dead,
445}
446
447/// The user's date column whose variation points at `table` — through the
448/// variation's relationship or its default-hierarchy reference.
449fn 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    /// A visual on page `page` projecting `targets` into its Values well.
553    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    /// The finding for `id`, panicking with a readable message when absent.
602    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            // The measure's outgoing edges: containment in its table, plus
663            // exactly ONE DAX edge to the column even though the reference is
664            // written twice.
665            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            // …while the column's only consumer is the measure's DAX edge; its
676            // containment edge points the other way, at the table.
677            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        /// A shared expression whose M text names itself keeps nothing alive:
701        /// self-references are dropped rather than recorded.
702        #[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        /// The far-table policy: a live table keeps its relationship and both
726        /// key columns alive, but the far table stays unused — its key column,
727        /// alive only as a relationship endpoint, cannot keep it.
728        #[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            // The used side is entirely live, weak parts included.
760            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            // The far table is unused despite its live key column…
773            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            // …and so are its other column and its partition, annotated.
792            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        /// An inactive relationship nothing activates is itself a finding,
807        /// and its key columns are findings pointing back at it — the
808        /// `only used by … (also unused)` chain shape. Only a live
809        /// `USERELATIONSHIP` reference can switch it on at query time.
810        #[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            // Only `Total` is bound: `Sales` is live, `DimOld` is not, and
845            // the inactive relationship must not rescue its keys — or itself.
846            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            // The relationship is a finding; its two tables are the recorded
853            // consumers that could not keep it alive — `Sales` live, `DimOld`
854            // itself unused.
855            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            // Both keys point back at the unactivated relationship — the
871            // `only used by … (also unused)` chain shape.
872            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            // And `DimOld` is still a finding: a dead key column must not
889            // pull its own table along.
890            find(&unused, &table_id("DimOld"));
891        }
892
893        /// The other half of the rule: a live measure switching the inactive
894        /// relationship on with `USERELATIONSHIP` is an ordinary DAX
895        /// reference, and it keeps both key columns alive.
896        #[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            // The live measure's call is the activation edge itself: the
934            // relationship stays alive even though no table needs it.
935            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            // The measure's `USERELATIONSHIP` arguments are ordinary DAX
945            // references, so containment applies on top: `DimOld` stays alive
946            // through its live key column, and only `Notes` is left dead.
947            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        /// An RLS filter is rooted at its role: the filtered column stays alive
953        /// even though no report binding and no DAX references it.
954        #[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        /// A metadata-only role permission keeps the granted table alive.
996        #[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        /// With no reports and no roles, nothing is reachable: everything is
1016        /// unused, which is the caller's signal that no roots were found.
1017        #[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        /// An unused report measure is dead, and what only it references
1036        /// carries the "also unused" annotation.
1037        #[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        /// A visual can bind a report measure directly; the report measure
1068        /// shadows a model measure of the same name, which then reads as
1069        /// unreferenced from this report.
1070        #[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            // The binding landed on the report measure, not the model measure.
1090            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        /// Sort-by chains: an unused sorted column drags its unused sort
1099        /// column along, with the annotation naming the chain.
1100        #[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        /// Group-by chains mirror sort-by: an unused grouping column drags
1136        /// its unused group column along, with the annotation naming the chain.
1137        #[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        /// A used column keeps its group-by column alive: grouping is part of
1173        /// how the engine aggregates the column, so a column referenced only
1174        /// through a group-by is not dead.
1175        #[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        /// A dead hierarchy keeps its level columns from being orphans: they
1200        /// are referenced only by the hierarchy, which is itself unused.
1201        #[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        /// A hierarchy referenced from DAX (`ISINSCOPE('Date'[Calendar])`) is
1243        /// an extended-resolution candidate the plain binder does not know.
1244        #[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        /// A report binding on a calculation-group column keeps every item of
1271        /// its group alive: a slicer or filter over the column can select any
1272        /// item by name at query time. Structural liveness of the group alone
1273        /// does not: the dead-chain fixture pins an unselected item staying
1274        /// dead when only another item's explicit DAX use keeps the table up.
1275        #[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        /// A qualified reference into a calculation group keeps the named
1337        /// calculation item alive.
1338        #[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        /// A qualified reference matching nothing keeps its qualifying table
1389        /// alive — the nearest resolvable candidate.
1390        #[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        /// A reference whose table does not exist either keeps nothing alive.
1411        #[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        /// A shared expression named in an M partition is referenced by it —
1429        /// and if the partition's table is dead, the annotation says so.
1430        #[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            // The visual binds a column that does not exist; the written form
1464            // still keeps its qualifying table alive.
1465            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        /// Shared expressions reference each other: a partition keeps its
1495        /// staging query alive, and the staging query keeps the parameter it
1496        /// names alive — one M edge per hop.
1497        #[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            // The second hop is the M-to-M edge: the staging query, not the
1539            // partition, is what names ServerName.
1540            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        /// A column named only inside its own table's Power Query partition
1554        /// is **not** kept alive. M produces the column and the model maps
1555        /// onto the query's output, so unloading the column cannot break
1556        /// refresh — the issue #39 keep was inverted. What the partition's
1557        /// mention is worth rides on the finding instead
1558        /// ([`UnusedObject::named_by_m`]): removing the column from the
1559        /// *script* too means editing those steps.
1560        #[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            // The report binds Pk only: that keeps the table (and with it the
1592            // partition) alive, while Amount, Region, and Orphaned have no
1593            // DAX or report binding anywhere.
1594            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            // The shared expression the partition's M reads is still kept —
1613            // the liveness half of the rule.
1614            not_unused(
1615                &unused,
1616                &ObjectId::Expression {
1617                    name: NameKey::new("ServerName"),
1618                },
1619            );
1620        }
1621
1622        /// A liveness edge must never outrun its owner: when the table is
1623        /// dead, its partition is unreachable and keeps nothing alive — the
1624        /// columns die with the table they belong to.
1625        #[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            // The partition names DimOld itself and [Key]; the self-table
1644            // reference is dropped, but nothing else could keep the table
1645            // alive either.
1646            find(&unused, &table_id("DimOld"));
1647        }
1648
1649        /// A table consumed only as another query's merge source is
1650        /// refresh-critical: `#"DimOld"` in a NestedJoin deletes the query the
1651        /// join reads when the table goes, so the table keeps alive. Its
1652        /// *column* does not — the `{"Key"}` strings merely name it.
1653        #[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            // Sales is reachable only through a relationship-free report
1686            // binding on its column; DimOld has no binding anywhere.
1687            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            // The join keys are named, not kept: Sales' partition rides on
1694            // DimOld's column finding as supply-chain context.
1695            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        /// A qualified field access names the query it reads from:
1706        /// `#"DimOld"[Key]` keeps the whole DimOld table alive even when no
1707        /// argument-position mention of the table exists anywhere.
1708        #[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        /// The lexer narrowed the old substring match, deliberately: a shared
1739        /// expression whose name appears only inside an M comment or an
1740        /// unrelated string is no longer "referenced".
1741        #[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        /// A bookmark's saved filter is a root like a live one.
1777        #[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        /// Engine-managed columns ride along with their table: calculated-table
1820        /// columns cannot be dropped independently.
1821        #[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        /// Calendar-bound columns ride along with their table: the engine
1849        /// materializes them through the calendar, so a column referenced
1850        /// only through a calendar is not dead.
1851        #[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        /// A dead table drags its calendar-bound columns along, annotated:
1874        /// the calendar is the only thing that ever referenced them.
1875        #[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    /// The auto date/time story end to end: a varied date column, the engine's
1974    /// hidden `LocalDateTable_*`, and the three verdicts no reachability pass
1975    /// can produce on its own.
1976    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        /// `'Sales'[Date]` varying through `LocalDateTable_x` — the model's
1996        /// declaration plus the hidden relationship it names.
1997        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        /// The headline fix: a visual's date hierarchy over a varied column
2065        /// resolves through the variation declaration onto the hidden table's
2066        /// hierarchy, and the whole machinery goes alive.
2067        #[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            // The machinery is bound, so the verdict is InUse and names the
2100            // varied column.
2101            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        /// A serialization that dropped the variation object still carries the
2108        /// relationship — and the flag marks which related table is the
2109        /// machinery.
2110        #[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        /// The flag keeps the fallback honest: a related table that is not
2140        /// date machinery does not absorb the binding.
2141        #[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            // The coarse fallback still keeps the table the binding named.
2161            assert_eq!(graph.roots_of(&table_id("Sales")).len(), 1);
2162        }
2163
2164        /// The verdict no reachability pass can produce: DAX keeps the
2165        /// machinery alive, so it is not dead — but no report binds it, which
2166        /// is the bloat the scan findings cannot express.
2167        #[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        /// With no DAX and no variation keeping it alive, the machinery is
2198        /// simply dead.
2199        #[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}