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}