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
85impl PartialEq for NameKey {
86    fn eq(&self, other: &Self) -> bool {
87        self.folded == other.folded
88    }
89}
90
91impl Eq for NameKey {}
92
93impl Hash for NameKey {
94    fn hash<H: Hasher>(&self, state: &mut H) {
95        self.folded.hash(state);
96    }
97}
98
99impl PartialOrd for NameKey {
100    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
101        Some(self.cmp(other))
102    }
103}
104
105impl Ord for NameKey {
106    /// Orders by the folded form only. Tie-breaking on `original` would make two
107    /// `Eq` keys compare as `Less`/`Greater`, violating the `Ord`/`Eq` consistency
108    /// contract that `BTreeMap` and `sort` rely on.
109    fn cmp(&self, other: &Self) -> Ordering {
110        self.folded.cmp(&other.folded)
111    }
112}
113
114impl fmt::Display for NameKey {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.original)
117    }
118}
119
120impl From<&str> for NameKey {
121    fn from(value: &str) -> Self {
122        Self::new(value)
123    }
124}
125
126impl From<String> for NameKey {
127    fn from(value: String) -> Self {
128        Self::new(value)
129    }
130}
131
132/// An unresolved field reference as written in DAX or in a report binding.
133///
134/// Holds the *logical* name: quote-unescaping (`''` → `'`) is the producer's job — the
135/// DAX lexer or the PBIR parser — so `'Sales''s Data'[Amount]` arrives here as the table
136/// name `Sales's Data`. [`Display`](fmt::Display) re-applies the escaping.
137///
138/// Unresolved means the reference has not yet been bound to an [`ObjectId`]: `[Total]`
139/// could be a measure or a column of the current row context.
140///
141/// # Examples
142///
143/// ```
144/// use ripbi_core::{FieldRef, NameKey};
145///
146/// let qualified = FieldRef {
147///     table: Some(NameKey::new("Sales's Data")),
148///     name: NameKey::new("Amount"),
149/// };
150/// // Display re-applies DAX quoting, doubling the internal quote.
151/// assert_eq!(qualified.to_string(), "'Sales''s Data'[Amount]");
152///
153/// let unqualified = FieldRef { table: None, name: NameKey::new("Total") };
154/// assert_eq!(unqualified.to_string(), "[Total]");
155/// ```
156#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub struct FieldRef {
158    /// Qualifying table, if the reference was written qualified.
159    /// `'Sales'[Amount]` → `Some("Sales")`; `[Total]` → `None`.
160    pub table: Option<NameKey>,
161    /// The column or measure name inside the square brackets.
162    pub name: NameKey,
163}
164
165impl fmt::Display for FieldRef {
166    /// Emits valid DAX. Table names are always single-quoted — quoting is optional in
167    /// DAX only for names without spaces or punctuation, so quoting unconditionally is
168    /// always correct. The bracketed part is not escaped: `]` cannot appear in an
169    /// Analysis Services object name, so there is nothing to escape.
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        if let Some(table) = &self.table {
172            write!(f, "{}", Quoted(table.as_str()))?;
173        }
174        write!(f, "[{}]", self.name.as_str())
175    }
176}
177
178/// Stable, case-insensitive identity of a model or report object — the node key
179/// of the dependency graph.
180///
181/// Every name is a [`NameKey`], so two `ObjectId`s that differ only in casing are the
182/// same node. Ordering (used to give analysis output a deterministic order) follows
183/// the folded names, never the original casing.
184#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
185pub enum ObjectId {
186    /// A table.
187    Table {
188        /// Table name.
189        table: NameKey,
190    },
191    /// A column, identified by its owning table.
192    Column {
193        /// Owning table.
194        table: NameKey,
195        /// Column name.
196        column: NameKey,
197    },
198    /// A measure. The home table is part of the identity for display purposes only;
199    /// the engine guarantees measure names are unique across the whole model.
200    Measure {
201        /// Home table.
202        table: NameKey,
203        /// Measure name.
204        measure: NameKey,
205    },
206    /// A hierarchy defined on a table.
207    Hierarchy {
208        /// Owning table.
209        table: NameKey,
210        /// Hierarchy name.
211        hierarchy: NameKey,
212    },
213    /// A partition (Power Query / M source) of a table.
214    Partition {
215        /// Owning table.
216        table: NameKey,
217        /// Partition name.
218        partition: NameKey,
219    },
220    /// A relationship, identified by its endpoints. TMDL relationship names are
221    /// GUIDs kept for diagnostics only, and a column pair carries at most one
222    /// relationship, so the four endpoint names are the stable identity.
223    Relationship {
224        /// Table on the "from" (typically many) side.
225        from_table: NameKey,
226        /// Key column in `from_table`.
227        from_column: NameKey,
228        /// Table on the "to" (typically one) side.
229        to_table: NameKey,
230        /// Key column in `to_table`.
231        to_column: NameKey,
232    },
233    /// A security role.
234    Role {
235        /// Role name.
236        role: NameKey,
237    },
238    /// An item of a calculation group, identified by the calculation group's table.
239    CalculationItem {
240        /// Calculation group table.
241        table: NameKey,
242        /// Calculation item name.
243        item: NameKey,
244    },
245    /// A shared model-level M expression (e.g. a parameter or a shared query).
246    Expression {
247        /// Expression name.
248        name: NameKey,
249    },
250    /// A user-defined DAX function (TOM function). Names are model-global.
251    Function {
252        /// Function name.
253        name: NameKey,
254    },
255    /// A report-level measure (reportExtensions.json). Lives in the report, not the
256    /// model, so it does not share the model's measure namespace: a distinct variant
257    /// avoids ever conflating the two.
258    ReportMeasure {
259        /// Report measure name; unique within its report.
260        measure: NameKey,
261    },
262}
263
264impl fmt::Display for ObjectId {
265    /// Human-readable form for diagnostics. Quoted names use the same `''` escaping
266    /// as [`FieldRef`]; bracketed names are unescaped for the same reason.
267    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
268        match self {
269            ObjectId::Table { table } => {
270                write!(f, "table {}", Quoted(table.as_str()))
271            }
272            ObjectId::Column { table, column } => {
273                write!(f, "{}[{}]", Quoted(table.as_str()), column.as_str())
274            }
275            ObjectId::Measure { table, measure } => {
276                write!(f, "{}[{}]", Quoted(table.as_str()), measure.as_str())
277            }
278            ObjectId::Hierarchy { table, hierarchy } => {
279                write!(
280                    f,
281                    "hierarchy {}[{}]",
282                    Quoted(table.as_str()),
283                    hierarchy.as_str()
284                )
285            }
286            ObjectId::Partition { table, partition } => {
287                write!(
288                    f,
289                    "partition {}[{}]",
290                    Quoted(table.as_str()),
291                    partition.as_str()
292                )
293            }
294            ObjectId::Relationship {
295                from_table,
296                from_column,
297                to_table,
298                to_column,
299            } => {
300                write!(
301                    f,
302                    "relationship {}[{}] -> {}[{}]",
303                    Quoted(from_table.as_str()),
304                    from_column.as_str(),
305                    Quoted(to_table.as_str()),
306                    to_column.as_str()
307                )
308            }
309            ObjectId::Role { role } => {
310                write!(f, "role {}", Quoted(role.as_str()))
311            }
312            ObjectId::CalculationItem { table, item } => {
313                write!(
314                    f,
315                    "calculation item {}[{}]",
316                    Quoted(table.as_str()),
317                    item.as_str()
318                )
319            }
320            ObjectId::Expression { name } => {
321                write!(f, "expression {}", Quoted(name.as_str()))
322            }
323            ObjectId::Function { name } => {
324                write!(f, "function {}", Quoted(name.as_str()))
325            }
326            ObjectId::ReportMeasure { measure } => {
327                write!(f, "report measure {}", Quoted(measure.as_str()))
328            }
329        }
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use rstest::rstest;
337    use std::collections::HashSet;
338
339    fn column(table: &str, column: &str) -> ObjectId {
340        ObjectId::Column {
341            table: NameKey::new(table),
342            column: NameKey::new(column),
343        }
344    }
345
346    fn qualified(table: &str, name: &str) -> FieldRef {
347        FieldRef {
348            table: Some(NameKey::new(table)),
349            name: NameKey::new(name),
350        }
351    }
352
353    fn unqualified(name: &str) -> FieldRef {
354        FieldRef {
355            table: None,
356            name: NameKey::new(name),
357        }
358    }
359
360    mod fold_name {
361        use super::*;
362
363        #[rstest]
364        #[case::ascii("SaLeS", "sales")]
365        #[case::danish_a_ring("MÅNED", "måned")]
366        #[case::danish_ae_and_o_slash("ÆRØ", "ærø")]
367        fn lowercases(#[case] input: &str, #[case] expected: &str) {
368            assert_eq!(fold_name(input), expected);
369        }
370    }
371
372    mod name_key {
373        use super::*;
374
375        #[rstest]
376        #[case::ascii_upper("Sales", "SALES")]
377        #[case::ascii_lower("Sales", "sales")]
378        #[case::danish_a_ring("MÅNED", "måned")]
379        #[case::danish_ae_and_o_slash("Ærø", "ærø")]
380        fn compares_equal_ignoring_case(#[case] left: &str, #[case] right: &str) {
381            assert_eq!(NameKey::new(left), NameKey::new(right));
382        }
383
384        #[rstest]
385        #[case::one_letter_apart("Sales", "Salez")]
386        #[case::danish_suffix("Måned", "Måneder")]
387        fn compares_unequal_when_letters_differ(#[case] left: &str, #[case] right: &str) {
388            assert_ne!(NameKey::new(left), NameKey::new(right));
389        }
390
391        #[test]
392        fn hashes_case_variants_into_one_entry() {
393            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("SALES")]);
394
395            assert_eq!(set.len(), 1);
396        }
397
398        #[test]
399        fn hashes_distinct_names_separately() {
400            let set = HashSet::from([NameKey::new("Sales"), NameKey::new("Salez")]);
401
402            assert_eq!(set.len(), 2);
403        }
404
405        #[rstest]
406        #[case::mixed_case("sAlEs")]
407        #[case::upper("SALES")]
408        fn is_found_in_a_set_under_any_casing(#[case] probe: &str) {
409            let set = HashSet::from([NameKey::new("Sales")]);
410
411            assert!(
412                set.contains(&NameKey::new(probe)),
413                "{probe:?} should match the stored key \"Sales\""
414            );
415        }
416
417        #[test]
418        fn is_not_found_in_a_set_by_a_prefix() {
419            let set = HashSet::from([NameKey::new("Sales")]);
420
421            assert!(
422                !set.contains(&NameKey::new("Sale")),
423                "folding must not truncate: \"Sale\" is a different name"
424            );
425        }
426
427        #[test]
428        fn as_str_keeps_the_original_casing() {
429            assert_eq!(NameKey::new("SaLeS").as_str(), "SaLeS");
430        }
431
432        #[test]
433        fn display_keeps_the_original_casing() {
434            assert_eq!(NameKey::new("SaLeS").to_string(), "SaLeS");
435        }
436
437        #[test]
438        fn folded_is_the_lowercased_form() {
439            assert_eq!(NameKey::new("SaLeS").folded(), "sales");
440        }
441
442        /// `Ord` must agree with `Eq`, or `BTreeMap` and `sort` misbehave: two keys
443        /// that differ only in case have to compare `Equal`, never by their original
444        /// spelling.
445        #[rstest]
446        #[case::case_variants_are_equal("ABC", "abc", Ordering::Equal)]
447        #[case::earlier_letter_is_less("abc", "abd", Ordering::Less)]
448        #[case::later_letter_is_greater("ABD", "abc", Ordering::Greater)]
449        fn orders_by_folded_name(
450            #[case] left: &str,
451            #[case] right: &str,
452            #[case] expected: Ordering,
453        ) {
454            assert_eq!(NameKey::new(left).cmp(&NameKey::new(right)), expected);
455        }
456
457        #[test]
458        fn supports_comparison_operators() {
459            assert!(
460                NameKey::new("abc") < NameKey::new("abd"),
461                "PartialOrd must follow Ord"
462            );
463        }
464    }
465
466    mod object_id {
467        use super::*;
468
469        #[test]
470        fn compares_equal_ignoring_case() {
471            assert_eq!(column("Sales", "Amount"), column("SALES", "AMOUNT"));
472        }
473
474        #[test]
475        fn compares_unequal_when_a_name_differs() {
476            assert_ne!(column("Sales", "Amount"), column("Sales", "Amount2"));
477        }
478
479        /// A column and a measure can share a name; the variant keeps them apart.
480        #[test]
481        fn distinguishes_variants_carrying_the_same_names() {
482            let measure = ObjectId::Measure {
483                table: NameKey::new("Sales"),
484                measure: NameKey::new("Amount"),
485            };
486
487            assert_ne!(column("Sales", "Amount"), measure);
488        }
489
490        #[test]
491        fn hashes_case_variants_into_one_entry() {
492            let set = HashSet::from([column("Sales", "Amount"), column("SALES", "AMOUNT")]);
493
494            assert_eq!(set.len(), 1);
495        }
496
497        #[test]
498        fn hashes_distinct_columns_separately() {
499            let set = HashSet::from([column("Sales", "Amount"), column("Sales", "Amount2")]);
500
501            assert_eq!(set.len(), 2);
502        }
503
504        #[test]
505        fn hashes_a_column_and_a_measure_separately() {
506            let measure = ObjectId::Measure {
507                table: NameKey::new("Sales"),
508                measure: NameKey::new("Amount"),
509            };
510            let set = HashSet::from([column("Sales", "Amount"), measure]);
511
512            assert_eq!(set.len(), 2);
513        }
514
515        /// Relationship identity is its endpoints, ignoring case: TMDL names are
516        /// GUIDs, so endpoints are all a graph node can be keyed by.
517        #[test]
518        fn relationships_compare_by_their_endpoints() {
519            let relationship = |from: &str, to: &str| ObjectId::Relationship {
520                from_table: NameKey::new(from),
521                from_column: NameKey::new("Key"),
522                to_table: NameKey::new(to),
523                to_column: NameKey::new("Key"),
524            };
525
526            assert_eq!(
527                relationship("Sales", "DimOld"),
528                relationship("SALES", "dimold")
529            );
530            assert_ne!(
531                relationship("Sales", "DimOld"),
532                relationship("Sales", "DimNew")
533            );
534            // Direction is identity: the reverse relationship is a different edge.
535            assert_ne!(
536                relationship("Sales", "DimOld"),
537                relationship("DimOld", "Sales")
538            );
539        }
540    }
541
542    mod field_ref {
543        use super::*;
544
545        #[rstest]
546        #[case::qualified(qualified("Sales", "Amount"), "'Sales'[Amount]")]
547        #[case::internal_quote_is_doubled(
548            qualified("Sales's Data", "Amount"),
549            "'Sales''s Data'[Amount]"
550        )]
551        #[case::unqualified(unqualified("Total"), "[Total]")]
552        fn displays_as_valid_dax(#[case] reference: FieldRef, #[case] expected: &str) {
553            assert_eq!(reference.to_string(), expected);
554        }
555
556        #[test]
557        fn compares_equal_ignoring_case() {
558            assert_eq!(qualified("Sales", "Amount"), qualified("SALES", "AMOUNT"));
559        }
560
561        #[test]
562        fn distinguishes_a_qualified_reference_from_an_unqualified_one() {
563            assert_ne!(qualified("Sales", "Amount"), unqualified("Amount"));
564        }
565    }
566
567    mod object_id_display {
568        use super::*;
569
570        #[rstest]
571        #[case::table(ObjectId::Table { table: NameKey::new("Sales") }, "table 'Sales'")]
572        #[case::column(column("Sales", "Amount"), "'Sales'[Amount]")]
573        #[case::measure(
574            ObjectId::Measure { table: NameKey::new("Sales"), measure: NameKey::new("Total") },
575            "'Sales'[Total]"
576        )]
577        #[case::hierarchy(
578            ObjectId::Hierarchy { table: NameKey::new("Date"), hierarchy: NameKey::new("Calendar") },
579            "hierarchy 'Date'[Calendar]"
580        )]
581        #[case::partition(
582            ObjectId::Partition {
583                table: NameKey::new("Sales"),
584                partition: NameKey::new("Sales-Part1"),
585            },
586            "partition 'Sales'[Sales-Part1]"
587        )]
588        #[case::relationship(
589            ObjectId::Relationship {
590                from_table: NameKey::new("Sales"),
591                from_column: NameKey::new("Key"),
592                to_table: NameKey::new("Dim Old"),
593                to_column: NameKey::new("Key"),
594            },
595            "relationship 'Sales'[Key] -> 'Dim Old'[Key]"
596        )]
597        #[case::role(ObjectId::Role { role: NameKey::new("Reader") }, "role 'Reader'")]
598        #[case::calculation_item(
599            ObjectId::CalculationItem {
600                table: NameKey::new("Time Intelligence"),
601                item: NameKey::new("YTD"),
602            },
603            "calculation item 'Time Intelligence'[YTD]"
604        )]
605        #[case::expression(
606            ObjectId::Expression { name: NameKey::new("Param1") },
607            "expression 'Param1'"
608        )]
609        #[case::function(
610            ObjectId::Function { name: NameKey::new("Sales.Margin") },
611            "function 'Sales.Margin'"
612        )]
613        #[case::report_measure(
614            ObjectId::ReportMeasure { measure: NameKey::new("Growth %") },
615            "report measure 'Growth %'"
616        )]
617        #[case::internal_quotes_are_doubled(
618            column("Bob's 'Best' Data", "AmOuNt"),
619            "'Bob''s ''Best'' Data'[AmOuNt]"
620        )]
621        #[case::quoted_name_keeps_its_casing(
622            ObjectId::Table { table: NameKey::new("O'Brien") },
623            "table 'O''Brien'"
624        )]
625        fn renders(#[case] id: ObjectId, #[case] expected: &str) {
626            assert_eq!(id.to_string(), expected);
627        }
628    }
629}