use std::cmp::Ordering;
use std::fmt;
use std::hash::{Hash, Hasher};
pub(crate) fn fold_name(s: &str) -> String {
s.to_lowercase()
}
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("'")
}
}
#[derive(Debug, Clone)]
pub struct NameKey {
original: String,
folded: String,
}
impl NameKey {
pub fn new(name: impl Into<String>) -> Self {
let original = name.into();
let folded = fold_name(&original);
Self { original, folded }
}
pub fn as_str(&self) -> &str {
&self.original
}
pub fn folded(&self) -> &str {
&self.folded
}
#[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 {
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)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FieldRef {
pub table: Option<NameKey>,
pub name: NameKey,
}
impl fmt::Display for FieldRef {
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())
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ObjectId {
Table {
table: NameKey,
},
Column {
table: NameKey,
column: NameKey,
},
Measure {
table: NameKey,
measure: NameKey,
},
Hierarchy {
table: NameKey,
hierarchy: NameKey,
},
Partition {
table: NameKey,
partition: NameKey,
},
Relationship {
from_table: NameKey,
from_column: NameKey,
to_table: NameKey,
to_column: NameKey,
},
Role {
role: NameKey,
},
CalculationItem {
table: NameKey,
item: NameKey,
},
Expression {
name: NameKey,
},
Function {
name: NameKey,
},
ReportMeasure {
measure: NameKey,
},
}
impl fmt::Display for ObjectId {
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 {
#[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);
}
#[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"));
}
#[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);
}
#[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")
);
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);
}
}
}