Skip to main content

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    /// Resolves an unqualified reference, `[Name]`, to **all** its candidates.
299    ///
300    /// `home_table` is the row-context table of the expression the reference was
301    /// found in; pass `None` where there is none. See [`UnqualifiedMatches`] for why
302    /// both a measure and a column can come back at once.
303    ///
304    /// # Examples
305    ///
306    /// ```
307    /// # use ripbi_core::{Column, Measure, ModelIndex, Table, TabularDatabase};
308    /// # let db = TabularDatabase {
309    /// #     tables: vec![
310    /// #         Table {
311    /// #             name: "Sales".to_string(),
312    /// #             measures: vec![Measure { name: "Antal".to_string(), ..Default::default() }],
313    /// #             ..Default::default()
314    /// #         },
315    /// #         Table {
316    /// #             name: "Dato".to_string(),
317    /// #             columns: vec![Column { name: "Antal".to_string(), ..Default::default() }],
318    /// #             ..Default::default()
319    /// #         },
320    /// #     ],
321    /// #     ..Default::default()
322    /// # };
323    /// // `Antal` is a measure on `Sales` and, separately, a column of `Dato`.
324    /// let index = ModelIndex::build(&db);
325    ///
326    /// // Inside a `Dato` row context both are live candidates, so both come back.
327    /// let ambiguous = index.resolve_unqualified("ANTAL", Some("Dato"));
328    /// assert!(ambiguous.measure.is_some());
329    /// assert!(ambiguous.column.is_some());
330    ///
331    /// // With no row context there is no column candidate to consider.
332    /// assert!(index.resolve_unqualified("antal", None).column.is_none());
333    ///
334    /// // An unknown name is data, not an error.
335    /// assert!(index.resolve_unqualified("Ukendt", Some("Dato")).is_empty());
336    /// ```
337    #[must_use]
338    pub fn resolve_unqualified(&self, name: &str, home_table: Option<&str>) -> UnqualifiedMatches {
339        let folded_name = fold_name(name);
340        UnqualifiedMatches {
341            measure: self.measures.get(&folded_name).copied(),
342            column: home_table.and_then(|table| {
343                self.tables
344                    .get(&fold_name(table))
345                    .and_then(|entry| entry.columns.get(&folded_name))
346                    .copied()
347            }),
348        }
349    }
350
351    /// Looks up a hierarchy on a specific table, as written in `ISINSCOPE('Date'[Calendar])`
352    /// or in a PBIR hierarchy binding. Hierarchy names are only unique per table, so
353    /// there is no unqualified form and no cross-table fallback.
354    #[must_use]
355    pub fn resolve_hierarchy(&self, table: &str, name: &str) -> Option<HierarchyHandle> {
356        self.tables
357            .get(&fold_name(table))?
358            .hierarchies
359            .get(&fold_name(name))
360            .copied()
361    }
362
363    /// Looks up a model-level shared M expression by name — how one M query
364    /// references a parameter or another query.
365    #[must_use]
366    pub fn resolve_expression(&self, name: &str) -> Option<ExpressionHandle> {
367        self.expressions.get(&fold_name(name)).copied()
368    }
369
370    /// Looks up a user-defined DAX function by name — how a DAX expression
371    /// calls it. Function names are model-global.
372    #[must_use]
373    pub fn resolve_function(&self, name: &str) -> Option<FunctionHandle> {
374        self.functions.get(&fold_name(name)).copied()
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::model::{Column, Function, Hierarchy, Measure, SharedExpression, Table};
382    use rstest::rstest;
383
384    fn column(name: &str) -> Column {
385        Column {
386            name: name.to_string(),
387            ..Default::default()
388        }
389    }
390
391    fn measure(name: &str) -> Measure {
392        Measure {
393            name: name.to_string(),
394            expression: "0".to_string(),
395            ..Default::default()
396        }
397    }
398
399    /// Fixture with hand-checked positions. Every assertion below names these
400    /// indices as literals, so a resolution that drifts by one position fails.
401    ///
402    /// ```text
403    /// table 0  "Sales"   columns  0 "Amount"  1 "Beløb"
404    ///                    measures 0 "Total Sales"  1 "Antal"
405    /// table 1  "Dato"    columns  0 "Måned"   1 "Antal"
406    ///                    measures 0 "Omsætning"
407    ///                    hierarchies 0 "Kalender"
408    /// table 2  "sales"   columns  0 "Amount"        <- duplicate of table 0
409    /// expressions 0 "Server"  1 "Database"
410    /// functions   0 "Sales.NetPrice"
411    /// ```
412    ///
413    /// "Antal" is deliberately both a measure (on `Sales`) and a column (on `Dato`):
414    /// that is the ambiguity the zero-false-positive rule exists for.
415    fn model() -> TabularDatabase {
416        TabularDatabase {
417            name: Some("Contoso".to_string()),
418            tables: vec![
419                Table {
420                    name: "Sales".to_string(),
421                    columns: vec![column("Amount"), column("Beløb")],
422                    measures: vec![measure("Total Sales"), measure("Antal")],
423                    ..Default::default()
424                },
425                Table {
426                    name: "Dato".to_string(),
427                    columns: vec![column("Måned"), column("Antal")],
428                    measures: vec![measure("Omsætning")],
429                    hierarchies: vec![Hierarchy {
430                        name: "Kalender".to_string(),
431                        ..Default::default()
432                    }],
433                    ..Default::default()
434                },
435                Table {
436                    name: "sales".to_string(),
437                    columns: vec![column("Amount")],
438                    ..Default::default()
439                },
440            ],
441            expressions: vec![
442                SharedExpression {
443                    name: "Server".to_string(),
444                    expression: "\"contoso.database.windows.net\"".to_string(),
445                },
446                SharedExpression {
447                    name: "Database".to_string(),
448                    expression: "\"AdventureWorks\"".to_string(),
449                },
450            ],
451            functions: vec![Function {
452                name: "Sales.NetPrice".to_string(),
453                expression: "(price: SCALAR) => price * 0.75".to_string(),
454                is_hidden: false,
455            }],
456            ..Default::default()
457        }
458    }
459
460    fn index() -> ModelIndex {
461        ModelIndex::build(&model())
462    }
463
464    fn column_handle(table: usize, column: usize) -> Resolved {
465        Resolved::Column(ColumnHandle { table, column })
466    }
467
468    fn measure_handle(table: usize, measure: usize) -> Resolved {
469        Resolved::Measure(MeasureHandle { table, measure })
470    }
471
472    mod resolve_table {
473        use super::*;
474
475        #[rstest]
476        #[case::upper("SALES", 0)]
477        #[case::as_written("Sales", 0)]
478        #[case::danish_lower("dato", 1)]
479        #[case::danish_upper("DATO", 1)]
480        fn finds_a_table_ignoring_case(#[case] name: &str, #[case] expected: usize) {
481            assert_eq!(index().resolve_table(name), Some(TableHandle(expected)));
482        }
483
484        #[rstest]
485        #[case::misspelled("Salez")]
486        #[case::empty("")]
487        fn returns_none_for_an_unknown_name(#[case] name: &str) {
488            assert_eq!(index().resolve_table(name), None);
489        }
490    }
491
492    mod resolve_qualified {
493        use super::*;
494
495        #[rstest]
496        #[case::lower_table_upper_column("sales", "AMOUNT", 0, 0)]
497        #[case::upper_table_danish_column("DATO", "måned", 1, 0)]
498        #[case::danish_column_upper("Dato", "MÅNED", 1, 0)]
499        fn finds_a_column_on_the_named_table(
500            #[case] table: &str,
501            #[case] name: &str,
502            #[case] expected_table: usize,
503            #[case] expected_column: usize,
504        ) {
505            assert_eq!(
506                index().resolve_qualified(table, name),
507                Some(column_handle(expected_table, expected_column))
508            );
509        }
510
511        /// Measure names are model-global, so a qualified reference resolves to one
512        /// even when the prefix is wrong or names a table that no longer exists —
513        /// keeping it alive rather than reporting a live measure as unused.
514        #[rstest]
515        #[case::on_its_home_table("SALES", "total sales", 0, 0)]
516        #[case::danish_on_its_home_table("Dato", "OMSÆTNING", 1, 0)]
517        #[case::under_a_wrong_table_prefix("Dato", "Total Sales", 0, 0)]
518        #[case::under_a_nonexistent_table_prefix("Ukendt Tabel", "omsætning", 1, 0)]
519        fn finds_a_measure(
520            #[case] table: &str,
521            #[case] name: &str,
522            #[case] expected_table: usize,
523            #[case] expected_measure: usize,
524        ) {
525            assert_eq!(
526                index().resolve_qualified(table, name),
527                Some(measure_handle(expected_table, expected_measure))
528            );
529        }
530
531        #[rstest]
532        // "Amount" is a column of Sales and a measure nowhere, so a wrong prefix has
533        // nothing to fall back to.
534        #[case::column_of_a_different_table("Dato", "Amount")]
535        #[case::unknown_name("Sales", "Nope")]
536        #[case::unknown_table_and_name("Ukendt Tabel", "Måned")]
537        fn returns_none_when_nothing_matches(#[case] table: &str, #[case] name: &str) {
538            assert_eq!(index().resolve_qualified(table, name), None);
539        }
540
541        /// "Antal" is a column of Dato and a measure of Sales. The measure fallback is
542        /// a last resort, not a shortcut past the named table's own columns.
543        #[test]
544        fn binds_to_the_named_tables_column_when_both_exist() {
545            assert_eq!(
546                index().resolve_qualified("Dato", "Antal"),
547                Some(column_handle(1, 1))
548            );
549        }
550
551        #[test]
552        fn binds_to_the_measure_when_the_named_table_has_no_such_column() {
553            assert_eq!(
554                index().resolve_qualified("Sales", "antal"),
555                Some(measure_handle(0, 1))
556            );
557        }
558    }
559
560    mod resolve_unqualified {
561        use super::*;
562
563        /// Measures are model-global, so a Danish measure on `Dato` resolves from an
564        /// expression with no row context at all.
565        #[test]
566        fn finds_a_global_measure_without_a_home_table() {
567            assert_eq!(
568                index().resolve_unqualified("OMSÆTNING", None).measure,
569                Some(MeasureHandle {
570                    table: 1,
571                    measure: 0
572                })
573            );
574        }
575
576        #[test]
577        fn offers_no_column_candidate_without_a_home_table() {
578            assert_eq!(index().resolve_unqualified("OMSÆTNING", None).column, None);
579        }
580
581        /// `[Antal]` inside a `Dato` row context could bind to either object, so both
582        /// come back and both stay alive.
583        #[test]
584        fn returns_the_measure_candidate_for_an_ambiguous_name() {
585            assert_eq!(
586                index().resolve_unqualified("antal", Some("Dato")).measure,
587                Some(MeasureHandle {
588                    table: 0,
589                    measure: 1
590                })
591            );
592        }
593
594        #[test]
595        fn returns_the_column_candidate_for_an_ambiguous_name() {
596            assert_eq!(
597                index().resolve_unqualified("antal", Some("Dato")).column,
598                Some(ColumnHandle {
599                    table: 1,
600                    column: 1
601                })
602            );
603        }
604
605        #[test]
606        fn drops_the_column_candidate_when_there_is_no_home_table() {
607            assert_eq!(index().resolve_unqualified("antal", None).column, None);
608        }
609
610        #[test]
611        fn finds_a_column_of_the_home_table() {
612            assert_eq!(
613                index().resolve_unqualified("BELØB", Some("sales")).column,
614                Some(ColumnHandle {
615                    table: 0,
616                    column: 1
617                })
618            );
619        }
620
621        #[test]
622        fn offers_no_measure_candidate_for_a_column_only_name() {
623            assert_eq!(
624                index().resolve_unqualified("BELØB", Some("sales")).measure,
625                None
626            );
627        }
628
629        /// A column is never global: the same name against the wrong row context is
630        /// not a match.
631        #[test]
632        fn matches_nothing_against_the_wrong_home_table() {
633            let found = index().resolve_unqualified("Beløb", Some("Dato"));
634
635            assert!(found.is_empty(), "expected no candidates, got {found:?}");
636        }
637
638        #[rstest]
639        #[case::known_home_table(Some("Sales"))]
640        #[case::unknown_home_table(Some("Ukendt Tabel"))]
641        #[case::no_home_table(None)]
642        fn is_empty_for_an_unknown_name(#[case] home_table: Option<&str>) {
643            let found = index().resolve_unqualified("Ukendt", home_table);
644
645            assert!(found.is_empty(), "expected no candidates, got {found:?}");
646        }
647
648        #[test]
649        fn primary_prefers_the_measure_when_a_name_is_ambiguous() {
650            assert_eq!(
651                index().resolve_unqualified("antal", Some("Dato")).primary(),
652                Some(measure_handle(0, 1))
653            );
654        }
655
656        #[test]
657        fn primary_returns_the_column_when_there_is_no_measure() {
658            assert_eq!(
659                index()
660                    .resolve_unqualified("BELØB", Some("sales"))
661                    .primary(),
662                Some(column_handle(0, 1))
663            );
664        }
665
666        #[test]
667        fn primary_is_none_for_an_unknown_name() {
668            assert_eq!(
669                index()
670                    .resolve_unqualified("Ukendt", Some("Sales"))
671                    .primary(),
672                None
673            );
674        }
675    }
676
677    mod resolve_hierarchy {
678        use super::*;
679
680        #[rstest]
681        #[case::lower_table_upper_name("dato", "KALENDER")]
682        #[case::upper_table_as_written("DATO", "Kalender")]
683        fn finds_a_hierarchy_ignoring_case(#[case] table: &str, #[case] name: &str) {
684            assert_eq!(
685                index().resolve_hierarchy(table, name),
686                Some(HierarchyHandle {
687                    table: 1,
688                    hierarchy: 0
689                })
690            );
691        }
692
693        #[rstest]
694        // Hierarchy names are unique per table only: no cross-table fallback, and no
695        // confusion with the columns or measures of the same table.
696        #[case::another_table("Sales", "Kalender")]
697        #[case::a_column_name("Dato", "Måned")]
698        #[case::unknown_table("Ukendt Tabel", "Kalender")]
699        fn returns_none(#[case] table: &str, #[case] name: &str) {
700            assert_eq!(index().resolve_hierarchy(table, name), None);
701        }
702    }
703
704    mod resolve_expression {
705        use super::*;
706
707        #[rstest]
708        #[case::upper("SERVER", 0)]
709        #[case::as_written("Server", 0)]
710        #[case::lower("database", 1)]
711        fn finds_an_expression_ignoring_case(#[case] name: &str, #[case] expected: usize) {
712            assert_eq!(
713                index().resolve_expression(name),
714                Some(ExpressionHandle(expected))
715            );
716        }
717
718        #[test]
719        fn returns_none_for_an_unknown_name() {
720            assert_eq!(index().resolve_expression("Ukendt"), None);
721        }
722    }
723
724    mod resolve_function {
725        use super::*;
726
727        #[rstest]
728        #[case::as_written("Sales.NetPrice")]
729        #[case::upper("SALES.NETPRICE")]
730        #[case::lower("sales.netprice")]
731        fn finds_a_function_ignoring_case(#[case] name: &str) {
732            assert_eq!(index().resolve_function(name), Some(FunctionHandle(0)));
733        }
734
735        #[rstest]
736        // A function name is not an expression name and vice versa.
737        #[case::a_shared_expression_name("Server")]
738        #[case::a_measure_name("Total Sales")]
739        #[case::unknown("Ukendt")]
740        fn returns_none(#[case] name: &str) {
741            assert_eq!(index().resolve_function(name), None);
742        }
743    }
744
745    /// Tables 0 ("Sales") and 2 ("sales") fold to the same key. Duplicates are invalid
746    /// in a real model but occur in hand-edited files, so the first one wins.
747    mod build_with_duplicate_names {
748        use super::*;
749
750        #[rstest]
751        #[case::as_written("Sales")]
752        #[case::as_the_duplicate_is_spelled("sales")]
753        fn resolves_the_table_to_the_first_occurrence(#[case] name: &str) {
754            assert_eq!(index().resolve_table(name), Some(TableHandle(0)));
755        }
756
757        #[test]
758        fn resolves_a_shared_column_name_to_the_first_tables_column() {
759            assert_eq!(
760                index().resolve_qualified("Sales", "Amount"),
761                Some(column_handle(0, 0))
762            );
763        }
764
765        #[test]
766        fn resolves_a_shared_column_name_unqualified_to_the_first_tables_column() {
767            assert_eq!(
768                index().resolve_unqualified("Amount", Some("SALES")).column,
769                Some(ColumnHandle {
770                    table: 0,
771                    column: 0
772                })
773            );
774        }
775    }
776
777    mod accessors {
778        use super::*;
779
780        #[test]
781        fn a_resolved_table_handle_reaches_its_table() {
782            let db = model();
783            let handle = ModelIndex::build(&db).resolve_table("SALES").unwrap();
784
785            assert_eq!(db.table(handle).unwrap().name, "Sales");
786        }
787
788        #[test]
789        fn a_resolved_column_handle_reaches_its_column() {
790            let db = model();
791            let Some(Resolved::Column(handle)) =
792                ModelIndex::build(&db).resolve_qualified("sales", "BELØB")
793            else {
794                panic!("expected 'sales'[BELØB] to resolve to a column");
795            };
796
797            assert_eq!(db.column(handle).unwrap().name, "Beløb");
798        }
799
800        #[test]
801        fn a_resolved_measure_handle_reaches_its_measure() {
802            let db = model();
803            let handle = ModelIndex::build(&db)
804                .resolve_unqualified("omsætning", None)
805                .measure
806                .unwrap();
807
808            assert_eq!(db.measure(handle).unwrap().name, "Omsætning");
809        }
810
811        #[test]
812        fn a_resolved_hierarchy_handle_reaches_its_hierarchy() {
813            let db = model();
814            let handle = ModelIndex::build(&db)
815                .resolve_hierarchy("DATO", "kalender")
816                .unwrap();
817
818            assert_eq!(db.hierarchy(handle).unwrap().name, "Kalender");
819        }
820
821        #[test]
822        fn a_resolved_expression_handle_reaches_its_expression() {
823            let db = model();
824            let handle = ModelIndex::build(&db)
825                .resolve_expression("DATABASE")
826                .unwrap();
827
828            assert_eq!(db.shared_expression(handle).unwrap().name, "Database");
829        }
830
831        #[test]
832        fn a_stale_table_handle_is_none() {
833            assert!(model().table(TableHandle(9)).is_none());
834        }
835
836        #[test]
837        fn a_column_handle_with_an_out_of_range_table_is_none() {
838            let handle = ColumnHandle {
839                table: 9,
840                column: 9,
841            };
842
843            assert!(model().column(handle).is_none());
844        }
845
846        #[test]
847        fn a_column_handle_with_an_out_of_range_column_is_none() {
848            let handle = ColumnHandle {
849                table: 0,
850                column: 9,
851            };
852
853            assert!(model().column(handle).is_none());
854        }
855
856        #[test]
857        fn a_measure_handle_with_an_out_of_range_table_is_none() {
858            let handle = MeasureHandle {
859                table: 9,
860                measure: 0,
861            };
862
863            assert!(model().measure(handle).is_none());
864        }
865
866        #[test]
867        fn a_measure_handle_into_a_table_without_measures_is_none() {
868            let handle = MeasureHandle {
869                table: 2,
870                measure: 0,
871            };
872
873            assert!(model().measure(handle).is_none());
874        }
875
876        #[test]
877        fn a_hierarchy_handle_into_a_table_without_hierarchies_is_none() {
878            let handle = HierarchyHandle {
879                table: 0,
880                hierarchy: 0,
881            };
882
883            assert!(model().hierarchy(handle).is_none());
884        }
885
886        #[test]
887        fn a_stale_expression_handle_is_none() {
888            assert!(model().shared_expression(ExpressionHandle(9)).is_none());
889        }
890
891        #[test]
892        fn a_resolved_function_handle_reaches_its_function() {
893            let db = model();
894            let handle = ModelIndex::build(&db)
895                .resolve_function("SALES.NETPRICE")
896                .unwrap();
897
898            assert_eq!(db.function(handle).unwrap().name, "Sales.NetPrice");
899        }
900
901        #[test]
902        fn a_stale_function_handle_is_none() {
903            assert!(model().function(FunctionHandle(9)).is_none());
904        }
905
906        #[test]
907        fn an_object_id_for_a_stale_column_handle_is_none() {
908            assert!(model().object_id(column_handle(9, 9)).is_none());
909        }
910
911        #[test]
912        fn an_object_id_for_a_stale_measure_handle_is_none() {
913            assert!(model().object_id(measure_handle(9, 9)).is_none());
914        }
915    }
916
917    /// Ids are what diagnostics print, so they must carry the model's own casing even
918    /// when the lookup that produced them was written in another one.
919    mod object_id {
920        use super::*;
921
922        #[test]
923        fn carries_the_models_casing_for_a_measure() {
924            let db = model();
925            let resolved = ModelIndex::build(&db)
926                .resolve_qualified("SALES", "TOTAL SALES")
927                .unwrap();
928
929            assert_eq!(
930                db.object_id(resolved).unwrap().to_string(),
931                "'Sales'[Total Sales]"
932            );
933        }
934
935        #[test]
936        fn carries_the_models_casing_for_a_danish_column() {
937            let db = model();
938            let resolved = ModelIndex::build(&db)
939                .resolve_qualified("DATO", "MÅNED")
940                .unwrap();
941
942            assert_eq!(db.object_id(resolved).unwrap().to_string(), "'Dato'[Måned]");
943        }
944
945        #[test]
946        fn distinguishes_a_column_from_a_measure_of_the_same_name() {
947            let db = model();
948
949            assert_ne!(
950                db.object_id(column_handle(1, 1)).unwrap(),
951                db.object_id(measure_handle(0, 1)).unwrap()
952            );
953        }
954
955        #[rstest]
956        #[case::the_dato_column(column_handle(1, 1), "'Dato'[Antal]")]
957        #[case::the_sales_measure(measure_handle(0, 1), "'Sales'[Antal]")]
958        fn renders_each_antal_under_its_own_table(
959            #[case] resolved: Resolved,
960            #[case] expected: &str,
961        ) {
962            assert_eq!(model().object_id(resolved).unwrap().to_string(), expected);
963        }
964    }
965}