Skip to main content

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: &parameter.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(&section.page);
485
486                for filter in &section.filters {
487                    extend_with_filter(&mut out, page_id, None, bookmark_id, filter);
488                }
489
490                for visual in &section.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}