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