Skip to main content

ripbi_core/
model.rs

1//! Format-agnostic tabular AST: the normalized shape every source format
2//! (TMDL, TMSL `model.bim`, `.pbix` `DataModelSchema`) is parsed into.
3//!
4//! The types here are plain data with no parsing or I/O behaviour. Their only logic is
5//! the expression enumeration at the bottom of this module
6//! ([`TabularDatabase::dax_expressions`] and [`TabularDatabase::m_expressions`]), which
7//! is the single place that knows where expressions live. The graph layer consumes those
8//! two functions instead of walking the AST itself, so a new expression-bearing field
9//! cannot be silently omitted from reachability analysis.
10//!
11//! Name-based lookup lives in the [`index`] submodule; the handle accessors on
12//! [`TabularDatabase`] ([`table`](TabularDatabase::table),
13//! [`column`](TabularDatabase::column), … and [`object_id`](TabularDatabase::object_id))
14//! turn the handles it hands out back into borrowed AST nodes.
15//!
16//! String fields hold names with their original casing and compare case-sensitively.
17//! Case-insensitive comparison is the job of [`crate::identity::NameKey`], which these
18//! names are converted into when they become graph nodes.
19
20pub mod index;
21
22use crate::identity::{NameKey, ObjectId};
23use crate::model::index::{
24    ColumnHandle, ExpressionHandle, FunctionHandle, HierarchyHandle, MeasureHandle, Resolved,
25    TableHandle,
26};
27
28/// Normalized semantic model, regardless of source format (TMDL, model.bim,
29/// .pbix DataModelSchema). Downstream code never branches on source format.
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct TabularDatabase {
32    /// Model name, when the source format records one.
33    pub name: Option<String>,
34    /// Tables in source order.
35    pub tables: Vec<Table>,
36    /// Relationships between table columns.
37    pub relationships: Vec<Relationship>,
38    /// Row-level-security roles.
39    pub roles: Vec<Role>,
40    /// Model-level shared M expressions (TMDL expressions.tmdl / TMSL
41    /// model.expressions): Power Query parameters and shared queries.
42    pub expressions: Vec<SharedExpression>,
43    /// User-defined DAX functions (TOM functions). Names are model-global.
44    pub functions: Vec<Function>,
45}
46
47/// A table and everything defined on it.
48///
49/// A calculation-group table carries its synthetic columns (the group's field column
50/// and its ordinal column) in `columns` like any other table; nothing distinguishes
51/// them structurally from data columns.
52#[derive(Debug, Clone, PartialEq, Eq, Default)]
53pub struct Table {
54    /// Table name.
55    pub name: String,
56    /// Columns in source order.
57    pub columns: Vec<Column>,
58    /// Measures whose home table this is.
59    pub measures: Vec<Measure>,
60    /// Partitions supplying the table's rows.
61    pub partitions: Vec<Partition>,
62    /// The table's incremental refresh policy (TOM refreshPolicy), when configured.
63    pub refresh_policy: Option<RefreshPolicy>,
64    /// User-defined hierarchies.
65    pub hierarchies: Vec<Hierarchy>,
66    /// Calendars (TOM calendars) binding groups of the table's columns.
67    pub calendars: Vec<Calendar>,
68    /// In TOM a calculation group is a property of a table.
69    pub calculation_group: Option<CalculationGroup>,
70    /// DAX defaultDetailRowsDefinition (drillthrough detail rows).
71    pub detail_rows_expression: Option<String>,
72    /// Hidden from report authors; hidden objects are still live if referenced.
73    pub is_hidden: bool,
74    /// Engine-private (TOM isPrivate): reserved for the engine, never authored
75    /// against. Display-only metadata — never liveness.
76    pub is_private: bool,
77    /// An engine-generated auto date/time table serving one date column
78    /// (TOM annotation `__PBI_LocalDateTable`; name-prefix fallback). Hidden
79    /// machinery a report cannot author against directly. Display-only
80    /// metadata — never liveness; the graph layer reads it for the
81    /// auto-date/time verdict
82    /// ([`DependencyGraph::auto_date_time_tables`](crate::graph::DependencyGraph::auto_date_time_tables)).
83    pub is_local_date_table: bool,
84    /// The engine-generated date table template the `LocalDateTable_*` family
85    /// is derived from (TOM annotation `__PBI_TemplateDateTable`; name-prefix
86    /// fallback). Display-only metadata — never liveness; see
87    /// [`Self::is_local_date_table`].
88    pub is_template_date_table: bool,
89}
90
91impl Table {
92    /// A calculated table is a table whose partition source is DAX.
93    ///
94    /// There is no flag for this in TOM, and none here: the partition decides.
95    ///
96    /// # Examples
97    ///
98    /// ```
99    /// use ripbi_core::{Partition, PartitionSource, Table};
100    ///
101    /// let top_products = Table {
102    ///     name: "Top Products".to_string(),
103    ///     partitions: vec![Partition {
104    ///         name: "Top Products".to_string(),
105    ///         source: PartitionSource::Calculated {
106    ///             expression: "TOPN(10, Products, Products[Sales])".to_string(),
107    ///         },
108    ///     }],
109    ///     ..Default::default()
110    /// };
111    /// assert!(top_products.is_calculated());
112    ///
113    /// // An imported table is not, however it was loaded.
114    /// let imported = Table {
115    ///     name: "Products".to_string(),
116    ///     partitions: vec![Partition {
117    ///         name: "Products".to_string(),
118    ///         source: PartitionSource::M { expression: "Sql.Database(...)".to_string() },
119    ///     }],
120    ///     ..Default::default()
121    /// };
122    /// assert!(!imported.is_calculated());
123    /// ```
124    pub fn is_calculated(&self) -> bool {
125        self.partitions
126            .iter()
127            .any(|partition| matches!(partition.source, PartitionSource::Calculated { .. }))
128    }
129}
130
131/// A column of a table.
132#[derive(Debug, Clone, PartialEq, Eq, Default)]
133pub struct Column {
134    /// Column name.
135    pub name: String,
136    /// How the column's values are produced.
137    pub kind: ColumnKind,
138    /// Hidden from report authors; hidden objects are still live if referenced.
139    pub is_hidden: bool,
140    /// Name of another column in the same table (TOM sortByColumn).
141    /// Liveness edge: a used column keeps its sort-by column alive.
142    pub sort_by_column: Option<String>,
143    /// Names of other columns in the same table (TOM groupByColumns).
144    /// Liveness edge: a used column keeps its group-by columns alive.
145    pub group_by_columns: Vec<String>,
146    /// Column variations (TOM variations): bindings of this column to
147    /// hierarchies on other tables — for auto date/time, the engine's hidden
148    /// `LocalDateTable_*` machinery.
149    pub variations: Vec<Variation>,
150}
151
152/// A column variation (TOM variation): the model's declaration that the owning
153/// column is served by a hierarchy on another table — for auto date/time, a
154/// hidden relationship to an engine-generated `LocalDateTable_*`.
155///
156/// Report bindings written against the varied column resolve through this
157/// declaration: the graph joins the referenced relationship or hierarchy
158/// instead of guessing which of a column's relationships is the variation.
159#[derive(Debug, Clone, PartialEq, Eq, Default)]
160pub struct Variation {
161    /// Variation name (the TMDL descriptor name, e.g. `Variation`).
162    pub name: String,
163    /// Whether this is the column's default variation (TOM isDefault; TMDL
164    /// writes the key only when true).
165    pub is_default: bool,
166    /// Name of the TOM relationship realizing this variation — for auto
167    /// date/time, the hidden relationship from the owning column to the date
168    /// table's key column. TMDL relationship names are GUIDs.
169    pub relationship: Option<String>,
170    /// The table-qualified default hierarchy (TOM defaultHierarchy), e.g.
171    /// `LocalDateTable_x.'Date Hierarchy'` — where report bindings on the
172    /// varied column land.
173    pub default_hierarchy: Option<HierarchyRef>,
174}
175
176/// A table-qualified reference to a hierarchy defined on another table.
177#[derive(Debug, Clone, PartialEq, Eq, Default)]
178pub struct HierarchyRef {
179    /// Name of the table owning the hierarchy.
180    pub table: String,
181    /// Hierarchy name in that table.
182    pub hierarchy: String,
183}
184
185/// How a column's values are produced.
186#[derive(Debug, Clone, PartialEq, Eq, Default)]
187pub enum ColumnKind {
188    /// Sourced from the partition query (TOM dataColumn). The default.
189    #[default]
190    Data,
191    /// DAX-defined column (TOM calculatedColumn).
192    Calculated {
193        /// DAX expression evaluated per row.
194        expression: String,
195    },
196    /// Column of a calculated table (TOM calculatedTableColumn);
197    /// materialized by the table's DAX partition, no own expression.
198    CalculatedTableColumn,
199}
200
201/// A DAX measure.
202#[derive(Debug, Clone, PartialEq, Eq, Default)]
203pub struct Measure {
204    /// Measure name; unique across the whole model, not just its home table.
205    pub name: String,
206    /// The measure's DAX expression.
207    pub expression: String,
208    /// Hidden from report authors; hidden objects are still live if referenced.
209    pub is_hidden: bool,
210    /// Dynamic format string (DAX).
211    pub format_string_expression: Option<String>,
212    /// DAX detailRowsDefinition (drillthrough detail rows).
213    pub detail_rows_expression: Option<String>,
214    /// KPI attached to this measure.
215    pub kpi: Option<Kpi>,
216}
217
218/// KPI expressions are DAX and can be the sole reference keeping an object alive.
219#[derive(Debug, Clone, PartialEq, Eq, Default)]
220pub struct Kpi {
221    /// DAX expression for the KPI target value.
222    pub target_expression: Option<String>,
223    /// DAX expression for the KPI status.
224    pub status_expression: Option<String>,
225    /// DAX expression for the KPI trend.
226    pub trend_expression: Option<String>,
227}
228
229/// A partition supplying a table's rows.
230#[derive(Debug, Clone, PartialEq, Eq, Default)]
231pub struct Partition {
232    /// Partition name.
233    pub name: String,
234    /// The partition's source query and its language.
235    pub source: PartitionSource,
236}
237
238/// A partition's source query, discriminated by query language.
239#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum PartitionSource {
241    /// Power Query (TOM m).
242    M {
243        /// M expression text.
244        expression: String,
245    },
246    /// DAX — this is what makes a table a calculated table (TOM calculated).
247    Calculated {
248        /// DAX expression producing the table.
249        expression: String,
250    },
251    /// Legacy native query partition (TOM query).
252    Query {
253        /// Native query text, in the data source's own dialect.
254        query: String,
255    },
256    /// entity (DirectLake), inferred, future kinds — schema drift never panics;
257    /// the raw kind string is kept for diagnostics.
258    Other {
259        /// The source kind as written in the model, when one was present.
260        kind: Option<String>,
261    },
262}
263
264impl Default for PartitionSource {
265    /// An unparsed source is `Other`, never a query language, so an unrecognized
266    /// partition can never be mistaken for DAX or M by the expression enumeration.
267    fn default() -> Self {
268        PartitionSource::Other { kind: None }
269    }
270}
271
272/// A table's incremental refresh policy (TMDL refreshPolicy / TOM refreshPolicy).
273///
274/// Only the expressions are modeled. The policy's ranges, periods, and offsets
275/// cannot consume a model object, so they are skipped as Tier-1 metadata — but
276/// both expression properties are evaluated at refresh time, where deleting
277/// what they reference breaks refresh (see [`crate::m`] and
278/// [`DaxExpressionKind::ChangeDetection`]).
279#[derive(Debug, Clone, PartialEq, Eq, Default)]
280pub struct RefreshPolicy {
281    /// Policy type as written (TOM policyType, e.g. `basicRefreshPolicy`).
282    /// Diagnostics only.
283    pub policy_type: Option<String>,
284    /// The policy's source expression (TOM sourceExpression): the M query new
285    /// policy-range partitions are created from, filtered by the
286    /// RangeStart/RangeEnd parameters.
287    pub source_expression: Option<String>,
288    /// The change-detection expression (TOM pollingExpression): evaluated per
289    /// partition at refresh time to decide whether the partition has new data.
290    pub change_detection: Option<String>,
291}
292
293/// A relationship between a column of one table and a column of another.
294#[derive(Debug, Clone, PartialEq, Eq)]
295pub struct Relationship {
296    /// TMDL relationship names are GUIDs; kept for diagnostics only.
297    pub name: Option<String>,
298    /// Table on the "from" (typically many) side.
299    pub from_table: String,
300    /// Key column in `from_table`.
301    pub from_column: String,
302    /// Table on the "to" (typically one) side.
303    pub to_table: String,
304    /// Key column in `to_table`.
305    pub to_column: String,
306    /// Active relationships keep both key columns alive while either endpoint
307    /// table is reachable; inactive ones are live only when a live DAX
308    /// reference (`USERELATIONSHIP`) activates them — otherwise the
309    /// relationship and its key columns are all findings.
310    pub is_active: bool,
311}
312
313impl Default for Relationship {
314    /// `is_active` defaults to `true`, matching TOM: the flag is omitted from the
315    /// source for active relationships. A derived `Default` would make every
316    /// relationship built field-by-field silently inactive.
317    fn default() -> Self {
318        Self {
319            name: None,
320            from_table: String::new(),
321            from_column: String::new(),
322            to_table: String::new(),
323            to_column: String::new(),
324            is_active: true,
325        }
326    }
327}
328
329/// A user-defined hierarchy on a table.
330#[derive(Debug, Clone, PartialEq, Eq, Default)]
331pub struct Hierarchy {
332    /// Hierarchy name.
333    pub name: String,
334    /// Levels from coarsest to finest, in source order.
335    pub levels: Vec<HierarchyLevel>,
336    /// Hidden from report authors; hidden objects are still live if referenced.
337    pub is_hidden: bool,
338}
339
340/// One level of a hierarchy.
341#[derive(Debug, Clone, PartialEq, Eq, Default)]
342pub struct HierarchyLevel {
343    /// Level name; may differ from the underlying column name.
344    pub name: String,
345    /// Column name in the owning table.
346    pub column: String,
347}
348
349/// A row-level-security role.
350#[derive(Debug, Clone, PartialEq, Eq, Default)]
351pub struct Role {
352    /// Role name.
353    pub name: String,
354    /// Per-table permissions granted by this role.
355    pub table_permissions: Vec<TablePermission>,
356}
357
358/// A role's permission on one table.
359#[derive(Debug, Clone, PartialEq, Eq, Default)]
360pub struct TablePermission {
361    /// Target table name.
362    pub table: String,
363    /// DAX row filter; None = metadata-only permission.
364    pub filter_expression: Option<String>,
365}
366
367/// The calculation group defined on a table.
368#[derive(Debug, Clone, PartialEq, Eq, Default)]
369pub struct CalculationGroup {
370    /// Calculation items in source order.
371    pub items: Vec<CalculationItem>,
372    /// DAX evaluated when no calculation item is selected (TOM noSelectionExpression).
373    pub no_selection_expression: Option<String>,
374    /// Dynamic format string (DAX) for the no-selection case.
375    pub no_selection_format_string_expression: Option<String>,
376    /// DAX evaluated when multiple items are selected or the selection is empty
377    /// (TOM multipleOrEmptySelectionExpression).
378    pub multiple_or_empty_selection_expression: Option<String>,
379    /// Dynamic format string (DAX) for the multiple-or-empty-selection case.
380    pub multiple_or_empty_selection_format_string_expression: Option<String>,
381}
382
383/// One item of a calculation group.
384#[derive(Debug, Clone, PartialEq, Eq, Default)]
385pub struct CalculationItem {
386    /// Item name.
387    pub name: String,
388    /// The item's DAX expression, typically wrapping SELECTEDMEASURE().
389    pub expression: String,
390    /// Dynamic format string (DAX) applied when this item is selected.
391    pub format_string_expression: Option<String>,
392}
393
394/// A model-level shared M expression: a Power Query parameter or shared query.
395#[derive(Debug, Clone, PartialEq, Eq, Default)]
396pub struct SharedExpression {
397    /// Expression name, as referenced from other M queries.
398    pub name: String,
399    /// M expression text.
400    pub expression: String,
401    /// The column this parameter's view-time values are bound from — TMDL
402    /// `parameterValuesColumn: Table.Column` on a dynamic M query parameter.
403    /// The binding keeps the column alive (the graph links the expression to
404    /// it structurally). The column side carries only an anonymous marker
405    /// extended property, so this property is the binding's authoritative
406    /// half; `None` for parameters and shared queries without a binding.
407    pub parameter_values_column: Option<ParameterValuesColumn>,
408}
409
410/// The `Table.Column` a dynamic M query parameter binds its view-time values
411/// from — the parameter-to-column binding of a dynamic M parameter.
412#[derive(Debug, Clone, PartialEq, Eq, Default)]
413pub struct ParameterValuesColumn {
414    /// Name of the table owning the bound column.
415    pub table: String,
416    /// Name of the bound column.
417    pub column: String,
418}
419
420/// A user-defined DAX function (TOM function). Referenced from DAX by name;
421/// its body can be the sole reference keeping another object alive — and the
422/// function itself can be dead.
423#[derive(Debug, Clone, PartialEq, Eq, Default)]
424pub struct Function {
425    /// Function name; model-global, as referenced from DAX.
426    pub name: String,
427    /// The function's DAX body.
428    pub expression: String,
429    /// Hidden from report authors; hidden objects are still live if referenced.
430    pub is_hidden: bool,
431}
432
433/// A calendar (TOM calendar) defined on a table, binding groups of its columns.
434///
435/// Modeled minimally — the name and the bound column names — which is all a static
436/// source file can contribute to liveness: a referenced calendar keeps its bound
437/// columns alive.
438#[derive(Debug, Clone, PartialEq, Eq, Default)]
439pub struct Calendar {
440    /// Calendar name.
441    pub name: String,
442    /// Names of the columns (in the owning table) the calendar binds.
443    pub columns: Vec<String>,
444}
445
446/// Handle dereferencing: turning a positional handle from
447/// [`ModelIndex`](index::ModelIndex) back into the object it points at.
448///
449/// Every accessor goes through `.get()` and returns [`Option`]. A handle is only
450/// meaningful against the database its index was built from, and a handle from a
451/// different or since-mutated database is a normal miss, never a panic.
452impl TabularDatabase {
453    /// The table a handle points at, or `None` if the handle is stale.
454    pub fn table(&self, h: TableHandle) -> Option<&Table> {
455        self.tables.get(h.0)
456    }
457
458    /// The column a handle points at, or `None` if either index is out of range.
459    pub fn column(&self, h: ColumnHandle) -> Option<&Column> {
460        self.tables.get(h.table)?.columns.get(h.column)
461    }
462
463    /// The measure a handle points at, or `None` if either index is out of range.
464    pub fn measure(&self, h: MeasureHandle) -> Option<&Measure> {
465        self.tables.get(h.table)?.measures.get(h.measure)
466    }
467
468    /// The hierarchy a handle points at, or `None` if either index is out of range.
469    pub fn hierarchy(&self, h: HierarchyHandle) -> Option<&Hierarchy> {
470        self.tables.get(h.table)?.hierarchies.get(h.hierarchy)
471    }
472
473    /// The shared M expression a handle points at, or `None` if the handle is stale.
474    pub fn shared_expression(&self, h: ExpressionHandle) -> Option<&SharedExpression> {
475        self.expressions.get(h.0)
476    }
477
478    /// The user-defined function a handle points at, or `None` if the handle is stale.
479    pub fn function(&self, h: FunctionHandle) -> Option<&Function> {
480        self.functions.get(h.0)
481    }
482
483    /// The stable graph-node identity of a resolved reference.
484    ///
485    /// Names come from the objects themselves, so the id carries the model's own
486    /// casing for display; [`ObjectId`] still compares case-insensitively.
487    pub fn object_id(&self, r: Resolved) -> Option<ObjectId> {
488        match r {
489            Resolved::Column(h) => {
490                let table = self.tables.get(h.table)?;
491                let column = table.columns.get(h.column)?;
492                Some(ObjectId::Column {
493                    table: NameKey::new(table.name.as_str()),
494                    column: NameKey::new(column.name.as_str()),
495                })
496            }
497            Resolved::Measure(h) => {
498                let table = self.tables.get(h.table)?;
499                let measure = table.measures.get(h.measure)?;
500                Some(ObjectId::Measure {
501                    table: NameKey::new(table.name.as_str()),
502                    measure: NameKey::new(measure.name.as_str()),
503                })
504            }
505        }
506    }
507}
508
509/// Which property of its owner a DAX expression came from — model-side or
510/// report-side.
511///
512/// The graph layer matches on this to decide what kind of edge a discovered
513/// reference produces; the two enumerations ([`TabularDatabase::dax_expressions`]
514/// and [`crate::report::ReportModel::dax_expressions`]) guarantee every variant
515/// has exactly one production site.
516#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
517pub enum DaxExpressionKind {
518    /// A measure's own expression.
519    Measure,
520    /// A measure's dynamic format string.
521    MeasureFormatString,
522    /// A measure's detail-rows (drillthrough) expression.
523    MeasureDetailRows,
524    /// A KPI's target expression.
525    KpiTarget,
526    /// A KPI's status expression.
527    KpiStatus,
528    /// A KPI's trend expression.
529    KpiTrend,
530    /// A calculated column's expression.
531    CalculatedColumn,
532    /// The DAX partition expression that materializes a calculated table.
533    CalculatedTable,
534    /// An incremental refresh policy's change-detection expression (TOM
535    /// pollingExpression): evaluated per partition at refresh time, so deleting
536    /// what it references breaks refresh.
537    ChangeDetection,
538    /// A table's default detail-rows (drillthrough) expression.
539    TableDetailRows,
540    /// A role's row-level-security filter on one table.
541    RlsFilter,
542    /// A calculation item's expression.
543    CalculationItem,
544    /// A calculation item's dynamic format string.
545    CalculationItemFormatString,
546    /// A calculation group's no-selection expression.
547    CalculationGroupNoSelection,
548    /// A calculation group's no-selection dynamic format string.
549    CalculationGroupNoSelectionFormatString,
550    /// A calculation group's multiple-or-empty-selection expression.
551    CalculationGroupMultipleOrEmptySelection,
552    /// A calculation group's multiple-or-empty-selection dynamic format string.
553    CalculationGroupMultipleOrEmptySelectionFormatString,
554    /// A user-defined function's body.
555    Function,
556    /// A report-level measure's own expression (reportExtensions.json).
557    ReportMeasure,
558    /// A report-level measure's dynamic format string.
559    ReportMeasureFormatString,
560}
561
562/// The model or report object an enumerated expression belongs to.
563///
564/// Names are borrowed from the AST, so enumerating a model's expressions
565/// allocates nothing. Call [`to_object_id`](ExpressionOwner::to_object_id) to
566/// materialize a graph node key — once per node the graph actually creates, rather
567/// than once per expression.
568///
569/// The variants are exactly the objects that can own an expression, which is why
570/// there is no hierarchy here: hierarchies reference columns but define no DAX.
571/// The one report-side variant is the report-level measure, whose DAX body is an
572/// expression source the model knows nothing about (see
573/// [`crate::report::ReportModel::dax_expressions`]).
574#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
575pub enum ExpressionOwner<'a> {
576    /// A table, owning its detail-rows expression.
577    Table {
578        /// Table name.
579        table: &'a str,
580    },
581    /// A calculated column.
582    Column {
583        /// Owning table.
584        table: &'a str,
585        /// Column name.
586        column: &'a str,
587    },
588    /// A measure, owning its expression, format string, detail rows, and KPI.
589    Measure {
590        /// Home table.
591        table: &'a str,
592        /// Measure name.
593        measure: &'a str,
594    },
595    /// A partition, owning its M or DAX source query.
596    Partition {
597        /// Owning table.
598        table: &'a str,
599        /// Partition name.
600        partition: &'a str,
601    },
602    /// A security role, owning its row-level-security filters.
603    Role {
604        /// Role name.
605        role: &'a str,
606    },
607    /// A calculation item.
608    CalculationItem {
609        /// Calculation group table.
610        table: &'a str,
611        /// Calculation item name.
612        item: &'a str,
613    },
614    /// A model-level shared M expression.
615    Expression {
616        /// Expression name.
617        name: &'a str,
618    },
619    /// A user-defined DAX function.
620    Function {
621        /// Function name.
622        name: &'a str,
623    },
624    /// A report-level measure (reportExtensions.json), owning its expression and
625    /// dynamic format string. Lives in the report, not the model.
626    ReportMeasure {
627        /// Measure name, report-scoped.
628        measure: &'a str,
629    },
630}
631
632impl ExpressionOwner<'_> {
633    /// The owner's stable graph-node identity, allocating the owned name keys.
634    #[must_use]
635    pub fn to_object_id(&self) -> ObjectId {
636        match *self {
637            ExpressionOwner::Table { table } => ObjectId::Table {
638                table: NameKey::new(table),
639            },
640            ExpressionOwner::Column { table, column } => ObjectId::Column {
641                table: NameKey::new(table),
642                column: NameKey::new(column),
643            },
644            ExpressionOwner::Measure { table, measure } => ObjectId::Measure {
645                table: NameKey::new(table),
646                measure: NameKey::new(measure),
647            },
648            ExpressionOwner::Partition { table, partition } => ObjectId::Partition {
649                table: NameKey::new(table),
650                partition: NameKey::new(partition),
651            },
652            ExpressionOwner::Role { role } => ObjectId::Role {
653                role: NameKey::new(role),
654            },
655            ExpressionOwner::CalculationItem { table, item } => ObjectId::CalculationItem {
656                table: NameKey::new(table),
657                item: NameKey::new(item),
658            },
659            ExpressionOwner::Expression { name } => ObjectId::Expression {
660                name: NameKey::new(name),
661            },
662            ExpressionOwner::Function { name } => ObjectId::Function {
663                name: NameKey::new(name),
664            },
665            ExpressionOwner::ReportMeasure { measure } => ObjectId::ReportMeasure {
666                measure: NameKey::new(measure),
667            },
668        }
669    }
670}
671
672/// Borrowed view of one DAX expression owned by a model object.
673#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
674pub struct DaxExpressionRef<'a> {
675    /// The object the expression belongs to — the source node of any edge derived
676    /// from references found in `text`.
677    pub owner: ExpressionOwner<'a>,
678    /// Which property of `owner` this expression is.
679    pub kind: DaxExpressionKind,
680    /// Context table for unqualified-column resolution by the lexer.
681    pub home_table: Option<&'a str>,
682    /// The expression text, borrowed from the model.
683    pub text: &'a str,
684}
685
686/// Borrowed view of one M expression owned by a model object.
687#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
688pub struct MExpressionRef<'a> {
689    /// The object the expression belongs to: a partition or a shared expression.
690    pub owner: ExpressionOwner<'a>,
691    /// The expression text, borrowed from the model.
692    pub text: &'a str,
693}
694
695impl TabularDatabase {
696    /// Every DAX expression in the model, with its owner and home-table context.
697    ///
698    /// Order follows model order (tables, then each table's measures, columns,
699    /// partitions, refresh-policy change detection, table-level expressions,
700    /// calculation items and their group's selection expressions, then roles,
701    /// then functions), so the result is deterministic for a given model and
702    /// diffable across runs.
703    ///
704    /// Owners borrow their names, so this allocates only the returned `Vec`.
705    #[must_use]
706    pub fn dax_expressions(&self) -> Vec<DaxExpressionRef<'_>> {
707        let mut out = Vec::new();
708
709        for table in &self.tables {
710            let home = Some(table.name.as_str());
711
712            for measure in &table.measures {
713                let owner = ExpressionOwner::Measure {
714                    table: &table.name,
715                    measure: &measure.name,
716                };
717                let kpi = measure.kpi.as_ref();
718                let sources = [
719                    (DaxExpressionKind::Measure, Some(&measure.expression)),
720                    (
721                        DaxExpressionKind::MeasureFormatString,
722                        measure.format_string_expression.as_ref(),
723                    ),
724                    (
725                        DaxExpressionKind::MeasureDetailRows,
726                        measure.detail_rows_expression.as_ref(),
727                    ),
728                    (
729                        DaxExpressionKind::KpiTarget,
730                        kpi.and_then(|kpi| kpi.target_expression.as_ref()),
731                    ),
732                    (
733                        DaxExpressionKind::KpiStatus,
734                        kpi.and_then(|kpi| kpi.status_expression.as_ref()),
735                    ),
736                    (
737                        DaxExpressionKind::KpiTrend,
738                        kpi.and_then(|kpi| kpi.trend_expression.as_ref()),
739                    ),
740                ];
741                for (kind, text) in sources {
742                    if let Some(text) = text {
743                        out.push(DaxExpressionRef {
744                            owner,
745                            kind,
746                            home_table: home,
747                            text,
748                        });
749                    }
750                }
751            }
752
753            for column in &table.columns {
754                if let ColumnKind::Calculated { expression } = &column.kind {
755                    out.push(DaxExpressionRef {
756                        owner: ExpressionOwner::Column {
757                            table: &table.name,
758                            column: &column.name,
759                        },
760                        kind: DaxExpressionKind::CalculatedColumn,
761                        home_table: home,
762                        text: expression,
763                    });
764                }
765            }
766
767            for partition in &table.partitions {
768                if let PartitionSource::Calculated { expression } = &partition.source {
769                    // The home table is the calculated table itself. Unqualified columns
770                    // in a calculated-table expression usually belong to the source
771                    // table, so this is conservative: it can only add candidate edges.
772                    out.push(DaxExpressionRef {
773                        owner: ExpressionOwner::Partition {
774                            table: &table.name,
775                            partition: &partition.name,
776                        },
777                        kind: DaxExpressionKind::CalculatedTable,
778                        home_table: home,
779                        text: expression,
780                    });
781                }
782            }
783
784            // A change-detection expression is evaluated per partition at refresh
785            // time, so the policy references what deleting would break. The owner is
786            // each partition the policy refreshes, which keeps the ordinary rule
787            // intact: a dead table's policy keeps nothing alive.
788            if let Some(text) = table
789                .refresh_policy
790                .as_ref()
791                .and_then(|policy| policy.change_detection.as_ref())
792            {
793                for partition in &table.partitions {
794                    out.push(DaxExpressionRef {
795                        owner: ExpressionOwner::Partition {
796                            table: &table.name,
797                            partition: &partition.name,
798                        },
799                        kind: DaxExpressionKind::ChangeDetection,
800                        home_table: home,
801                        text,
802                    });
803                }
804            }
805
806            if let Some(text) = &table.detail_rows_expression {
807                out.push(DaxExpressionRef {
808                    owner: ExpressionOwner::Table { table: &table.name },
809                    kind: DaxExpressionKind::TableDetailRows,
810                    home_table: home,
811                    text,
812                });
813            }
814
815            if let Some(group) = &table.calculation_group {
816                for item in &group.items {
817                    let owner = ExpressionOwner::CalculationItem {
818                        table: &table.name,
819                        item: &item.name,
820                    };
821                    out.push(DaxExpressionRef {
822                        owner,
823                        kind: DaxExpressionKind::CalculationItem,
824                        home_table: home,
825                        text: item.expression.as_str(),
826                    });
827                    if let Some(text) = &item.format_string_expression {
828                        out.push(DaxExpressionRef {
829                            owner,
830                            kind: DaxExpressionKind::CalculationItemFormatString,
831                            home_table: home,
832                            text,
833                        });
834                    }
835                }
836
837                // Group-level selection expressions. The calc group is a property of
838                // its table in TOM, so — like detail rows — the table is the owner and
839                // the kind is what discriminates.
840                let group_owner = ExpressionOwner::Table { table: &table.name };
841                let sources = [
842                    (
843                        DaxExpressionKind::CalculationGroupNoSelection,
844                        group.no_selection_expression.as_ref(),
845                    ),
846                    (
847                        DaxExpressionKind::CalculationGroupNoSelectionFormatString,
848                        group.no_selection_format_string_expression.as_ref(),
849                    ),
850                    (
851                        DaxExpressionKind::CalculationGroupMultipleOrEmptySelection,
852                        group.multiple_or_empty_selection_expression.as_ref(),
853                    ),
854                    (
855                        DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString,
856                        group
857                            .multiple_or_empty_selection_format_string_expression
858                            .as_ref(),
859                    ),
860                ];
861                for (kind, text) in sources {
862                    if let Some(text) = text {
863                        out.push(DaxExpressionRef {
864                            owner: group_owner,
865                            kind,
866                            home_table: home,
867                            text,
868                        });
869                    }
870                }
871            }
872        }
873
874        for role in &self.roles {
875            for permission in &role.table_permissions {
876                if let Some(text) = &permission.filter_expression {
877                    // The row context of an RLS filter is the table it is applied to,
878                    // not anything owned by the role.
879                    out.push(DaxExpressionRef {
880                        owner: ExpressionOwner::Role { role: &role.name },
881                        kind: DaxExpressionKind::RlsFilter,
882                        home_table: Some(permission.table.as_str()),
883                        text,
884                    });
885                }
886            }
887        }
888
889        for function in &self.functions {
890            // A function body has no row context of its own: unqualified `[Name]`
891            // references inside it can only be measures.
892            out.push(DaxExpressionRef {
893                owner: ExpressionOwner::Function {
894                    name: &function.name,
895                },
896                kind: DaxExpressionKind::Function,
897                home_table: None,
898                text: &function.expression,
899            });
900        }
901
902        out
903    }
904
905    /// Every M expression: M partitions, a refresh policy's expressions, and
906    /// shared model expressions.
907    ///
908    /// `Query` and `Other` partition sources are not M and are excluded. The
909    /// policy's `sourceExpression` is M by definition; its change-detection
910    /// expression is handed to both lexers — the M side resolves the tables and
911    /// shared expressions it reads (polling by shared-query name is the
912    /// documented custom pattern), while the DAX side ([`TabularDatabase::dax_expressions`])
913    /// resolves its measure references.
914    #[must_use]
915    pub fn m_expressions(&self) -> Vec<MExpressionRef<'_>> {
916        let mut out = Vec::new();
917
918        for table in &self.tables {
919            for partition in &table.partitions {
920                if let PartitionSource::M { expression } = &partition.source {
921                    out.push(MExpressionRef {
922                        owner: ExpressionOwner::Partition {
923                            table: &table.name,
924                            partition: &partition.name,
925                        },
926                        text: expression,
927                    });
928                }
929            }
930
931            // Same per-partition ownership as the DAX side: the policy speaks
932            // for the partitions it refreshes, and a partition that does not
933            // exist cannot vouch for anything.
934            if let Some(policy) = &table.refresh_policy {
935                let texts = [
936                    policy.source_expression.as_ref(),
937                    policy.change_detection.as_ref(),
938                ];
939                for text in texts.into_iter().flatten() {
940                    for partition in &table.partitions {
941                        out.push(MExpressionRef {
942                            owner: ExpressionOwner::Partition {
943                                table: &table.name,
944                                partition: &partition.name,
945                            },
946                            text,
947                        });
948                    }
949                }
950            }
951        }
952
953        for expression in &self.expressions {
954            out.push(MExpressionRef {
955                owner: ExpressionOwner::Expression {
956                    name: &expression.name,
957                },
958                text: expression.expression.as_str(),
959            });
960        }
961
962        out
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969    use rstest::rstest;
970
971    fn table_id(table: &str) -> ObjectId {
972        ObjectId::Table {
973            table: NameKey::new(table),
974        }
975    }
976
977    fn column_id(table: &str, column: &str) -> ObjectId {
978        ObjectId::Column {
979            table: NameKey::new(table),
980            column: NameKey::new(column),
981        }
982    }
983
984    fn measure_id(table: &str, measure: &str) -> ObjectId {
985        ObjectId::Measure {
986            table: NameKey::new(table),
987            measure: NameKey::new(measure),
988        }
989    }
990
991    fn partition_id(table: &str, partition: &str) -> ObjectId {
992        ObjectId::Partition {
993            table: NameKey::new(table),
994            partition: NameKey::new(partition),
995        }
996    }
997
998    fn calc_item_id(table: &str, item: &str) -> ObjectId {
999        ObjectId::CalculationItem {
1000            table: NameKey::new(table),
1001            item: NameKey::new(item),
1002        }
1003    }
1004
1005    fn expression_id(name: &str) -> ObjectId {
1006        ObjectId::Expression {
1007            name: NameKey::new(name),
1008        }
1009    }
1010
1011    fn function_id(name: &str) -> ObjectId {
1012        ObjectId::Function {
1013            name: NameKey::new(name),
1014        }
1015    }
1016
1017    fn role_id(role: &str) -> ObjectId {
1018        ObjectId::Role {
1019            role: NameKey::new(role),
1020        }
1021    }
1022
1023    fn partition(name: &str, source: PartitionSource) -> Partition {
1024        Partition {
1025            name: name.to_string(),
1026            source,
1027        }
1028    }
1029
1030    /// `(kind, owner, home_table, text)` for every DAX expression, in order.
1031    fn dax_tuples(db: &TabularDatabase) -> Vec<(DaxExpressionKind, ObjectId, Option<&str>, &str)> {
1032        db.dax_expressions()
1033            .into_iter()
1034            .map(|e| (e.kind, e.owner.to_object_id(), e.home_table, e.text))
1035            .collect()
1036    }
1037
1038    /// `(owner, text)` for every M expression, in order.
1039    fn m_tuples(db: &TabularDatabase) -> Vec<(ObjectId, &str)> {
1040        db.m_expressions()
1041            .into_iter()
1042            .map(|e| (e.owner.to_object_id(), e.text))
1043            .collect()
1044    }
1045
1046    fn owners(db: &TabularDatabase) -> Vec<ObjectId> {
1047        dax_tuples(db)
1048            .into_iter()
1049            .map(|(_, owner, _, _)| owner)
1050            .collect()
1051    }
1052
1053    /// Exercises every [`DaxExpressionKind`] exactly once, plus two objects that
1054    /// must contribute nothing on their own: a `Data` column and a metadata-only
1055    /// table permission. The M partition contributes no DAX of its own — its
1056    /// `ChangeDetection` entry is the refresh policy's, not the M query.
1057    fn every_kind_fixture() -> TabularDatabase {
1058        TabularDatabase {
1059            name: Some("Contoso".to_string()),
1060            tables: vec![
1061                Table {
1062                    name: "Sales".to_string(),
1063                    columns: vec![
1064                        Column {
1065                            name: "Amount".to_string(),
1066                            kind: ColumnKind::Data,
1067                            ..Default::default()
1068                        },
1069                        Column {
1070                            name: "Margin".to_string(),
1071                            kind: ColumnKind::Calculated {
1072                                expression: "'Sales'[Amount] * 0.2".to_string(),
1073                            },
1074                            ..Default::default()
1075                        },
1076                    ],
1077                    measures: vec![Measure {
1078                        name: "Total Sales".to_string(),
1079                        expression: "SUM('Sales'[Amount])".to_string(),
1080                        is_hidden: false,
1081                        format_string_expression: Some("\"#,##0\"".to_string()),
1082                        detail_rows_expression: Some("SELECTCOLUMNS('Sales')".to_string()),
1083                        kpi: Some(Kpi {
1084                            target_expression: Some("[Budget]".to_string()),
1085                            status_expression: Some("IF([Total Sales] > 0, 1, -1)".to_string()),
1086                            trend_expression: Some("[Total Sales] - [Prior]".to_string()),
1087                        }),
1088                    }],
1089                    partitions: vec![partition(
1090                        "Sales-Part1",
1091                        PartitionSource::M {
1092                            expression: "let Source = Sql.Database() in Source".to_string(),
1093                        },
1094                    )],
1095                    refresh_policy: Some(RefreshPolicy {
1096                        policy_type: Some("basicRefreshPolicy".to_string()),
1097                        source_expression: Some(
1098                            "let Source = Sql.Database(Server, DB) in Source".to_string(),
1099                        ),
1100                        change_detection: Some(
1101                            "EVALUATE ROW(\"Bookmark\", [Total Sales])".to_string(),
1102                        ),
1103                    }),
1104                    detail_rows_expression: Some(
1105                        "SELECTCOLUMNS('Sales', \"A\", [Amount])".to_string(),
1106                    ),
1107                    ..Default::default()
1108                },
1109                Table {
1110                    name: "Top Products".to_string(),
1111                    partitions: vec![partition(
1112                        "Top Products",
1113                        PartitionSource::Calculated {
1114                            expression: "TOPN(10, 'Product', [Total Sales])".to_string(),
1115                        },
1116                    )],
1117                    ..Default::default()
1118                },
1119                Table {
1120                    name: "Time Intelligence".to_string(),
1121                    calculation_group: Some(CalculationGroup {
1122                        items: vec![CalculationItem {
1123                            name: "YTD".to_string(),
1124                            expression: "TOTALYTD(SELECTEDMEASURE(), 'Date'[Date])".to_string(),
1125                            format_string_expression: Some("\"#,##0;;\"".to_string()),
1126                        }],
1127                        no_selection_expression: Some("SELECTEDMEASURE()".to_string()),
1128                        no_selection_format_string_expression: Some(
1129                            "SELECTEDMEASUREFORMATSTRING()".to_string(),
1130                        ),
1131                        multiple_or_empty_selection_expression: Some(
1132                            "ERROR(\"Pick one item\")".to_string(),
1133                        ),
1134                        multiple_or_empty_selection_format_string_expression: Some(
1135                            "\"General\"".to_string(),
1136                        ),
1137                    }),
1138                    ..Default::default()
1139                },
1140            ],
1141            functions: vec![Function {
1142                name: "Sales.NetPrice".to_string(),
1143                expression: "(price: SCALAR) => price * (1 - [Discount Pct])".to_string(),
1144                is_hidden: false,
1145            }],
1146            roles: vec![Role {
1147                name: "Reader".to_string(),
1148                table_permissions: vec![
1149                    TablePermission {
1150                        table: "Sales".to_string(),
1151                        filter_expression: Some("'Sales'[Amount] > 0".to_string()),
1152                    },
1153                    TablePermission {
1154                        table: "Top Products".to_string(),
1155                        filter_expression: None,
1156                    },
1157                ],
1158            }],
1159            ..Default::default()
1160        }
1161    }
1162
1163    fn m_fixture() -> TabularDatabase {
1164        TabularDatabase {
1165            tables: vec![Table {
1166                name: "Sales".to_string(),
1167                partitions: vec![
1168                    partition(
1169                        "Sales-M",
1170                        PartitionSource::M {
1171                            expression: "let Source = Sql.Database(Server) in Source".to_string(),
1172                        },
1173                    ),
1174                    partition(
1175                        "Sales-Native",
1176                        PartitionSource::Query {
1177                            query: "SELECT * FROM dbo.Sales".to_string(),
1178                        },
1179                    ),
1180                    partition(
1181                        "Sales-Lake",
1182                        PartitionSource::Other {
1183                            kind: Some("entity".to_string()),
1184                        },
1185                    ),
1186                ],
1187                ..Default::default()
1188            }],
1189            expressions: vec![
1190                SharedExpression {
1191                    name: "Server".to_string(),
1192                    expression: "\"contoso.database.windows.net\"".to_string(),
1193                    ..Default::default()
1194                },
1195                SharedExpression {
1196                    name: "Database".to_string(),
1197                    expression: "\"AdventureWorks\"".to_string(),
1198                    ..Default::default()
1199                },
1200            ],
1201            ..Default::default()
1202        }
1203    }
1204
1205    mod expression_views {
1206        use super::*;
1207
1208        /// Both expression views must stay `Copy`, which is only possible while every
1209        /// field borrows. It is the structural guarantee that enumerating a model's
1210        /// expressions allocates nothing but the returned `Vec` — adding an owned
1211        /// field (an `ObjectId`, a `String`) breaks this and reintroduces a
1212        /// per-expression allocation on the graph layer's hot path.
1213        #[test]
1214        fn are_copy_so_enumeration_borrows_everything() {
1215            fn assert_copy<T: Copy>() {}
1216            assert_copy::<DaxExpressionRef<'_>>();
1217            assert_copy::<MExpressionRef<'_>>();
1218            assert_copy::<ExpressionOwner<'_>>();
1219        }
1220    }
1221
1222    /// A calculated table is one whose partition source is DAX. There is no flag in
1223    /// TOM and none here, so every other source kind — including ones this crate does
1224    /// not recognize — must read as not calculated.
1225    mod is_calculated {
1226        use super::*;
1227
1228        fn table_with(source: Option<PartitionSource>) -> Table {
1229            Table {
1230                name: "Anything".to_string(),
1231                partitions: source.into_iter().map(|s| partition("P", s)).collect(),
1232                ..Default::default()
1233            }
1234        }
1235
1236        #[rstest]
1237        #[case::dax_partition(
1238            Some(PartitionSource::Calculated { expression: "TOPN(10, 'Sales')".to_string() }),
1239            true
1240        )]
1241        #[case::m_partition(
1242            Some(PartitionSource::M { expression: "let Source = Sql.Database() in Source".to_string() }),
1243            false
1244        )]
1245        #[case::native_query(
1246            Some(PartitionSource::Query { query: "SELECT * FROM dbo.Sales".to_string() }),
1247            false
1248        )]
1249        #[case::direct_lake_entity(
1250            Some(PartitionSource::Other { kind: Some("entity".to_string()) }),
1251            false
1252        )]
1253        #[case::unknown_future_source(Some(PartitionSource::Other { kind: None }), false)]
1254        #[case::no_partitions(None, false)]
1255        fn follows_the_partition_source(
1256            #[case] source: Option<PartitionSource>,
1257            #[case] expected: bool,
1258        ) {
1259            assert_eq!(table_with(source).is_calculated(), expected);
1260        }
1261
1262        #[test]
1263        fn is_true_when_only_one_of_several_partitions_is_dax() {
1264            let mixed = Table {
1265                name: "Sales".to_string(),
1266                partitions: vec![
1267                    partition(
1268                        "Sales-2023",
1269                        PartitionSource::Query {
1270                            query: "SELECT * FROM dbo.Sales".to_string(),
1271                        },
1272                    ),
1273                    partition(
1274                        "Sales-2024",
1275                        PartitionSource::Calculated {
1276                            expression: "FILTER('Raw', TRUE())".to_string(),
1277                        },
1278                    ),
1279                ],
1280                ..Default::default()
1281            };
1282
1283            assert!(
1284                mixed.is_calculated(),
1285                "one DAX partition makes the table calculated"
1286            );
1287        }
1288    }
1289
1290    mod defaults {
1291        use super::*;
1292
1293        /// TMDL omits the flag for active relationships, so a relationship built
1294        /// field-by-field must come out active. A derived `Default` would silently
1295        /// make every one of them inactive.
1296        #[test]
1297        fn a_relationship_is_active() {
1298            assert!(Relationship::default().is_active);
1299        }
1300
1301        #[test]
1302        fn a_relationship_has_no_other_content() {
1303            assert_eq!(
1304                Relationship::default(),
1305                Relationship {
1306                    name: None,
1307                    from_table: String::new(),
1308                    from_column: String::new(),
1309                    to_table: String::new(),
1310                    to_column: String::new(),
1311                    is_active: true,
1312                }
1313            );
1314        }
1315
1316        /// An unparsed source must never be mistaken for a query language, or schema
1317        /// drift would feed junk to the DAX lexer.
1318        #[test]
1319        fn a_partition_source_is_other_with_no_kind() {
1320            assert_eq!(
1321                PartitionSource::default(),
1322                PartitionSource::Other { kind: None }
1323            );
1324        }
1325
1326        #[test]
1327        fn a_partition_carries_the_default_source() {
1328            assert_eq!(
1329                Partition::default().source,
1330                PartitionSource::Other { kind: None }
1331            );
1332        }
1333
1334        #[test]
1335        fn a_column_kind_is_data() {
1336            assert_eq!(ColumnKind::default(), ColumnKind::Data);
1337        }
1338
1339        #[test]
1340        fn a_column_carries_the_default_kind() {
1341            assert_eq!(Column::default().kind, ColumnKind::Data);
1342        }
1343    }
1344
1345    mod dax_expressions {
1346        use super::*;
1347
1348        #[test]
1349        fn enumerates_every_kind_with_exact_owner_home_and_text() {
1350            let db = every_kind_fixture();
1351
1352            assert_eq!(
1353                dax_tuples(&db),
1354                vec![
1355                    (
1356                        DaxExpressionKind::Measure,
1357                        measure_id("Sales", "Total Sales"),
1358                        Some("Sales"),
1359                        "SUM('Sales'[Amount])",
1360                    ),
1361                    (
1362                        DaxExpressionKind::MeasureFormatString,
1363                        measure_id("Sales", "Total Sales"),
1364                        Some("Sales"),
1365                        "\"#,##0\"",
1366                    ),
1367                    (
1368                        DaxExpressionKind::MeasureDetailRows,
1369                        measure_id("Sales", "Total Sales"),
1370                        Some("Sales"),
1371                        "SELECTCOLUMNS('Sales')",
1372                    ),
1373                    (
1374                        DaxExpressionKind::KpiTarget,
1375                        measure_id("Sales", "Total Sales"),
1376                        Some("Sales"),
1377                        "[Budget]",
1378                    ),
1379                    (
1380                        DaxExpressionKind::KpiStatus,
1381                        measure_id("Sales", "Total Sales"),
1382                        Some("Sales"),
1383                        "IF([Total Sales] > 0, 1, -1)",
1384                    ),
1385                    (
1386                        DaxExpressionKind::KpiTrend,
1387                        measure_id("Sales", "Total Sales"),
1388                        Some("Sales"),
1389                        "[Total Sales] - [Prior]",
1390                    ),
1391                    (
1392                        DaxExpressionKind::CalculatedColumn,
1393                        column_id("Sales", "Margin"),
1394                        Some("Sales"),
1395                        "'Sales'[Amount] * 0.2",
1396                    ),
1397                    (
1398                        DaxExpressionKind::ChangeDetection,
1399                        partition_id("Sales", "Sales-Part1"),
1400                        Some("Sales"),
1401                        "EVALUATE ROW(\"Bookmark\", [Total Sales])",
1402                    ),
1403                    (
1404                        DaxExpressionKind::TableDetailRows,
1405                        table_id("Sales"),
1406                        Some("Sales"),
1407                        "SELECTCOLUMNS('Sales', \"A\", [Amount])",
1408                    ),
1409                    (
1410                        DaxExpressionKind::CalculatedTable,
1411                        partition_id("Top Products", "Top Products"),
1412                        Some("Top Products"),
1413                        "TOPN(10, 'Product', [Total Sales])",
1414                    ),
1415                    (
1416                        DaxExpressionKind::CalculationItem,
1417                        calc_item_id("Time Intelligence", "YTD"),
1418                        Some("Time Intelligence"),
1419                        "TOTALYTD(SELECTEDMEASURE(), 'Date'[Date])",
1420                    ),
1421                    (
1422                        DaxExpressionKind::CalculationItemFormatString,
1423                        calc_item_id("Time Intelligence", "YTD"),
1424                        Some("Time Intelligence"),
1425                        "\"#,##0;;\"",
1426                    ),
1427                    (
1428                        DaxExpressionKind::CalculationGroupNoSelection,
1429                        table_id("Time Intelligence"),
1430                        Some("Time Intelligence"),
1431                        "SELECTEDMEASURE()",
1432                    ),
1433                    (
1434                        DaxExpressionKind::CalculationGroupNoSelectionFormatString,
1435                        table_id("Time Intelligence"),
1436                        Some("Time Intelligence"),
1437                        "SELECTEDMEASUREFORMATSTRING()",
1438                    ),
1439                    (
1440                        DaxExpressionKind::CalculationGroupMultipleOrEmptySelection,
1441                        table_id("Time Intelligence"),
1442                        Some("Time Intelligence"),
1443                        "ERROR(\"Pick one item\")",
1444                    ),
1445                    (
1446                        DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString,
1447                        table_id("Time Intelligence"),
1448                        Some("Time Intelligence"),
1449                        "\"General\"",
1450                    ),
1451                    (
1452                        DaxExpressionKind::RlsFilter,
1453                        role_id("Reader"),
1454                        Some("Sales"),
1455                        "'Sales'[Amount] > 0",
1456                    ),
1457                    (
1458                        DaxExpressionKind::Function,
1459                        function_id("Sales.NetPrice"),
1460                        None,
1461                        "(price: SCALAR) => price * (1 - [Discount Pct])",
1462                    ),
1463                ]
1464            );
1465        }
1466
1467        #[test]
1468        fn enumerates_one_expression_per_populated_site() {
1469            assert_eq!(dax_tuples(&every_kind_fixture()).len(), 18);
1470        }
1471
1472        /// `ObjectId` equality is case-insensitive, so the tuple assertion above
1473        /// cannot catch an owner built from a lowercased or rewritten name.
1474        #[test]
1475        fn owners_preserve_source_casing() {
1476            let db = every_kind_fixture();
1477            let displayed: Vec<String> = owners(&db).iter().map(ObjectId::to_string).collect();
1478
1479            assert_eq!(
1480                displayed,
1481                vec![
1482                    "'Sales'[Total Sales]",
1483                    "'Sales'[Total Sales]",
1484                    "'Sales'[Total Sales]",
1485                    "'Sales'[Total Sales]",
1486                    "'Sales'[Total Sales]",
1487                    "'Sales'[Total Sales]",
1488                    "'Sales'[Margin]",
1489                    "partition 'Sales'[Sales-Part1]",
1490                    "table 'Sales'",
1491                    "partition 'Top Products'[Top Products]",
1492                    "calculation item 'Time Intelligence'[YTD]",
1493                    "calculation item 'Time Intelligence'[YTD]",
1494                    "table 'Time Intelligence'",
1495                    "table 'Time Intelligence'",
1496                    "table 'Time Intelligence'",
1497                    "table 'Time Intelligence'",
1498                    "role 'Reader'",
1499                    "function 'Sales.NetPrice'",
1500                ]
1501            );
1502        }
1503
1504        #[rstest]
1505        #[case::a_data_column(column_id("Sales", "Amount"))]
1506        fn excludes(#[case] unwanted: ObjectId) {
1507            let db = every_kind_fixture();
1508
1509            assert!(
1510                !owners(&db).contains(&unwanted),
1511                "{unwanted} owns no DAX and must not be enumerated"
1512            );
1513        }
1514
1515        #[test]
1516        fn excludes_m_partition_text() {
1517            let db = every_kind_fixture();
1518
1519            assert!(
1520                !dax_tuples(&db)
1521                    .iter()
1522                    .any(|(_, _, _, text)| text.starts_with("let Source")),
1523                "an M query must never be handed to the DAX lexer"
1524            );
1525        }
1526
1527        /// The policy speaks for each partition it refreshes: the same text is
1528        /// enumerated once per partition, under that partition's owner.
1529        #[test]
1530        fn a_change_detection_expression_is_emitted_once_per_partition() {
1531            let db = TabularDatabase {
1532                tables: vec![Table {
1533                    name: "Sales".to_string(),
1534                    partitions: vec![
1535                        partition(
1536                            "Sales-2023",
1537                            PartitionSource::M {
1538                                expression: "let Source = 1 in Source".to_string(),
1539                            },
1540                        ),
1541                        partition(
1542                            "Sales-2024",
1543                            PartitionSource::M {
1544                                expression: "let Source = 2 in Source".to_string(),
1545                            },
1546                        ),
1547                    ],
1548                    refresh_policy: Some(RefreshPolicy {
1549                        change_detection: Some("[Total Sales]".to_string()),
1550                        ..Default::default()
1551                    }),
1552                    ..Default::default()
1553                }],
1554                ..Default::default()
1555            };
1556
1557            let found = dax_tuples(&db);
1558            assert_eq!(found.len(), 2);
1559            assert!(found.iter().all(|(kind, _, _, text)| *kind
1560                == DaxExpressionKind::ChangeDetection
1561                && *text == "[Total Sales]"));
1562            let owners: Vec<ObjectId> = found.into_iter().map(|(_, owner, _, _)| owner).collect();
1563            assert!(owners.contains(&partition_id("Sales", "Sales-2023")));
1564            assert!(owners.contains(&partition_id("Sales", "Sales-2024")));
1565        }
1566
1567        /// A policy on a partition-less table has nothing to vouch for.
1568        #[test]
1569        fn a_change_detection_expression_without_partitions_is_not_emitted() {
1570            let db = TabularDatabase {
1571                tables: vec![Table {
1572                    name: "Sales".to_string(),
1573                    refresh_policy: Some(RefreshPolicy {
1574                        change_detection: Some("[Total Sales]".to_string()),
1575                        ..Default::default()
1576                    }),
1577                    ..Default::default()
1578                }],
1579                ..Default::default()
1580            };
1581
1582            assert_eq!(db.dax_expressions().len(), 0);
1583        }
1584
1585        /// The fixture's role filters one table and holds metadata-only permission on
1586        /// another; only the filtered one is an expression.
1587        #[test]
1588        fn emits_one_filter_for_a_role_with_one_filtered_permission() {
1589            let db = every_kind_fixture();
1590
1591            assert_eq!(
1592                dax_tuples(&db)
1593                    .iter()
1594                    .filter(|(kind, _, _, _)| *kind == DaxExpressionKind::RlsFilter)
1595                    .count(),
1596                1
1597            );
1598        }
1599
1600        #[test]
1601        fn excludes_metadata_only_permissions() {
1602            let db = every_kind_fixture();
1603
1604            assert!(
1605                !dax_tuples(&db).iter().any(|(kind, _, home, _)| {
1606                    *kind == DaxExpressionKind::RlsFilter && *home == Some("Top Products")
1607                }),
1608                "a permission with no filter expression contributes nothing"
1609            );
1610        }
1611
1612        #[test]
1613        fn is_empty_for_a_model_with_no_dax() {
1614            let db = TabularDatabase {
1615                tables: vec![Table {
1616                    name: "Sales".to_string(),
1617                    columns: vec![Column {
1618                        name: "Amount".to_string(),
1619                        ..Default::default()
1620                    }],
1621                    partitions: vec![partition(
1622                        "Sales",
1623                        PartitionSource::M {
1624                            expression: "let Source = 1 in Source".to_string(),
1625                        },
1626                    )],
1627                    ..Default::default()
1628                }],
1629                ..Default::default()
1630            };
1631
1632            assert_eq!(db.dax_expressions().len(), 0);
1633        }
1634
1635        /// Order is model order, so a run is deterministic and diffable.
1636        #[test]
1637        fn preserves_model_order_within_a_table() {
1638            let db = every_kind_fixture();
1639            let kinds: Vec<DaxExpressionKind> =
1640                db.dax_expressions().iter().map(|e| e.kind).collect();
1641
1642            assert_eq!(
1643                kinds,
1644                vec![
1645                    DaxExpressionKind::Measure,
1646                    DaxExpressionKind::MeasureFormatString,
1647                    DaxExpressionKind::MeasureDetailRows,
1648                    DaxExpressionKind::KpiTarget,
1649                    DaxExpressionKind::KpiStatus,
1650                    DaxExpressionKind::KpiTrend,
1651                    DaxExpressionKind::CalculatedColumn,
1652                    DaxExpressionKind::ChangeDetection,
1653                    DaxExpressionKind::TableDetailRows,
1654                    DaxExpressionKind::CalculatedTable,
1655                    DaxExpressionKind::CalculationItem,
1656                    DaxExpressionKind::CalculationItemFormatString,
1657                    DaxExpressionKind::CalculationGroupNoSelection,
1658                    DaxExpressionKind::CalculationGroupNoSelectionFormatString,
1659                    DaxExpressionKind::CalculationGroupMultipleOrEmptySelection,
1660                    DaxExpressionKind::CalculationGroupMultipleOrEmptySelectionFormatString,
1661                    DaxExpressionKind::RlsFilter,
1662                    DaxExpressionKind::Function,
1663                ]
1664            );
1665        }
1666
1667        #[test]
1668        fn follows_table_and_measure_declaration_order() {
1669            let measure = |name: &str, expression: &str| Measure {
1670                name: name.to_string(),
1671                expression: expression.to_string(),
1672                ..Default::default()
1673            };
1674            let db = TabularDatabase {
1675                tables: vec![
1676                    Table {
1677                        name: "Zebra".to_string(),
1678                        measures: vec![measure("M2", "2"), measure("M1", "1")],
1679                        ..Default::default()
1680                    },
1681                    Table {
1682                        name: "Apple".to_string(),
1683                        measures: vec![measure("M3", "3")],
1684                        ..Default::default()
1685                    },
1686                ],
1687                ..Default::default()
1688            };
1689
1690            let found: Vec<(ObjectId, &str)> = db
1691                .dax_expressions()
1692                .into_iter()
1693                .map(|e| (e.owner.to_object_id(), e.text))
1694                .collect();
1695
1696            assert_eq!(
1697                found,
1698                vec![
1699                    (measure_id("Zebra", "M2"), "2"),
1700                    (measure_id("Zebra", "M1"), "1"),
1701                    (measure_id("Apple", "M3"), "3"),
1702                ]
1703            );
1704        }
1705    }
1706
1707    mod m_expressions {
1708        use super::*;
1709
1710        #[test]
1711        fn covers_m_partitions_and_shared_expressions_with_exact_owner_and_text() {
1712            let db = m_fixture();
1713
1714            assert_eq!(
1715                m_tuples(&db),
1716                vec![
1717                    (
1718                        partition_id("Sales", "Sales-M"),
1719                        "let Source = Sql.Database(Server) in Source",
1720                    ),
1721                    (expression_id("Server"), "\"contoso.database.windows.net\""),
1722                    (expression_id("Database"), "\"AdventureWorks\""),
1723                ]
1724            );
1725        }
1726
1727        #[rstest]
1728        #[case::a_native_query_partition(partition_id("Sales", "Sales-Native"))]
1729        #[case::an_unrecognized_source_partition(partition_id("Sales", "Sales-Lake"))]
1730        fn excludes(#[case] unwanted: ObjectId) {
1731            let db = m_fixture();
1732
1733            assert!(
1734                !m_tuples(&db)
1735                    .into_iter()
1736                    .any(|(owner, _)| owner == unwanted),
1737                "{unwanted} holds no M and must not be enumerated"
1738            );
1739        }
1740
1741        /// A native query is neither M nor DAX, so it reaches no lexer at all.
1742        #[test]
1743        fn leaves_native_query_partitions_out_of_the_dax_enumeration_too() {
1744            assert_eq!(m_fixture().dax_expressions().len(), 0);
1745        }
1746
1747        #[test]
1748        fn is_empty_for_a_model_with_no_m() {
1749            let db = TabularDatabase {
1750                tables: vec![Table {
1751                    name: "Top Products".to_string(),
1752                    partitions: vec![partition(
1753                        "Top Products",
1754                        PartitionSource::Calculated {
1755                            expression: "TOPN(10, 'Product')".to_string(),
1756                        },
1757                    )],
1758                    ..Default::default()
1759                }],
1760                ..Default::default()
1761            };
1762
1763            assert_eq!(db.m_expressions().len(), 0);
1764        }
1765
1766        /// The policy's expressions ride the M pipeline per partition: the
1767        /// source names the query pipeline it partitions, change detection may
1768        /// name the shared query it polls. Change detection is deliberately
1769        /// double-tracked — the DAX side resolves its measure references.
1770        #[test]
1771        fn refresh_policy_expressions_flow_through_the_m_enumeration() {
1772            let db = every_kind_fixture();
1773
1774            assert_eq!(
1775                m_tuples(&db),
1776                vec![
1777                    (
1778                        partition_id("Sales", "Sales-Part1"),
1779                        "let Source = Sql.Database() in Source",
1780                    ),
1781                    (
1782                        partition_id("Sales", "Sales-Part1"),
1783                        "let Source = Sql.Database(Server, DB) in Source",
1784                    ),
1785                    (
1786                        partition_id("Sales", "Sales-Part1"),
1787                        "EVALUATE ROW(\"Bookmark\", [Total Sales])",
1788                    ),
1789                ]
1790            );
1791        }
1792    }
1793}