ripbi_core/model/index.rs
1//! Case-insensitive name lookup over a [`TabularDatabase`].
2//!
3//! DAX and PBIR bindings reference model objects by name; the graph layer needs a
4//! stable node key. This module bridges the two: [`ModelIndex`] is built once after
5//! ingestion and turns a written name into a positional handle
6//! ([`TableHandle`], [`ColumnHandle`], [`MeasureHandle`], [`HierarchyHandle`],
7//! [`ExpressionHandle`]), which the accessors on [`TabularDatabase`] turn back into
8//! borrowed AST nodes and [`ObjectId`](crate::identity::ObjectId)s.
9//!
10//! Two rules govern every resolution here:
11//!
12//! - **Zero false positives.** When a reference is genuinely ambiguous — an
13//! unqualified `[Name]` that is both a measure and a column of the home table —
14//! [`resolve_unqualified`](ModelIndex::resolve_unqualified) returns *all*
15//! candidates. Marking too much used is safe; marking too little deletes live code.
16//! - **Never fail on drift.** Duplicate names are invalid in a real model but do
17//! occur in hand-edited files; the first occurrence wins and the build never panics.
18//! An unresolvable name is data (`None`), not an error.
19//!
20//! All keys and all lookup inputs pass through `identity::fold_name`, the single
21//! case-folding chokepoint.
22
23use std::collections::HashMap;
24
25use crate::identity::fold_name;
26use crate::model::TabularDatabase;
27
28/// Positional index of a table in [`TabularDatabase::tables`].
29///
30/// Only meaningful against the database the index was built from, which must not
31/// be mutated afterwards. Every accessor treats a stale handle as a miss.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub struct TableHandle(
34 /// Index into [`TabularDatabase::tables`].
35 pub usize,
36);
37
38/// Positional index of a column: its table, then its position in
39/// [`Table::columns`](crate::model::Table::columns).
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
41pub struct ColumnHandle {
42 /// Index into [`TabularDatabase::tables`].
43 pub table: usize,
44 /// Index into that table's `columns`.
45 pub column: usize,
46}
47
48/// Positional index of a measure: its home table, then its position in
49/// [`Table::measures`](crate::model::Table::measures).
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
51pub struct MeasureHandle {
52 /// Index into [`TabularDatabase::tables`].
53 pub table: usize,
54 /// Index into that table's `measures`.
55 pub measure: usize,
56}
57
58/// Positional index of a hierarchy: its table, then its position in
59/// [`Table::hierarchies`](crate::model::Table::hierarchies).
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
61pub struct HierarchyHandle {
62 /// Index into [`TabularDatabase::tables`].
63 pub table: usize,
64 /// Index into that table's `hierarchies`.
65 pub hierarchy: usize,
66}
67
68/// Positional index of a model-level shared M expression in
69/// [`TabularDatabase::expressions`].
70#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
71pub struct ExpressionHandle(
72 /// Index into [`TabularDatabase::expressions`].
73 pub usize,
74);
75
76/// Positional index of a user-defined DAX function in
77/// [`TabularDatabase::functions`].
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
79pub struct FunctionHandle(
80 /// Index into [`TabularDatabase::functions`].
81 pub usize,
82);
83
84/// What a field reference resolved to.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86pub enum Resolved {
87 /// The reference names a column.
88 Column(ColumnHandle),
89 /// The reference names a measure.
90 Measure(MeasureHandle),
91}
92
93/// Every candidate an unqualified `[Name]` reference could bind to.
94///
95/// The graph layer must add an edge to **every** candidate. In DAX row context
96/// `[Name]` binds to the home table's column; outside row context it binds to the
97/// measure of that name. A lexer cannot tell the two apart without a full parse and
98/// semantic analysis, so both objects must stay alive.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
100pub struct UnqualifiedMatches {
101 /// The model-global measure of that name. Measure names are unique across the
102 /// whole model (the engine enforces it), so no home table is needed.
103 pub measure: Option<MeasureHandle>,
104 /// The home table's column of that name, when a home table was supplied.
105 pub column: Option<ColumnHandle>,
106}
107
108impl UnqualifiedMatches {
109 /// The single best answer for callers that cannot carry ambiguity: the measure
110 /// if there is one, otherwise the column.
111 ///
112 /// The graph layer must **not** use this — it would drop a live candidate. It
113 /// exists for diagnostics and for callers that only need something to display.
114 #[must_use]
115 pub fn primary(&self) -> Option<Resolved> {
116 match (self.measure, self.column) {
117 (Some(measure), _) => Some(Resolved::Measure(measure)),
118 (None, Some(column)) => Some(Resolved::Column(column)),
119 (None, None) => None,
120 }
121 }
122
123 /// True when the name matched nothing. An unresolved reference is data — a
124 /// stale expression, a typo, a table removed by hand — not an error.
125 #[must_use]
126 pub fn is_empty(&self) -> bool {
127 self.measure.is_none() && self.column.is_none()
128 }
129}
130
131/// One table's share of the index: everything reachable by name *within* a table.
132///
133/// Handles are stored whole rather than as bare positions because two tables can
134/// fold to the same name. The entry then belongs to the first of them, while a
135/// handle inserted from the second still points at the table it actually came from.
136#[derive(Debug, Clone, Default)]
137struct TableEntry {
138 /// Index of the first table with this folded name.
139 table: usize,
140 /// Folded column name → handle.
141 columns: HashMap<String, ColumnHandle>,
142 /// Folded hierarchy name → handle.
143 hierarchies: HashMap<String, HierarchyHandle>,
144}
145
146/// Case-insensitive lookup index over a [`TabularDatabase`].
147///
148/// Build it once, after ingestion, with [`ModelIndex::build`]. Building never fails:
149/// duplicate names are invalid in a valid model but tolerated here, with the **first**
150/// occurrence kept and later ones ignored.
151///
152/// Per-table names are nested under their table rather than keyed by a
153/// `(table, name)` pair, so a lookup folds each half once and allocates no tuple —
154/// this is the DAX lexer's hot path, one call per reference in every expression.
155#[derive(Debug, Clone)]
156pub struct ModelIndex {
157 /// Folded table name → that table's names.
158 tables: HashMap<String, TableEntry>,
159 /// Folded measure name → measure handle. Global: measure names are unique
160 /// across the whole model, not just within their home table.
161 measures: HashMap<String, MeasureHandle>,
162 /// Folded shared-expression name → expression handle.
163 expressions: HashMap<String, ExpressionHandle>,
164 /// Folded function name → function handle. Global: function names are
165 /// model-global, like measures.
166 functions: HashMap<String, FunctionHandle>,
167}
168
169impl ModelIndex {
170 /// Indexes every table, column, measure, hierarchy, and shared expression.
171 ///
172 /// Runs in one pass over the model, folding each name once. On a duplicate
173 /// folded name the first occurrence is kept.
174 #[must_use]
175 pub fn build(db: &TabularDatabase) -> Self {
176 let mut tables: HashMap<String, TableEntry> = HashMap::new();
177 let mut measures: HashMap<String, MeasureHandle> = HashMap::new();
178 let mut expressions: HashMap<String, ExpressionHandle> = HashMap::new();
179 let mut functions: HashMap<String, FunctionHandle> = HashMap::new();
180
181 for (table_idx, table) in db.tables.iter().enumerate() {
182 // `or_insert_with` — not `insert` — is what makes the first occurrence win.
183 let entry = tables
184 .entry(fold_name(&table.name))
185 .or_insert_with(|| TableEntry {
186 table: table_idx,
187 ..TableEntry::default()
188 });
189
190 for (column_idx, column) in table.columns.iter().enumerate() {
191 entry
192 .columns
193 .entry(fold_name(&column.name))
194 .or_insert(ColumnHandle {
195 table: table_idx,
196 column: column_idx,
197 });
198 }
199
200 for (hierarchy_idx, hierarchy) in table.hierarchies.iter().enumerate() {
201 entry
202 .hierarchies
203 .entry(fold_name(&hierarchy.name))
204 .or_insert(HierarchyHandle {
205 table: table_idx,
206 hierarchy: hierarchy_idx,
207 });
208 }
209
210 for (measure_idx, measure) in table.measures.iter().enumerate() {
211 measures
212 .entry(fold_name(&measure.name))
213 .or_insert(MeasureHandle {
214 table: table_idx,
215 measure: measure_idx,
216 });
217 }
218 }
219
220 for (expression_idx, expression) in db.expressions.iter().enumerate() {
221 expressions
222 .entry(fold_name(&expression.name))
223 .or_insert(ExpressionHandle(expression_idx));
224 }
225
226 for (function_idx, function) in db.functions.iter().enumerate() {
227 functions
228 .entry(fold_name(&function.name))
229 .or_insert(FunctionHandle(function_idx));
230 }
231
232 Self {
233 tables,
234 measures,
235 expressions,
236 functions,
237 }
238 }
239
240 /// Looks up a table by name, case-insensitively.
241 #[must_use]
242 pub fn resolve_table(&self, name: &str) -> Option<TableHandle> {
243 self.tables
244 .get(&fold_name(name))
245 .map(|entry| TableHandle(entry.table))
246 }
247
248 /// Resolves a qualified reference, `Table[Name]`.
249 ///
250 /// The named table's columns are tried first. Falling through to a measure is
251 /// deliberate and conservative: measure names are model-global, so a qualified
252 /// reference carrying a wrong or stale table prefix — `'Dato'[Total Sales]` for a
253 /// measure that lives on `Sales`, or a prefix naming a table that no longer
254 /// exists — still keeps that measure alive. Marking one object used too many is
255 /// safe; marking one too few deletes live code.
256 ///
257 /// # Examples
258 ///
259 /// ```
260 /// # use ripbi_core::{Measure, ModelIndex, Table, TabularDatabase};
261 /// # let db = TabularDatabase {
262 /// # tables: vec![
263 /// # Table {
264 /// # name: "Sales".to_string(),
265 /// # measures: vec![Measure { name: "Total".to_string(), ..Default::default() }],
266 /// # ..Default::default()
267 /// # },
268 /// # Table { name: "Dato".to_string(), ..Default::default() },
269 /// # ],
270 /// # ..Default::default()
271 /// # };
272 /// // The model has one measure, `Total`, whose home table is `Sales`.
273 /// let index = ModelIndex::build(&db);
274 ///
275 /// // A stale prefix still keeps it alive: measure names are model-global.
276 /// assert!(index.resolve_qualified("Dato", "Total").is_some());
277 /// assert!(index.resolve_qualified("No Such Table", "total").is_some());
278 ///
279 /// // A name that matches nothing resolves to nothing.
280 /// assert!(index.resolve_qualified("Sales", "Nope").is_none());
281 /// ```
282 #[must_use]
283 pub fn resolve_qualified(&self, table: &str, name: &str) -> Option<Resolved> {
284 let folded_name = fold_name(name);
285 let column = self
286 .tables
287 .get(&fold_name(table))
288 .and_then(|entry| entry.columns.get(&folded_name));
289 if let Some(column) = column {
290 return Some(Resolved::Column(*column));
291 }
292 self.measures
293 .get(&folded_name)
294 .copied()
295 .map(Resolved::Measure)
296 }
297
298 /// Looks up a column on a specific table — how M field access
299 /// `#"Sales"[Amount]` resolves. Unlike [`resolve_qualified`](Self::resolve_qualified)
300 /// there is no measure fallback: an M expression cannot reference a
301 /// measure, so a measure of the same name must not be kept alive by one.
302 #[must_use]
303 pub fn resolve_column(&self, table: &str, name: &str) -> Option<ColumnHandle> {
304 self.tables
305 .get(&fold_name(table))?
306 .columns
307 .get(&fold_name(name))
308 .copied()
309 }
310
311 /// Finds **every** column of a name, on every table — how M resolves the
312 /// string arguments of its column-centric built-ins and an unqualified
313 /// `[Name]` field access.
314 ///
315 /// M string arguments carry no row context: `Table.NestedJoin(Source,
316 /// "Key", …)` can name any table's column, and a lexer cannot tell which
317 /// without a full dataflow analysis. The conservative direction is to keep
318 /// **all** candidates alive; the result is sorted by table, then column,
319 /// so it is deterministic for a given model.
320 ///
321 /// ```
322 /// # use ripbi_core::{Column, ModelIndex, Table, TabularDatabase};
323 /// # let db = TabularDatabase {
324 /// # tables: vec![
325 /// # Table {
326 /// # name: "Sales".to_string(),
327 /// # columns: vec![Column { name: "Key".to_string(), ..Default::default() }],
328 /// # ..Default::default()
329 /// # },
330 /// # Table {
331 /// # name: "Dato".to_string(),
332 /// # columns: vec![Column { name: "Key".to_string(), ..Default::default() }],
333 /// # ..Default::default()
334 /// # },
335 /// # ],
336 /// # ..Default::default()
337 /// # };
338 /// let index = ModelIndex::build(&db);
339 ///
340 /// // Both tables have a `Key` column; a merge step naming "Key" keeps
341 /// // both alive.
342 /// assert_eq!(index.resolve_columns("key").len(), 2);
343 ///
344 /// // An unknown name is data, not an error.
345 /// assert!(index.resolve_columns("Ukendt").is_empty());
346 /// ```
347 #[must_use]
348 pub fn resolve_columns(&self, name: &str) -> Vec<ColumnHandle> {
349 let folded_name = fold_name(name);
350 let mut handles: Vec<ColumnHandle> = self
351 .tables
352 .values()
353 .filter_map(|entry| entry.columns.get(&folded_name).copied())
354 .collect();
355 handles.sort_by_key(|handle| (handle.table, handle.column));
356 handles
357 }
358
359 /// Resolves an unqualified reference, `[Name]`, to **all** its candidates.
360 ///
361 /// `home_table` is the row-context table of the expression the reference was
362 /// found in; pass `None` where there is none. See [`UnqualifiedMatches`] for why
363 /// both a measure and a column can come back at once.
364 ///
365 /// # Examples
366 ///
367 /// ```
368 /// # use ripbi_core::{Column, Measure, ModelIndex, Table, TabularDatabase};
369 /// # let db = TabularDatabase {
370 /// # tables: vec![
371 /// # Table {
372 /// # name: "Sales".to_string(),
373 /// # measures: vec![Measure { name: "Antal".to_string(), ..Default::default() }],
374 /// # ..Default::default()
375 /// # },
376 /// # Table {
377 /// # name: "Dato".to_string(),
378 /// # columns: vec![Column { name: "Antal".to_string(), ..Default::default() }],
379 /// # ..Default::default()
380 /// # },
381 /// # ],
382 /// # ..Default::default()
383 /// # };
384 /// // `Antal` is a measure on `Sales` and, separately, a column of `Dato`.
385 /// let index = ModelIndex::build(&db);
386 ///
387 /// // Inside a `Dato` row context both are live candidates, so both come back.
388 /// let ambiguous = index.resolve_unqualified("ANTAL", Some("Dato"));
389 /// assert!(ambiguous.measure.is_some());
390 /// assert!(ambiguous.column.is_some());
391 ///
392 /// // With no row context there is no column candidate to consider.
393 /// assert!(index.resolve_unqualified("antal", None).column.is_none());
394 ///
395 /// // An unknown name is data, not an error.
396 /// assert!(index.resolve_unqualified("Ukendt", Some("Dato")).is_empty());
397 /// ```
398 #[must_use]
399 pub fn resolve_unqualified(&self, name: &str, home_table: Option<&str>) -> UnqualifiedMatches {
400 let folded_name = fold_name(name);
401 UnqualifiedMatches {
402 measure: self.measures.get(&folded_name).copied(),
403 column: home_table.and_then(|table| {
404 self.tables
405 .get(&fold_name(table))
406 .and_then(|entry| entry.columns.get(&folded_name))
407 .copied()
408 }),
409 }
410 }
411
412 /// Looks up a hierarchy on a specific table, as written in `ISINSCOPE('Date'[Calendar])`
413 /// or in a PBIR hierarchy binding. Hierarchy names are only unique per table, so
414 /// there is no unqualified form and no cross-table fallback.
415 #[must_use]
416 pub fn resolve_hierarchy(&self, table: &str, name: &str) -> Option<HierarchyHandle> {
417 self.tables
418 .get(&fold_name(table))?
419 .hierarchies
420 .get(&fold_name(name))
421 .copied()
422 }
423
424 /// Looks up a model-level shared M expression by name — how one M query
425 /// references a parameter or another query.
426 #[must_use]
427 pub fn resolve_expression(&self, name: &str) -> Option<ExpressionHandle> {
428 self.expressions.get(&fold_name(name)).copied()
429 }
430
431 /// Looks up a user-defined DAX function by name — how a DAX expression
432 /// calls it. Function names are model-global.
433 #[must_use]
434 pub fn resolve_function(&self, name: &str) -> Option<FunctionHandle> {
435 self.functions.get(&fold_name(name)).copied()
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442 use crate::model::{Column, Function, Hierarchy, Measure, SharedExpression, Table};
443 use rstest::rstest;
444
445 fn column(name: &str) -> Column {
446 Column {
447 name: name.to_string(),
448 ..Default::default()
449 }
450 }
451
452 fn measure(name: &str) -> Measure {
453 Measure {
454 name: name.to_string(),
455 expression: "0".to_string(),
456 ..Default::default()
457 }
458 }
459
460 /// Fixture with hand-checked positions. Every assertion below names these
461 /// indices as literals, so a resolution that drifts by one position fails.
462 ///
463 /// ```text
464 /// table 0 "Sales" columns 0 "Amount" 1 "Beløb"
465 /// measures 0 "Total Sales" 1 "Antal"
466 /// table 1 "Dato" columns 0 "Måned" 1 "Antal"
467 /// measures 0 "Omsætning"
468 /// hierarchies 0 "Kalender"
469 /// table 2 "sales" columns 0 "Amount" <- duplicate of table 0
470 /// expressions 0 "Server" 1 "Database"
471 /// functions 0 "Sales.NetPrice"
472 /// ```
473 ///
474 /// "Antal" is deliberately both a measure (on `Sales`) and a column (on `Dato`):
475 /// that is the ambiguity the zero-false-positive rule exists for.
476 fn model() -> TabularDatabase {
477 TabularDatabase {
478 name: Some("Contoso".to_string()),
479 tables: vec![
480 Table {
481 name: "Sales".to_string(),
482 columns: vec![column("Amount"), column("Beløb")],
483 measures: vec![measure("Total Sales"), measure("Antal")],
484 ..Default::default()
485 },
486 Table {
487 name: "Dato".to_string(),
488 columns: vec![column("Måned"), column("Antal")],
489 measures: vec![measure("Omsætning")],
490 hierarchies: vec![Hierarchy {
491 name: "Kalender".to_string(),
492 ..Default::default()
493 }],
494 ..Default::default()
495 },
496 Table {
497 name: "sales".to_string(),
498 columns: vec![column("Amount")],
499 ..Default::default()
500 },
501 ],
502 expressions: vec![
503 SharedExpression {
504 name: "Server".to_string(),
505 expression: "\"contoso.database.windows.net\"".to_string(),
506 },
507 SharedExpression {
508 name: "Database".to_string(),
509 expression: "\"AdventureWorks\"".to_string(),
510 },
511 ],
512 functions: vec![Function {
513 name: "Sales.NetPrice".to_string(),
514 expression: "(price: SCALAR) => price * 0.75".to_string(),
515 is_hidden: false,
516 }],
517 ..Default::default()
518 }
519 }
520
521 fn index() -> ModelIndex {
522 ModelIndex::build(&model())
523 }
524
525 fn column_handle(table: usize, column: usize) -> Resolved {
526 Resolved::Column(ColumnHandle { table, column })
527 }
528
529 fn measure_handle(table: usize, measure: usize) -> Resolved {
530 Resolved::Measure(MeasureHandle { table, measure })
531 }
532
533 mod resolve_table {
534 use super::*;
535
536 #[rstest]
537 #[case::upper("SALES", 0)]
538 #[case::as_written("Sales", 0)]
539 #[case::danish_lower("dato", 1)]
540 #[case::danish_upper("DATO", 1)]
541 fn finds_a_table_ignoring_case(#[case] name: &str, #[case] expected: usize) {
542 assert_eq!(index().resolve_table(name), Some(TableHandle(expected)));
543 }
544
545 #[rstest]
546 #[case::misspelled("Salez")]
547 #[case::empty("")]
548 fn returns_none_for_an_unknown_name(#[case] name: &str) {
549 assert_eq!(index().resolve_table(name), None);
550 }
551 }
552
553 mod resolve_qualified {
554 use super::*;
555
556 #[rstest]
557 #[case::lower_table_upper_column("sales", "AMOUNT", 0, 0)]
558 #[case::upper_table_danish_column("DATO", "måned", 1, 0)]
559 #[case::danish_column_upper("Dato", "MÅNED", 1, 0)]
560 fn finds_a_column_on_the_named_table(
561 #[case] table: &str,
562 #[case] name: &str,
563 #[case] expected_table: usize,
564 #[case] expected_column: usize,
565 ) {
566 assert_eq!(
567 index().resolve_qualified(table, name),
568 Some(column_handle(expected_table, expected_column))
569 );
570 }
571
572 /// Measure names are model-global, so a qualified reference resolves to one
573 /// even when the prefix is wrong or names a table that no longer exists —
574 /// keeping it alive rather than reporting a live measure as unused.
575 #[rstest]
576 #[case::on_its_home_table("SALES", "total sales", 0, 0)]
577 #[case::danish_on_its_home_table("Dato", "OMSÆTNING", 1, 0)]
578 #[case::under_a_wrong_table_prefix("Dato", "Total Sales", 0, 0)]
579 #[case::under_a_nonexistent_table_prefix("Ukendt Tabel", "omsætning", 1, 0)]
580 fn finds_a_measure(
581 #[case] table: &str,
582 #[case] name: &str,
583 #[case] expected_table: usize,
584 #[case] expected_measure: usize,
585 ) {
586 assert_eq!(
587 index().resolve_qualified(table, name),
588 Some(measure_handle(expected_table, expected_measure))
589 );
590 }
591
592 #[rstest]
593 // "Amount" is a column of Sales and a measure nowhere, so a wrong prefix has
594 // nothing to fall back to.
595 #[case::column_of_a_different_table("Dato", "Amount")]
596 #[case::unknown_name("Sales", "Nope")]
597 #[case::unknown_table_and_name("Ukendt Tabel", "Måned")]
598 fn returns_none_when_nothing_matches(#[case] table: &str, #[case] name: &str) {
599 assert_eq!(index().resolve_qualified(table, name), None);
600 }
601
602 /// "Antal" is a column of Dato and a measure of Sales. The measure fallback is
603 /// a last resort, not a shortcut past the named table's own columns.
604 #[test]
605 fn binds_to_the_named_tables_column_when_both_exist() {
606 assert_eq!(
607 index().resolve_qualified("Dato", "Antal"),
608 Some(column_handle(1, 1))
609 );
610 }
611
612 #[test]
613 fn binds_to_the_measure_when_the_named_table_has_no_such_column() {
614 assert_eq!(
615 index().resolve_qualified("Sales", "antal"),
616 Some(measure_handle(0, 1))
617 );
618 }
619 }
620
621 mod resolve_unqualified {
622 use super::*;
623
624 /// Measures are model-global, so a Danish measure on `Dato` resolves from an
625 /// expression with no row context at all.
626 #[test]
627 fn finds_a_global_measure_without_a_home_table() {
628 assert_eq!(
629 index().resolve_unqualified("OMSÆTNING", None).measure,
630 Some(MeasureHandle {
631 table: 1,
632 measure: 0
633 })
634 );
635 }
636
637 #[test]
638 fn offers_no_column_candidate_without_a_home_table() {
639 assert_eq!(index().resolve_unqualified("OMSÆTNING", None).column, None);
640 }
641
642 /// `[Antal]` inside a `Dato` row context could bind to either object, so both
643 /// come back and both stay alive.
644 #[test]
645 fn returns_the_measure_candidate_for_an_ambiguous_name() {
646 assert_eq!(
647 index().resolve_unqualified("antal", Some("Dato")).measure,
648 Some(MeasureHandle {
649 table: 0,
650 measure: 1
651 })
652 );
653 }
654
655 #[test]
656 fn returns_the_column_candidate_for_an_ambiguous_name() {
657 assert_eq!(
658 index().resolve_unqualified("antal", Some("Dato")).column,
659 Some(ColumnHandle {
660 table: 1,
661 column: 1
662 })
663 );
664 }
665
666 #[test]
667 fn drops_the_column_candidate_when_there_is_no_home_table() {
668 assert_eq!(index().resolve_unqualified("antal", None).column, None);
669 }
670
671 #[test]
672 fn finds_a_column_of_the_home_table() {
673 assert_eq!(
674 index().resolve_unqualified("BELØB", Some("sales")).column,
675 Some(ColumnHandle {
676 table: 0,
677 column: 1
678 })
679 );
680 }
681
682 #[test]
683 fn offers_no_measure_candidate_for_a_column_only_name() {
684 assert_eq!(
685 index().resolve_unqualified("BELØB", Some("sales")).measure,
686 None
687 );
688 }
689
690 /// A column is never global: the same name against the wrong row context is
691 /// not a match.
692 #[test]
693 fn matches_nothing_against_the_wrong_home_table() {
694 let found = index().resolve_unqualified("Beløb", Some("Dato"));
695
696 assert!(found.is_empty(), "expected no candidates, got {found:?}");
697 }
698
699 #[rstest]
700 #[case::known_home_table(Some("Sales"))]
701 #[case::unknown_home_table(Some("Ukendt Tabel"))]
702 #[case::no_home_table(None)]
703 fn is_empty_for_an_unknown_name(#[case] home_table: Option<&str>) {
704 let found = index().resolve_unqualified("Ukendt", home_table);
705
706 assert!(found.is_empty(), "expected no candidates, got {found:?}");
707 }
708
709 #[test]
710 fn primary_prefers_the_measure_when_a_name_is_ambiguous() {
711 assert_eq!(
712 index().resolve_unqualified("antal", Some("Dato")).primary(),
713 Some(measure_handle(0, 1))
714 );
715 }
716
717 #[test]
718 fn primary_returns_the_column_when_there_is_no_measure() {
719 assert_eq!(
720 index()
721 .resolve_unqualified("BELØB", Some("sales"))
722 .primary(),
723 Some(column_handle(0, 1))
724 );
725 }
726
727 #[test]
728 fn primary_is_none_for_an_unknown_name() {
729 assert_eq!(
730 index()
731 .resolve_unqualified("Ukendt", Some("Sales"))
732 .primary(),
733 None
734 );
735 }
736 }
737
738 mod resolve_hierarchy {
739 use super::*;
740
741 #[rstest]
742 #[case::lower_table_upper_name("dato", "KALENDER")]
743 #[case::upper_table_as_written("DATO", "Kalender")]
744 fn finds_a_hierarchy_ignoring_case(#[case] table: &str, #[case] name: &str) {
745 assert_eq!(
746 index().resolve_hierarchy(table, name),
747 Some(HierarchyHandle {
748 table: 1,
749 hierarchy: 0
750 })
751 );
752 }
753
754 #[rstest]
755 // Hierarchy names are unique per table only: no cross-table fallback, and no
756 // confusion with the columns or measures of the same table.
757 #[case::another_table("Sales", "Kalender")]
758 #[case::a_column_name("Dato", "Måned")]
759 #[case::unknown_table("Ukendt Tabel", "Kalender")]
760 fn returns_none(#[case] table: &str, #[case] name: &str) {
761 assert_eq!(index().resolve_hierarchy(table, name), None);
762 }
763 }
764
765 mod resolve_expression {
766 use super::*;
767
768 #[rstest]
769 #[case::upper("SERVER", 0)]
770 #[case::as_written("Server", 0)]
771 #[case::lower("database", 1)]
772 fn finds_an_expression_ignoring_case(#[case] name: &str, #[case] expected: usize) {
773 assert_eq!(
774 index().resolve_expression(name),
775 Some(ExpressionHandle(expected))
776 );
777 }
778
779 #[test]
780 fn returns_none_for_an_unknown_name() {
781 assert_eq!(index().resolve_expression("Ukendt"), None);
782 }
783 }
784
785 mod resolve_function {
786 use super::*;
787
788 #[rstest]
789 #[case::as_written("Sales.NetPrice")]
790 #[case::upper("SALES.NETPRICE")]
791 #[case::lower("sales.netprice")]
792 fn finds_a_function_ignoring_case(#[case] name: &str) {
793 assert_eq!(index().resolve_function(name), Some(FunctionHandle(0)));
794 }
795
796 #[rstest]
797 // A function name is not an expression name and vice versa.
798 #[case::a_shared_expression_name("Server")]
799 #[case::a_measure_name("Total Sales")]
800 #[case::unknown("Ukendt")]
801 fn returns_none(#[case] name: &str) {
802 assert_eq!(index().resolve_function(name), None);
803 }
804 }
805
806 /// Tables 0 ("Sales") and 2 ("sales") fold to the same key. Duplicates are invalid
807 /// in a real model but occur in hand-edited files, so the first one wins.
808 mod build_with_duplicate_names {
809 use super::*;
810
811 #[rstest]
812 #[case::as_written("Sales")]
813 #[case::as_the_duplicate_is_spelled("sales")]
814 fn resolves_the_table_to_the_first_occurrence(#[case] name: &str) {
815 assert_eq!(index().resolve_table(name), Some(TableHandle(0)));
816 }
817
818 #[test]
819 fn resolves_a_shared_column_name_to_the_first_tables_column() {
820 assert_eq!(
821 index().resolve_qualified("Sales", "Amount"),
822 Some(column_handle(0, 0))
823 );
824 }
825
826 #[test]
827 fn resolves_a_shared_column_name_unqualified_to_the_first_tables_column() {
828 assert_eq!(
829 index().resolve_unqualified("Amount", Some("SALES")).column,
830 Some(ColumnHandle {
831 table: 0,
832 column: 0
833 })
834 );
835 }
836 }
837
838 mod accessors {
839 use super::*;
840
841 #[test]
842 fn a_resolved_table_handle_reaches_its_table() {
843 let db = model();
844 let handle = ModelIndex::build(&db).resolve_table("SALES").unwrap();
845
846 assert_eq!(db.table(handle).unwrap().name, "Sales");
847 }
848
849 #[test]
850 fn a_resolved_column_handle_reaches_its_column() {
851 let db = model();
852 let Some(Resolved::Column(handle)) =
853 ModelIndex::build(&db).resolve_qualified("sales", "BELØB")
854 else {
855 panic!("expected 'sales'[BELØB] to resolve to a column");
856 };
857
858 assert_eq!(db.column(handle).unwrap().name, "Beløb");
859 }
860
861 #[test]
862 fn a_resolved_measure_handle_reaches_its_measure() {
863 let db = model();
864 let handle = ModelIndex::build(&db)
865 .resolve_unqualified("omsætning", None)
866 .measure
867 .unwrap();
868
869 assert_eq!(db.measure(handle).unwrap().name, "Omsætning");
870 }
871
872 #[test]
873 fn a_resolved_hierarchy_handle_reaches_its_hierarchy() {
874 let db = model();
875 let handle = ModelIndex::build(&db)
876 .resolve_hierarchy("DATO", "kalender")
877 .unwrap();
878
879 assert_eq!(db.hierarchy(handle).unwrap().name, "Kalender");
880 }
881
882 #[test]
883 fn a_resolved_expression_handle_reaches_its_expression() {
884 let db = model();
885 let handle = ModelIndex::build(&db)
886 .resolve_expression("DATABASE")
887 .unwrap();
888
889 assert_eq!(db.shared_expression(handle).unwrap().name, "Database");
890 }
891
892 #[test]
893 fn a_stale_table_handle_is_none() {
894 assert!(model().table(TableHandle(9)).is_none());
895 }
896
897 #[test]
898 fn a_column_handle_with_an_out_of_range_table_is_none() {
899 let handle = ColumnHandle {
900 table: 9,
901 column: 9,
902 };
903
904 assert!(model().column(handle).is_none());
905 }
906
907 #[test]
908 fn a_column_handle_with_an_out_of_range_column_is_none() {
909 let handle = ColumnHandle {
910 table: 0,
911 column: 9,
912 };
913
914 assert!(model().column(handle).is_none());
915 }
916
917 #[test]
918 fn a_measure_handle_with_an_out_of_range_table_is_none() {
919 let handle = MeasureHandle {
920 table: 9,
921 measure: 0,
922 };
923
924 assert!(model().measure(handle).is_none());
925 }
926
927 #[test]
928 fn a_measure_handle_into_a_table_without_measures_is_none() {
929 let handle = MeasureHandle {
930 table: 2,
931 measure: 0,
932 };
933
934 assert!(model().measure(handle).is_none());
935 }
936
937 #[test]
938 fn a_hierarchy_handle_into_a_table_without_hierarchies_is_none() {
939 let handle = HierarchyHandle {
940 table: 0,
941 hierarchy: 0,
942 };
943
944 assert!(model().hierarchy(handle).is_none());
945 }
946
947 #[test]
948 fn a_stale_expression_handle_is_none() {
949 assert!(model().shared_expression(ExpressionHandle(9)).is_none());
950 }
951
952 #[test]
953 fn a_resolved_function_handle_reaches_its_function() {
954 let db = model();
955 let handle = ModelIndex::build(&db)
956 .resolve_function("SALES.NETPRICE")
957 .unwrap();
958
959 assert_eq!(db.function(handle).unwrap().name, "Sales.NetPrice");
960 }
961
962 #[test]
963 fn a_stale_function_handle_is_none() {
964 assert!(model().function(FunctionHandle(9)).is_none());
965 }
966
967 #[test]
968 fn an_object_id_for_a_stale_column_handle_is_none() {
969 assert!(model().object_id(column_handle(9, 9)).is_none());
970 }
971
972 #[test]
973 fn an_object_id_for_a_stale_measure_handle_is_none() {
974 assert!(model().object_id(measure_handle(9, 9)).is_none());
975 }
976 }
977
978 /// Ids are what diagnostics print, so they must carry the model's own casing even
979 /// when the lookup that produced them was written in another one.
980 mod object_id {
981 use super::*;
982
983 #[test]
984 fn carries_the_models_casing_for_a_measure() {
985 let db = model();
986 let resolved = ModelIndex::build(&db)
987 .resolve_qualified("SALES", "TOTAL SALES")
988 .unwrap();
989
990 assert_eq!(
991 db.object_id(resolved).unwrap().to_string(),
992 "'Sales'[Total Sales]"
993 );
994 }
995
996 #[test]
997 fn carries_the_models_casing_for_a_danish_column() {
998 let db = model();
999 let resolved = ModelIndex::build(&db)
1000 .resolve_qualified("DATO", "MÅNED")
1001 .unwrap();
1002
1003 assert_eq!(db.object_id(resolved).unwrap().to_string(), "'Dato'[Måned]");
1004 }
1005
1006 #[test]
1007 fn distinguishes_a_column_from_a_measure_of_the_same_name() {
1008 let db = model();
1009
1010 assert_ne!(
1011 db.object_id(column_handle(1, 1)).unwrap(),
1012 db.object_id(measure_handle(0, 1)).unwrap()
1013 );
1014 }
1015
1016 #[rstest]
1017 #[case::the_dato_column(column_handle(1, 1), "'Dato'[Antal]")]
1018 #[case::the_sales_measure(measure_handle(0, 1), "'Sales'[Antal]")]
1019 fn renders_each_antal_under_its_own_table(
1020 #[case] resolved: Resolved,
1021 #[case] expected: &str,
1022 ) {
1023 assert_eq!(model().object_id(resolved).unwrap().to_string(), expected);
1024 }
1025 }
1026}