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}
61
62impl Provenance {
63    /// True when the strong reachability pass may traverse this edge — every
64    /// edge except relationship endpoints (which keep a key column alive
65    /// without keeping its table alive) and inactive-relationship references
66    /// (which never confer liveness — see the module docs of [`super`]).
67    pub(super) fn is_strong_pass_edge(&self) -> bool {
68        !matches!(
69            self,
70            Provenance::Structural {
71                role: StructuralEdge::RelationshipEndpoint
72            } | Provenance::Structural {
73                role: StructuralEdge::InactiveRelationship
74            } | Provenance::Structural {
75                role: StructuralEdge::InactiveRelationshipEndpoint
76            }
77        )
78    }
79
80    /// True when the weak reachability pass may traverse this edge — every
81    /// edge except containment from a table member to its table, so liveness
82    /// gained weakly can never propagate into a table, and except the
83    /// inactive-relationship edges, which never confer liveness in any pass:
84    /// only a live `USERELATIONSHIP` reference can activate the relationship.
85    pub(super) fn is_weak_pass_edge(&self) -> bool {
86        !matches!(
87            self,
88            Provenance::Structural {
89                role: StructuralEdge::TableMember
90            } | Provenance::Structural {
91                role: StructuralEdge::InactiveRelationship
92            } | Provenance::Structural {
93                role: StructuralEdge::InactiveRelationshipEndpoint
94            }
95        )
96    }
97}
98
99impl fmt::Display for Provenance {
100    /// Human-readable site description for "used by" lines, e.g.
101    /// `field well 'Y' — visual 'V1', page 'P1', report 'Mini'` or `RLS filter`.
102    /// The edge's *source* object is rendered by the [`ObjectId`](crate::ObjectId)
103    /// at the other half of the pair, so a provenance never repeats it.
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Provenance::Dax { kind } => f.write_str(dax_site(*kind)),
107            Provenance::Binding(edge) => {
108                let BindingEdge {
109                    kind,
110                    report,
111                    page,
112                    visual,
113                    bookmark,
114                } = edge.as_ref();
115                write_site(f, kind)?;
116                if let Some(visual) = visual {
117                    write!(f, " — visual {}", Quoted(visual.as_str()))?;
118                }
119                if let Some(page) = page {
120                    write!(f, " on page {}", Quoted(page.as_str()))?;
121                }
122                if let Some(bookmark) = bookmark {
123                    write!(f, " in bookmark {}", Quoted(bookmark.as_str()))?;
124                }
125                if let Some(report) = report {
126                    write!(f, " in report {}", Quoted(report.as_str()))?;
127                }
128                Ok(())
129            }
130            Provenance::M => f.write_str("Power Query expression"),
131            Provenance::Structural { role } => write!(f, "{role}"),
132        }
133    }
134}
135
136/// What kind of report-side usage a binding represents — the owned form of
137/// [`BindingKind`](crate::BindingKind), whose field-well role is borrowed from
138/// the report AST.
139#[derive(Debug, Clone, PartialEq, Eq, Hash)]
140pub enum BindingSite {
141    /// A field projected into a visual's field well.
142    FieldWell {
143        /// Role name as written, e.g. `"Category"`, `"Y"`.
144        role: String,
145    },
146    /// A filter at report, page, visual, or bookmark level.
147    Filter,
148    /// A visual's sort-by field.
149    Sort,
150    /// A drillthrough parameter's bound field.
151    Drillthrough,
152    /// A field driving a conditional-formatting rule.
153    ConditionalFormatting,
154    /// A visual's accessibility alt text.
155    AltText,
156}
157
158fn write_site(f: &mut fmt::Formatter<'_>, site: &BindingSite) -> fmt::Result {
159    match site {
160        BindingSite::FieldWell { role } => {
161            write!(f, "field well {}", Quoted(role.as_str()))
162        }
163        BindingSite::Filter => f.write_str("filter"),
164        BindingSite::Sort => f.write_str("sort definition"),
165        BindingSite::Drillthrough => f.write_str("drillthrough parameter"),
166        BindingSite::ConditionalFormatting => f.write_str("conditional formatting"),
167        BindingSite::AltText => f.write_str("alt text"),
168    }
169}
170
171/// The structural rule an edge came from.
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173pub enum StructuralEdge {
174    /// The source is defined on the target table; a used member keeps its table
175    /// alive.
176    TableMember,
177    /// The table loads its rows through this partition.
178    TablePartition,
179    /// The relationship hangs off this table; either endpoint keeps it alive.
180    Relationship,
181    /// An inactive relationship hangs off this table, but the reference is
182    /// recorded without liveness: switching an inactive relationship on at
183    /// query time is DAX's job (`USERELATIONSHIP`), so an unactivated one is
184    /// itself a finding.
185    InactiveRelationship,
186    /// The relationship needs this key column.
187    RelationshipEndpoint,
188    /// An inactive relationship names this key column, but activating it at
189    /// query time is DAX's job (`USERELATIONSHIP`): the edge is recorded so
190    /// findings can point at the relationship, yet it confers no liveness —
191    /// until a live DAX reference switches the relationship on, the key is
192    /// unloadable bloat.
193    InactiveRelationshipEndpoint,
194    /// The source column is sorted by the target column.
195    SortByColumn,
196    /// The source column is grouped by the target column.
197    GroupByColumn,
198    /// The hierarchy drills down through this column.
199    HierarchyLevel,
200    /// The column is materialized by the engine together with its table
201    /// (calculated-table columns, calculation-group columns, calendar columns)
202    /// and cannot be dropped independently.
203    EngineManaged,
204    /// The role grants access to this table.
205    RolePermission,
206}
207
208impl fmt::Display for StructuralEdge {
209    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210        f.write_str(match self {
211            StructuralEdge::TableMember => "table member",
212            StructuralEdge::TablePartition => "table partition",
213            StructuralEdge::Relationship => "relationship",
214            StructuralEdge::InactiveRelationship => "inactive relationship",
215            StructuralEdge::RelationshipEndpoint => "relationship endpoint",
216            StructuralEdge::InactiveRelationshipEndpoint => "inactive relationship endpoint",
217            StructuralEdge::SortByColumn => "sort-by column",
218            StructuralEdge::GroupByColumn => "group-by column",
219            StructuralEdge::HierarchyLevel => "hierarchy level",
220            StructuralEdge::EngineManaged => "engine-managed column",
221            StructuralEdge::RolePermission => "role permission",
222        })
223    }
224}
225
226/// The site phrase for a DAX expression kind. The edge's source object names the
227/// owner; this only says which property of it made the reference.
228fn dax_site(kind: DaxExpressionKind) -> &'static str {
229    match kind {
230        DaxExpressionKind::Measure => "measure expression",
231        DaxExpressionKind::MeasureFormatString => "measure format string",
232        DaxExpressionKind::MeasureDetailRows => "measure detail rows",
233        DaxExpressionKind::KpiTarget => "KPI target",
234        DaxExpressionKind::KpiStatus => "KPI status",
235        DaxExpressionKind::KpiTrend => "KPI trend",
236        DaxExpressionKind::CalculatedColumn => "calculated column expression",
237        DaxExpressionKind::CalculatedTable => "calculated table expression",
238        DaxExpressionKind::TableDetailRows => "table detail rows",
239        DaxExpressionKind::RlsFilter => "RLS filter",
240        DaxExpressionKind::CalculationItem => "calculation item expression",
241        DaxExpressionKind::CalculationItemFormatString => "calculation item format string",
242        DaxExpressionKind::CalculationGroupNoSelection => "no-selection expression",
243        DaxExpressionKind::CalculationGroupNoSelectionFormatString => "no-selection format string",
244        DaxExpressionKind::CalculationGroupMultipleOrEmptySelection => {
245            "multiple-or-empty-selection expression"
246        }
247        DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString => {
248            "multiple-or-empty-selection format string"
249        }
250        DaxExpressionKind::Function => "function body",
251        DaxExpressionKind::ReportMeasure => "report measure expression",
252        DaxExpressionKind::ReportMeasureFormatString => "report measure format string",
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    fn binding(kind: BindingSite) -> Provenance {
261        Provenance::Binding(Box::new(BindingEdge {
262            kind,
263            report: Some(NameKey::new("Mini")),
264            page: Some(NameKey::new("P1")),
265            visual: Some(NameKey::new("V1")),
266            bookmark: None,
267        }))
268    }
269
270    mod display {
271        use super::*;
272
273        #[test]
274        fn a_field_well_renders_its_site_chain() {
275            assert_eq!(
276                binding(BindingSite::FieldWell {
277                    role: "Y".to_string(),
278                })
279                .to_string(),
280                "field well 'Y' — visual 'V1' on page 'P1' in report 'Mini'"
281            );
282        }
283
284        #[test]
285        fn a_bookmark_follows_the_visual_it_saved() {
286            let provenance = Provenance::Binding(Box::new(BindingEdge {
287                kind: BindingSite::Filter,
288                report: None,
289                page: Some(NameKey::new("P1")),
290                visual: Some(NameKey::new("V1")),
291                bookmark: Some(NameKey::new("B1")),
292            }));
293
294            assert_eq!(
295                provenance.to_string(),
296                "filter — visual 'V1' on page 'P1' in bookmark 'B1'"
297            );
298        }
299
300        #[test]
301        fn a_report_level_filter_names_no_site() {
302            let provenance = Provenance::Binding(Box::new(BindingEdge {
303                kind: BindingSite::Filter,
304                report: None,
305                page: None,
306                visual: None,
307                bookmark: None,
308            }));
309
310            assert_eq!(provenance.to_string(), "filter");
311        }
312
313        #[test]
314        fn structural_and_m_sites_render_as_phrases() {
315            assert_eq!(
316                Provenance::Structural {
317                    role: StructuralEdge::RelationshipEndpoint
318                }
319                .to_string(),
320                "relationship endpoint"
321            );
322            assert_eq!(
323                Provenance::Structural {
324                    role: StructuralEdge::InactiveRelationship
325                }
326                .to_string(),
327                "inactive relationship"
328            );
329            assert_eq!(Provenance::M.to_string(), "Power Query expression");
330        }
331
332        #[test]
333        fn dax_sites_render_as_phrases() {
334            assert_eq!(
335                Provenance::Dax {
336                    kind: DaxExpressionKind::RlsFilter
337                }
338                .to_string(),
339                "RLS filter"
340            );
341            assert_eq!(
342                Provenance::Dax {
343                    kind: DaxExpressionKind::Measure
344                }
345                .to_string(),
346                "measure expression"
347            );
348        }
349    }
350
351    mod classification {
352        use super::*;
353
354        #[test]
355        fn the_strong_pass_excludes_relationship_endpoints_and_inactive_relationships() {
356            let endpoint = Provenance::Structural {
357                role: StructuralEdge::RelationshipEndpoint,
358            };
359            let inactive = Provenance::Structural {
360                role: StructuralEdge::InactiveRelationship,
361            };
362            let inactive_key = Provenance::Structural {
363                role: StructuralEdge::InactiveRelationshipEndpoint,
364            };
365            let member = Provenance::Structural {
366                role: StructuralEdge::TableMember,
367            };
368
369            assert!(!endpoint.is_strong_pass_edge());
370            assert!(!inactive.is_strong_pass_edge());
371            assert!(!inactive_key.is_strong_pass_edge());
372            assert!(member.is_strong_pass_edge());
373            assert!(Provenance::M.is_strong_pass_edge());
374            assert!(
375                Provenance::Dax {
376                    kind: DaxExpressionKind::Measure
377                }
378                .is_strong_pass_edge()
379            );
380        }
381
382        #[test]
383        fn the_weak_pass_excludes_containment_and_the_inactive_relationship_edges() {
384            let endpoint = Provenance::Structural {
385                role: StructuralEdge::RelationshipEndpoint,
386            };
387            let inactive = Provenance::Structural {
388                role: StructuralEdge::InactiveRelationship,
389            };
390            let inactive_key = Provenance::Structural {
391                role: StructuralEdge::InactiveRelationshipEndpoint,
392            };
393            let member = Provenance::Structural {
394                role: StructuralEdge::TableMember,
395            };
396
397            assert!(!member.is_weak_pass_edge());
398            assert!(endpoint.is_weak_pass_edge());
399            // The whole point of the inactive variants: the relationship and
400            // its endpoints are recorded references, never sources of
401            // liveness. Only a live USERELATIONSHIP call (a Dax edge) can
402            // activate the relationship.
403            assert!(!inactive.is_weak_pass_edge());
404            assert!(!inactive_key.is_weak_pass_edge());
405            assert!(Provenance::M.is_weak_pass_edge());
406        }
407    }
408}