Skip to main content

ripbi_core/graph/
provenance.rs

1//! Per-edge provenance: what kind of use every dependency edge records.
2//!
3//! Provenance is first-class graph data, stored as the petgraph edge weight at
4//! build time — never derived at render time. The planned `ripbi deps` view
5//! consumes it straight off [`consumers_of`](super::DependencyGraph::consumers_of)
6//! to annotate edges (`visual 'Card' on page 'P2'`, `RLS role 'Reader' filter`),
7//! and [`scan`](super) uses it to explain why an unused object is referenced only
8//! by other unused objects.
9
10use std::fmt;
11
12use crate::identity::{NameKey, Quoted};
13use crate::model::DaxExpressionKind;
14
15/// Why one object depends on another — the edge weight of the dependency graph.
16///
17/// The report-binding payload is boxed so the enum stays small: it is cloned
18/// once per deduped edge, and the large variant would otherwise dominate every
19/// edge weight's size.
20#[derive(Debug, Clone, PartialEq, Eq, Hash)]
21pub enum Provenance {
22    /// The target is referenced inside a DAX expression. The edge's source node
23    /// plus `kind` identify the expression site exactly: every
24    /// `(owner, kind)` pair has exactly one production site in the expression
25    /// enumerations.
26    Dax {
27        /// Which property of the source object the expression came from.
28        kind: DaxExpressionKind,
29    },
30    /// The target is bound by a report: a field well, filter, sort, drillthrough
31    /// parameter, or conditional-formatting rule.
32    Binding(
33        /// Which binding, and where it lives.
34        Box<BindingEdge>,
35    ),
36    /// The target is referenced from an M expression by name.
37    M,
38    /// Liveness flows through model structure, with no written reference anywhere.
39    Structural {
40        /// Which structural rule produces the edge.
41        role: StructuralEdge,
42    },
43}
44
45/// One report binding and the site it lives in — the payload of
46/// [`Provenance::Binding`].
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub struct BindingEdge {
49    /// What the binding does.
50    pub kind: BindingSite,
51    /// The report carrying the binding, when the source recorded a name.
52    pub report: Option<NameKey>,
53    /// The page the binding lives on; `None` for report-level bindings.
54    pub page: Option<NameKey>,
55    /// The visual the binding lives in; `None` outside visuals.
56    pub visual: Option<NameKey>,
57    /// The bookmark whose saved state carries the binding; `None` for live
58    /// bindings.
59    pub bookmark: Option<NameKey>,
60    /// Whether the binding lives in the phone layout (`definition.mobile/`)
61    /// rather than the desktop tree. Provenance only — both layouts bind
62    /// identically — but it tells a user auditing a survivor which surface to
63    /// look at (issue #49).
64    pub mobile: bool,
65}
66
67impl fmt::Display for BindingEdge {
68    /// Human-readable site description, e.g.
69    /// `field well 'Y' — visual 'V1' on page 'P1' in report 'Mini'`. The same
70    /// phrase a [`Provenance::Binding`] renders, published on the edge itself
71    /// so findings that carry a binding without being a graph edge (the
72    /// broken-visual records) render identically.
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        let BindingEdge {
75            kind,
76            report,
77            page,
78            visual,
79            bookmark,
80            mobile,
81        } = self;
82        if *mobile {
83            f.write_str("mobile layout ")?;
84        }
85        write_site(f, kind)?;
86        if let Some(visual) = visual {
87            write!(f, " — visual {}", Quoted(visual.as_str()))?;
88        }
89        if let Some(page) = page {
90            write!(f, " on page {}", Quoted(page.as_str()))?;
91        }
92        if let Some(bookmark) = bookmark {
93            write!(f, " in bookmark {}", Quoted(bookmark.as_str()))?;
94        }
95        if let Some(report) = report {
96            write!(f, " in report {}", Quoted(report.as_str()))?;
97        }
98        Ok(())
99    }
100}
101
102impl Provenance {
103    /// True when the strong reachability pass may traverse this edge — every
104    /// edge except relationship endpoints (which keep a key column alive
105    /// without keeping its table alive) and inactive-relationship references
106    /// (which never confer liveness — see the module docs of [`super`]).
107    pub(super) fn is_strong_pass_edge(&self) -> bool {
108        !matches!(
109            self,
110            Provenance::Structural {
111                role: StructuralEdge::RelationshipEndpoint
112            } | Provenance::Structural {
113                role: StructuralEdge::InactiveRelationship
114            } | Provenance::Structural {
115                role: StructuralEdge::InactiveRelationshipEndpoint
116            }
117        )
118    }
119
120    /// True when the weak reachability pass may traverse this edge — every
121    /// edge except containment from a table member to its table, so liveness
122    /// gained weakly can never propagate into a table, and except the
123    /// inactive-relationship edges, which never confer liveness in any pass:
124    /// only a live `USERELATIONSHIP` reference can activate the relationship.
125    pub(super) fn is_weak_pass_edge(&self) -> bool {
126        !matches!(
127            self,
128            Provenance::Structural {
129                role: StructuralEdge::TableMember
130            } | Provenance::Structural {
131                role: StructuralEdge::InactiveRelationship
132            } | Provenance::Structural {
133                role: StructuralEdge::InactiveRelationshipEndpoint
134            }
135        )
136    }
137}
138
139impl fmt::Display for Provenance {
140    /// Human-readable site description for "used by" lines, e.g.
141    /// `field well 'Y' — visual 'V1', page 'P1', report 'Mini'` or `RLS filter`.
142    /// The edge's *source* object is rendered by the [`ObjectId`](crate::ObjectId)
143    /// at the other half of the pair, so a provenance never repeats it.
144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145        match self {
146            Provenance::Dax { kind } => f.write_str(dax_site(*kind)),
147            Provenance::Binding(edge) => edge.fmt(f),
148            Provenance::M => f.write_str("Power Query expression"),
149            Provenance::Structural { role } => write!(f, "{role}"),
150        }
151    }
152}
153
154/// What kind of report-side usage a binding represents — the owned form of
155/// [`BindingKind`](crate::BindingKind), whose field-well role is borrowed from
156/// the report AST.
157#[derive(Debug, Clone, PartialEq, Eq, Hash)]
158pub enum BindingSite {
159    /// A field projected into a visual's field well.
160    FieldWell {
161        /// Role name as written, e.g. `"Category"`, `"Y"`.
162        role: String,
163    },
164    /// A filter at report, page, visual, or bookmark level.
165    Filter,
166    /// A visual's sort-by field.
167    Sort,
168    /// A drillthrough parameter's bound field.
169    Drillthrough,
170    /// A field driving a conditional-formatting rule.
171    ConditionalFormatting,
172    /// A visual's accessibility alt text.
173    AltText,
174}
175
176fn write_site(f: &mut fmt::Formatter<'_>, site: &BindingSite) -> fmt::Result {
177    match site {
178        BindingSite::FieldWell { role } => {
179            write!(f, "field well {}", Quoted(role.as_str()))
180        }
181        BindingSite::Filter => f.write_str("filter"),
182        BindingSite::Sort => f.write_str("sort definition"),
183        BindingSite::Drillthrough => f.write_str("drillthrough parameter"),
184        BindingSite::ConditionalFormatting => f.write_str("conditional formatting"),
185        BindingSite::AltText => f.write_str("alt text"),
186    }
187}
188
189/// The structural rule an edge came from.
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
191pub enum StructuralEdge {
192    /// The source is defined on the target table; a used member keeps its table
193    /// alive.
194    TableMember,
195    /// The table loads its rows through this partition.
196    TablePartition,
197    /// The relationship hangs off this table; either endpoint keeps it alive.
198    Relationship,
199    /// An inactive relationship hangs off this table, but the reference is
200    /// recorded without liveness: switching an inactive relationship on at
201    /// query time is DAX's job (`USERELATIONSHIP`), so an unactivated one is
202    /// itself a finding.
203    InactiveRelationship,
204    /// The relationship needs this key column.
205    RelationshipEndpoint,
206    /// An inactive relationship names this key column, but activating it at
207    /// query time is DAX's job (`USERELATIONSHIP`): the edge is recorded so
208    /// findings can point at the relationship, yet it confers no liveness —
209    /// until a live DAX reference switches the relationship on, the key is
210    /// unloadable bloat.
211    InactiveRelationshipEndpoint,
212    /// The source column is sorted by the target column.
213    SortByColumn,
214    /// The source column is grouped by the target column.
215    GroupByColumn,
216    /// The hierarchy drills down through this column.
217    HierarchyLevel,
218    /// The column is materialized by the engine together with its table
219    /// (calculated-table columns, calculation-group columns, calendar columns)
220    /// and cannot be dropped independently.
221    EngineManaged,
222    /// A dynamic M query parameter is bound to this column
223    /// (`parameterValuesColumn`): at view time the report feeds the column's
224    /// values into the parameter, so a consumed parameter keeps its bound
225    /// column alive (issue #50).
226    MParameterBinding,
227    /// The role grants access to this table.
228    RolePermission,
229}
230
231impl fmt::Display for StructuralEdge {
232    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
233        f.write_str(match self {
234            StructuralEdge::TableMember => "table member",
235            StructuralEdge::TablePartition => "table partition",
236            StructuralEdge::Relationship => "relationship",
237            StructuralEdge::InactiveRelationship => "inactive relationship",
238            StructuralEdge::RelationshipEndpoint => "relationship endpoint",
239            StructuralEdge::InactiveRelationshipEndpoint => "inactive relationship endpoint",
240            StructuralEdge::SortByColumn => "sort-by column",
241            StructuralEdge::GroupByColumn => "group-by column",
242            StructuralEdge::HierarchyLevel => "hierarchy level",
243            StructuralEdge::EngineManaged => "engine-managed column",
244            StructuralEdge::MParameterBinding => "dynamic M parameter binding",
245            StructuralEdge::RolePermission => "role permission",
246        })
247    }
248}
249
250/// The site phrase for a DAX expression kind. The edge's source object names the
251/// owner; this only says which property of it made the reference.
252fn dax_site(kind: DaxExpressionKind) -> &'static str {
253    match kind {
254        DaxExpressionKind::Measure => "measure expression",
255        DaxExpressionKind::MeasureFormatString => "measure format string",
256        DaxExpressionKind::MeasureDetailRows => "measure detail rows",
257        DaxExpressionKind::KpiTarget => "KPI target",
258        DaxExpressionKind::KpiStatus => "KPI status",
259        DaxExpressionKind::KpiTrend => "KPI trend",
260        DaxExpressionKind::CalculatedColumn => "calculated column expression",
261        DaxExpressionKind::CalculatedTable => "calculated table expression",
262        DaxExpressionKind::ChangeDetection => "change detection expression",
263        DaxExpressionKind::TableDetailRows => "table detail rows",
264        DaxExpressionKind::RlsFilter => "RLS filter",
265        DaxExpressionKind::CalculationItem => "calculation item expression",
266        DaxExpressionKind::CalculationItemFormatString => "calculation item format string",
267        DaxExpressionKind::CalculationGroupNoSelection => "no-selection expression",
268        DaxExpressionKind::CalculationGroupNoSelectionFormatString => "no-selection format string",
269        DaxExpressionKind::CalculationGroupMultipleOrEmptySelection => {
270            "multiple-or-empty-selection expression"
271        }
272        DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString => {
273            "multiple-or-empty-selection format string"
274        }
275        DaxExpressionKind::Function => "function body",
276        DaxExpressionKind::ReportMeasure => "report measure expression",
277        DaxExpressionKind::ReportMeasureFormatString => "report measure format string",
278    }
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    fn binding(kind: BindingSite) -> Provenance {
286        Provenance::Binding(Box::new(BindingEdge {
287            kind,
288            report: Some(NameKey::new("Mini")),
289            page: Some(NameKey::new("P1")),
290            visual: Some(NameKey::new("V1")),
291            bookmark: None,
292            mobile: false,
293        }))
294    }
295
296    mod display {
297        use super::*;
298
299        #[test]
300        fn a_field_well_renders_its_site_chain() {
301            assert_eq!(
302                binding(BindingSite::FieldWell {
303                    role: "Y".to_string(),
304                })
305                .to_string(),
306                "field well 'Y' — visual 'V1' on page 'P1' in report 'Mini'"
307            );
308        }
309
310        #[test]
311        fn a_bookmark_follows_the_visual_it_saved() {
312            let provenance = Provenance::Binding(Box::new(BindingEdge {
313                kind: BindingSite::Filter,
314                report: None,
315                page: Some(NameKey::new("P1")),
316                visual: Some(NameKey::new("V1")),
317                bookmark: Some(NameKey::new("B1")),
318                mobile: false,
319            }));
320
321            assert_eq!(
322                provenance.to_string(),
323                "filter — visual 'V1' on page 'P1' in bookmark 'B1'"
324            );
325        }
326
327        /// A phone-layout binding says so up front: a user auditing why a field
328        /// survived needs to know which surface to look at (issue #49).
329        #[test]
330        fn a_mobile_layout_binding_says_so() {
331            let provenance = Provenance::Binding(Box::new(BindingEdge {
332                kind: BindingSite::FieldWell {
333                    role: "Values".to_string(),
334                },
335                report: Some(NameKey::new("Mini")),
336                page: Some(NameKey::new("P1")),
337                visual: Some(NameKey::new("V1")),
338                bookmark: None,
339                mobile: true,
340            }));
341
342            assert_eq!(
343                provenance.to_string(),
344                "mobile layout field well 'Values' — visual 'V1' on page 'P1' in report 'Mini'"
345            );
346        }
347
348        #[test]
349        fn a_report_level_filter_names_no_site() {
350            let provenance = Provenance::Binding(Box::new(BindingEdge {
351                kind: BindingSite::Filter,
352                report: None,
353                page: None,
354                visual: None,
355                bookmark: None,
356                mobile: false,
357            }));
358
359            assert_eq!(provenance.to_string(), "filter");
360        }
361
362        #[test]
363        fn structural_and_m_sites_render_as_phrases() {
364            assert_eq!(
365                Provenance::Structural {
366                    role: StructuralEdge::RelationshipEndpoint
367                }
368                .to_string(),
369                "relationship endpoint"
370            );
371            assert_eq!(
372                Provenance::Structural {
373                    role: StructuralEdge::InactiveRelationship
374                }
375                .to_string(),
376                "inactive relationship"
377            );
378            assert_eq!(Provenance::M.to_string(), "Power Query expression");
379        }
380
381        #[test]
382        fn dax_sites_render_as_phrases() {
383            assert_eq!(
384                Provenance::Dax {
385                    kind: DaxExpressionKind::RlsFilter
386                }
387                .to_string(),
388                "RLS filter"
389            );
390            assert_eq!(
391                Provenance::Dax {
392                    kind: DaxExpressionKind::Measure
393                }
394                .to_string(),
395                "measure expression"
396            );
397        }
398    }
399
400    mod classification {
401        use super::*;
402
403        #[test]
404        fn the_strong_pass_excludes_relationship_endpoints_and_inactive_relationships() {
405            let endpoint = Provenance::Structural {
406                role: StructuralEdge::RelationshipEndpoint,
407            };
408            let inactive = Provenance::Structural {
409                role: StructuralEdge::InactiveRelationship,
410            };
411            let inactive_key = Provenance::Structural {
412                role: StructuralEdge::InactiveRelationshipEndpoint,
413            };
414            let member = Provenance::Structural {
415                role: StructuralEdge::TableMember,
416            };
417
418            assert!(!endpoint.is_strong_pass_edge());
419            assert!(!inactive.is_strong_pass_edge());
420            assert!(!inactive_key.is_strong_pass_edge());
421            assert!(member.is_strong_pass_edge());
422            assert!(Provenance::M.is_strong_pass_edge());
423            assert!(
424                Provenance::Dax {
425                    kind: DaxExpressionKind::Measure
426                }
427                .is_strong_pass_edge()
428            );
429        }
430
431        #[test]
432        fn the_weak_pass_excludes_containment_and_the_inactive_relationship_edges() {
433            let endpoint = Provenance::Structural {
434                role: StructuralEdge::RelationshipEndpoint,
435            };
436            let inactive = Provenance::Structural {
437                role: StructuralEdge::InactiveRelationship,
438            };
439            let inactive_key = Provenance::Structural {
440                role: StructuralEdge::InactiveRelationshipEndpoint,
441            };
442            let member = Provenance::Structural {
443                role: StructuralEdge::TableMember,
444            };
445
446            assert!(!member.is_weak_pass_edge());
447            assert!(endpoint.is_weak_pass_edge());
448            // The whole point of the inactive variants: the relationship and
449            // its endpoints are recorded references, never sources of
450            // liveness. Only a live USERELATIONSHIP call (a Dax edge) can
451            // activate the relationship.
452            assert!(!inactive.is_weak_pass_edge());
453            assert!(!inactive_key.is_weak_pass_edge());
454            assert!(Provenance::M.is_weak_pass_edge());
455        }
456    }
457}