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 ..Default::default()
507 },
508 SharedExpression {
509 name: "Database".to_string(),
510 expression: "\"AdventureWorks\"".to_string(),
511 ..Default::default()
512 },
513 ],
514 functions: vec![Function {
515 name: "Sales.NetPrice".to_string(),
516 expression: "(price: SCALAR) => price * 0.75".to_string(),
517 is_hidden: false,
518 }],
519 ..Default::default()
520 }
521 }
522
523 fn index() -> ModelIndex {
524 ModelIndex::build(&model())
525 }
526
527 fn column_handle(table: usize, column: usize) -> Resolved {
528 Resolved::Column(ColumnHandle { table, column })
529 }
530
531 fn measure_handle(table: usize, measure: usize) -> Resolved {
532 Resolved::Measure(MeasureHandle { table, measure })
533 }
534
535 mod resolve_table {
536 use super::*;
537
538 #[rstest]
539 #[case::upper("SALES", 0)]
540 #[case::as_written("Sales", 0)]
541 #[case::danish_lower("dato", 1)]
542 #[case::danish_upper("DATO", 1)]
543 fn finds_a_table_ignoring_case(#[case] name: &str, #[case] expected: usize) {
544 assert_eq!(index().resolve_table(name), Some(TableHandle(expected)));
545 }
546
547 #[rstest]
548 #[case::misspelled("Salez")]
549 #[case::empty("")]
550 fn returns_none_for_an_unknown_name(#[case] name: &str) {
551 assert_eq!(index().resolve_table(name), None);
552 }
553 }
554
555 mod resolve_qualified {
556 use super::*;
557
558 #[rstest]
559 #[case::lower_table_upper_column("sales", "AMOUNT", 0, 0)]
560 #[case::upper_table_danish_column("DATO", "måned", 1, 0)]
561 #[case::danish_column_upper("Dato", "MÅNED", 1, 0)]
562 fn finds_a_column_on_the_named_table(
563 #[case] table: &str,
564 #[case] name: &str,
565 #[case] expected_table: usize,
566 #[case] expected_column: usize,
567 ) {
568 assert_eq!(
569 index().resolve_qualified(table, name),
570 Some(column_handle(expected_table, expected_column))
571 );
572 }
573
574 /// Measure names are model-global, so a qualified reference resolves to one
575 /// even when the prefix is wrong or names a table that no longer exists —
576 /// keeping it alive rather than reporting a live measure as unused.
577 #[rstest]
578 #[case::on_its_home_table("SALES", "total sales", 0, 0)]
579 #[case::danish_on_its_home_table("Dato", "OMSÆTNING", 1, 0)]
580 #[case::under_a_wrong_table_prefix("Dato", "Total Sales", 0, 0)]
581 #[case::under_a_nonexistent_table_prefix("Ukendt Tabel", "omsætning", 1, 0)]
582 fn finds_a_measure(
583 #[case] table: &str,
584 #[case] name: &str,
585 #[case] expected_table: usize,
586 #[case] expected_measure: usize,
587 ) {
588 assert_eq!(
589 index().resolve_qualified(table, name),
590 Some(measure_handle(expected_table, expected_measure))
591 );
592 }
593
594 #[rstest]
595 // "Amount" is a column of Sales and a measure nowhere, so a wrong prefix has
596 // nothing to fall back to.
597 #[case::column_of_a_different_table("Dato", "Amount")]
598 #[case::unknown_name("Sales", "Nope")]
599 #[case::unknown_table_and_name("Ukendt Tabel", "Måned")]
600 fn returns_none_when_nothing_matches(#[case] table: &str, #[case] name: &str) {
601 assert_eq!(index().resolve_qualified(table, name), None);
602 }
603
604 /// "Antal" is a column of Dato and a measure of Sales. The measure fallback is
605 /// a last resort, not a shortcut past the named table's own columns.
606 #[test]
607 fn binds_to_the_named_tables_column_when_both_exist() {
608 assert_eq!(
609 index().resolve_qualified("Dato", "Antal"),
610 Some(column_handle(1, 1))
611 );
612 }
613
614 #[test]
615 fn binds_to_the_measure_when_the_named_table_has_no_such_column() {
616 assert_eq!(
617 index().resolve_qualified("Sales", "antal"),
618 Some(measure_handle(0, 1))
619 );
620 }
621 }
622
623 mod resolve_unqualified {
624 use super::*;
625
626 /// Measures are model-global, so a Danish measure on `Dato` resolves from an
627 /// expression with no row context at all.
628 #[test]
629 fn finds_a_global_measure_without_a_home_table() {
630 assert_eq!(
631 index().resolve_unqualified("OMSÆTNING", None).measure,
632 Some(MeasureHandle {
633 table: 1,
634 measure: 0
635 })
636 );
637 }
638
639 #[test]
640 fn offers_no_column_candidate_without_a_home_table() {
641 assert_eq!(index().resolve_unqualified("OMSÆTNING", None).column, None);
642 }
643
644 /// `[Antal]` inside a `Dato` row context could bind to either object, so both
645 /// come back and both stay alive.
646 #[test]
647 fn returns_the_measure_candidate_for_an_ambiguous_name() {
648 assert_eq!(
649 index().resolve_unqualified("antal", Some("Dato")).measure,
650 Some(MeasureHandle {
651 table: 0,
652 measure: 1
653 })
654 );
655 }
656
657 #[test]
658 fn returns_the_column_candidate_for_an_ambiguous_name() {
659 assert_eq!(
660 index().resolve_unqualified("antal", Some("Dato")).column,
661 Some(ColumnHandle {
662 table: 1,
663 column: 1
664 })
665 );
666 }
667
668 #[test]
669 fn drops_the_column_candidate_when_there_is_no_home_table() {
670 assert_eq!(index().resolve_unqualified("antal", None).column, None);
671 }
672
673 #[test]
674 fn finds_a_column_of_the_home_table() {
675 assert_eq!(
676 index().resolve_unqualified("BELØB", Some("sales")).column,
677 Some(ColumnHandle {
678 table: 0,
679 column: 1
680 })
681 );
682 }
683
684 #[test]
685 fn offers_no_measure_candidate_for_a_column_only_name() {
686 assert_eq!(
687 index().resolve_unqualified("BELØB", Some("sales")).measure,
688 None
689 );
690 }
691
692 /// A column is never global: the same name against the wrong row context is
693 /// not a match.
694 #[test]
695 fn matches_nothing_against_the_wrong_home_table() {
696 let found = index().resolve_unqualified("Beløb", Some("Dato"));
697
698 assert!(found.is_empty(), "expected no candidates, got {found:?}");
699 }
700
701 #[rstest]
702 #[case::known_home_table(Some("Sales"))]
703 #[case::unknown_home_table(Some("Ukendt Tabel"))]
704 #[case::no_home_table(None)]
705 fn is_empty_for_an_unknown_name(#[case] home_table: Option<&str>) {
706 let found = index().resolve_unqualified("Ukendt", home_table);
707
708 assert!(found.is_empty(), "expected no candidates, got {found:?}");
709 }
710
711 #[test]
712 fn primary_prefers_the_measure_when_a_name_is_ambiguous() {
713 assert_eq!(
714 index().resolve_unqualified("antal", Some("Dato")).primary(),
715 Some(measure_handle(0, 1))
716 );
717 }
718
719 #[test]
720 fn primary_returns_the_column_when_there_is_no_measure() {
721 assert_eq!(
722 index()
723 .resolve_unqualified("BELØB", Some("sales"))
724 .primary(),
725 Some(column_handle(0, 1))
726 );
727 }
728
729 #[test]
730 fn primary_is_none_for_an_unknown_name() {
731 assert_eq!(
732 index()
733 .resolve_unqualified("Ukendt", Some("Sales"))
734 .primary(),
735 None
736 );
737 }
738 }
739
740 mod resolve_hierarchy {
741 use super::*;
742
743 #[rstest]
744 #[case::lower_table_upper_name("dato", "KALENDER")]
745 #[case::upper_table_as_written("DATO", "Kalender")]
746 fn finds_a_hierarchy_ignoring_case(#[case] table: &str, #[case] name: &str) {
747 assert_eq!(
748 index().resolve_hierarchy(table, name),
749 Some(HierarchyHandle {
750 table: 1,
751 hierarchy: 0
752 })
753 );
754 }
755
756 #[rstest]
757 // Hierarchy names are unique per table only: no cross-table fallback, and no
758 // confusion with the columns or measures of the same table.
759 #[case::another_table("Sales", "Kalender")]
760 #[case::a_column_name("Dato", "Måned")]
761 #[case::unknown_table("Ukendt Tabel", "Kalender")]
762 fn returns_none(#[case] table: &str, #[case] name: &str) {
763 assert_eq!(index().resolve_hierarchy(table, name), None);
764 }
765 }
766
767 mod resolve_expression {
768 use super::*;
769
770 #[rstest]
771 #[case::upper("SERVER", 0)]
772 #[case::as_written("Server", 0)]
773 #[case::lower("database", 1)]
774 fn finds_an_expression_ignoring_case(#[case] name: &str, #[case] expected: usize) {
775 assert_eq!(
776 index().resolve_expression(name),
777 Some(ExpressionHandle(expected))
778 );
779 }
780
781 #[test]
782 fn returns_none_for_an_unknown_name() {
783 assert_eq!(index().resolve_expression("Ukendt"), None);
784 }
785 }
786
787 mod resolve_function {
788 use super::*;
789
790 #[rstest]
791 #[case::as_written("Sales.NetPrice")]
792 #[case::upper("SALES.NETPRICE")]
793 #[case::lower("sales.netprice")]
794 fn finds_a_function_ignoring_case(#[case] name: &str) {
795 assert_eq!(index().resolve_function(name), Some(FunctionHandle(0)));
796 }
797
798 #[rstest]
799 // A function name is not an expression name and vice versa.
800 #[case::a_shared_expression_name("Server")]
801 #[case::a_measure_name("Total Sales")]
802 #[case::unknown("Ukendt")]
803 fn returns_none(#[case] name: &str) {
804 assert_eq!(index().resolve_function(name), None);
805 }
806 }
807
808 /// Tables 0 ("Sales") and 2 ("sales") fold to the same key. Duplicates are invalid
809 /// in a real model but occur in hand-edited files, so the first one wins.
810 mod build_with_duplicate_names {
811 use super::*;
812
813 #[rstest]
814 #[case::as_written("Sales")]
815 #[case::as_the_duplicate_is_spelled("sales")]
816 fn resolves_the_table_to_the_first_occurrence(#[case] name: &str) {
817 assert_eq!(index().resolve_table(name), Some(TableHandle(0)));
818 }
819
820 #[test]
821 fn resolves_a_shared_column_name_to_the_first_tables_column() {
822 assert_eq!(
823 index().resolve_qualified("Sales", "Amount"),
824 Some(column_handle(0, 0))
825 );
826 }
827
828 #[test]
829 fn resolves_a_shared_column_name_unqualified_to_the_first_tables_column() {
830 assert_eq!(
831 index().resolve_unqualified("Amount", Some("SALES")).column,
832 Some(ColumnHandle {
833 table: 0,
834 column: 0
835 })
836 );
837 }
838 }
839
840 mod accessors {
841 use super::*;
842
843 #[test]
844 fn a_resolved_table_handle_reaches_its_table() {
845 let db = model();
846 let handle = ModelIndex::build(&db).resolve_table("SALES").unwrap();
847
848 assert_eq!(db.table(handle).unwrap().name, "Sales");
849 }
850
851 #[test]
852 fn a_resolved_column_handle_reaches_its_column() {
853 let db = model();
854 let Some(Resolved::Column(handle)) =
855 ModelIndex::build(&db).resolve_qualified("sales", "BELØB")
856 else {
857 panic!("expected 'sales'[BELØB] to resolve to a column");
858 };
859
860 assert_eq!(db.column(handle).unwrap().name, "Beløb");
861 }
862
863 #[test]
864 fn a_resolved_measure_handle_reaches_its_measure() {
865 let db = model();
866 let handle = ModelIndex::build(&db)
867 .resolve_unqualified("omsætning", None)
868 .measure
869 .unwrap();
870
871 assert_eq!(db.measure(handle).unwrap().name, "Omsætning");
872 }
873
874 #[test]
875 fn a_resolved_hierarchy_handle_reaches_its_hierarchy() {
876 let db = model();
877 let handle = ModelIndex::build(&db)
878 .resolve_hierarchy("DATO", "kalender")
879 .unwrap();
880
881 assert_eq!(db.hierarchy(handle).unwrap().name, "Kalender");
882 }
883
884 #[test]
885 fn a_resolved_expression_handle_reaches_its_expression() {
886 let db = model();
887 let handle = ModelIndex::build(&db)
888 .resolve_expression("DATABASE")
889 .unwrap();
890
891 assert_eq!(db.shared_expression(handle).unwrap().name, "Database");
892 }
893
894 #[test]
895 fn a_stale_table_handle_is_none() {
896 assert!(model().table(TableHandle(9)).is_none());
897 }
898
899 #[test]
900 fn a_column_handle_with_an_out_of_range_table_is_none() {
901 let handle = ColumnHandle {
902 table: 9,
903 column: 9,
904 };
905
906 assert!(model().column(handle).is_none());
907 }
908
909 #[test]
910 fn a_column_handle_with_an_out_of_range_column_is_none() {
911 let handle = ColumnHandle {
912 table: 0,
913 column: 9,
914 };
915
916 assert!(model().column(handle).is_none());
917 }
918
919 #[test]
920 fn a_measure_handle_with_an_out_of_range_table_is_none() {
921 let handle = MeasureHandle {
922 table: 9,
923 measure: 0,
924 };
925
926 assert!(model().measure(handle).is_none());
927 }
928
929 #[test]
930 fn a_measure_handle_into_a_table_without_measures_is_none() {
931 let handle = MeasureHandle {
932 table: 2,
933 measure: 0,
934 };
935
936 assert!(model().measure(handle).is_none());
937 }
938
939 #[test]
940 fn a_hierarchy_handle_into_a_table_without_hierarchies_is_none() {
941 let handle = HierarchyHandle {
942 table: 0,
943 hierarchy: 0,
944 };
945
946 assert!(model().hierarchy(handle).is_none());
947 }
948
949 #[test]
950 fn a_stale_expression_handle_is_none() {
951 assert!(model().shared_expression(ExpressionHandle(9)).is_none());
952 }
953
954 #[test]
955 fn a_resolved_function_handle_reaches_its_function() {
956 let db = model();
957 let handle = ModelIndex::build(&db)
958 .resolve_function("SALES.NETPRICE")
959 .unwrap();
960
961 assert_eq!(db.function(handle).unwrap().name, "Sales.NetPrice");
962 }
963
964 #[test]
965 fn a_stale_function_handle_is_none() {
966 assert!(model().function(FunctionHandle(9)).is_none());
967 }
968
969 #[test]
970 fn an_object_id_for_a_stale_column_handle_is_none() {
971 assert!(model().object_id(column_handle(9, 9)).is_none());
972 }
973
974 #[test]
975 fn an_object_id_for_a_stale_measure_handle_is_none() {
976 assert!(model().object_id(measure_handle(9, 9)).is_none());
977 }
978 }
979
980 /// Ids are what diagnostics print, so they must carry the model's own casing even
981 /// when the lookup that produced them was written in another one.
982 mod object_id {
983 use super::*;
984
985 #[test]
986 fn carries_the_models_casing_for_a_measure() {
987 let db = model();
988 let resolved = ModelIndex::build(&db)
989 .resolve_qualified("SALES", "TOTAL SALES")
990 .unwrap();
991
992 assert_eq!(
993 db.object_id(resolved).unwrap().to_string(),
994 "'Sales'[Total Sales]"
995 );
996 }
997
998 #[test]
999 fn carries_the_models_casing_for_a_danish_column() {
1000 let db = model();
1001 let resolved = ModelIndex::build(&db)
1002 .resolve_qualified("DATO", "MÅNED")
1003 .unwrap();
1004
1005 assert_eq!(db.object_id(resolved).unwrap().to_string(), "'Dato'[Måned]");
1006 }
1007
1008 #[test]
1009 fn distinguishes_a_column_from_a_measure_of_the_same_name() {
1010 let db = model();
1011
1012 assert_ne!(
1013 db.object_id(column_handle(1, 1)).unwrap(),
1014 db.object_id(measure_handle(0, 1)).unwrap()
1015 );
1016 }
1017
1018 #[rstest]
1019 #[case::the_dato_column(column_handle(1, 1), "'Dato'[Antal]")]
1020 #[case::the_sales_measure(measure_handle(0, 1), "'Sales'[Antal]")]
1021 fn renders_each_antal_under_its_own_table(
1022 #[case] resolved: Resolved,
1023 #[case] expected: &str,
1024 ) {
1025 assert_eq!(model().object_id(resolved).unwrap().to_string(), expected);
1026 }
1027 }
1028}