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