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