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    /// 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}