1use std::cmp::Ordering;
9use std::fmt;
10use std::hash::{Hash, Hasher};
11
12pub(crate) fn fold_name(s: &str) -> String {
16 s.to_lowercase()
17}
18
19pub(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#[derive(Debug, Clone)]
61pub struct NameKey {
62 original: String,
63 folded: String,
64}
65
66impl NameKey {
67 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 pub fn as_str(&self) -> &str {
76 &self.original
77 }
78
79 pub fn folded(&self) -> &str {
81 &self.folded
82 }
83
84 #[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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
173pub struct FieldRef {
174 pub table: Option<NameKey>,
177 pub name: NameKey,
179}
180
181impl fmt::Display for FieldRef {
182 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
201pub enum ObjectId {
202 Table {
204 table: NameKey,
206 },
207 Column {
209 table: NameKey,
211 column: NameKey,
213 },
214 Measure {
217 table: NameKey,
219 measure: NameKey,
221 },
222 Hierarchy {
224 table: NameKey,
226 hierarchy: NameKey,
228 },
229 Partition {
231 table: NameKey,
233 partition: NameKey,
235 },
236 Relationship {
240 from_table: NameKey,
242 from_column: NameKey,
244 to_table: NameKey,
246 to_column: NameKey,
248 },
249 Role {
251 role: NameKey,
253 },
254 CalculationItem {
256 table: NameKey,
258 item: NameKey,
260 },
261 Expression {
263 name: NameKey,
265 },
266 Function {
268 name: NameKey,
270 },
271 ReportMeasure {
275 measure: NameKey,
277 },
278}
279
280impl fmt::Display for ObjectId {
281 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 #[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 #[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 #[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 #[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 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}