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