ripbi_core/report.rs
1//! Format-agnostic report AST: the normalized shape every report source format
2//! (PBIR `definition/` folders, PBIR-Legacy `report.json` Layout) is parsed into.
3//!
4//! The types here are plain data with no parsing or I/O behaviour. Their only logic
5//! is the enumeration at the bottom of this module ([`ReportModel::bindings`] and
6//! [`ReportModel::dax_expressions`]), which is the single place that knows where
7//! report-side reachability roots and report-owned DAX live. The graph layer
8//! consumes those two functions instead of walking the AST itself, so a new
9//! binding-bearing field cannot be silently omitted from reachability analysis.
10//!
11//! Bindings hold references *as written* — structured entity trees in PBIR,
12//! written names in legacy Layout — never resolved model objects. Resolution
13//! against the semantic model is the graph layer's job, via
14//! [`ModelIndex`](crate::ModelIndex); keeping the written form is what lets both
15//! source formats populate the same structures.
16
17use std::fmt;
18
19use crate::identity::{FieldRef, NameKey, Quoted};
20use crate::model::{DaxExpressionKind, DaxExpressionRef, ExpressionOwner};
21
22/// Normalized report definition, regardless of source format (PBIR, PBIR-Legacy
23/// Layout). One instance per report: an analysis runs one
24/// [`TabularDatabase`](crate::TabularDatabase) against the reports that share it,
25/// and each report's `name` completes its bindings' provenance.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct ReportModel {
28 /// Report identity for provenance: the `.platform` display name, or the report
29 /// folder's name when the source records none.
30 pub name: Option<String>,
31 /// The semantic model this report connects to (PBIR `datasetReference`).
32 pub dataset: DatasetReference,
33 /// Report-level filters (PBIR `report.json` filterConfig).
34 pub filters: Vec<Filter>,
35 /// Pages in source order.
36 pub pages: Vec<Page>,
37 /// Bookmarks in source order.
38 pub bookmarks: Vec<Bookmark>,
39 /// Report-level measures (PBIR `reportExtensions.json`): DAX that lives in the
40 /// report, not the model.
41 pub measures: Vec<ReportMeasure>,
42}
43
44/// How a report reaches its semantic model (PBIR `datasetReference`).
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum DatasetReference {
47 /// Relative path to a sibling semantic-model folder (`byPath`). Forward
48 /// slashes are the Power BI-written form; hand-edited files may carry
49 /// backslashes, so the CLI accepts both when resolving.
50 ByPath {
51 /// Path as written, e.g. `../Sales.SemanticModel`.
52 path: String,
53 },
54 /// Live connection to a remote semantic model (`byConnection`).
55 ByConnection {
56 /// Connection string as written.
57 connection_string: String,
58 },
59 /// Absent, unrecognized, or not yet parsed.
60 Unresolved,
61}
62
63impl Default for DatasetReference {
64 /// An unparsed reference is `Unresolved`, never a path or a connection,
65 /// mirroring [`PartitionSource::Other`](crate::PartitionSource): schema drift
66 /// must never fabricate a report↔model pairing.
67 fn default() -> Self {
68 Self::Unresolved
69 }
70}
71
72/// One page of a report.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Page {
75 /// Page object name, e.g. `ReportSectionacd41c847407a998c130`. PBIR keys its
76 /// folders and files by it, and bookmarks reference pages by it.
77 pub name: NameKey,
78 /// Author-facing name, e.g. `Overview`.
79 pub display_name: Option<String>,
80 /// Hidden pages still bind fields — their visuals render on demand — so this
81 /// flag is display-only, never liveness.
82 pub is_hidden: bool,
83 /// Filters applied to the whole page.
84 pub filters: Vec<Filter>,
85 /// The page's drillthrough/tooltip role, if it has one.
86 pub binding: Option<PageBinding>,
87 /// Visuals on the page, in source order.
88 pub visuals: Vec<Visual>,
89}
90
91/// The role a page plays in drillthrough and tooltips (PBIR `pageBinding.type`).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
93pub enum PageBindingKind {
94 /// An ordinary page. The default.
95 #[default]
96 Default,
97 /// Reached via drillthrough; its parameters bind fields.
98 Drillthrough,
99 /// Rendered as a tooltip for other visuals.
100 Tooltip,
101}
102
103/// A page's drillthrough/tooltip configuration (PBIR `pageBinding`).
104#[derive(Debug, Clone, PartialEq, Eq, Default)]
105pub struct PageBinding {
106 /// What kind of page binding this is.
107 pub kind: PageBindingKind,
108 /// Fields a drillthrough caller must supply, in source order.
109 pub parameters: Vec<DrillthroughParameter>,
110}
111
112/// One drillthrough field (PBIR `pageBinding.parameters[]`).
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct DrillthroughParameter {
115 /// Parameter name as written, e.g. `Param_Filter5`.
116 pub name: Option<NameKey>,
117 /// The bound field (PBIR `fieldExpr`).
118 pub target: FieldTarget,
119}
120
121/// One visual on a page.
122///
123/// Slicers are not a separate kind: a slicer is a visual with `visual_type`
124/// `"slicer"`, and its field wells carry the binding. Saved slicer *selections*
125/// are literal values, not references, and are not modeled.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct Visual {
128 /// Visual object name — the PBIR folder name and bookmarks' key.
129 pub name: NameKey,
130 /// Visual type as written, e.g. `"donutChart"`, `"slicer"`, `"card"`.
131 pub visual_type: String,
132 /// Field wells (PBIR `query.queryState`): role → projections.
133 pub wells: Vec<FieldWell>,
134 /// Filters applied to this visual only.
135 pub filters: Vec<Filter>,
136 /// Sort-by fields (PBIR `sortDefinition`), in sort order.
137 pub sorts: Vec<FieldTarget>,
138 /// Fields driving conditional-formatting rules.
139 pub conditional_formatting: Vec<FieldTarget>,
140 /// Fields referenced by the visual's accessibility alt text
141 /// (`visualContainerObjects.general.altText`): a screen reader reads it,
142 /// so dropping the field breaks the visual.
143 pub alt_text: Vec<FieldTarget>,
144 /// Page used as this visual's tooltip, by page object name. A report-internal
145 /// reference: it keeps the page reachable, not a model object.
146 pub tooltip_page: Option<NameKey>,
147}
148
149/// One field well of a visual: everything projected into a single role.
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct FieldWell {
152 /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`, `"Values"`.
153 pub role: String,
154 /// Projections in the well, in source order.
155 pub projections: Vec<Projection>,
156}
157
158/// One field projected into a well.
159#[derive(Debug, Clone, PartialEq, Eq)]
160pub struct Projection {
161 /// The projected field.
162 pub target: FieldTarget,
163 /// Written display form as it appears in the file (PBIR `queryRef`), e.g.
164 /// `Sales.Customers % of Total`. Diagnostics only — `target` is authoritative.
165 pub query_ref: Option<String>,
166 /// Whether the projection is active. Inactive projections still bind: they are
167 /// one toggle away from live, and dropping them would under-count roots.
168 pub active: bool,
169}
170
171/// A filter at report, page, visual, or bookmark level.
172///
173/// The filtered *values* (the condition tree's literals) are data, not references,
174/// and are not modeled — only the fields a filter touches can keep objects alive.
175#[derive(Debug, Clone, PartialEq, Eq, Default)]
176pub struct Filter {
177 /// Filter name within its scope, e.g. `Filter5`. Drillthrough parameters bind
178 /// to filters by this name (PBIR `boundFilter`).
179 pub name: Option<NameKey>,
180 /// The filtered field itself (PBIR `filterConfig.filters[].field`).
181 pub target: Option<FieldTarget>,
182 /// Further fields referenced by the filter's condition tree, with query aliases
183 /// (`SourceRef.Source`) already resolved to entities by the parser.
184 pub references: Vec<FieldTarget>,
185}
186
187/// A saved exploration state, restorable by a reader.
188///
189/// Bookmark bindings are enumerated like any other: applying a bookmark re-applies
190/// its saved filters and projections, so a field kept alive only by a bookmark is
191/// still alive.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct Bookmark {
194 /// Bookmark object name — the PBIR file name, and the provenance key.
195 pub name: NameKey,
196 /// Author-facing name.
197 pub display_name: Option<String>,
198 /// Filters saved at report level (`explorationState.filters`).
199 pub filters: Vec<Filter>,
200 /// Captured state, per page it spans (usually one).
201 pub sections: Vec<BookmarkSection>,
202}
203
204/// The slice of a bookmark's state belonging to one page.
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct BookmarkSection {
207 /// The captured page, by object name.
208 pub page: NameKey,
209 /// Saved filters (`byName` and `byExpr`).
210 pub filters: Vec<Filter>,
211 /// Saved per-visual state, in source order.
212 pub visuals: Vec<BookmarkVisual>,
213}
214
215/// A bookmark's saved state for one visual.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct BookmarkVisual {
218 /// The visual, by object name.
219 pub visual: NameKey,
220 /// Fields active when the bookmark was captured, as wells by role.
221 pub wells: Vec<FieldWell>,
222 /// Filters saved for this visual (`visualContainers.<id>.filters`).
223 pub filters: Vec<Filter>,
224}
225
226/// A DAX measure defined in the report (PBIR `reportExtensions.json`), not the
227/// model.
228///
229/// A report measure bridges usage in both directions: its body references model
230/// objects (so it is an expression source the graph must consume), and visuals
231/// reference it by name (so it is a reachability root of its own). Name lookup
232/// should try report measures before model measures — within its report, a report
233/// measure shadows a model measure of the same name.
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub struct ReportMeasure {
236 /// Measure name, unique within its report.
237 pub name: NameKey,
238 /// The measure's DAX expression.
239 pub expression: String,
240 /// Dynamic format string (DAX).
241 pub format_string: Option<String>,
242}
243
244/// A model-object reference as written in a report binding, before resolution.
245///
246/// PBIR bindings are structured JSON entity trees (`Column`, `Measure`,
247/// `HierarchyLevel`, `Aggregation`); legacy Layout binds written names. Both
248/// normalize here, so downstream code never branches on source format.
249///
250/// The column/measure discrimination is kept rather than collapsed into
251/// [`FieldRef`], because the binding states it outright and resolution would
252/// otherwise be guessing.
253#[derive(Debug, Clone, PartialEq, Eq, Hash)]
254pub enum FieldTarget {
255 /// A column, by its table (`SourceRef.Entity`) and name (`Property`).
256 Column {
257 /// Owning table as written.
258 table: NameKey,
259 /// Column name as written.
260 column: NameKey,
261 },
262 /// A measure, by name. Measures are model-global; the entity PBIR writes
263 /// alongside them is the home table as displayed, carried for provenance only.
264 Measure {
265 /// Home table as written, if any.
266 home_table: Option<NameKey>,
267 /// Measure name as written.
268 measure: NameKey,
269 },
270 /// A hierarchy level: the level a visual drills to, which keeps the whole
271 /// hierarchy (and its level columns) alive.
272 HierarchyLevel {
273 /// Owning table as written. For a hierarchy reached over a column
274 /// variation this is the *varied* (base) table; the hierarchy itself
275 /// lives on the variation's target table.
276 table: NameKey,
277 /// Hierarchy name as written.
278 hierarchy: NameKey,
279 /// Level name as written.
280 level: NameKey,
281 /// The varied column (PBIR `PropertyVariationSource.Property`), when
282 /// the hierarchy is reached over a column variation — the key that
283 /// joins this binding to the model-side
284 /// [`crate::model::Column::variations`] declaration.
285 via_column: Option<NameKey>,
286 /// The variation's name (PBIR `PropertyVariationSource.Name`), which
287 /// picks between a column's variations when there are several.
288 via_variation: Option<NameKey>,
289 },
290 /// An aggregation over an inner reference, e.g. Sum of `'Sales'[Units]`.
291 /// The inner target is what stays alive; the function is diagnostics.
292 Aggregation {
293 /// Aggregation function as written, e.g. `"Sum"`.
294 function: Option<String>,
295 /// The aggregated field.
296 inner: Box<FieldTarget>,
297 },
298 /// A written name the parser could not structure — legacy Layout strings,
299 /// unresolved query aliases. Kept anyway: a binding we cannot read is still a
300 /// binding, and dropping it would under-count roots.
301 Written(FieldRef),
302}
303
304impl fmt::Display for FieldTarget {
305 /// Human-readable form for diagnostics, quoting names the way [`FieldRef`]
306 /// does. The hierarchy-level and aggregation forms are illustrative, not valid
307 /// DAX — level references have no DAX syntax.
308 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
309 match self {
310 FieldTarget::Column { table, column } => {
311 write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
312 }
313 FieldTarget::Measure {
314 home_table,
315 measure,
316 } => match home_table {
317 Some(table) => write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str()),
318 None => write!(f, "[{}]", measure.as_str()),
319 },
320 FieldTarget::HierarchyLevel {
321 table,
322 hierarchy,
323 level,
324 ..
325 } => write!(
326 f,
327 "hierarchy {}[{}] level {}",
328 Quoted(table.as_str()),
329 hierarchy.as_str(),
330 Quoted(level.as_str())
331 ),
332 FieldTarget::Aggregation { function, inner } => match function {
333 Some(function) => write!(f, "{function}({inner})"),
334 None => write!(f, "Aggregation({inner})"),
335 },
336 FieldTarget::Written(reference) => write!(f, "{reference}"),
337 }
338 }
339}
340
341/// What kind of report-side usage a binding represents.
342///
343/// The kind powers "used by" explanations (`'Sales'[Amount]` ← filter on
344/// *Overview* ← page 2) and groups bindings for reporting.
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
346pub enum BindingKind<'a> {
347 /// A field projected into a visual's field well, with the well's role.
348 FieldWell {
349 /// Role name as written, e.g. `"Category"`, `"Y"`, `"Tooltips"`.
350 role: &'a str,
351 },
352 /// A filter at report, page, visual, or bookmark level.
353 Filter,
354 /// A visual's sort-by field.
355 Sort,
356 /// A drillthrough parameter's bound field.
357 Drillthrough,
358 /// A field driving a conditional-formatting rule.
359 ConditionalFormatting,
360 /// A visual's accessibility alt text (`general.altText`).
361 AltText,
362}
363
364/// Borrowed view of one report binding, with its provenance.
365///
366/// The page/visual/bookmark fields answer *where* the binding lives — the `None`s
367/// narrow it: a report-level filter has neither page nor visual, a page filter has
368/// no visual. Which *report* a binding came from is answered by the
369/// [`ReportModel`] it was enumerated from, so the report name is not repeated here.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
371pub struct BindingRef<'a> {
372 /// Page the binding lives on; `None` for report-level bindings.
373 pub page: Option<&'a NameKey>,
374 /// Visual the binding lives in; `None` outside a visual.
375 pub visual: Option<&'a NameKey>,
376 /// Bookmark whose saved state carries the binding; `None` for live bindings.
377 pub bookmark: Option<&'a NameKey>,
378 /// What kind of binding this is.
379 pub kind: BindingKind<'a>,
380 /// The model object referenced, as written.
381 pub target: &'a FieldTarget,
382}
383
384impl ReportModel {
385 /// Every model-object reference the report makes, with its provenance — the
386 /// reachability roots the graph's BFS starts from.
387 ///
388 /// Order follows report order (report filters, then per page: drillthrough
389 /// parameters, page filters, and each visual's wells, filters, sorts, and
390 /// conditional formatting; then per bookmark: report-level filters, and per
391 /// section: section filters and each visual's wells and filters), so the
392 /// result is deterministic for a given report and diffable across runs.
393 ///
394 /// Everything borrows from the report, so this allocates only the returned
395 /// `Vec`.
396 ///
397 /// # Examples
398 ///
399 /// ```
400 /// use ripbi_core::report::{BindingKind, FieldTarget, Filter, ReportModel};
401 /// use ripbi_core::NameKey;
402 ///
403 /// let report = ReportModel {
404 /// filters: vec![Filter {
405 /// target: Some(FieldTarget::Column {
406 /// table: NameKey::new("Product"),
407 /// column: NameKey::new("Category"),
408 /// }),
409 /// ..Default::default()
410 /// }],
411 /// ..Default::default()
412 /// };
413 ///
414 /// let bindings = report.bindings();
415 /// assert_eq!(bindings.len(), 1);
416 /// assert_eq!(bindings[0].kind, BindingKind::Filter);
417 /// // A report-level filter belongs to no page and no visual.
418 /// assert_eq!(bindings[0].page, None);
419 /// assert_eq!(bindings[0].visual, None);
420 /// ```
421 #[must_use]
422 pub fn bindings(&self) -> Vec<BindingRef<'_>> {
423 let mut out = Vec::new();
424
425 for filter in &self.filters {
426 extend_with_filter(&mut out, None, None, None, filter);
427 }
428
429 for page in &self.pages {
430 let page_id = Some(&page.name);
431
432 if let Some(binding) = &page.binding {
433 for parameter in &binding.parameters {
434 out.push(BindingRef {
435 page: page_id,
436 visual: None,
437 bookmark: None,
438 kind: BindingKind::Drillthrough,
439 target: ¶meter.target,
440 });
441 }
442 }
443
444 for filter in &page.filters {
445 extend_with_filter(&mut out, page_id, None, None, filter);
446 }
447
448 for visual in &page.visuals {
449 let visual_id = Some(&visual.name);
450 extend_with_wells(&mut out, page_id, visual_id, None, &visual.wells);
451
452 for filter in &visual.filters {
453 extend_with_filter(&mut out, page_id, visual_id, None, filter);
454 }
455
456 for target in &visual.sorts {
457 out.push(BindingRef {
458 page: page_id,
459 visual: visual_id,
460 bookmark: None,
461 kind: BindingKind::Sort,
462 target,
463 });
464 }
465
466 for target in &visual.conditional_formatting {
467 out.push(BindingRef {
468 page: page_id,
469 visual: visual_id,
470 bookmark: None,
471 kind: BindingKind::ConditionalFormatting,
472 target,
473 });
474 }
475
476 for target in &visual.alt_text {
477 out.push(BindingRef {
478 page: page_id,
479 visual: visual_id,
480 bookmark: None,
481 kind: BindingKind::AltText,
482 target,
483 });
484 }
485 }
486 }
487
488 for bookmark in &self.bookmarks {
489 let bookmark_id = Some(&bookmark.name);
490
491 for filter in &bookmark.filters {
492 extend_with_filter(&mut out, None, None, bookmark_id, filter);
493 }
494
495 for section in &bookmark.sections {
496 let page_id = Some(§ion.page);
497
498 for filter in §ion.filters {
499 extend_with_filter(&mut out, page_id, None, bookmark_id, filter);
500 }
501
502 for visual in §ion.visuals {
503 let visual_id = Some(&visual.visual);
504 extend_with_wells(&mut out, page_id, visual_id, bookmark_id, &visual.wells);
505
506 for filter in &visual.filters {
507 extend_with_filter(&mut out, page_id, visual_id, bookmark_id, filter);
508 }
509 }
510 }
511 }
512
513 out
514 }
515
516 /// Every DAX expression defined report-side — report-level measures — on top of
517 /// [`TabularDatabase::dax_expressions`](crate::TabularDatabase::dax_expressions),
518 /// which covers the model side.
519 ///
520 /// A report measure has no home table, so `home_table` is always `None`:
521 /// unqualified `[Name]` references in its body can only be measures — of the
522 /// report first, then the model.
523 ///
524 /// Owners borrow their names, so this allocates only the returned `Vec`.
525 #[must_use]
526 pub fn dax_expressions(&self) -> Vec<DaxExpressionRef<'_>> {
527 let mut out = Vec::new();
528
529 for measure in &self.measures {
530 let owner = ExpressionOwner::ReportMeasure {
531 measure: measure.name.as_str(),
532 };
533 out.push(DaxExpressionRef {
534 owner,
535 kind: DaxExpressionKind::ReportMeasure,
536 home_table: None,
537 text: &measure.expression,
538 });
539 if let Some(text) = &measure.format_string {
540 out.push(DaxExpressionRef {
541 owner,
542 kind: DaxExpressionKind::ReportMeasureFormatString,
543 home_table: None,
544 text,
545 });
546 }
547 }
548
549 out
550 }
551}
552
553/// Appends one [`BindingRef`] per field a filter carries, all tagged
554/// [`BindingKind::Filter`]: the declared `target` first, then the condition tree's
555/// `references`, preserving file order for stable diffs.
556fn extend_with_filter<'a>(
557 out: &mut Vec<BindingRef<'a>>,
558 page: Option<&'a NameKey>,
559 visual: Option<&'a NameKey>,
560 bookmark: Option<&'a NameKey>,
561 filter: &'a Filter,
562) {
563 for target in filter.target.iter().chain(&filter.references) {
564 out.push(BindingRef {
565 page,
566 visual,
567 bookmark,
568 kind: BindingKind::Filter,
569 target,
570 });
571 }
572}
573
574/// Appends one [`BindingRef`] per projection in the wells, tagged with its well's
575/// role. Inactive projections bind too: they are one toggle away from live.
576fn extend_with_wells<'a>(
577 out: &mut Vec<BindingRef<'a>>,
578 page: Option<&'a NameKey>,
579 visual: Option<&'a NameKey>,
580 bookmark: Option<&'a NameKey>,
581 wells: &'a [FieldWell],
582) {
583 for well in wells {
584 for projection in &well.projections {
585 out.push(BindingRef {
586 page,
587 visual,
588 bookmark,
589 kind: BindingKind::FieldWell {
590 role: well.role.as_str(),
591 },
592 target: &projection.target,
593 });
594 }
595 }
596}
597
598#[cfg(test)]
599mod tests {
600 use super::*;
601 use rstest::rstest;
602
603 fn column_target(table: &str, column: &str) -> FieldTarget {
604 FieldTarget::Column {
605 table: NameKey::new(table),
606 column: NameKey::new(column),
607 }
608 }
609
610 fn measure_target(home_table: Option<&str>, measure: &str) -> FieldTarget {
611 FieldTarget::Measure {
612 home_table: home_table.map(NameKey::new),
613 measure: NameKey::new(measure),
614 }
615 }
616
617 fn filter_on(target: FieldTarget) -> Filter {
618 Filter {
619 target: Some(target),
620 ..Default::default()
621 }
622 }
623
624 fn page(name: &str) -> Page {
625 Page {
626 name: NameKey::new(name),
627 display_name: None,
628 is_hidden: false,
629 filters: Vec::new(),
630 binding: None,
631 visuals: Vec::new(),
632 }
633 }
634
635 fn visual(name: &str, visual_type: &str) -> Visual {
636 Visual {
637 name: NameKey::new(name),
638 visual_type: visual_type.to_string(),
639 wells: Vec::new(),
640 filters: Vec::new(),
641 sorts: Vec::new(),
642 conditional_formatting: Vec::new(),
643 alt_text: Vec::new(),
644 tooltip_page: None,
645 }
646 }
647
648 fn well(role: &str, targets: &[FieldTarget]) -> FieldWell {
649 FieldWell {
650 role: role.to_string(),
651 projections: targets
652 .iter()
653 .cloned()
654 .map(|target| Projection {
655 target,
656 query_ref: None,
657 active: true,
658 })
659 .collect(),
660 }
661 }
662
663 mod field_target {
664 use super::*;
665
666 #[rstest]
667 #[case::column(column_target("Product", "Category"), "'Product'[Category]")]
668 #[case::measure_with_home_table(measure_target(Some("Sales"), "Cost"), "'Sales'[Cost]")]
669 #[case::measure_without_home_table(measure_target(None, "Cost"), "[Cost]")]
670 #[case::hierarchy_level(
671 FieldTarget::HierarchyLevel {
672 table: NameKey::new("Accounts"),
673 hierarchy: NameKey::new("Street Hierarchy"),
674 level: NameKey::new("State or Province"),
675 via_column: None,
676 via_variation: None,
677 },
678 "hierarchy 'Accounts'[Street Hierarchy] level 'State or Province'"
679 )]
680 #[case::aggregation(
681 FieldTarget::Aggregation {
682 function: Some("Sum".to_string()),
683 inner: Box::new(column_target("Sales", "Units")),
684 },
685 "Sum('Sales'[Units])"
686 )]
687 #[case::aggregation_without_function(
688 FieldTarget::Aggregation {
689 function: None,
690 inner: Box::new(column_target("Sales", "Units")),
691 },
692 "Aggregation('Sales'[Units])"
693 )]
694 #[case::written(
695 FieldTarget::Written(FieldRef {
696 table: Some(NameKey::new("Sales")),
697 name: NameKey::new("Amount"),
698 }),
699 "'Sales'[Amount]"
700 )]
701 fn displays_for_diagnostics(#[case] target: FieldTarget, #[case] expected: &str) {
702 assert_eq!(target.to_string(), expected);
703 }
704
705 #[test]
706 fn compares_equal_ignoring_case() {
707 assert_eq!(
708 column_target("Product", "Category"),
709 column_target("PRODUCT", "CATEGORY")
710 );
711 assert_eq!(
712 measure_target(Some("Sales"), "Cost"),
713 measure_target(Some("sales"), "COST")
714 );
715 }
716
717 /// The binding states column-or-measure outright; losing it would make
718 /// resolution guess where it currently knows.
719 #[test]
720 fn distinguishes_a_column_from_a_measure_with_the_same_names() {
721 assert_ne!(
722 column_target("Sales", "Cost"),
723 measure_target(Some("Sales"), "Cost")
724 );
725 }
726 }
727
728 mod bindings {
729 use super::*;
730
731 /// The common report identity; each test adds the binding site it checks.
732 fn sample() -> ReportModel {
733 ReportModel {
734 name: Some("Sales overview".to_string()),
735 dataset: DatasetReference::ByPath {
736 path: "../Sales.SemanticModel".to_string(),
737 },
738 ..Default::default()
739 }
740 }
741
742 fn sample_with_page(page: Page) -> ReportModel {
743 ReportModel {
744 pages: vec![page],
745 ..sample()
746 }
747 }
748
749 /// One binding's full provenance: page, visual, bookmark, kind, and the
750 /// target as `Display` — what resolution consumes.
751 type Provenance<'a> = (
752 Option<&'a str>,
753 Option<&'a str>,
754 Option<&'a str>,
755 BindingKind<'a>,
756 String,
757 );
758
759 /// `Provenance` per binding, in enumeration order — the full
760 /// specification `bindings()` must satisfy.
761 fn provenance(report: &ReportModel) -> Vec<Provenance<'_>> {
762 report
763 .bindings()
764 .into_iter()
765 .map(|binding| {
766 (
767 binding.page.map(NameKey::as_str),
768 binding.visual.map(NameKey::as_str),
769 binding.bookmark.map(NameKey::as_str),
770 binding.kind,
771 binding.target.to_string(),
772 )
773 })
774 .collect()
775 }
776
777 #[test]
778 fn a_report_filter_has_no_page_visual_or_bookmark() {
779 let report = ReportModel {
780 filters: vec![filter_on(column_target("Product", "Category"))],
781 ..sample()
782 };
783 let bindings = report.bindings();
784
785 assert_eq!(bindings.len(), 1);
786 assert_eq!(bindings[0].page, None);
787 assert_eq!(bindings[0].visual, None);
788 assert_eq!(bindings[0].bookmark, None);
789 assert_eq!(bindings[0].kind, BindingKind::Filter);
790 }
791
792 #[test]
793 fn a_drillthrough_parameter_is_tagged_on_its_page() {
794 let report = sample_with_page(Page {
795 binding: Some(PageBinding {
796 kind: PageBindingKind::Drillthrough,
797 parameters: vec![DrillthroughParameter {
798 name: Some(NameKey::new("Param_Filter5")),
799 target: column_target("Industries", "Industry"),
800 }],
801 }),
802 ..page("ReportSection1")
803 });
804
805 let bindings = report.bindings();
806 assert_eq!(bindings.len(), 1);
807 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
808 assert_eq!(bindings[0].visual, None);
809 assert_eq!(bindings[0].kind, BindingKind::Drillthrough);
810 }
811
812 #[test]
813 fn a_page_filter_carries_its_page_but_no_visual() {
814 let report = sample_with_page(Page {
815 filters: vec![filter_on(column_target("Owners", "Sales owner"))],
816 ..page("ReportSection1")
817 });
818
819 let bindings = report.bindings();
820 assert_eq!(bindings.len(), 1);
821 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
822 assert_eq!(bindings[0].visual, None);
823 assert_eq!(bindings[0].kind, BindingKind::Filter);
824 }
825
826 #[test]
827 fn a_visual_well_carries_role_page_and_visual() {
828 let report = sample_with_page(Page {
829 visuals: vec![Visual {
830 wells: vec![well("Category", &[column_target("Product", "Category")])],
831 ..visual("visual1", "donutChart")
832 }],
833 ..page("ReportSection1")
834 });
835
836 let bindings = report.bindings();
837 assert_eq!(bindings.len(), 1);
838 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
839 assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
840 assert_eq!(bindings[0].bookmark, None);
841 assert_eq!(
842 bindings[0].kind,
843 BindingKind::FieldWell { role: "Category" }
844 );
845 }
846
847 #[test]
848 fn sorts_and_conditional_formatting_are_tagged_as_such() {
849 let report = sample_with_page(Page {
850 visuals: vec![Visual {
851 sorts: vec![column_target("Product", "Category")],
852 conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
853 ..visual("visual1", "tableEx")
854 }],
855 ..page("ReportSection1")
856 });
857
858 let bindings = report.bindings();
859 assert_eq!(
860 bindings.iter().map(|b| b.kind).collect::<Vec<_>>(),
861 vec![BindingKind::Sort, BindingKind::ConditionalFormatting]
862 );
863 // Both bindings belong to their visual, at page level.
864 for binding in &bindings {
865 assert_eq!(binding.page.unwrap().as_str(), "ReportSection1");
866 assert_eq!(binding.visual.unwrap().as_str(), "visual1");
867 }
868 }
869
870 #[test]
871 fn a_bookmark_filter_carries_bookmark_and_page() {
872 let report = ReportModel {
873 bookmarks: vec![Bookmark {
874 name: NameKey::new("Bookmark1"),
875 display_name: Some("FY24".to_string()),
876 filters: Vec::new(),
877 sections: vec![BookmarkSection {
878 page: NameKey::new("ReportSection1"),
879 filters: vec![filter_on(column_target("Products", "Product category"))],
880 visuals: Vec::new(),
881 }],
882 }],
883 ..sample()
884 };
885
886 let bindings = report.bindings();
887 assert_eq!(bindings.len(), 1);
888 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
889 assert_eq!(bindings[0].visual, None);
890 assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
891 assert_eq!(bindings[0].kind, BindingKind::Filter);
892 }
893
894 #[test]
895 fn bookmark_wells_carry_bookmark_page_and_visual() {
896 let report = ReportModel {
897 bookmarks: vec![Bookmark {
898 name: NameKey::new("Bookmark1"),
899 display_name: None,
900 filters: Vec::new(),
901 sections: vec![BookmarkSection {
902 page: NameKey::new("ReportSection1"),
903 filters: Vec::new(),
904 visuals: vec![BookmarkVisual {
905 visual: NameKey::new("visual1"),
906 wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
907 filters: Vec::new(),
908 }],
909 }],
910 }],
911 ..sample()
912 };
913
914 let bindings = report.bindings();
915 assert_eq!(bindings.len(), 1);
916 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
917 assert_eq!(bindings[0].visual.unwrap().as_str(), "visual1");
918 assert_eq!(bindings[0].bookmark.unwrap().as_str(), "Bookmark1");
919 assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Rows" });
920 }
921
922 /// The declared target comes first, then the condition tree's references,
923 /// in file order.
924 #[test]
925 fn a_filter_yields_target_then_references_in_order() {
926 let report = sample_with_page(Page {
927 visuals: vec![Visual {
928 filters: vec![Filter {
929 name: Some(NameKey::new("Filter5")),
930 target: Some(column_target("Product", "Category")),
931 references: vec![
932 column_target("Product", "Subcategory"),
933 measure_target(None, "Units"),
934 ],
935 }],
936 ..visual("visual1", "donutChart")
937 }],
938 ..page("ReportSection1")
939 });
940
941 let targets: Vec<&FieldTarget> =
942 report.bindings().into_iter().map(|b| b.target).collect();
943 assert_eq!(
944 targets,
945 vec![
946 &column_target("Product", "Category"),
947 &column_target("Product", "Subcategory"),
948 &measure_target(None, "Units"),
949 ]
950 );
951 }
952
953 /// A filter the parser could not give a structured target still binds:
954 /// its references are roots even when `target` is `None`.
955 #[test]
956 fn a_filter_without_a_target_still_binds_its_references() {
957 let report = sample_with_page(Page {
958 visuals: vec![Visual {
959 filters: vec![Filter {
960 name: Some(NameKey::new("Filter5")),
961 target: None,
962 references: vec![
963 column_target("Product", "Subcategory"),
964 measure_target(None, "Units"),
965 ],
966 }],
967 ..visual("visual1", "donutChart")
968 }],
969 ..page("ReportSection1")
970 });
971
972 let targets: Vec<&FieldTarget> =
973 report.bindings().into_iter().map(|b| b.target).collect();
974 assert_eq!(
975 targets,
976 vec![
977 &column_target("Product", "Subcategory"),
978 &measure_target(None, "Units"),
979 ]
980 );
981 }
982
983 /// An inactive projection is one toggle away from live; dropping it would
984 /// under-count roots and report live code as unused.
985 #[test]
986 fn an_inactive_projection_still_binds() {
987 let report = sample_with_page(Page {
988 visuals: vec![Visual {
989 wells: vec![FieldWell {
990 role: "Y".to_string(),
991 projections: vec![Projection {
992 target: column_target("Sales", "Units"),
993 query_ref: None,
994 active: false,
995 }],
996 }],
997 ..visual("visual1", "lineChart")
998 }],
999 ..page("ReportSection1")
1000 });
1001
1002 assert_eq!(report.bindings().len(), 1);
1003 }
1004
1005 /// Hidden is not dead: a hidden page's visuals render on demand, so their
1006 /// wells bind like any other page's. Skipping hidden pages would
1007 /// under-count roots and report live code as unused.
1008 #[test]
1009 fn a_hidden_pages_visuals_still_bind() {
1010 let report = sample_with_page(Page {
1011 is_hidden: true,
1012 visuals: vec![Visual {
1013 wells: vec![well("Values", &[column_target("Sales", "Units")])],
1014 ..visual("visual1", "card")
1015 }],
1016 ..page("ReportSection1")
1017 });
1018
1019 let bindings = report.bindings();
1020 assert_eq!(bindings.len(), 1);
1021 assert_eq!(bindings[0].page.unwrap().as_str(), "ReportSection1");
1022 assert_eq!(bindings[0].kind, BindingKind::FieldWell { role: "Values" });
1023 }
1024
1025 /// Enumeration walks report order — report filters, page (parameters,
1026 /// filters, visuals: wells, filters, sorts, conditional formatting), then
1027 /// bookmarks — so runs are diffable. Every binding's full provenance is
1028 /// pinned, not just its kind: a slipped page, visual, or bookmark on any
1029 /// site must fail here.
1030 #[test]
1031 fn order_follows_report_structure() {
1032 let report = ReportModel {
1033 filters: vec![filter_on(column_target("Product", "Category"))],
1034 pages: vec![
1035 Page {
1036 binding: Some(PageBinding {
1037 kind: PageBindingKind::Drillthrough,
1038 parameters: vec![DrillthroughParameter {
1039 name: None,
1040 target: column_target("Industries", "Industry"),
1041 }],
1042 }),
1043 filters: vec![filter_on(column_target("Owners", "Sales owner"))],
1044 visuals: vec![Visual {
1045 wells: vec![well("Category", &[column_target("Product", "Category")])],
1046 filters: vec![filter_on(column_target("Region", "Country"))],
1047 sorts: vec![measure_target(Some("Sales"), "Sales")],
1048 conditional_formatting: vec![measure_target(Some("Sales"), "Margin")],
1049 ..visual("visual1", "donutChart")
1050 }],
1051 ..page("ReportSection1")
1052 },
1053 Page {
1054 visuals: vec![Visual {
1055 wells: vec![well(
1056 "Tooltips",
1057 &[measure_target(Some("Sales"), "Customers %")],
1058 )],
1059 ..visual("visual2", "slicer")
1060 }],
1061 ..page("ReportSection2")
1062 },
1063 ],
1064 bookmarks: vec![Bookmark {
1065 name: NameKey::new("Bookmark1"),
1066 display_name: None,
1067 filters: vec![filter_on(measure_target(None, "Total Units"))],
1068 sections: vec![BookmarkSection {
1069 page: NameKey::new("ReportSection1"),
1070 filters: vec![filter_on(column_target("Products", "Product category"))],
1071 visuals: vec![BookmarkVisual {
1072 visual: NameKey::new("visual1"),
1073 wells: vec![well("Rows", &[column_target("Product", "Subcategory")])],
1074 filters: vec![filter_on(column_target("Product", "Color"))],
1075 }],
1076 }],
1077 }],
1078 measures: Vec::new(),
1079 ..sample()
1080 };
1081
1082 assert_eq!(
1083 provenance(&report),
1084 vec![
1085 // Report filter.
1086 (
1087 None,
1088 None,
1089 None,
1090 BindingKind::Filter,
1091 "'Product'[Category]".to_string(),
1092 ),
1093 // Page 1 drillthrough parameter.
1094 (
1095 Some("ReportSection1"),
1096 None,
1097 None,
1098 BindingKind::Drillthrough,
1099 "'Industries'[Industry]".to_string(),
1100 ),
1101 // Page 1 filter.
1102 (
1103 Some("ReportSection1"),
1104 None,
1105 None,
1106 BindingKind::Filter,
1107 "'Owners'[Sales owner]".to_string(),
1108 ),
1109 // Visual 1 well.
1110 (
1111 Some("ReportSection1"),
1112 Some("visual1"),
1113 None,
1114 BindingKind::FieldWell { role: "Category" },
1115 "'Product'[Category]".to_string(),
1116 ),
1117 // Visual 1 filter.
1118 (
1119 Some("ReportSection1"),
1120 Some("visual1"),
1121 None,
1122 BindingKind::Filter,
1123 "'Region'[Country]".to_string(),
1124 ),
1125 // Visual 1 sort.
1126 (
1127 Some("ReportSection1"),
1128 Some("visual1"),
1129 None,
1130 BindingKind::Sort,
1131 "'Sales'[Sales]".to_string(),
1132 ),
1133 // Visual 1 conditional formatting.
1134 (
1135 Some("ReportSection1"),
1136 Some("visual1"),
1137 None,
1138 BindingKind::ConditionalFormatting,
1139 "'Sales'[Margin]".to_string(),
1140 ),
1141 // Visual 2 well.
1142 (
1143 Some("ReportSection2"),
1144 Some("visual2"),
1145 None,
1146 BindingKind::FieldWell { role: "Tooltips" },
1147 "'Sales'[Customers %]".to_string(),
1148 ),
1149 // Bookmark report-level filter: no page, no visual.
1150 (
1151 None,
1152 None,
1153 Some("Bookmark1"),
1154 BindingKind::Filter,
1155 "[Total Units]".to_string(),
1156 ),
1157 // Bookmark section filter.
1158 (
1159 Some("ReportSection1"),
1160 None,
1161 Some("Bookmark1"),
1162 BindingKind::Filter,
1163 "'Products'[Product category]".to_string(),
1164 ),
1165 // Bookmark well.
1166 (
1167 Some("ReportSection1"),
1168 Some("visual1"),
1169 Some("Bookmark1"),
1170 BindingKind::FieldWell { role: "Rows" },
1171 "'Product'[Subcategory]".to_string(),
1172 ),
1173 // Bookmark visual filter.
1174 (
1175 Some("ReportSection1"),
1176 Some("visual1"),
1177 Some("Bookmark1"),
1178 BindingKind::Filter,
1179 "'Product'[Color]".to_string(),
1180 ),
1181 ]
1182 );
1183 }
1184 }
1185
1186 /// The same field, bound through PBIR's structured entities and through legacy
1187 /// Layout's written names, must enumerate to the same provenance: this
1188 /// equivalence is the AST's whole reason to exist.
1189 mod format_agnostic {
1190 use super::*;
1191
1192 fn report_with(well_target: FieldTarget) -> ReportModel {
1193 ReportModel {
1194 pages: vec![Page {
1195 visuals: vec![Visual {
1196 wells: vec![well("Y", &[well_target])],
1197 ..visual("visual1", "clusteredColumnChart")
1198 }],
1199 ..page("ReportSection1")
1200 }],
1201 ..Default::default()
1202 }
1203 }
1204
1205 #[test]
1206 fn structured_and_written_targets_bind_alike() {
1207 let pbir = report_with(FieldTarget::Measure {
1208 home_table: Some(NameKey::new("Sales")),
1209 measure: NameKey::new("Cost"),
1210 });
1211 let legacy = report_with(FieldTarget::Written(FieldRef {
1212 table: Some(NameKey::new("Sales")),
1213 name: NameKey::new("Cost"),
1214 }));
1215
1216 let pbir_bindings = pbir.bindings();
1217 let legacy_bindings = legacy.bindings();
1218 assert_eq!(pbir_bindings.len(), 1);
1219 assert_eq!(legacy_bindings.len(), 1);
1220
1221 // Provenance and kind are identical; only the target's variant differs.
1222 assert_eq!(pbir_bindings[0].page, legacy_bindings[0].page);
1223 assert_eq!(pbir_bindings[0].visual, legacy_bindings[0].visual);
1224 assert_eq!(pbir_bindings[0].bookmark, legacy_bindings[0].bookmark);
1225 assert_eq!(pbir_bindings[0].kind, legacy_bindings[0].kind);
1226 }
1227 }
1228
1229 mod dax_expressions {
1230 use super::*;
1231
1232 #[test]
1233 fn enumerates_a_report_measures_body_and_format_string() {
1234 let report = ReportModel {
1235 measures: vec![ReportMeasure {
1236 name: NameKey::new("Growth %"),
1237 expression: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])".to_string(),
1238 format_string: Some("0.0%;-0.0%;0.0%".to_string()),
1239 }],
1240 ..Default::default()
1241 };
1242
1243 let expressions = report.dax_expressions();
1244 assert_eq!(expressions.len(), 2);
1245
1246 assert_eq!(
1247 expressions[0],
1248 DaxExpressionRef {
1249 owner: ExpressionOwner::ReportMeasure {
1250 measure: "Growth %"
1251 },
1252 kind: DaxExpressionKind::ReportMeasure,
1253 home_table: None,
1254 text: "DIVIDE([Sales] - [Prior Sales], [Prior Sales])",
1255 }
1256 );
1257 assert_eq!(
1258 expressions[1],
1259 DaxExpressionRef {
1260 owner: ExpressionOwner::ReportMeasure {
1261 measure: "Growth %"
1262 },
1263 kind: DaxExpressionKind::ReportMeasureFormatString,
1264 home_table: None,
1265 text: "0.0%;-0.0%;0.0%",
1266 }
1267 );
1268 }
1269
1270 /// Most report measures carry no dynamic format string; only the body is
1271 /// then an expression source.
1272 #[test]
1273 fn a_measure_without_a_format_string_enumerates_only_its_body() {
1274 let report = ReportModel {
1275 measures: vec![ReportMeasure {
1276 name: NameKey::new("Total Units"),
1277 expression: "SUM('Sales'[Units])".to_string(),
1278 format_string: None,
1279 }],
1280 ..Default::default()
1281 };
1282
1283 let expressions = report.dax_expressions();
1284 assert_eq!(expressions.len(), 1);
1285 assert_eq!(expressions[0].kind, DaxExpressionKind::ReportMeasure);
1286 }
1287
1288 #[test]
1289 fn a_report_without_measures_has_none() {
1290 assert!(ReportModel::default().dax_expressions().is_empty());
1291 }
1292
1293 /// The owner is the measure's reachability identity: visuals reference it,
1294 /// its body references model objects, and the graph node must match both.
1295 /// Compared through `Display`: `ObjectId` equality ignores case, so it
1296 /// could never catch a lowercased or rewritten name.
1297 #[test]
1298 fn owner_materializes_a_report_measure_object_id() {
1299 let owner = ExpressionOwner::ReportMeasure {
1300 measure: "Growth %",
1301 };
1302 assert_eq!(
1303 owner.to_object_id().to_string(),
1304 "report measure 'Growth %'"
1305 );
1306 }
1307 }
1308
1309 mod expression_views {
1310 use super::*;
1311
1312 /// Mirrors the model-side guarantee: bindings enumerate without allocating
1313 /// beyond the returned `Vec`, which holds only while every field borrows.
1314 #[test]
1315 fn are_copy_so_enumeration_borrows_everything() {
1316 fn assert_copy<T: Copy>() {}
1317 assert_copy::<BindingRef<'_>>();
1318 assert_copy::<BindingKind<'_>>();
1319 }
1320 }
1321
1322 mod defaults {
1323 use super::*;
1324
1325 /// An unparsed dataset reference must never masquerade as a path or a
1326 /// connection, or a wrong report↔model pairing would reach the graph.
1327 #[test]
1328 fn a_dataset_reference_is_unresolved() {
1329 assert_eq!(DatasetReference::default(), DatasetReference::Unresolved);
1330 assert_eq!(ReportModel::default().dataset, DatasetReference::Unresolved);
1331 }
1332
1333 /// Most pages are plain pages; PBIR omits the binding for them entirely.
1334 #[test]
1335 fn a_page_binding_kind_is_default() {
1336 assert_eq!(PageBindingKind::default(), PageBindingKind::Default);
1337 assert_eq!(PageBinding::default().kind, PageBindingKind::Default);
1338 }
1339 }
1340}