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