Skip to main content

ripbi_core/
identity.rs

1//! Shared object-identity layer for the tabular AST, the report AST, and the DAX lexer.
2//!
3//! Analysis Services compares object names case-insensitively under the invariant
4//! culture, so every name that participates in equality, hashing, or graph lookups is
5//! wrapped in [`NameKey`]. Original casing is preserved for display; only the folded
6//! form is ever compared.
7
8use std::cmp::Ordering;
9use std::fmt;
10use std::hash::{Hash, Hasher};
11
12/// Canonical case folding for object-name comparison, matching the Analysis
13/// Services engine's case-insensitive (invariant-culture) semantics.
14/// Unicode-aware: Danish "MÅNED" == "måned". Locale-insensitive by design.
15pub(crate) fn fold_name(s: &str) -> String {
16    s.to_lowercase()
17}
18
19/// Writes a name as a single-quoted DAX identifier, doubling any internal quote.
20///
21/// Allocation-free: the input is emitted in slices around each quote character.
22pub(crate) struct Quoted<'a>(pub(crate) &'a str);
23
24impl fmt::Display for Quoted<'_> {
25    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
26        f.write_str("'")?;
27        let mut rest = self.0;
28        while let Some(i) = rest.find('\'') {
29            f.write_str(&rest[..i])?;
30            f.write_str("''")?;
31            rest = &rest[i + 1..];
32        }
33        f.write_str(rest)?;
34        f.write_str("'")
35    }
36}
37
38/// An object name that compares, hashes, and orders case-insensitively while
39/// preserving the original casing for display.
40///
41/// The folded form is computed once at construction, so equality and hashing are
42/// plain string operations on a precomputed field.
43///
44/// [`std::borrow::Borrow<str>`] is deliberately **not** implemented: `Borrow` requires
45/// that the borrowed value hash and compare identically to the owner, which cannot hold
46/// when [`Eq`]/[`Hash`] use `folded` while [`as_str`](NameKey::as_str) yields `original`.
47///
48/// # Examples
49///
50/// ```
51/// use ripbi_core::NameKey;
52///
53/// // Case is irrelevant to identity, in ASCII and beyond.
54/// assert_eq!(NameKey::new("Sales"), NameKey::new("SALES"));
55/// assert_eq!(NameKey::new("MÅNED"), NameKey::new("måned"));
56///
57/// // ...but the model's own casing survives for display.
58/// assert_eq!(NameKey::new("SaLeS").as_str(), "SaLeS");
59/// ```
60#[derive(Debug, Clone)]
61pub struct NameKey {
62    original: String,
63    folded: String,
64}
65
66impl NameKey {
67    /// Creates a key from a name as written in the source model.
68    pub fn new(name: impl Into<String>) -> Self {
69        let original = name.into();
70        let folded = fold_name(&original);
71        Self { original, folded }
72    }
73
74    /// The name with its original casing, as written in the source model.
75    pub fn as_str(&self) -> &str {
76        &self.original
77    }
78
79    /// The case-folded form used for equality, hashing, and ordering.
80    pub fn folded(&self) -> &str {
81        &self.folded
82    }
83
84    /// Writes the name as a single-quoted DAX identifier, doubling any internal
85    /// quote — the same form [`ObjectId`] and [`FieldRef`] display table names in.
86    ///
87    /// # Examples
88    ///
89    /// ```
90    /// use ripbi_core::NameKey;
91    ///
92    /// assert_eq!(NameKey::new("Sales").quoted().to_string(), "'Sales'");
93    /// assert_eq!(NameKey::new("O'Brien").quoted().to_string(), "'O''Brien'");
94    /// ```
95    #[must_use]
96    pub fn quoted(&self) -> impl fmt::Display + '_ {
97        Quoted(self.as_str())
98    }
99}
100
101impl PartialEq for NameKey {
102    fn eq(&self, other: &Self) -> bool {
103        self.folded == other.folded
104    }
105}
106
107impl Eq for NameKey {}
108
109impl Hash for NameKey {
110    fn hash<H: Hasher>(&self, state: &mut H) {
111        self.folded.hash(state);
112    }
113}
114
115impl PartialOrd for NameKey {
116    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
117        Some(self.cmp(other))
118    }
119}
120
121impl Ord for NameKey {
122    /// Orders by the folded form only. Tie-breaking on `original` would make two
123    /// `Eq` keys compare as `Less`/`Greater`, violating the `Ord`/`Eq` consistency
124    /// contract that `BTreeMap` and `sort` rely on.
125    fn cmp(&self, other: &Self) -> Ordering {
126        self.folded.cmp(&other.folded)
127    }
128}
129
130impl fmt::Display for NameKey {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.write_str(&self.original)
133    }
134}
135
136impl From<&str> for NameKey {
137    fn from(value: &str) -> Self {
138        Self::new(value)
139    }
140}
141
142impl From<String> for NameKey {
143    fn from(value: String) -> Self {
144        Self::new(value)
145    }
146}
147
148/// An unresolved field reference as written in DAX or in a report binding.
149///
150/// Holds the *logical* name: quote-unescaping (`''` → `'`) is the producer's job — the
151/// DAX lexer or the PBIR parser — so `'Sales''s Data'[Amount]` arrives here as the table
152/// name `Sales's Data`. [`Display`](fmt::Display) re-applies the escaping.
153///
154/// Unresolved means the reference has not yet been bound to an [`ObjectId`]: `[Total]`
155/// could be a measure or a column of the current row context.
156///
157/// # Examples
158///
159/// ```
160/// use ripbi_core::{FieldRef, NameKey};
161///
162/// let qualified = FieldRef {
163///     table: Some(NameKey::new("Sales's Data")),
164///     name: NameKey::new("Amount"),
165/// };
166/// // Display re-applies DAX quoting, doubling the internal quote.
167/// assert_eq!(qualified.to_string(), "'Sales''s Data'[Amount]");
168///
169/// let unqualified = FieldRef { table: None, name: NameKey::new("Total") };
170/// assert_eq!(unqualified.to_string(), "[Total]");
171/// ```
172#[derive(Debug, Clone, PartialEq, Eq, Hash)]
173pub struct FieldRef {
174    /// Qualifying table, if the reference was written qualified.
175    /// `'Sales'[Amount]` → `Some("Sales")`; `[Total]` → `None`.
176    pub table: Option<NameKey>,
177    /// The column or measure name inside the square brackets.
178    pub name: NameKey,
179}
180
181impl fmt::Display for FieldRef {
182    /// Emits valid DAX. Table names are always single-quoted — quoting is optional in
183    /// DAX only for names without spaces or punctuation, so quoting unconditionally is
184    /// always correct. The bracketed part is not escaped: `]` cannot appear in an
185    /// Analysis Services object name, so there is nothing to escape.
186    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187        if let Some(table) = &self.table {
188            write!(f, "{}", Quoted(table.as_str()))?;
189        }
190        write!(f, "[{}]", self.name.as_str())
191    }
192}
193
194/// Stable, case-insensitive identity of a model or report object — the node key
195/// of the dependency graph.
196///
197/// Every name is a [`NameKey`], so two `ObjectId`s that differ only in casing are the
198/// same node. Ordering (used to give analysis output a deterministic order) follows
199/// the folded names, never the original casing.
200#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
201pub enum ObjectId {
202    /// A table.
203    Table {
204        /// Table name.
205        table: NameKey,
206    },
207    /// A column, identified by its owning table.
208    Column {
209        /// Owning table.
210        table: NameKey,
211        /// Column name.
212        column: NameKey,
213    },
214    /// A measure. The home table is part of the identity for display purposes only;
215    /// the engine guarantees measure names are unique across the whole model.
216    Measure {
217        /// Home table.
218        table: NameKey,
219        /// Measure name.
220        measure: NameKey,
221    },
222    /// A hierarchy defined on a table.
223    Hierarchy {
224        /// Owning table.
225        table: NameKey,
226        /// Hierarchy name.
227        hierarchy: NameKey,
228    },
229    /// A partition (Power Query / M source) of a table.
230    Partition {
231        /// Owning table.
232        table: NameKey,
233        /// Partition name.
234        partition: NameKey,
235    },
236    /// A relationship, identified by its endpoints. TMDL relationship names are
237    /// GUIDs kept for diagnostics only, and a column pair carries at most one
238    /// relationship, so the four endpoint names are the stable identity.
239    Relationship {
240        /// Table on the "from" (typically many) side.
241        from_table: NameKey,
242        /// Key column in `from_table`.
243        from_column: NameKey,
244        /// Table on the "to" (typically one) side.
245        to_table: NameKey,
246        /// Key column in `to_table`.
247        to_column: NameKey,
248    },
249    /// A security role.
250    Role {
251        /// Role name.
252        role: NameKey,
253    },
254    /// An item of a calculation group, identified by the calculation group's table.
255    CalculationItem {
256        /// Calculation group table.
257        table: NameKey,
258        /// Calculation item name.
259        item: NameKey,
260    },
261    /// A shared model-level M expression (e.g. a parameter or a shared query).
262    Expression {
263        /// Expression name.
264        name: NameKey,
265    },
266    /// A user-defined DAX function (TOM function). Names are model-global.
267    Function {
268        /// Function name.
269        name: NameKey,
270    },
271    /// A report-level measure (reportExtensions.json). Lives in the report, not the
272    /// model, so it does not share the model's measure namespace: a distinct variant
273    /// avoids ever conflating the two.
274    ReportMeasure {
275        /// Report measure name; unique within its report.
276        measure: NameKey,
277    },
278}
279
280impl fmt::Display for ObjectId {
281    /// Human-readable form for diagnostics. Quoted names use the same `''` escaping
282    /// as [`FieldRef`]; bracketed names are unescaped for the same reason.
283    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
284        match self {
285            ObjectId::Table { table } => {
286                write!(f, "table {}", Quoted(table.as_str()))
287            }
288            ObjectId::Column { table, column } => {
289                write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
290            }
291            ObjectId::Measure { table, measure } => {
292                write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str())
293            }
294            ObjectId::Hierarchy { table, hierarchy } => {
295                write!(
296                    f,
297                    "hierarchy {}[{}]",
298                    Quoted(table.as_str()),
299                    hierarchy.as_str()
300                )
301            }
302            ObjectId::Partition { table, partition } => {
303                write!(
304                    f,
305                    "partition {}[{}]",
306                    Quoted(table.as_str()),
307                    partition.as_str()
308                )
309            }
310            ObjectId::Relationship {
311                from_table,
312                from_column,
313                to_table,
314                to_column,
315            } => {
316                write!(
317                    f,
318                    "relationship {}[{}] -> {}[{}]",
319                    Quoted(from_table.as_str()),
320                    from_column.as_str(),
321                    Quoted(to_table.as_str()),
322                    to_column.as_str()
323                )
324            }
325            ObjectId::Role { role } => {
326                write!(f, "role {}", Quoted(role.as_str()))
327            }
328            ObjectId::CalculationItem { table, item } => {
329                write!(
330                    f,
331                    "calculation item {}[{}]",
332                    Quoted(table.as_str()),
333                    item.as_str()
334                )
335            }
336            ObjectId::Expression { name } => {
337                write!(f, "expression {}", Quoted(name.as_str()))
338            }
339            ObjectId::Function { name } => {
340                write!(f, "function {}", Quoted(name.as_str()))
341            }
342            ObjectId::ReportMeasure { measure } => {
343                write!(f, "report measure {}", Quoted(measure.as_str()))
344            }
345        }
346    }
347}
348
349impl ObjectId {
350    /// The model table this object belongs to — a relationship reports its "from"
351    /// side, and objects with no model table (roles, shared expressions, functions,
352    /// report measures) have none. The name is data, not display: render it with
353    /// [`NameKey::quoted`] for the single-quoted form the finding ids use.
354    #[must_use]
355    pub fn owning_table(&self) -> Option<&NameKey> {
356        match self {
357            ObjectId::Table { table }
358            | ObjectId::Column { table, .. }
359            | ObjectId::Measure { table, .. }
360            | ObjectId::Hierarchy { table, .. }
361            | ObjectId::Partition { table, .. }
362            | ObjectId::CalculationItem { table, .. } => Some(table),
363            ObjectId::Relationship { from_table, .. } => Some(from_table),
364            ObjectId::Role { .. }
365            | ObjectId::Expression { .. }
366            | ObjectId::Function { .. }
367            | ObjectId::ReportMeasure { .. } => None,
368        }
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use rstest::rstest;
376    use std::collections::HashSet;
377
378    fn column(table: &str, column: &str) -> ObjectId {
379        ObjectId::Column {
380            table: NameKey::new(table),
381            column: NameKey::new(column),
382        }
383    }
384
385    fn qualified(table: &str, name: &str) -> FieldRef {
386        FieldRef {
387            table: Some(NameKey::new(table)),
388            name: NameKey::new(name),
389        }
390    }
391
392    fn unqualified(name: &str) -> FieldRef {
393        FieldRef {
394            table: None,
395            name: NameKey::new(name),
396        }
397    }
398
399    mod fold_name {
400        use super::*;
401
402        #[rstest]
403        #[case::ascii("SaLeS", "sales")]
404        #[case::danish_a_ring("MÅNED", "måned")]
405        #[case::danish_ae_and_o_slash("ÆRØ", "ærø")]
406        fn lowercases(#[case] input: &str, #[case] expected: &str) {
407            assert_eq!(fold_name(input), expected);
408        }
409    }
410
411    mod name_key {
412        use super::*;
413
414        #[rstest]
415        #[case::ascii_upper("Sales", "SALES")]
416        #[case::ascii_lower("Sales", "sales")]
417        #[case::danish_a_ring("MÅNED", "måned")]
418        #[case::danish_ae_and_o_slash("Ærø", "ærø")]
419        fn compares_equal_ignoring_case(#[case] left: &str, #[case] right: &str) {
420            assert_eq!(NameKey::new(left), NameKey::new(right));
421        }
422
423        #[rstest]
424        #[case::one_letter_apart("Sales", "Salez")]
425        #[case::danish_suffix("Måned", "Måneder")]
426        fn compares_unequal_when_letters_differ(#[case] left: &str, #[case] right: &str) {
427            assert_ne!(NameKey::new(left), NameKey::new(right));
428        }
429
430        #[test]
431        fn hashes_case_variants_into_one_entry() {
432            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("SALES")]);
433
434            assert_eq!(set.len(), 1);
435        }
436
437        #[test]
438        fn hashes_distinct_names_separately() {
439            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("Salez")]);
440
441            assert_eq!(set.len(), 2);
442        }
443
444        #[rstest]
445        #[case::mixed_case("sAlEs")]
446        #[case::upper("SALES")]
447        fn is_found_in_a_set_under_any_casing(#[case] probe: &str) {
448            let set = HashSet::from([NameKey::new("Sales")]);
449
450            assert!(
451                set.contains(&NameKey::new(probe)),
452                "{probe:?} should match the stored key \"Sales\""
453            );
454        }
455
456        #[test]
457        fn is_not_found_in_a_set_by_a_prefix() {
458            let set = HashSet::from([NameKey::new("Sales")]);
459
460            assert!(
461                !set.contains(&NameKey::new("Sale")),
462                "folding must not truncate: \"Sale\" is a different name"
463            );
464        }
465
466        #[test]
467        fn as_str_keeps_the_original_casing() {
468            assert_eq!(NameKey::new("SaLeS").as_str(), "SaLeS");
469        }
470
471        #[test]
472        fn display_keeps_the_original_casing() {
473            assert_eq!(NameKey::new("SaLeS").to_string(), "SaLeS");
474        }
475
476        #[test]
477        fn folded_is_the_lowercased_form() {
478            assert_eq!(NameKey::new("SaLeS").folded(), "sales");
479        }
480
481        #[rstest]
482        #[case::plain("Sales", "'Sales'")]
483        #[case::internal_quote_doubled("O'Brien", "'O''Brien'")]
484        #[case::casing_is_kept("SaLeS", "'SaLeS'")]
485        fn quotes_as_a_dax_identifier(#[case] name: &str, #[case] expected: &str) {
486            assert_eq!(NameKey::new(name).quoted().to_string(), expected);
487        }
488
489        /// `Ord` must agree with `Eq`, or `BTreeMap` and `sort` misbehave: two keys
490        /// that differ only in case have to compare `Equal`, never by their original
491        /// spelling.
492        #[rstest]
493        #[case::case_variants_are_equal("ABC", "abc", Ordering::Equal)]
494        #[case::earlier_letter_is_less("abc", "abd", Ordering::Less)]
495        #[case::later_letter_is_greater("ABD", "abc", Ordering::Greater)]
496        fn orders_by_folded_name(
497            #[case] left: &str,
498            #[case] right: &str,
499            #[case] expected: Ordering,
500        ) {
501            assert_eq!(NameKey::new(left).cmp(&NameKey::new(right)), expected);
502        }
503
504        #[test]
505        fn supports_comparison_operators() {
506            assert!(
507                NameKey::new("abc") < NameKey::new("abd"),
508                "PartialOrd must follow Ord"
509            );
510        }
511    }
512
513    mod object_id {
514        use super::*;
515
516        #[test]
517        fn compares_equal_ignoring_case() {
518            assert_eq!(column("Sales", "Amount"), column("SALES", "AMOUNT"));
519        }
520
521        #[test]
522        fn compares_unequal_when_a_name_differs() {
523            assert_ne!(column("Sales", "Amount"), column("Sales", "Amount2"));
524        }
525
526        /// A column and a measure can share a name; the variant keeps them apart.
527        #[test]
528        fn distinguishes_variants_carrying_the_same_names() {
529            let measure = ObjectId::Measure {
530                table: NameKey::new("Sales"),
531                measure: NameKey::new("Amount"),
532            };
533
534            assert_ne!(column("Sales", "Amount"), measure);
535        }
536
537        #[test]
538        fn hashes_case_variants_into_one_entry() {
539            let set = HashSet::from([column("Sales", "Amount"), column("SALES", "AMOUNT")]);
540
541            assert_eq!(set.len(), 1);
542        }
543
544        #[test]
545        fn hashes_distinct_columns_separately() {
546            let set = HashSet::from([column("Sales", "Amount"), column("Sales", "Amount2")]);
547
548            assert_eq!(set.len(), 2);
549        }
550
551        #[test]
552        fn hashes_a_column_and_a_measure_separately() {
553            let measure = ObjectId::Measure {
554                table: NameKey::new("Sales"),
555                measure: NameKey::new("Amount"),
556            };
557            let set = HashSet::from([column("Sales", "Amount"), measure]);
558
559            assert_eq!(set.len(), 2);
560        }
561
562        /// Relationship identity is its endpoints, ignoring case: TMDL names are
563        /// GUIDs, so endpoints are all a graph node can be keyed by.
564        #[test]
565        fn relationships_compare_by_their_endpoints() {
566            let relationship = |from: &str, to: &str| ObjectId::Relationship {
567                from_table: NameKey::new(from),
568                from_column: NameKey::new("Key"),
569                to_table: NameKey::new(to),
570                to_column: NameKey::new("Key"),
571            };
572
573            assert_eq!(
574                relationship("Sales", "DimOld"),
575                relationship("SALES", "dimold")
576            );
577            assert_ne!(
578                relationship("Sales", "DimOld"),
579                relationship("Sales", "DimNew")
580            );
581            // Direction is identity: the reverse relationship is a different edge.
582            assert_ne!(
583                relationship("Sales", "DimOld"),
584                relationship("DimOld", "Sales")
585            );
586        }
587    }
588
589    mod field_ref {
590        use super::*;
591
592        #[rstest]
593        #[case::qualified(qualified("Sales", "Amount"), "'Sales'[Amount]")]
594        #[case::internal_quote_is_doubled(
595            qualified("Sales's Data", "Amount"),
596            "'Sales''s Data'[Amount]"
597        )]
598        #[case::unqualified(unqualified("Total"), "[Total]")]
599        fn displays_as_valid_dax(#[case] reference: FieldRef, #[case] expected: &str) {
600            assert_eq!(reference.to_string(), expected);
601        }
602
603        #[test]
604        fn compares_equal_ignoring_case() {
605            assert_eq!(qualified("Sales", "Amount"), qualified("SALES", "AMOUNT"));
606        }
607
608        #[test]
609        fn distinguishes_a_qualified_reference_from_an_unqualified_one() {
610            assert_ne!(qualified("Sales", "Amount"), unqualified("Amount"));
611        }
612    }
613
614    mod object_id_display {
615        use super::*;
616
617        #[rstest]
618        #[case::table(ObjectId::Table { table: NameKey::new("Sales") }, "table 'Sales'")]
619        #[case::column(column("Sales", "Amount"), "'Sales'[Amount]")]
620        #[case::measure(
621            ObjectId::Measure { table: NameKey::new("Sales"), measure: NameKey::new("Total") },
622            "'Sales'[Total]"
623        )]
624        #[case::hierarchy(
625            ObjectId::Hierarchy { table: NameKey::new("Date"), hierarchy: NameKey::new("Calendar") },
626            "hierarchy 'Date'[Calendar]"
627        )]
628        #[case::partition(
629            ObjectId::Partition {
630                table: NameKey::new("Sales"),
631                partition: NameKey::new("Sales-Part1"),
632            },
633            "partition 'Sales'[Sales-Part1]"
634        )]
635        #[case::relationship(
636            ObjectId::Relationship {
637                from_table: NameKey::new("Sales"),
638                from_column: NameKey::new("Key"),
639                to_table: NameKey::new("Dim Old"),
640                to_column: NameKey::new("Key"),
641            },
642            "relationship 'Sales'[Key] -> 'Dim Old'[Key]"
643        )]
644        #[case::role(ObjectId::Role { role: NameKey::new("Reader") }, "role 'Reader'")]
645        #[case::calculation_item(
646            ObjectId::CalculationItem {
647                table: NameKey::new("Time Intelligence"),
648                item: NameKey::new("YTD"),
649            },
650            "calculation item 'Time Intelligence'[YTD]"
651        )]
652        #[case::expression(
653            ObjectId::Expression { name: NameKey::new("Param1") },
654            "expression 'Param1'"
655        )]
656        #[case::function(
657            ObjectId::Function { name: NameKey::new("Sales.Margin") },
658            "function 'Sales.Margin'"
659        )]
660        #[case::report_measure(
661            ObjectId::ReportMeasure { measure: NameKey::new("Growth %") },
662            "report measure 'Growth %'"
663        )]
664        #[case::internal_quotes_are_doubled(
665            column("Bob's 'Best' Data", "AmOuNt"),
666            "'Bob''s ''Best'' Data'[AmOuNt]"
667        )]
668        #[case::quoted_name_keeps_its_casing(
669            ObjectId::Table { table: NameKey::new("O'Brien") },
670            "table 'O''Brien'"
671        )]
672        fn renders(#[case] id: ObjectId, #[case] expected: &str) {
673            assert_eq!(id.to_string(), expected);
674        }
675    }
676
677    mod object_id_owning_table {
678        use super::*;
679
680        #[rstest]
681        #[case::table(ObjectId::Table { table: NameKey::new("Sales") }, Some("Sales"))]
682        #[case::column(column("Sales", "Amount"), Some("Sales"))]
683        #[case::measure(
684            ObjectId::Measure { table: NameKey::new("Sales"), measure: NameKey::new("Total") },
685            Some("Sales")
686        )]
687        #[case::hierarchy(
688            ObjectId::Hierarchy { table: NameKey::new("Date"), hierarchy: NameKey::new("Calendar") },
689            Some("Date")
690        )]
691        #[case::partition(
692            ObjectId::Partition {
693                table: NameKey::new("Sales"),
694                partition: NameKey::new("Sales-Part1"),
695            },
696            Some("Sales")
697        )]
698        #[case::relationship_counts_under_the_from_side(
699            ObjectId::Relationship {
700                from_table: NameKey::new("Sales"),
701                from_column: NameKey::new("Key"),
702                to_table: NameKey::new("Dim Old"),
703                to_column: NameKey::new("Key"),
704            },
705            Some("Sales")
706        )]
707        #[case::calculation_item(
708            ObjectId::CalculationItem {
709                table: NameKey::new("Time Intelligence"),
710                item: NameKey::new("YTD"),
711            },
712            Some("Time Intelligence")
713        )]
714        #[case::role(ObjectId::Role { role: NameKey::new("Reader") }, None)]
715        #[case::expression(ObjectId::Expression { name: NameKey::new("Param1") }, None)]
716        #[case::function(ObjectId::Function { name: NameKey::new("Sales.Margin") }, None)]
717        #[case::report_measure(
718            ObjectId::ReportMeasure { measure: NameKey::new("Growth %") },
719            None
720        )]
721        fn resolves(#[case] id: ObjectId, #[case] expected: Option<&str>) {
722            assert_eq!(id.owning_table().map(NameKey::as_str), expected);
723        }
724    }
725}