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