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