ripbi-core 0.3.0

Static analysis engine for Power BI semantic models: TMDL and PBIR ingestion, DAX reference extraction, dependency graph, and reachability
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
//! Shared object-identity layer for the tabular AST, the report AST, and the DAX lexer.
//!
//! Analysis Services compares object names case-insensitively under the invariant
//! culture, so every name that participates in equality, hashing, or graph lookups is
//! wrapped in [`NameKey`]. Original casing is preserved for display; only the folded
//! form is ever compared.

use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};

/// Canonical case folding for object-name comparison, matching the Analysis
/// Services engine's case-insensitive (invariant-culture) semantics.
/// Unicode-aware: Danish "MÅNED" == "måned". Locale-insensitive by design.
pub(crate) fn fold_name(s: &str) -> String {
    s.to_lowercase()
}

/// Writes a name as a single-quoted DAX identifier, doubling any internal quote.
///
/// Allocation-free: the input is emitted in slices around each quote character.
pub(crate) struct Quoted<'a>(pub(crate) &'a str);

impl fmt::Display for Quoted<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str("'")?;
        let mut rest = self.0;
        while let Some(i) = rest.find('\'') {
            f.write_str(&rest[..i])?;
            f.write_str("''")?;
            rest = &rest[i + 1..];
        }
        f.write_str(rest)?;
        f.write_str("'")
    }
}

/// An object name that compares, hashes, and orders case-insensitively while
/// preserving the original casing for display.
///
/// The folded form is computed once at construction, so equality and hashing are
/// plain string operations on a precomputed field.
///
/// [`std::borrow::Borrow<str>`] is deliberately **not** implemented: `Borrow` requires
/// that the borrowed value hash and compare identically to the owner, which cannot hold
/// when [`Eq`]/[`Hash`] use `folded` while [`as_str`](NameKey::as_str) yields `original`.
///
/// # Examples
///
/// ```
/// use ripbi_core::NameKey;
///
/// // Case is irrelevant to identity, in ASCII and beyond.
/// assert_eq!(NameKey::new("Sales"), NameKey::new("SALES"));
/// assert_eq!(NameKey::new("MÅNED"), NameKey::new("måned"));
///
/// // ...but the model's own casing survives for display.
/// assert_eq!(NameKey::new("SaLeS").as_str(), "SaLeS");
/// ```
#[derive(Debug, Clone)]
pub struct NameKey {
    original: String,
    folded: String,
}

impl NameKey {
    /// Creates a key from a name as written in the source model.
    pub fn new(name: impl Into<String>) -> Self {
        let original = name.into();
        let folded = fold_name(&original);
        Self { original, folded }
    }

    /// The name with its original casing, as written in the source model.
    pub fn as_str(&self) -> &str {
        &self.original
    }

    /// The case-folded form used for equality, hashing, and ordering.
    pub fn folded(&self) -> &str {
        &self.folded
    }

    /// Writes the name as a single-quoted DAX identifier, doubling any internal
    /// quote — the same form [`ObjectId`] and [`FieldRef`] display table names in.
    ///
    /// # Examples
    ///
    /// ```
    /// use ripbi_core::NameKey;
    ///
    /// assert_eq!(NameKey::new("Sales").quoted().to_string(), "'Sales'");
    /// assert_eq!(NameKey::new("O'Brien").quoted().to_string(), "'O''Brien'");
    /// ```
    #[must_use]
    pub fn quoted(&self) -> impl fmt::Display + '_ {
        Quoted(self.as_str())
    }
}

impl PartialEq for NameKey {
    fn eq(&self, other: &Self) -> bool {
        self.folded == other.folded
    }
}

impl Eq for NameKey {}

impl Hash for NameKey {
    fn hash<H: Hasher>(&self, state: &mut H) {
        self.folded.hash(state);
    }
}

impl PartialOrd for NameKey {
    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
        Some(self.cmp(other))
    }
}

impl Ord for NameKey {
    /// Orders by the folded form only. Tie-breaking on `original` would make two
    /// `Eq` keys compare as `Less`/`Greater`, violating the `Ord`/`Eq` consistency
    /// contract that `BTreeMap` and `sort` rely on.
    fn cmp(&self, other: &Self) -> Ordering {
        self.folded.cmp(&other.folded)
    }
}

impl fmt::Display for NameKey {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(&self.original)
    }
}

impl From<&str> for NameKey {
    fn from(value: &str) -> Self {
        Self::new(value)
    }
}

impl From<String> for NameKey {
    fn from(value: String) -> Self {
        Self::new(value)
    }
}

/// An unresolved field reference as written in DAX or in a report binding.
///
/// Holds the *logical* name: quote-unescaping (`''` → `'`) is the producer's job — the
/// DAX lexer or the PBIR parser — so `'Sales''s Data'[Amount]` arrives here as the table
/// name `Sales's Data`. [`Display`](fmt::Display) re-applies the escaping.
///
/// Unresolved means the reference has not yet been bound to an [`ObjectId`]: `[Total]`
/// could be a measure or a column of the current row context.
///
/// # Examples
///
/// ```
/// use ripbi_core::{FieldRef, NameKey};
///
/// let qualified = FieldRef {
///     table: Some(NameKey::new("Sales's Data")),
///     name: NameKey::new("Amount"),
/// };
/// // Display re-applies DAX quoting, doubling the internal quote.
/// assert_eq!(qualified.to_string(), "'Sales''s Data'[Amount]");
///
/// let unqualified = FieldRef { table: None, name: NameKey::new("Total") };
/// assert_eq!(unqualified.to_string(), "[Total]");
/// ```
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FieldRef {
    /// Qualifying table, if the reference was written qualified.
    /// `'Sales'[Amount]` → `Some("Sales")`; `[Total]` → `None`.
    pub table: Option<NameKey>,
    /// The column or measure name inside the square brackets.
    pub name: NameKey,
}

impl fmt::Display for FieldRef {
    /// Emits valid DAX. Table names are always single-quoted — quoting is optional in
    /// DAX only for names without spaces or punctuation, so quoting unconditionally is
    /// always correct. The bracketed part is not escaped: `]` cannot appear in an
    /// Analysis Services object name, so there is nothing to escape.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if let Some(table) = &self.table {
            write!(f, "{}", Quoted(table.as_str()))?;
        }
        write!(f, "[{}]", self.name.as_str())
    }
}

/// Stable, case-insensitive identity of a model or report object — the node key
/// of the dependency graph.
///
/// Every name is a [`NameKey`], so two `ObjectId`s that differ only in casing are the
/// same node. Ordering (used to give analysis output a deterministic order) follows
/// the folded names, never the original casing.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ObjectId {
    /// A table.
    Table {
        /// Table name.
        table: NameKey,
    },
    /// A column, identified by its owning table.
    Column {
        /// Owning table.
        table: NameKey,
        /// Column name.
        column: NameKey,
    },
    /// A measure. The home table is part of the identity for display purposes only;
    /// the engine guarantees measure names are unique across the whole model.
    Measure {
        /// Home table.
        table: NameKey,
        /// Measure name.
        measure: NameKey,
    },
    /// A hierarchy defined on a table.
    Hierarchy {
        /// Owning table.
        table: NameKey,
        /// Hierarchy name.
        hierarchy: NameKey,
    },
    /// A partition (Power Query / M source) of a table.
    Partition {
        /// Owning table.
        table: NameKey,
        /// Partition name.
        partition: NameKey,
    },
    /// A relationship, identified by its endpoints. TMDL relationship names are
    /// GUIDs kept for diagnostics only, and a column pair carries at most one
    /// relationship, so the four endpoint names are the stable identity.
    Relationship {
        /// Table on the "from" (typically many) side.
        from_table: NameKey,
        /// Key column in `from_table`.
        from_column: NameKey,
        /// Table on the "to" (typically one) side.
        to_table: NameKey,
        /// Key column in `to_table`.
        to_column: NameKey,
    },
    /// A security role.
    Role {
        /// Role name.
        role: NameKey,
    },
    /// An item of a calculation group, identified by the calculation group's table.
    CalculationItem {
        /// Calculation group table.
        table: NameKey,
        /// Calculation item name.
        item: NameKey,
    },
    /// A shared model-level M expression (e.g. a parameter or a shared query).
    Expression {
        /// Expression name.
        name: NameKey,
    },
    /// A user-defined DAX function (TOM function). Names are model-global.
    Function {
        /// Function name.
        name: NameKey,
    },
    /// A report-level measure (reportExtensions.json). Lives in the report, not the
    /// model, so it does not share the model's measure namespace: a distinct variant
    /// avoids ever conflating the two.
    ReportMeasure {
        /// Report measure name; unique within its report.
        measure: NameKey,
    },
}

impl fmt::Display for ObjectId {
    /// Human-readable form for diagnostics. Quoted names use the same `''` escaping
    /// as [`FieldRef`]; bracketed names are unescaped for the same reason.
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ObjectId::Table { table } => {
                write!(f, "table {}", Quoted(table.as_str()))
            }
            ObjectId::Column { table, column } => {
                write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
            }
            ObjectId::Measure { table, measure } => {
                write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str())
            }
            ObjectId::Hierarchy { table, hierarchy } => {
                write!(
                    f,
                    "hierarchy {}[{}]",
                    Quoted(table.as_str()),
                    hierarchy.as_str()
                )
            }
            ObjectId::Partition { table, partition } => {
                write!(
                    f,
                    "partition {}[{}]",
                    Quoted(table.as_str()),
                    partition.as_str()
                )
            }
            ObjectId::Relationship {
                from_table,
                from_column,
                to_table,
                to_column,
            } => {
                write!(
                    f,
                    "relationship {}[{}] -> {}[{}]",
                    Quoted(from_table.as_str()),
                    from_column.as_str(),
                    Quoted(to_table.as_str()),
                    to_column.as_str()
                )
            }
            ObjectId::Role { role } => {
                write!(f, "role {}", Quoted(role.as_str()))
            }
            ObjectId::CalculationItem { table, item } => {
                write!(
                    f,
                    "calculation item {}[{}]",
                    Quoted(table.as_str()),
                    item.as_str()
                )
            }
            ObjectId::Expression { name } => {
                write!(f, "expression {}", Quoted(name.as_str()))
            }
            ObjectId::Function { name } => {
                write!(f, "function {}", Quoted(name.as_str()))
            }
            ObjectId::ReportMeasure { measure } => {
                write!(f, "report measure {}", Quoted(measure.as_str()))
            }
        }
    }
}

impl ObjectId {
    /// The model table this object belongs to — a relationship reports its "from"
    /// side, and objects with no model table (roles, shared expressions, functions,
    /// report measures) have none. The name is data, not display: render it with
    /// [`NameKey::quoted`] for the single-quoted form the finding ids use.
    #[must_use]
    pub fn owning_table(&self) -> Option<&NameKey> {
        match self {
            ObjectId::Table { table }
            | ObjectId::Column { table, .. }
            | ObjectId::Measure { table, .. }
            | ObjectId::Hierarchy { table, .. }
            | ObjectId::Partition { table, .. }
            | ObjectId::CalculationItem { table, .. } => Some(table),
            ObjectId::Relationship { from_table, .. } => Some(from_table),
            ObjectId::Role { .. }
            | ObjectId::Expression { .. }
            | ObjectId::Function { .. }
            | ObjectId::ReportMeasure { .. } => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rstest::rstest;
    use std::collections::HashSet;

    fn column(table: &str, column: &str) -> ObjectId {
        ObjectId::Column {
            table: NameKey::new(table),
            column: NameKey::new(column),
        }
    }

    fn qualified(table: &str, name: &str) -> FieldRef {
        FieldRef {
            table: Some(NameKey::new(table)),
            name: NameKey::new(name),
        }
    }

    fn unqualified(name: &str) -> FieldRef {
        FieldRef {
            table: None,
            name: NameKey::new(name),
        }
    }

    mod fold_name {
        use super::*;

        #[rstest]
        #[case::ascii("SaLeS", "sales")]
        #[case::danish_a_ring("MÅNED", "måned")]
        #[case::danish_ae_and_o_slash("ÆRØ", "ærø")]
        fn lowercases(#[case] input: &str, #[case] expected: &str) {
            assert_eq!(fold_name(input), expected);
        }
    }

    mod name_key {
        use super::*;

        #[rstest]
        #[case::ascii_upper("Sales", "SALES")]
        #[case::ascii_lower("Sales", "sales")]
        #[case::danish_a_ring("MÅNED", "måned")]
        #[case::danish_ae_and_o_slash("Ærø", "ærø")]
        fn compares_equal_ignoring_case(#[case] left: &str, #[case] right: &str) {
            assert_eq!(NameKey::new(left), NameKey::new(right));
        }

        #[rstest]
        #[case::one_letter_apart("Sales", "Salez")]
        #[case::danish_suffix("Måned", "Måneder")]
        fn compares_unequal_when_letters_differ(#[case] left: &str, #[case] right: &str) {
            assert_ne!(NameKey::new(left), NameKey::new(right));
        }

        #[test]
        fn hashes_case_variants_into_one_entry() {
            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("SALES")]);

            assert_eq!(set.len(), 1);
        }

        #[test]
        fn hashes_distinct_names_separately() {
            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("Salez")]);

            assert_eq!(set.len(), 2);
        }

        #[rstest]
        #[case::mixed_case("sAlEs")]
        #[case::upper("SALES")]
        fn is_found_in_a_set_under_any_casing(#[case] probe: &str) {
            let set = HashSet::from([NameKey::new("Sales")]);

            assert!(
                set.contains(&NameKey::new(probe)),
                "{probe:?} should match the stored key \"Sales\""
            );
        }

        #[test]
        fn is_not_found_in_a_set_by_a_prefix() {
            let set = HashSet::from([NameKey::new("Sales")]);

            assert!(
                !set.contains(&NameKey::new("Sale")),
                "folding must not truncate: \"Sale\" is a different name"
            );
        }

        #[test]
        fn as_str_keeps_the_original_casing() {
            assert_eq!(NameKey::new("SaLeS").as_str(), "SaLeS");
        }

        #[test]
        fn display_keeps_the_original_casing() {
            assert_eq!(NameKey::new("SaLeS").to_string(), "SaLeS");
        }

        #[test]
        fn folded_is_the_lowercased_form() {
            assert_eq!(NameKey::new("SaLeS").folded(), "sales");
        }

        #[rstest]
        #[case::plain("Sales", "'Sales'")]
        #[case::internal_quote_doubled("O'Brien", "'O''Brien'")]
        #[case::casing_is_kept("SaLeS", "'SaLeS'")]
        fn quotes_as_a_dax_identifier(#[case] name: &str, #[case] expected: &str) {
            assert_eq!(NameKey::new(name).quoted().to_string(), expected);
        }

        /// `Ord` must agree with `Eq`, or `BTreeMap` and `sort` misbehave: two keys
        /// that differ only in case have to compare `Equal`, never by their original
        /// spelling.
        #[rstest]
        #[case::case_variants_are_equal("ABC", "abc", Ordering::Equal)]
        #[case::earlier_letter_is_less("abc", "abd", Ordering::Less)]
        #[case::later_letter_is_greater("ABD", "abc", Ordering::Greater)]
        fn orders_by_folded_name(
            #[case] left: &str,
            #[case] right: &str,
            #[case] expected: Ordering,
        ) {
            assert_eq!(NameKey::new(left).cmp(&NameKey::new(right)), expected);
        }

        #[test]
        fn supports_comparison_operators() {
            assert!(
                NameKey::new("abc") < NameKey::new("abd"),
                "PartialOrd must follow Ord"
            );
        }
    }

    mod object_id {
        use super::*;

        #[test]
        fn compares_equal_ignoring_case() {
            assert_eq!(column("Sales", "Amount"), column("SALES", "AMOUNT"));
        }

        #[test]
        fn compares_unequal_when_a_name_differs() {
            assert_ne!(column("Sales", "Amount"), column("Sales", "Amount2"));
        }

        /// A column and a measure can share a name; the variant keeps them apart.
        #[test]
        fn distinguishes_variants_carrying_the_same_names() {
            let measure = ObjectId::Measure {
                table: NameKey::new("Sales"),
                measure: NameKey::new("Amount"),
            };

            assert_ne!(column("Sales", "Amount"), measure);
        }

        #[test]
        fn hashes_case_variants_into_one_entry() {
            let set = HashSet::from([column("Sales", "Amount"), column("SALES", "AMOUNT")]);

            assert_eq!(set.len(), 1);
        }

        #[test]
        fn hashes_distinct_columns_separately() {
            let set = HashSet::from([column("Sales", "Amount"), column("Sales", "Amount2")]);

            assert_eq!(set.len(), 2);
        }

        #[test]
        fn hashes_a_column_and_a_measure_separately() {
            let measure = ObjectId::Measure {
                table: NameKey::new("Sales"),
                measure: NameKey::new("Amount"),
            };
            let set = HashSet::from([column("Sales", "Amount"), measure]);

            assert_eq!(set.len(), 2);
        }

        /// Relationship identity is its endpoints, ignoring case: TMDL names are
        /// GUIDs, so endpoints are all a graph node can be keyed by.
        #[test]
        fn relationships_compare_by_their_endpoints() {
            let relationship = |from: &str, to: &str| ObjectId::Relationship {
                from_table: NameKey::new(from),
                from_column: NameKey::new("Key"),
                to_table: NameKey::new(to),
                to_column: NameKey::new("Key"),
            };

            assert_eq!(
                relationship("Sales", "DimOld"),
                relationship("SALES", "dimold")
            );
            assert_ne!(
                relationship("Sales", "DimOld"),
                relationship("Sales", "DimNew")
            );
            // Direction is identity: the reverse relationship is a different edge.
            assert_ne!(
                relationship("Sales", "DimOld"),
                relationship("DimOld", "Sales")
            );
        }
    }

    mod field_ref {
        use super::*;

        #[rstest]
        #[case::qualified(qualified("Sales", "Amount"), "'Sales'[Amount]")]
        #[case::internal_quote_is_doubled(
            qualified("Sales's Data", "Amount"),
            "'Sales''s Data'[Amount]"
        )]
        #[case::unqualified(unqualified("Total"), "[Total]")]
        fn displays_as_valid_dax(#[case] reference: FieldRef, #[case] expected: &str) {
            assert_eq!(reference.to_string(), expected);
        }

        #[test]
        fn compares_equal_ignoring_case() {
            assert_eq!(qualified("Sales", "Amount"), qualified("SALES", "AMOUNT"));
        }

        #[test]
        fn distinguishes_a_qualified_reference_from_an_unqualified_one() {
            assert_ne!(qualified("Sales", "Amount"), unqualified("Amount"));
        }
    }

    mod object_id_display {
        use super::*;

        #[rstest]
        #[case::table(ObjectId::Table { table: NameKey::new("Sales") }, "table 'Sales'")]
        #[case::column(column("Sales", "Amount"), "'Sales'[Amount]")]
        #[case::measure(
            ObjectId::Measure { table: NameKey::new("Sales"), measure: NameKey::new("Total") },
            "'Sales'[Total]"
        )]
        #[case::hierarchy(
            ObjectId::Hierarchy { table: NameKey::new("Date"), hierarchy: NameKey::new("Calendar") },
            "hierarchy 'Date'[Calendar]"
        )]
        #[case::partition(
            ObjectId::Partition {
                table: NameKey::new("Sales"),
                partition: NameKey::new("Sales-Part1"),
            },
            "partition 'Sales'[Sales-Part1]"
        )]
        #[case::relationship(
            ObjectId::Relationship {
                from_table: NameKey::new("Sales"),
                from_column: NameKey::new("Key"),
                to_table: NameKey::new("Dim Old"),
                to_column: NameKey::new("Key"),
            },
            "relationship 'Sales'[Key] -> 'Dim Old'[Key]"
        )]
        #[case::role(ObjectId::Role { role: NameKey::new("Reader") }, "role 'Reader'")]
        #[case::calculation_item(
            ObjectId::CalculationItem {
                table: NameKey::new("Time Intelligence"),
                item: NameKey::new("YTD"),
            },
            "calculation item 'Time Intelligence'[YTD]"
        )]
        #[case::expression(
            ObjectId::Expression { name: NameKey::new("Param1") },
            "expression 'Param1'"
        )]
        #[case::function(
            ObjectId::Function { name: NameKey::new("Sales.Margin") },
            "function 'Sales.Margin'"
        )]
        #[case::report_measure(
            ObjectId::ReportMeasure { measure: NameKey::new("Growth %") },
            "report measure 'Growth %'"
        )]
        #[case::internal_quotes_are_doubled(
            column("Bob's 'Best' Data", "AmOuNt"),
            "'Bob''s ''Best'' Data'[AmOuNt]"
        )]
        #[case::quoted_name_keeps_its_casing(
            ObjectId::Table { table: NameKey::new("O'Brien") },
            "table 'O''Brien'"
        )]
        fn renders(#[case] id: ObjectId, #[case] expected: &str) {
            assert_eq!(id.to_string(), expected);
        }
    }

    mod object_id_owning_table {
        use super::*;

        #[rstest]
        #[case::table(ObjectId::Table { table: NameKey::new("Sales") }, Some("Sales"))]
        #[case::column(column("Sales", "Amount"), Some("Sales"))]
        #[case::measure(
            ObjectId::Measure { table: NameKey::new("Sales"), measure: NameKey::new("Total") },
            Some("Sales")
        )]
        #[case::hierarchy(
            ObjectId::Hierarchy { table: NameKey::new("Date"), hierarchy: NameKey::new("Calendar") },
            Some("Date")
        )]
        #[case::partition(
            ObjectId::Partition {
                table: NameKey::new("Sales"),
                partition: NameKey::new("Sales-Part1"),
            },
            Some("Sales")
        )]
        #[case::relationship_counts_under_the_from_side(
            ObjectId::Relationship {
                from_table: NameKey::new("Sales"),
                from_column: NameKey::new("Key"),
                to_table: NameKey::new("Dim Old"),
                to_column: NameKey::new("Key"),
            },
            Some("Sales")
        )]
        #[case::calculation_item(
            ObjectId::CalculationItem {
                table: NameKey::new("Time Intelligence"),
                item: NameKey::new("YTD"),
            },
            Some("Time Intelligence")
        )]
        #[case::role(ObjectId::Role { role: NameKey::new("Reader") }, None)]
        #[case::expression(ObjectId::Expression { name: NameKey::new("Param1") }, None)]
        #[case::function(ObjectId::Function { name: NameKey::new("Sales.Margin") }, None)]
        #[case::report_measure(
            ObjectId::ReportMeasure { measure: NameKey::new("Growth %") },
            None
        )]
        fn resolves(#[case] id: ObjectId, #[case] expected: Option<&str>) {
            assert_eq!(id.owning_table().map(NameKey::as_str), expected);
        }
    }
}