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**: a shared expression named inside an M expression
33//!   (matched whole-word, case-insensitively — M is case-sensitive, so
34//!   over-matching is the safe direction).
35//! - **Report bindings** with their provenance, report measures shadowing
36//!   model measures of the same name. An unused report measure is dead like
37//!   any other node — its body's references stay alive only through it.
38//! - **Containment**: a used member (column, measure, hierarchy, calculation
39//!   item) keeps its table alive; a used table keeps its partitions,
40//!   relationships, and engine-managed columns (calculated-table columns,
41//!   calculation-group columns, calendar columns) alive.
42//! - **Relationships**: live if either endpoint table is reachable, and they
43//!   keep both key columns alive — but a key column kept alive *only* as a
44//!   relationship endpoint does **not** keep its table alive, so a table
45//!   referenced by nothing but a relationship is still unused.
46//!
47//! The conservatism rule from name resolution governs everything: marking an
48//! object used too many is harmless; marking one too few tells a user to
49//! delete live code. A model scanned with no reports and no roles therefore
50//! reports *everything* as unused — callers decide whether that is a finding
51//! or a missing report.
52//!
53//! # Examples
54//!
55//! ```
56//! use ripbi_core::{
57//!     Column, FieldTarget, FieldWell, Measure, NameKey, Page, Projection,
58//!     ReportModel, Table, TabularDatabase, Visual,
59//! };
60//! use ripbi_core::graph::DependencyGraph;
61//!
62//! let db = TabularDatabase {
63//!     tables: vec![Table {
64//!         name: "Sales".to_string(),
65//!         columns: vec![
66//!             Column { name: "Amount".to_string(), ..Default::default() },
67//!             Column { name: "Legacy".to_string(), ..Default::default() },
68//!         ],
69//!         measures: vec![Measure {
70//!             name: "Total".to_string(),
71//!             expression: "SUM('Sales'[Amount])".to_string(),
72//!             ..Default::default()
73//!         }],
74//!         ..Default::default()
75//!     }],
76//!     ..Default::default()
77//! };
78//! // One visual projecting the Total measure keeps it — and its column — alive.
79//! let report = ReportModel {
80//!     pages: vec![Page {
81//!         name: NameKey::new("P1"),
82//!         display_name: None,
83//!         is_hidden: false,
84//!         filters: Vec::new(),
85//!         binding: None,
86//!         visuals: vec![Visual {
87//!             name: NameKey::new("V1"),
88//!             visual_type: "card".to_string(),
89//!             wells: vec![FieldWell {
90//!                 role: "Values".to_string(),
91//!                 projections: vec![Projection {
92//!                     target: FieldTarget::Measure {
93//!                         home_table: Some(NameKey::new("Sales")),
94//!                         measure: NameKey::new("Total"),
95//!                     },
96//!                     query_ref: None,
97//!                     active: true,
98//!                 }],
99//!             }],
100//!             filters: Vec::new(),
101//!             sorts: Vec::new(),
102//!             conditional_formatting: Vec::new(),
103//!             alt_text: Vec::new(),
104//!             tooltip_page: None,
105//!         }],
106//!     }],
107//!     ..Default::default()
108//! };
109//!
110//! let graph = DependencyGraph::build(&db, &[&report]);
111//!
112//! // The visual well is a root with provenance…
113//! assert_eq!(graph.roots().len(), 1);
114//! let total = ripbi_core::ObjectId::Measure {
115//!     table: NameKey::new("Sales"),
116//!     measure: NameKey::new("Total"),
117//! };
118//! assert_eq!(graph.roots_of(&total).len(), 1);
119//! // …and nothing touches `Legacy`, so it is the one unused object.
120//! let unused = graph.unused_objects();
121//! assert_eq!(unused.len(), 1);
122//! assert_eq!(unused[0].id.to_string(), "'Sales'[Legacy]");
123//! assert!(unused[0].used_by.is_empty(), "nothing references it at all");
124//! ```
125
126use std::collections::{HashMap, HashSet};
127
128use petgraph::Direction;
129use petgraph::graph::{DiGraph, NodeIndex};
130use petgraph::visit::EdgeRef;
131
132pub mod provenance;
133
134mod builder;
135mod reachability;
136
137pub use provenance::{BindingEdge, BindingSite, Provenance, StructuralEdge};
138pub use reachability::{UnusedObject, UsedBy};
139
140use crate::identity::ObjectId;
141use crate::model::TabularDatabase;
142use crate::report::ReportModel;
143
144/// The dependency graph of one semantic model and the reports sharing it.
145///
146/// Build it once with [`DependencyGraph::build`], then query: who uses an
147/// object ([`consumers_of`](DependencyGraph::consumers_of)), what an object
148/// uses ([`producers_of`](DependencyGraph::producers_of)), and what nothing
149/// reaches ([`unused_objects`](DependencyGraph::unused_objects)).
150#[derive(Debug)]
151pub struct DependencyGraph {
152    /// The object-to-object edges, user → used, weighted by provenance.
153    graph: DiGraph<ObjectId, Provenance>,
154    /// Node key → petgraph index. Every model and report object has a node.
155    nodes: HashMap<ObjectId, NodeIndex>,
156    /// The reachability roots: report bindings pointing at model objects, with
157    /// their binding provenance, in report order.
158    roots: Vec<(ObjectId, Provenance)>,
159}
160
161impl DependencyGraph {
162    /// Builds the graph for one model and every report that shares it.
163    ///
164    /// Never fails: resolution misses are data, never errors. Passing no
165    /// reports leaves every model object unused unless a role keeps it alive.
166    #[must_use]
167    pub fn build(db: &TabularDatabase, reports: &[&ReportModel]) -> Self {
168        builder::build(db, reports)
169    }
170
171    /// Assembles a finished graph from its parts. Only the builder calls this.
172    pub(super) fn assemble(
173        graph: DiGraph<ObjectId, Provenance>,
174        nodes: HashMap<ObjectId, NodeIndex>,
175        roots: Vec<(ObjectId, Provenance)>,
176    ) -> Self {
177        Self {
178            graph,
179            nodes,
180            roots,
181        }
182    }
183
184    /// Every object in the graph, in build order (model order, then
185    /// relationships, roles, shared expressions, functions, report measures).
186    pub fn object_ids(&self) -> impl Iterator<Item = &ObjectId> {
187        self.graph.node_indices().map(|index| &self.graph[index])
188    }
189
190    /// The objects that use `id`, with what kind of use each edge records —
191    /// the query the `ripbi deps` view is built on. Report bindings are not
192    /// object-to-object edges; they are answered by
193    /// [`roots_of`](DependencyGraph::roots_of).
194    pub fn consumers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
195        self.neighbors(id, Direction::Incoming)
196    }
197
198    /// The objects that `id` uses, with what kind of use each edge records.
199    pub fn producers_of(&self, id: &ObjectId) -> Vec<(ObjectId, Provenance)> {
200        self.neighbors(id, Direction::Outgoing)
201    }
202
203    /// Every reachability root: the report bindings, with their targets and
204    /// provenance, in report order. Deterministic for a given set of reports.
205    pub fn roots(&self) -> &[(ObjectId, Provenance)] {
206        &self.roots
207    }
208
209    /// The provenance of every report binding that targets `id`.
210    pub fn roots_of(&self, id: &ObjectId) -> Vec<&Provenance> {
211        self.roots
212            .iter()
213            .filter(|(target, _)| target == id)
214            .map(|(_, provenance)| provenance)
215            .collect()
216    }
217
218    /// Every object reachability never reached, sorted by object identity:
219    /// the `scan` findings. Each finding names who still references it —
220    /// empty for a true orphan, and every referencing object is either itself
221    /// unused or a key column kept alive only as a relationship endpoint.
222    pub fn unused_objects(&self) -> Vec<UnusedObject> {
223        let reach = reachability::Reachability::compute(self);
224        let mut out: Vec<UnusedObject> = self
225            .graph
226            .node_indices()
227            .filter(|index| !reach.is_live(&self.graph[*index]))
228            .map(|index| {
229                let id = self.graph[index].clone();
230                let mut used_by: Vec<UsedBy> = self
231                    .graph
232                    .edges_directed(index, Direction::Incoming)
233                    .map(|edge| UsedBy {
234                        id: self.graph[edge.source()].clone(),
235                        provenance: edge.weight().clone(),
236                        also_unused: !reach.is_live(&self.graph[edge.source()]),
237                    })
238                    .collect();
239                used_by.sort_by(|a, b| a.id.cmp(&b.id));
240                UnusedObject { id, used_by }
241            })
242            .collect();
243        out.sort_by(|a, b| a.id.cmp(&b.id));
244        out
245    }
246
247    fn neighbors(&self, id: &ObjectId, direction: Direction) -> Vec<(ObjectId, Provenance)> {
248        let Some(&index) = self.nodes.get(id) else {
249            return Vec::new();
250        };
251        self.graph
252            .edges_directed(index, direction)
253            .map(|edge| {
254                let other = match direction {
255                    Direction::Incoming => edge.source(),
256                    Direction::Outgoing => edge.target(),
257                };
258                (self.graph[other].clone(), edge.weight().clone())
259            })
260            .collect()
261    }
262
263    /// The petgraph indices reachability starts from: every root target and
264    /// every role.
265    pub(super) fn seed_indices(&self) -> Vec<NodeIndex> {
266        let mut seeds: Vec<NodeIndex> = self
267            .roots
268            .iter()
269            .filter_map(|(id, _)| self.nodes.get(id).copied())
270            .collect();
271        seeds.extend(
272            self.nodes
273                .iter()
274                .filter(|(id, _)| matches!(id, ObjectId::Role { .. }))
275                .map(|(_, &index)| index),
276        );
277        seeds
278    }
279
280    /// The set of nodes reachable from `seeds` over the edges `allowed`.
281    pub(super) fn reach(
282        &self,
283        seeds: impl IntoIterator<Item = NodeIndex>,
284        allowed: fn(&Provenance) -> bool,
285    ) -> HashSet<NodeIndex> {
286        let mut seen: HashSet<NodeIndex> = seeds.into_iter().collect();
287        let mut queue: Vec<NodeIndex> = seen.iter().copied().collect();
288        while let Some(index) = queue.pop() {
289            for edge in self.graph.edges_directed(index, Direction::Outgoing) {
290                if !allowed(edge.weight()) {
291                    continue;
292                }
293                if seen.insert(edge.target()) {
294                    queue.push(edge.target());
295                }
296            }
297        }
298        seen
299    }
300
301    /// The node key at a petgraph index.
302    pub(super) fn object_at(&self, index: NodeIndex) -> &ObjectId {
303        &self.graph[index]
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310    use crate::identity::NameKey;
311    use crate::model::{
312        Column, ColumnKind, DaxExpressionKind, Function, Measure, Partition, PartitionSource,
313        Relationship, Role, SharedExpression, Table, TablePermission,
314    };
315    use crate::report::{
316        Bookmark, BookmarkSection, BookmarkVisual, FieldTarget, FieldWell, Filter, Page,
317        Projection, Visual,
318    };
319
320    fn column(name: &str) -> Column {
321        Column {
322            name: name.to_string(),
323            ..Default::default()
324        }
325    }
326
327    fn measure(name: &str, expression: &str) -> Measure {
328        Measure {
329            name: name.to_string(),
330            expression: expression.to_string(),
331            ..Default::default()
332        }
333    }
334
335    fn m_partition(name: &str, expression: &str) -> Partition {
336        Partition {
337            name: name.to_string(),
338            source: PartitionSource::M {
339                expression: expression.to_string(),
340            },
341        }
342    }
343
344    fn table(name: &str) -> Table {
345        Table {
346            name: name.to_string(),
347            ..Default::default()
348        }
349    }
350
351    fn table_id(name: &str) -> ObjectId {
352        ObjectId::Table {
353            table: NameKey::new(name),
354        }
355    }
356
357    fn column_id(table: &str, column: &str) -> ObjectId {
358        ObjectId::Column {
359            table: NameKey::new(table),
360            column: NameKey::new(column),
361        }
362    }
363
364    fn measure_id(table: &str, measure: &str) -> ObjectId {
365        ObjectId::Measure {
366            table: NameKey::new(table),
367            measure: NameKey::new(measure),
368        }
369    }
370
371    fn report_measure_id(name: &str) -> ObjectId {
372        ObjectId::ReportMeasure {
373            measure: NameKey::new(name),
374        }
375    }
376
377    /// A visual on page `page` projecting `targets` into its Values well.
378    fn visual_page(page: &str, visual: &str, targets: &[FieldTarget]) -> ReportModel {
379        ReportModel {
380            name: Some("Mini".to_string()),
381            pages: vec![Page {
382                name: NameKey::new(page),
383                display_name: None,
384                is_hidden: false,
385                filters: Vec::new(),
386                binding: None,
387                visuals: vec![Visual {
388                    name: NameKey::new(visual),
389                    visual_type: "card".to_string(),
390                    wells: vec![FieldWell {
391                        role: "Values".to_string(),
392                        projections: targets
393                            .iter()
394                            .map(|target| Projection {
395                                target: target.clone(),
396                                query_ref: None,
397                                active: true,
398                            })
399                            .collect(),
400                    }],
401                    filters: Vec::new(),
402                    sorts: Vec::new(),
403                    conditional_formatting: Vec::new(),
404                    alt_text: Vec::new(),
405                    tooltip_page: None,
406                }],
407            }],
408            ..Default::default()
409        }
410    }
411
412    fn measure_target(table: &str, name: &str) -> FieldTarget {
413        FieldTarget::Measure {
414            home_table: Some(NameKey::new(table)),
415            measure: NameKey::new(name),
416        }
417    }
418
419    fn column_target(table: &str, column: &str) -> FieldTarget {
420        FieldTarget::Column {
421            table: NameKey::new(table),
422            column: NameKey::new(column),
423        }
424    }
425
426    /// The finding for `id`, panicking with a readable message when absent.
427    fn find<'a>(unused: &'a [UnusedObject], id: &ObjectId) -> &'a UnusedObject {
428        unused
429            .iter()
430            .find(|finding| &finding.id == id)
431            .unwrap_or_else(|| panic!("{id} expected in the unused set"))
432    }
433
434    fn not_unused(unused: &[UnusedObject], id: &ObjectId) {
435        assert!(
436            !unused.iter().any(|finding| &finding.id == id),
437            "{id} must be live"
438        );
439    }
440
441    mod construction {
442        use super::*;
443
444        #[test]
445        fn every_model_object_gets_a_node_even_when_isolated() {
446            let db = TabularDatabase {
447                tables: vec![Table {
448                    name: "Sales".to_string(),
449                    columns: vec![column("Amount")],
450                    ..Default::default()
451                }],
452                functions: vec![Function {
453                    name: "MyFunc".to_string(),
454                    expression: "1".to_string(),
455                    is_hidden: false,
456                }],
457                ..Default::default()
458            };
459
460            let graph = DependencyGraph::build(&db, &[]);
461
462            let ids: Vec<_> = graph.object_ids().cloned().collect();
463            assert!(ids.contains(&table_id("Sales")));
464            assert!(ids.contains(&column_id("Sales", "Amount")));
465            assert!(ids.contains(&ObjectId::Function {
466                name: NameKey::new("MyFunc")
467            }));
468        }
469
470        #[test]
471        fn identical_edges_are_deduped_but_distinct_provenance_is_kept() {
472            let db = TabularDatabase {
473                tables: vec![Table {
474                    name: "Sales".to_string(),
475                    columns: vec![column("Amount")],
476                    measures: vec![measure(
477                        "Total",
478                        "SUM('Sales'[Amount]) + SUM('Sales'[Amount])",
479                    )],
480                    ..Default::default()
481                }],
482                ..Default::default()
483            };
484
485            let graph = DependencyGraph::build(&db, &[]);
486
487            // The measure's outgoing edges: containment in its table, plus
488            // exactly ONE DAX edge to the column even though the reference is
489            // written twice.
490            let producers = graph.producers_of(&measure_id("Sales", "Total"));
491            assert_eq!(producers.len(), 2);
492            assert_eq!(
493                producers
494                    .iter()
495                    .filter(|(id, _)| *id == column_id("Sales", "Amount"))
496                    .count(),
497                1,
498                "identical (from, to, provenance) triples dedupe"
499            );
500            // …while the column's only consumer is the measure's DAX edge; its
501            // containment edge points the other way, at the table.
502            let consumers = graph.consumers_of(&column_id("Sales", "Amount"));
503            assert_eq!(consumers.len(), 1);
504            assert!(matches!(
505                consumers[0].1,
506                Provenance::Dax {
507                    kind: DaxExpressionKind::Measure
508                }
509            ));
510            assert_eq!(consumers[0].0, measure_id("Sales", "Total"));
511            assert!(
512                graph
513                    .consumers_of(&table_id("Sales"))
514                    .iter()
515                    .any(|(id, p)| *id == column_id("Sales", "Amount")
516                        && matches!(
517                            p,
518                            Provenance::Structural {
519                                role: StructuralEdge::TableMember
520                            }
521                        ))
522            );
523        }
524
525        /// A shared expression whose M text names itself keeps nothing alive:
526        /// self-references are dropped rather than recorded.
527        #[test]
528        fn self_references_are_dropped() {
529            let db = TabularDatabase {
530                expressions: vec![SharedExpression {
531                    name: "Recursive".to_string(),
532                    expression: "Recursive + 1".to_string(),
533                }],
534                ..Default::default()
535            };
536
537            let graph = DependencyGraph::build(&db, &[]);
538            let id = ObjectId::Expression {
539                name: NameKey::new("Recursive"),
540            };
541
542            assert!(graph.producers_of(&id).is_empty());
543            assert!(graph.consumers_of(&id).is_empty());
544        }
545    }
546
547    mod liveness {
548        use super::*;
549
550        /// The far-table policy: a live table keeps its relationship and both
551        /// key columns alive, but the far table stays unused — its key column,
552        /// alive only as a relationship endpoint, cannot keep it.
553        #[test]
554        fn a_relationship_does_not_keep_its_far_table_alive() {
555            let db = TabularDatabase {
556                tables: vec![
557                    Table {
558                        name: "Sales".to_string(),
559                        columns: vec![column("Key")],
560                        partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
561                        ..Default::default()
562                    },
563                    Table {
564                        name: "DimOld".to_string(),
565                        columns: vec![column("Key"), column("Notes")],
566                        partitions: vec![m_partition("DimOld", "let Source = 2 in Source")],
567                        ..Default::default()
568                    },
569                ],
570                relationships: vec![Relationship {
571                    name: None,
572                    from_table: "Sales".to_string(),
573                    from_column: "Key".to_string(),
574                    to_table: "DimOld".to_string(),
575                    to_column: "Key".to_string(),
576                    is_active: true,
577                }],
578                ..Default::default()
579            };
580            let report = visual_page("P1", "V1", &[column_target("Sales", "Key")]);
581            let graph = DependencyGraph::build(&db, &[&report]);
582            let unused = graph.unused_objects();
583
584            // The used side is entirely live, weak parts included.
585            not_unused(&unused, &table_id("Sales"));
586            not_unused(&unused, &column_id("Sales", "Key"));
587            not_unused(
588                &unused,
589                &ObjectId::Relationship {
590                    from_table: NameKey::new("Sales"),
591                    from_column: NameKey::new("Key"),
592                    to_table: NameKey::new("DimOld"),
593                    to_column: NameKey::new("Key"),
594                },
595            );
596
597            // The far table is unused despite its live key column…
598            let dim_old = find(&unused, &table_id("DimOld"));
599            assert_eq!(dim_old.used_by.len(), 2, "its two columns contain it");
600            let by_key = dim_old
601                .used_by
602                .iter()
603                .find(|used| used.id == column_id("DimOld", "Key"))
604                .expect("the key column references its table");
605            assert!(
606                !by_key.also_unused,
607                "the key column is live, kept by the relationship endpoint"
608            );
609            assert!(matches!(
610                by_key.provenance,
611                Provenance::Structural {
612                    role: StructuralEdge::TableMember
613                }
614            ));
615
616            // …and so are its other column and its partition, annotated.
617            let notes = find(&unused, &column_id("DimOld", "Notes"));
618            assert!(notes.used_by.is_empty(), "an orphan has no consumers");
619            let partition = find(
620                &unused,
621                &ObjectId::Partition {
622                    table: NameKey::new("DimOld"),
623                    partition: NameKey::new("DimOld"),
624                },
625            );
626            assert_eq!(partition.used_by.len(), 1);
627            assert!(partition.used_by[0].also_unused);
628            assert_eq!(partition.used_by[0].id, table_id("DimOld"));
629        }
630
631        /// An RLS filter is rooted at its role: the filtered column stays alive
632        /// even though no report binding and no DAX references it.
633        #[test]
634        fn an_rls_filter_keeps_its_column_and_table_alive() {
635            let db = TabularDatabase {
636                tables: vec![Table {
637                    name: "Sales".to_string(),
638                    columns: vec![column("Region")],
639                    ..Default::default()
640                }],
641                roles: vec![Role {
642                    name: "Reader".to_string(),
643                    table_permissions: vec![TablePermission {
644                        table: "Sales".to_string(),
645                        filter_expression: Some("'Sales'[Region] = \"West\"".to_string()),
646                    }],
647                }],
648                ..Default::default()
649            };
650
651            let graph = DependencyGraph::build(&db, &[]);
652            let unused = graph.unused_objects();
653
654            assert!(
655                unused.is_empty(),
656                "the role seeds the filter, the filter keeps the column, the column keeps the table"
657            );
658            let consumers = graph.consumers_of(&column_id("Sales", "Region"));
659            assert_eq!(consumers.len(), 1);
660            assert_eq!(
661                consumers[0].0,
662                ObjectId::Role {
663                    role: NameKey::new("Reader")
664                }
665            );
666            assert!(matches!(
667                consumers[0].1,
668                Provenance::Dax {
669                    kind: DaxExpressionKind::RlsFilter
670                }
671            ));
672        }
673
674        /// A metadata-only role permission keeps the granted table alive.
675        #[test]
676        fn a_metadata_only_permission_keeps_its_table_alive() {
677            let db = TabularDatabase {
678                tables: vec![table("Sales")],
679                roles: vec![Role {
680                    name: "Reader".to_string(),
681                    table_permissions: vec![TablePermission {
682                        table: "Sales".to_string(),
683                        filter_expression: None,
684                    }],
685                }],
686                ..Default::default()
687            };
688
689            let graph = DependencyGraph::build(&db, &[]);
690
691            assert!(graph.unused_objects().is_empty());
692        }
693
694        /// With no reports and no roles, nothing is reachable: everything is
695        /// unused, which is the caller's signal that no roots were found.
696        #[test]
697        fn a_model_with_no_roots_reports_everything_unused() {
698            let db = TabularDatabase {
699                tables: vec![Table {
700                    name: "Sales".to_string(),
701                    columns: vec![column("Amount")],
702                    partitions: vec![m_partition("Sales", "let Source = 1 in Source")],
703                    ..Default::default()
704                }],
705                ..Default::default()
706            };
707
708            let graph = DependencyGraph::build(&db, &[]);
709
710            assert_eq!(graph.unused_objects().len(), 3);
711            assert!(graph.roots().is_empty());
712        }
713
714        /// An unused report measure is dead, and what only it references
715        /// carries the "also unused" annotation.
716        #[test]
717        fn an_unused_report_measure_is_dead_and_annotates_its_chain() {
718            let db = TabularDatabase {
719                tables: vec![Table {
720                    name: "Sales".to_string(),
721                    columns: vec![column("Amount"), column("Old")],
722                    measures: vec![measure("Total", "SUM('Sales'[Amount])")],
723                    ..Default::default()
724                }],
725                ..Default::default()
726            };
727            let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
728            report.measures.push(crate::report::ReportMeasure {
729                name: NameKey::new("Local"),
730                expression: "SUM('Sales'[Old])".to_string(),
731                format_string: None,
732            });
733
734            let graph = DependencyGraph::build(&db, &[&report]);
735            let unused = graph.unused_objects();
736
737            let local = find(&unused, &report_measure_id("Local"));
738            assert!(local.used_by.is_empty(), "no visual binds it");
739            let old = find(&unused, &column_id("Sales", "Old"));
740            assert_eq!(old.used_by.len(), 1);
741            assert_eq!(old.used_by[0].id, report_measure_id("Local"));
742            assert!(old.used_by[0].also_unused);
743            not_unused(&unused, &column_id("Sales", "Amount"));
744        }
745
746        /// A visual can bind a report measure directly; the report measure
747        /// shadows a model measure of the same name, which then reads as
748        /// unreferenced from this report.
749        #[test]
750        fn a_visual_binding_resolves_to_the_shadowing_report_measure() {
751            let db = TabularDatabase {
752                tables: vec![Table {
753                    name: "Sales".to_string(),
754                    measures: vec![measure("Total", "0")],
755                    ..Default::default()
756                }],
757                ..Default::default()
758            };
759            let mut report = visual_page("P1", "V1", &[measure_target("Sales", "Total")]);
760            report.measures.push(crate::report::ReportMeasure {
761                name: NameKey::new("Total"),
762                expression: "[Model Total]".to_string(),
763                format_string: None,
764            });
765
766            let graph = DependencyGraph::build(&db, &[&report]);
767
768            // The binding landed on the report measure, not the model measure.
769            assert_eq!(graph.roots_of(&report_measure_id("Total")).len(), 1);
770            assert!(graph.roots_of(&measure_id("Sales", "Total")).is_empty());
771            let unused = graph.unused_objects();
772            not_unused(&unused, &report_measure_id("Total"));
773            let shadowed = find(&unused, &measure_id("Sales", "Total"));
774            assert!(shadowed.used_by.is_empty());
775        }
776
777        /// Sort-by chains: an unused sorted column drags its unused sort
778        /// column along, with the annotation naming the chain.
779        #[test]
780        fn a_sort_by_chain_is_annotated() {
781            let db = TabularDatabase {
782                tables: vec![Table {
783                    name: "Date".to_string(),
784                    columns: vec![
785                        Column {
786                            name: "Month Name".to_string(),
787                            sort_by_column: Some("Month Num".to_string()),
788                            ..Default::default()
789                        },
790                        column("Month Num"),
791                    ],
792                    ..Default::default()
793                }],
794                ..Default::default()
795            };
796
797            let graph = DependencyGraph::build(&db, &[]);
798            let unused = graph.unused_objects();
799
800            let month_name = find(&unused, &column_id("Date", "Month Name"));
801            assert!(month_name.used_by.is_empty());
802            let month_num = find(&unused, &column_id("Date", "Month Num"));
803            assert_eq!(month_num.used_by.len(), 1);
804            assert_eq!(month_num.used_by[0].id, column_id("Date", "Month Name"));
805            assert!(month_num.used_by[0].also_unused);
806            assert!(matches!(
807                month_num.used_by[0].provenance,
808                Provenance::Structural {
809                    role: StructuralEdge::SortByColumn
810                }
811            ));
812        }
813
814        /// Group-by chains mirror sort-by: an unused grouping column drags
815        /// its unused group column along, with the annotation naming the chain.
816        #[test]
817        fn a_group_by_chain_is_annotated() {
818            let db = TabularDatabase {
819                tables: vec![Table {
820                    name: "Sales".to_string(),
821                    columns: vec![
822                        Column {
823                            name: "Amount".to_string(),
824                            group_by_columns: vec!["Bucket".to_string()],
825                            ..Default::default()
826                        },
827                        column("Bucket"),
828                    ],
829                    ..Default::default()
830                }],
831                ..Default::default()
832            };
833
834            let graph = DependencyGraph::build(&db, &[]);
835            let unused = graph.unused_objects();
836
837            let amount = find(&unused, &column_id("Sales", "Amount"));
838            assert!(amount.used_by.is_empty());
839            let bucket = find(&unused, &column_id("Sales", "Bucket"));
840            assert_eq!(bucket.used_by.len(), 1);
841            assert_eq!(bucket.used_by[0].id, column_id("Sales", "Amount"));
842            assert!(bucket.used_by[0].also_unused);
843            assert!(matches!(
844                bucket.used_by[0].provenance,
845                Provenance::Structural {
846                    role: StructuralEdge::GroupByColumn
847                }
848            ));
849        }
850
851        /// A used column keeps its group-by column alive: grouping is part of
852        /// how the engine aggregates the column, so a column referenced only
853        /// through a group-by is not dead.
854        #[test]
855        fn a_used_column_keeps_its_group_by_column_alive() {
856            let db = TabularDatabase {
857                tables: vec![Table {
858                    name: "Sales".to_string(),
859                    columns: vec![
860                        Column {
861                            name: "Amount".to_string(),
862                            group_by_columns: vec!["Bucket".to_string()],
863                            ..Default::default()
864                        },
865                        column("Bucket"),
866                    ],
867                    ..Default::default()
868                }],
869                ..Default::default()
870            };
871            let report = visual_page("P1", "V1", &[column_target("Sales", "Amount")]);
872
873            let graph = DependencyGraph::build(&db, &[&report]);
874
875            assert!(graph.unused_objects().is_empty());
876        }
877
878        /// A dead hierarchy keeps its level columns from being orphans: they
879        /// are referenced only by the hierarchy, which is itself unused.
880        #[test]
881        fn a_dead_hierarchy_annotates_its_level_columns() {
882            let db = TabularDatabase {
883                tables: vec![Table {
884                    name: "Date".to_string(),
885                    columns: vec![column("Year")],
886                    hierarchies: vec![crate::model::Hierarchy {
887                        name: "Calendar".to_string(),
888                        levels: vec![crate::model::HierarchyLevel {
889                            name: "Year".to_string(),
890                            column: "Year".to_string(),
891                        }],
892                        is_hidden: false,
893                    }],
894                    ..Default::default()
895                }],
896                ..Default::default()
897            };
898
899            let graph = DependencyGraph::build(&db, &[]);
900            let unused = graph.unused_objects();
901
902            let hierarchy = find(
903                &unused,
904                &ObjectId::Hierarchy {
905                    table: NameKey::new("Date"),
906                    hierarchy: NameKey::new("Calendar"),
907                },
908            );
909            assert!(hierarchy.used_by.is_empty());
910            let year = find(&unused, &column_id("Date", "Year"));
911            assert_eq!(year.used_by.len(), 1);
912            assert!(matches!(
913                year.used_by[0].provenance,
914                Provenance::Structural {
915                    role: StructuralEdge::HierarchyLevel
916                }
917            ));
918            assert!(year.used_by[0].also_unused);
919        }
920
921        /// A hierarchy referenced from DAX (`ISINSCOPE('Date'[Calendar])`) is
922        /// an extended-resolution candidate the plain binder does not know.
923        #[test]
924        fn dax_keeps_a_referenced_hierarchy_alive() {
925            let db = TabularDatabase {
926                tables: vec![Table {
927                    name: "Date".to_string(),
928                    columns: vec![column("Year")],
929                    hierarchies: vec![crate::model::Hierarchy {
930                        name: "Calendar".to_string(),
931                        levels: vec![crate::model::HierarchyLevel {
932                            name: "Year".to_string(),
933                            column: "Year".to_string(),
934                        }],
935                        is_hidden: false,
936                    }],
937                    measures: vec![measure("In Scope", "ISINSCOPE('Date'[Calendar])")],
938                    ..Default::default()
939                }],
940                ..Default::default()
941            };
942            let report = visual_page("P1", "V1", &[measure_target("Date", "In Scope")]);
943
944            let graph = DependencyGraph::build(&db, &[&report]);
945
946            assert!(graph.unused_objects().is_empty());
947        }
948
949        /// A report binding on a calculation-group column keeps every item of
950        /// its group alive: a slicer or filter over the column can select any
951        /// item by name at query time. Structural liveness of the group alone
952        /// does not: the dead-chain fixture pins an unselected item staying
953        /// dead when only another item's explicit DAX use keeps the table up.
954        #[test]
955        fn a_binding_on_a_calculation_group_column_keeps_its_items_alive() {
956            let db = TabularDatabase {
957                tables: vec![
958                    Table {
959                        name: "Sales".to_string(),
960                        columns: vec![column("Amount")],
961                        measures: vec![measure("Total", "SUM('Sales'[Amount])")],
962                        ..Default::default()
963                    },
964                    Table {
965                        name: "Date Role".to_string(),
966                        columns: vec![column("Date Role")],
967                        calculation_group: Some(crate::model::CalculationGroup {
968                            items: vec![
969                                crate::model::CalculationItem {
970                                    name: "By Ship Date".to_string(),
971                                    expression: "SELECTEDMEASURE()".to_string(),
972                                    format_string_expression: None,
973                                },
974                                crate::model::CalculationItem {
975                                    name: "By Due Date".to_string(),
976                                    expression: "SELECTEDMEASURE()".to_string(),
977                                    format_string_expression: None,
978                                },
979                            ],
980                            ..Default::default()
981                        }),
982                        ..Default::default()
983                    },
984                ],
985                ..Default::default()
986            };
987            let report = visual_page(
988                "P1",
989                "Slicer",
990                &[
991                    measure_target("Sales", "Total"),
992                    column_target("Date Role", "Date Role"),
993                ],
994            );
995
996            let graph = DependencyGraph::build(&db, &[&report]);
997
998            assert!(
999                graph.unused_objects().is_empty(),
1000                "the bound column keeps the group, the group's items, and the model alive"
1001            );
1002            let consumers = graph.consumers_of(&ObjectId::CalculationItem {
1003                table: NameKey::new("Date Role"),
1004                item: NameKey::new("By Ship Date"),
1005            });
1006            assert!(
1007                consumers.iter().any(|(id, provenance)| {
1008                    *id == column_id("Date Role", "Date Role")
1009                        && matches!(provenance, Provenance::Binding(_))
1010                }),
1011                "the column's binding edge names the item, with the binding site as provenance"
1012            );
1013        }
1014
1015        /// A qualified reference into a calculation group keeps the named
1016        /// calculation item alive.
1017        #[test]
1018        fn dax_keeps_a_referenced_calculation_item_alive() {
1019            let db = TabularDatabase {
1020                tables: vec![
1021                    Table {
1022                        name: "Sales".to_string(),
1023                        measures: vec![measure(
1024                            "YTD Sales",
1025                            "CALCULATE(SUM('Sales'[Amount]), 'Time Intelligence'[YTD])",
1026                        )],
1027                        ..Default::default()
1028                    },
1029                    Table {
1030                        name: "Time Intelligence".to_string(),
1031                        calculation_group: Some(crate::model::CalculationGroup {
1032                            items: vec![
1033                                crate::model::CalculationItem {
1034                                    name: "YTD".to_string(),
1035                                    expression: "SELECTEDMEASURE()".to_string(),
1036                                    format_string_expression: None,
1037                                },
1038                                crate::model::CalculationItem {
1039                                    name: "MTD".to_string(),
1040                                    expression: "SELECTEDMEASURE()".to_string(),
1041                                    format_string_expression: None,
1042                                },
1043                            ],
1044                            ..Default::default()
1045                        }),
1046                        ..Default::default()
1047                    },
1048                ],
1049                ..Default::default()
1050            };
1051            let report = visual_page("P1", "V1", &[measure_target("Sales", "YTD Sales")]);
1052
1053            let graph = DependencyGraph::build(&db, &[&report]);
1054            let unused = graph.unused_objects();
1055            let unused_ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1056
1057            assert_eq!(
1058                unused_ids,
1059                [&ObjectId::CalculationItem {
1060                    table: NameKey::new("Time Intelligence"),
1061                    item: NameKey::new("MTD"),
1062                }],
1063                "only the unselected calculation item is unused"
1064            );
1065        }
1066
1067        /// A qualified reference matching nothing keeps its qualifying table
1068        /// alive — the nearest resolvable candidate.
1069        #[test]
1070        fn an_unresolved_qualified_reference_keeps_its_table_alive() {
1071            let db = TabularDatabase {
1072                tables: vec![
1073                    Table {
1074                        name: "Sales".to_string(),
1075                        measures: vec![measure("M", "'Ghost'[Nope]")],
1076                        ..Default::default()
1077                    },
1078                    table("Ghost"),
1079                ],
1080                ..Default::default()
1081            };
1082            let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1083
1084            let graph = DependencyGraph::build(&db, &[&report]);
1085
1086            assert!(graph.unused_objects().is_empty(), "Ghost stays alive");
1087        }
1088
1089        /// A reference whose table does not exist either keeps nothing alive.
1090        #[test]
1091        fn an_unresolved_reference_without_a_resolvable_part_keeps_nothing_alive() {
1092            let db = TabularDatabase {
1093                tables: vec![Table {
1094                    name: "Sales".to_string(),
1095                    measures: vec![measure("M", "'Ghost'[Nope] + [Also Nope]")],
1096                    ..Default::default()
1097                }],
1098                ..Default::default()
1099            };
1100            let report = visual_page("P1", "V1", &[measure_target("Sales", "M")]);
1101
1102            let graph = DependencyGraph::build(&db, &[&report]);
1103
1104            assert_eq!(graph.unused_objects().len(), 0, "only Sales and M exist");
1105        }
1106
1107        /// A shared expression named in an M partition is referenced by it —
1108        /// and if the partition's table is dead, the annotation says so.
1109        #[test]
1110        fn m_references_keep_shared_expressions_alive() {
1111            let db = TabularDatabase {
1112                tables: vec![
1113                    Table {
1114                        name: "Sales".to_string(),
1115                        partitions: vec![m_partition(
1116                            "Sales",
1117                            "let Source = Sql.Database(ServerName) in Source",
1118                        )],
1119                        ..Default::default()
1120                    },
1121                    Table {
1122                        name: "DimOld".to_string(),
1123                        partitions: vec![m_partition(
1124                            "DimOld",
1125                            "let Source = LegacyParam in Source",
1126                        )],
1127                        ..Default::default()
1128                    },
1129                ],
1130                expressions: vec![
1131                    SharedExpression {
1132                        name: "ServerName".to_string(),
1133                        expression: "\"localhost\"".to_string(),
1134                    },
1135                    SharedExpression {
1136                        name: "LegacyParam".to_string(),
1137                        expression: "5".to_string(),
1138                    },
1139                ],
1140                ..Default::default()
1141            };
1142            // The visual binds a column that does not exist; the written form
1143            // still keeps its qualifying table alive.
1144            let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1145
1146            let graph = DependencyGraph::build(&db, &[&report]);
1147            let unused = graph.unused_objects();
1148
1149            not_unused(
1150                &unused,
1151                &ObjectId::Expression {
1152                    name: NameKey::new("ServerName"),
1153                },
1154            );
1155            let legacy = find(
1156                &unused,
1157                &ObjectId::Expression {
1158                    name: NameKey::new("LegacyParam"),
1159                },
1160            );
1161            assert_eq!(legacy.used_by.len(), 1);
1162            assert_eq!(
1163                legacy.used_by[0].id,
1164                ObjectId::Partition {
1165                    table: NameKey::new("DimOld"),
1166                    partition: NameKey::new("DimOld"),
1167                }
1168            );
1169            assert!(legacy.used_by[0].also_unused);
1170            assert!(matches!(legacy.used_by[0].provenance, Provenance::M));
1171        }
1172
1173        /// Shared expressions reference each other: a partition keeps its
1174        /// staging query alive, and the staging query keeps the parameter it
1175        /// names alive — one M edge per hop.
1176        #[test]
1177        fn an_m_chain_keeps_shared_expressions_alive() {
1178            let db = TabularDatabase {
1179                tables: vec![Table {
1180                    name: "Sales".to_string(),
1181                    partitions: vec![m_partition(
1182                        "Sales",
1183                        "let Source = Sql.Database(#\"Staging Query\") in Source",
1184                    )],
1185                    ..Default::default()
1186                }],
1187                expressions: vec![
1188                    SharedExpression {
1189                        name: "Staging Query".to_string(),
1190                        expression: "ServerName".to_string(),
1191                    },
1192                    SharedExpression {
1193                        name: "ServerName".to_string(),
1194                        expression: "\"localhost\"".to_string(),
1195                    },
1196                ],
1197                ..Default::default()
1198            };
1199            let report = visual_page("P1", "V1", &[column_target("Sales", "Anything")]);
1200
1201            let graph = DependencyGraph::build(&db, &[&report]);
1202            let unused = graph.unused_objects();
1203
1204            not_unused(
1205                &unused,
1206                &ObjectId::Expression {
1207                    name: NameKey::new("Staging Query"),
1208                },
1209            );
1210            not_unused(
1211                &unused,
1212                &ObjectId::Expression {
1213                    name: NameKey::new("ServerName"),
1214                },
1215            );
1216
1217            // The second hop is the M-to-M edge: the staging query, not the
1218            // partition, is what names ServerName.
1219            assert_eq!(
1220                graph.consumers_of(&ObjectId::Expression {
1221                    name: NameKey::new("ServerName"),
1222                }),
1223                [(
1224                    ObjectId::Expression {
1225                        name: NameKey::new("Staging Query"),
1226                    },
1227                    Provenance::M
1228                )]
1229            );
1230        }
1231
1232        /// A bookmark's saved filter is a root like a live one.
1233        #[test]
1234        fn a_bookmark_saved_filter_is_a_root() {
1235            let db = TabularDatabase {
1236                tables: vec![Table {
1237                    name: "Sales".to_string(),
1238                    columns: vec![column("Region")],
1239                    ..Default::default()
1240                }],
1241                ..Default::default()
1242            };
1243            let report = ReportModel {
1244                bookmarks: vec![Bookmark {
1245                    name: NameKey::new("B1"),
1246                    display_name: None,
1247                    filters: Vec::new(),
1248                    sections: vec![BookmarkSection {
1249                        page: NameKey::new("P1"),
1250                        filters: Vec::new(),
1251                        visuals: vec![BookmarkVisual {
1252                            visual: NameKey::new("V1"),
1253                            wells: Vec::new(),
1254                            filters: vec![Filter {
1255                                target: Some(column_target("Sales", "Region")),
1256                                ..Default::default()
1257                            }],
1258                        }],
1259                    }],
1260                }],
1261                ..Default::default()
1262            };
1263
1264            let graph = DependencyGraph::build(&db, &[&report]);
1265
1266            assert!(graph.unused_objects().is_empty());
1267            let roots = graph.roots();
1268            assert_eq!(roots.len(), 1);
1269            assert!(matches!(
1270                &roots[0].1,
1271                Provenance::Binding(edge) if edge.bookmark.is_some()
1272            ));
1273        }
1274
1275        /// Engine-managed columns ride along with their table: calculated-table
1276        /// columns cannot be dropped independently.
1277        #[test]
1278        fn calculated_table_columns_stay_with_their_table() {
1279            let db = TabularDatabase {
1280                tables: vec![Table {
1281                    name: "Top Products".to_string(),
1282                    columns: vec![Column {
1283                        name: "Product".to_string(),
1284                        kind: ColumnKind::CalculatedTableColumn,
1285                        ..Default::default()
1286                    }],
1287                    partitions: vec![Partition {
1288                        name: "Top Products".to_string(),
1289                        source: PartitionSource::Calculated {
1290                            expression: "TOPN(10, 'Product')".to_string(),
1291                        },
1292                    }],
1293                    ..Default::default()
1294                }],
1295                ..Default::default()
1296            };
1297            let report = visual_page("P1", "V1", &[column_target("Top Products", "Product")]);
1298
1299            let graph = DependencyGraph::build(&db, &[&report]);
1300
1301            assert!(graph.unused_objects().is_empty());
1302        }
1303
1304        /// Calendar-bound columns ride along with their table: the engine
1305        /// materializes them through the calendar, so a column referenced
1306        /// only through a calendar is not dead.
1307        #[test]
1308        fn calendar_columns_stay_with_their_table() {
1309            let db = TabularDatabase {
1310                tables: vec![Table {
1311                    name: "Date".to_string(),
1312                    columns: vec![column("Day")],
1313                    calendars: vec![crate::model::Calendar {
1314                        name: "Fiscal Calendar".to_string(),
1315                        columns: vec!["Day".to_string()],
1316                    }],
1317                    measures: vec![measure("Rows", "COUNTROWS('Date')")],
1318                    ..Default::default()
1319                }],
1320                ..Default::default()
1321            };
1322            let report = visual_page("P1", "V1", &[measure_target("Date", "Rows")]);
1323
1324            let graph = DependencyGraph::build(&db, &[&report]);
1325
1326            assert!(graph.unused_objects().is_empty());
1327        }
1328
1329        /// A dead table drags its calendar-bound columns along, annotated:
1330        /// the calendar is the only thing that ever referenced them.
1331        #[test]
1332        fn a_dead_table_annotates_its_calendar_columns() {
1333            let db = TabularDatabase {
1334                tables: vec![Table {
1335                    name: "Date".to_string(),
1336                    columns: vec![column("Day")],
1337                    calendars: vec![crate::model::Calendar {
1338                        name: "Fiscal Calendar".to_string(),
1339                        columns: vec!["Day".to_string()],
1340                    }],
1341                    ..Default::default()
1342                }],
1343                ..Default::default()
1344            };
1345
1346            let graph = DependencyGraph::build(&db, &[]);
1347            let unused = graph.unused_objects();
1348
1349            let day = find(&unused, &column_id("Date", "Day"));
1350            assert_eq!(day.used_by.len(), 1);
1351            assert_eq!(day.used_by[0].id, table_id("Date"));
1352            assert!(day.used_by[0].also_unused);
1353            assert!(matches!(
1354                day.used_by[0].provenance,
1355                Provenance::Structural {
1356                    role: StructuralEdge::EngineManaged
1357                }
1358            ));
1359        }
1360    }
1361
1362    mod queries {
1363        use super::*;
1364
1365        #[test]
1366        fn queries_on_an_unknown_object_are_empty() {
1367            let graph = DependencyGraph::build(&TabularDatabase::default(), &[]);
1368
1369            assert!(graph.consumers_of(&table_id("Nope")).is_empty());
1370            assert!(graph.producers_of(&table_id("Nope")).is_empty());
1371            assert!(graph.roots_of(&table_id("Nope")).is_empty());
1372        }
1373
1374        #[test]
1375        fn unused_objects_are_sorted_by_identity() {
1376            let db = TabularDatabase {
1377                tables: vec![Table {
1378                    name: "Sales".to_string(),
1379                    columns: vec![column("B"), column("A")],
1380                    ..Default::default()
1381                }],
1382                ..Default::default()
1383            };
1384
1385            let graph = DependencyGraph::build(&db, &[]);
1386            let unused = graph.unused_objects();
1387            let ids: Vec<&ObjectId> = unused.iter().map(|finding| &finding.id).collect();
1388            let mut sorted = ids.clone();
1389            sorted.sort();
1390
1391            assert_eq!(ids, sorted);
1392        }
1393
1394        #[test]
1395        fn the_root_carries_the_full_binding_provenance() {
1396            let db = TabularDatabase {
1397                tables: vec![Table {
1398                    name: "Sales".to_string(),
1399                    measures: vec![measure("Total", "0")],
1400                    ..Default::default()
1401                }],
1402                ..Default::default()
1403            };
1404            let report = visual_page("P2", "Card", &[measure_target("Sales", "Total")]);
1405
1406            let graph = DependencyGraph::build(&db, &[&report]);
1407            let roots = graph.roots();
1408
1409            assert_eq!(roots.len(), 1);
1410            assert_eq!(roots[0].0, measure_id("Sales", "Total"));
1411            let Provenance::Binding(edge) = &roots[0].1 else {
1412                panic!("a root carries binding provenance");
1413            };
1414            let BindingEdge {
1415                kind,
1416                report: report_name,
1417                page,
1418                visual,
1419                bookmark,
1420            } = edge.as_ref();
1421            assert!(matches!(kind, BindingSite::FieldWell { role } if role == "Values"));
1422            assert_eq!(report_name.as_ref().map(NameKey::as_str), Some("Mini"));
1423            assert_eq!(page.as_ref().map(NameKey::as_str), Some("P2"));
1424            assert_eq!(visual.as_ref().map(NameKey::as_str), Some("Card"));
1425            assert!(bookmark.is_none());
1426        }
1427    }
1428}