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