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
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 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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
157pub struct FieldRef {
158 pub table: Option<NameKey>,
161 pub name: NameKey,
163}
164
165impl fmt::Display for FieldRef {
166 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
185pub enum ObjectId {
186 Table {
188 table: NameKey,
190 },
191 Column {
193 table: NameKey,
195 column: NameKey,
197 },
198 Measure {
201 table: NameKey,
203 measure: NameKey,
205 },
206 Hierarchy {
208 table: NameKey,
210 hierarchy: NameKey,
212 },
213 Partition {
215 table: NameKey,
217 partition: NameKey,
219 },
220 Relationship {
224 from_table: NameKey,
226 from_column: NameKey,
228 to_table: NameKey,
230 to_column: NameKey,
232 },
233 Role {
235 role: NameKey,
237 },
238 CalculationItem {
240 table: NameKey,
242 item: NameKey,
244 },
245 Expression {
247 name: NameKey,
249 },
250 Function {
252 name: NameKey,
254 },
255 ReportMeasure {
259 measure: NameKey,
261 },
262}
263
264impl fmt::Display for ObjectId {
265 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 #[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 #[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 #[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 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}