ripbi-core 0.2.0

Static analysis engine for Power BI semantic models: TMDL and PBIR ingestion, DAX reference extraction, dependency graph, and reachability
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
//! Per-edge provenance: what kind of use every dependency edge records.
//!
//! Provenance is first-class graph data, stored as the petgraph edge weight at
//! build time — never derived at render time. The planned `ripbi deps` view
//! consumes it straight off [`consumers_of`](super::DependencyGraph::consumers_of)
//! to annotate edges (`visual 'Card' on page 'P2'`, `RLS role 'Reader' filter`),
//! and [`scan`](super) uses it to explain why an unused object is referenced only
//! by other unused objects.

use std::fmt;

use crate::identity::{NameKey, Quoted};
use crate::model::DaxExpressionKind;

/// Why one object depends on another — the edge weight of the dependency graph.
///
/// The report-binding payload is boxed so the enum stays small: it is cloned
/// once per deduped edge, and the large variant would otherwise dominate every
/// edge weight's size.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Provenance {
    /// The target is referenced inside a DAX expression. The edge's source node
    /// plus `kind` identify the expression site exactly: every
    /// `(owner, kind)` pair has exactly one production site in the expression
    /// enumerations.
    Dax {
        /// Which property of the source object the expression came from.
        kind: DaxExpressionKind,
    },
    /// The target is bound by a report: a field well, filter, sort, drillthrough
    /// parameter, or conditional-formatting rule.
    Binding(
        /// Which binding, and where it lives.
        Box<BindingEdge>,
    ),
    /// The target is referenced from an M expression by name.
    M,
    /// Liveness flows through model structure, with no written reference anywhere.
    Structural {
        /// Which structural rule produces the edge.
        role: StructuralEdge,
    },
}

/// One report binding and the site it lives in — the payload of
/// [`Provenance::Binding`].
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct BindingEdge {
    /// What the binding does.
    pub kind: BindingSite,
    /// The report carrying the binding, when the source recorded a name.
    pub report: Option<NameKey>,
    /// The page the binding lives on; `None` for report-level bindings.
    pub page: Option<NameKey>,
    /// The visual the binding lives in; `None` outside visuals.
    pub visual: Option<NameKey>,
    /// The bookmark whose saved state carries the binding; `None` for live
    /// bindings.
    pub bookmark: Option<NameKey>,
}

impl Provenance {
    /// True when the strong reachability pass may traverse this edge — every
    /// edge except relationship endpoints (which keep a key column alive
    /// without keeping its table alive) and inactive-relationship references
    /// (which never confer liveness — see the module docs of [`super`]).
    pub(super) fn is_strong_pass_edge(&self) -> bool {
        !matches!(
            self,
            Provenance::Structural {
                role: StructuralEdge::RelationshipEndpoint
            } | Provenance::Structural {
                role: StructuralEdge::InactiveRelationship
            } | Provenance::Structural {
                role: StructuralEdge::InactiveRelationshipEndpoint
            }
        )
    }

    /// True when the weak reachability pass may traverse this edge — every
    /// edge except containment from a table member to its table, so liveness
    /// gained weakly can never propagate into a table, and except the
    /// inactive-relationship edges, which never confer liveness in any pass:
    /// only a live `USERELATIONSHIP` reference can activate the relationship.
    pub(super) fn is_weak_pass_edge(&self) -> bool {
        !matches!(
            self,
            Provenance::Structural {
                role: StructuralEdge::TableMember
            } | Provenance::Structural {
                role: StructuralEdge::InactiveRelationship
            } | Provenance::Structural {
                role: StructuralEdge::InactiveRelationshipEndpoint
            }
        )
    }
}

impl fmt::Display for Provenance {
    /// Human-readable site description for "used by" lines, e.g.
    /// `field well 'Y' — visual 'V1', page 'P1', report 'Mini'` or `RLS filter`.
    /// The edge's *source* object is rendered by the [`ObjectId`](crate::ObjectId)
    /// at the other half of the pair, so a provenance never repeats it.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Provenance::Dax { kind } => f.write_str(dax_site(*kind)),
            Provenance::Binding(edge) => {
                let BindingEdge {
                    kind,
                    report,
                    page,
                    visual,
                    bookmark,
                } = edge.as_ref();
                write_site(f, kind)?;
                if let Some(visual) = visual {
                    write!(f, " — visual {}", Quoted(visual.as_str()))?;
                }
                if let Some(page) = page {
                    write!(f, " on page {}", Quoted(page.as_str()))?;
                }
                if let Some(bookmark) = bookmark {
                    write!(f, " in bookmark {}", Quoted(bookmark.as_str()))?;
                }
                if let Some(report) = report {
                    write!(f, " in report {}", Quoted(report.as_str()))?;
                }
                Ok(())
            }
            Provenance::M => f.write_str("Power Query expression"),
            Provenance::Structural { role } => write!(f, "{role}"),
        }
    }
}

/// What kind of report-side usage a binding represents — the owned form of
/// [`BindingKind`](crate::BindingKind), whose field-well role is borrowed from
/// the report AST.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum BindingSite {
    /// A field projected into a visual's field well.
    FieldWell {
        /// Role name as written, e.g. `"Category"`, `"Y"`.
        role: String,
    },
    /// A filter at report, page, visual, or bookmark level.
    Filter,
    /// A visual's sort-by field.
    Sort,
    /// A drillthrough parameter's bound field.
    Drillthrough,
    /// A field driving a conditional-formatting rule.
    ConditionalFormatting,
    /// A visual's accessibility alt text.
    AltText,
}

fn write_site(f: &mut fmt::Formatter<'_>, site: &BindingSite) -> fmt::Result {
    match site {
        BindingSite::FieldWell { role } => {
            write!(f, "field well {}", Quoted(role.as_str()))
        }
        BindingSite::Filter => f.write_str("filter"),
        BindingSite::Sort => f.write_str("sort definition"),
        BindingSite::Drillthrough => f.write_str("drillthrough parameter"),
        BindingSite::ConditionalFormatting => f.write_str("conditional formatting"),
        BindingSite::AltText => f.write_str("alt text"),
    }
}

/// The structural rule an edge came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StructuralEdge {
    /// The source is defined on the target table; a used member keeps its table
    /// alive.
    TableMember,
    /// The table loads its rows through this partition.
    TablePartition,
    /// The relationship hangs off this table; either endpoint keeps it alive.
    Relationship,
    /// An inactive relationship hangs off this table, but the reference is
    /// recorded without liveness: switching an inactive relationship on at
    /// query time is DAX's job (`USERELATIONSHIP`), so an unactivated one is
    /// itself a finding.
    InactiveRelationship,
    /// The relationship needs this key column.
    RelationshipEndpoint,
    /// An inactive relationship names this key column, but activating it at
    /// query time is DAX's job (`USERELATIONSHIP`): the edge is recorded so
    /// findings can point at the relationship, yet it confers no liveness —
    /// until a live DAX reference switches the relationship on, the key is
    /// unloadable bloat.
    InactiveRelationshipEndpoint,
    /// The source column is sorted by the target column.
    SortByColumn,
    /// The source column is grouped by the target column.
    GroupByColumn,
    /// The hierarchy drills down through this column.
    HierarchyLevel,
    /// The column is materialized by the engine together with its table
    /// (calculated-table columns, calculation-group columns, calendar columns)
    /// and cannot be dropped independently.
    EngineManaged,
    /// The role grants access to this table.
    RolePermission,
}

impl fmt::Display for StructuralEdge {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(match self {
            StructuralEdge::TableMember => "table member",
            StructuralEdge::TablePartition => "table partition",
            StructuralEdge::Relationship => "relationship",
            StructuralEdge::InactiveRelationship => "inactive relationship",
            StructuralEdge::RelationshipEndpoint => "relationship endpoint",
            StructuralEdge::InactiveRelationshipEndpoint => "inactive relationship endpoint",
            StructuralEdge::SortByColumn => "sort-by column",
            StructuralEdge::GroupByColumn => "group-by column",
            StructuralEdge::HierarchyLevel => "hierarchy level",
            StructuralEdge::EngineManaged => "engine-managed column",
            StructuralEdge::RolePermission => "role permission",
        })
    }
}

/// The site phrase for a DAX expression kind. The edge's source object names the
/// owner; this only says which property of it made the reference.
fn dax_site(kind: DaxExpressionKind) -> &'static str {
    match kind {
        DaxExpressionKind::Measure => "measure expression",
        DaxExpressionKind::MeasureFormatString => "measure format string",
        DaxExpressionKind::MeasureDetailRows => "measure detail rows",
        DaxExpressionKind::KpiTarget => "KPI target",
        DaxExpressionKind::KpiStatus => "KPI status",
        DaxExpressionKind::KpiTrend => "KPI trend",
        DaxExpressionKind::CalculatedColumn => "calculated column expression",
        DaxExpressionKind::CalculatedTable => "calculated table expression",
        DaxExpressionKind::TableDetailRows => "table detail rows",
        DaxExpressionKind::RlsFilter => "RLS filter",
        DaxExpressionKind::CalculationItem => "calculation item expression",
        DaxExpressionKind::CalculationItemFormatString => "calculation item format string",
        DaxExpressionKind::CalculationGroupNoSelection => "no-selection expression",
        DaxExpressionKind::CalculationGroupNoSelectionFormatString => "no-selection format string",
        DaxExpressionKind::CalculationGroupMultipleOrEmptySelection => {
            "multiple-or-empty-selection expression"
        }
        DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString => {
            "multiple-or-empty-selection format string"
        }
        DaxExpressionKind::Function => "function body",
        DaxExpressionKind::ReportMeasure => "report measure expression",
        DaxExpressionKind::ReportMeasureFormatString => "report measure format string",
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn binding(kind: BindingSite) -> Provenance {
        Provenance::Binding(Box::new(BindingEdge {
            kind,
            report: Some(NameKey::new("Mini")),
            page: Some(NameKey::new("P1")),
            visual: Some(NameKey::new("V1")),
            bookmark: None,
        }))
    }

    mod display {
        use super::*;

        #[test]
        fn a_field_well_renders_its_site_chain() {
            assert_eq!(
                binding(BindingSite::FieldWell {
                    role: "Y".to_string(),
                })
                .to_string(),
                "field well 'Y' — visual 'V1' on page 'P1' in report 'Mini'"
            );
        }

        #[test]
        fn a_bookmark_follows_the_visual_it_saved() {
            let provenance = Provenance::Binding(Box::new(BindingEdge {
                kind: BindingSite::Filter,
                report: None,
                page: Some(NameKey::new("P1")),
                visual: Some(NameKey::new("V1")),
                bookmark: Some(NameKey::new("B1")),
            }));

            assert_eq!(
                provenance.to_string(),
                "filter — visual 'V1' on page 'P1' in bookmark 'B1'"
            );
        }

        #[test]
        fn a_report_level_filter_names_no_site() {
            let provenance = Provenance::Binding(Box::new(BindingEdge {
                kind: BindingSite::Filter,
                report: None,
                page: None,
                visual: None,
                bookmark: None,
            }));

            assert_eq!(provenance.to_string(), "filter");
        }

        #[test]
        fn structural_and_m_sites_render_as_phrases() {
            assert_eq!(
                Provenance::Structural {
                    role: StructuralEdge::RelationshipEndpoint
                }
                .to_string(),
                "relationship endpoint"
            );
            assert_eq!(
                Provenance::Structural {
                    role: StructuralEdge::InactiveRelationship
                }
                .to_string(),
                "inactive relationship"
            );
            assert_eq!(Provenance::M.to_string(), "Power Query expression");
        }

        #[test]
        fn dax_sites_render_as_phrases() {
            assert_eq!(
                Provenance::Dax {
                    kind: DaxExpressionKind::RlsFilter
                }
                .to_string(),
                "RLS filter"
            );
            assert_eq!(
                Provenance::Dax {
                    kind: DaxExpressionKind::Measure
                }
                .to_string(),
                "measure expression"
            );
        }
    }

    mod classification {
        use super::*;

        #[test]
        fn the_strong_pass_excludes_relationship_endpoints_and_inactive_relationships() {
            let endpoint = Provenance::Structural {
                role: StructuralEdge::RelationshipEndpoint,
            };
            let inactive = Provenance::Structural {
                role: StructuralEdge::InactiveRelationship,
            };
            let inactive_key = Provenance::Structural {
                role: StructuralEdge::InactiveRelationshipEndpoint,
            };
            let member = Provenance::Structural {
                role: StructuralEdge::TableMember,
            };

            assert!(!endpoint.is_strong_pass_edge());
            assert!(!inactive.is_strong_pass_edge());
            assert!(!inactive_key.is_strong_pass_edge());
            assert!(member.is_strong_pass_edge());
            assert!(Provenance::M.is_strong_pass_edge());
            assert!(
                Provenance::Dax {
                    kind: DaxExpressionKind::Measure
                }
                .is_strong_pass_edge()
            );
        }

        #[test]
        fn the_weak_pass_excludes_containment_and_the_inactive_relationship_edges() {
            let endpoint = Provenance::Structural {
                role: StructuralEdge::RelationshipEndpoint,
            };
            let inactive = Provenance::Structural {
                role: StructuralEdge::InactiveRelationship,
            };
            let inactive_key = Provenance::Structural {
                role: StructuralEdge::InactiveRelationshipEndpoint,
            };
            let member = Provenance::Structural {
                role: StructuralEdge::TableMember,
            };

            assert!(!member.is_weak_pass_edge());
            assert!(endpoint.is_weak_pass_edge());
            // The whole point of the inactive variants: the relationship and
            // its endpoints are recorded references, never sources of
            // liveness. Only a live USERELATIONSHIP call (a Dax edge) can
            // activate the relationship.
            assert!(!inactive.is_weak_pass_edge());
            assert!(!inactive_key.is_weak_pass_edge());
            assert!(Provenance::M.is_weak_pass_edge());
        }
    }
}