use std::collections::HashMap;
use crate::identity::fold_name;
use crate::model::TabularDatabase;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct TableHandle(
pub usize,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ColumnHandle {
pub table: usize,
pub column: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct MeasureHandle {
pub table: usize,
pub measure: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct HierarchyHandle {
pub table: usize,
pub hierarchy: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ExpressionHandle(
pub usize,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct FunctionHandle(
pub usize,
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Resolved {
Column(ColumnHandle),
Measure(MeasureHandle),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct UnqualifiedMatches {
pub measure: Option<MeasureHandle>,
pub column: Option<ColumnHandle>,
}
impl UnqualifiedMatches {
#[must_use]
pub fn primary(&self) -> Option<Resolved> {
match (self.measure, self.column) {
(Some(measure), _) => Some(Resolved::Measure(measure)),
(None, Some(column)) => Some(Resolved::Column(column)),
(None, None) => None,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.measure.is_none() && self.column.is_none()
}
}
#[derive(Debug, Clone, Default)]
struct TableEntry {
table: usize,
columns: HashMap<String, ColumnHandle>,
hierarchies: HashMap<String, HierarchyHandle>,
}
#[derive(Debug, Clone)]
pub struct ModelIndex {
tables: HashMap<String, TableEntry>,
measures: HashMap<String, MeasureHandle>,
expressions: HashMap<String, ExpressionHandle>,
functions: HashMap<String, FunctionHandle>,
}
impl ModelIndex {
#[must_use]
pub fn build(db: &TabularDatabase) -> Self {
let mut tables: HashMap<String, TableEntry> = HashMap::new();
let mut measures: HashMap<String, MeasureHandle> = HashMap::new();
let mut expressions: HashMap<String, ExpressionHandle> = HashMap::new();
let mut functions: HashMap<String, FunctionHandle> = HashMap::new();
for (table_idx, table) in db.tables.iter().enumerate() {
let entry = tables
.entry(fold_name(&table.name))
.or_insert_with(|| TableEntry {
table: table_idx,
..TableEntry::default()
});
for (column_idx, column) in table.columns.iter().enumerate() {
entry
.columns
.entry(fold_name(&column.name))
.or_insert(ColumnHandle {
table: table_idx,
column: column_idx,
});
}
for (hierarchy_idx, hierarchy) in table.hierarchies.iter().enumerate() {
entry
.hierarchies
.entry(fold_name(&hierarchy.name))
.or_insert(HierarchyHandle {
table: table_idx,
hierarchy: hierarchy_idx,
});
}
for (measure_idx, measure) in table.measures.iter().enumerate() {
measures
.entry(fold_name(&measure.name))
.or_insert(MeasureHandle {
table: table_idx,
measure: measure_idx,
});
}
}
for (expression_idx, expression) in db.expressions.iter().enumerate() {
expressions
.entry(fold_name(&expression.name))
.or_insert(ExpressionHandle(expression_idx));
}
for (function_idx, function) in db.functions.iter().enumerate() {
functions
.entry(fold_name(&function.name))
.or_insert(FunctionHandle(function_idx));
}
Self {
tables,
measures,
expressions,
functions,
}
}
#[must_use]
pub fn resolve_table(&self, name: &str) -> Option<TableHandle> {
self.tables
.get(&fold_name(name))
.map(|entry| TableHandle(entry.table))
}
#[must_use]
pub fn resolve_qualified(&self, table: &str, name: &str) -> Option<Resolved> {
let folded_name = fold_name(name);
let column = self
.tables
.get(&fold_name(table))
.and_then(|entry| entry.columns.get(&folded_name));
if let Some(column) = column {
return Some(Resolved::Column(*column));
}
self.measures
.get(&folded_name)
.copied()
.map(Resolved::Measure)
}
#[must_use]
pub fn resolve_column(&self, table: &str, name: &str) -> Option<ColumnHandle> {
self.tables
.get(&fold_name(table))?
.columns
.get(&fold_name(name))
.copied()
}
#[must_use]
pub fn resolve_columns(&self, name: &str) -> Vec<ColumnHandle> {
let folded_name = fold_name(name);
let mut handles: Vec<ColumnHandle> = self
.tables
.values()
.filter_map(|entry| entry.columns.get(&folded_name).copied())
.collect();
handles.sort_by_key(|handle| (handle.table, handle.column));
handles
}
#[must_use]
pub fn resolve_unqualified(&self, name: &str, home_table: Option<&str>) -> UnqualifiedMatches {
let folded_name = fold_name(name);
UnqualifiedMatches {
measure: self.measures.get(&folded_name).copied(),
column: home_table.and_then(|table| {
self.tables
.get(&fold_name(table))
.and_then(|entry| entry.columns.get(&folded_name))
.copied()
}),
}
}
#[must_use]
pub fn resolve_hierarchy(&self, table: &str, name: &str) -> Option<HierarchyHandle> {
self.tables
.get(&fold_name(table))?
.hierarchies
.get(&fold_name(name))
.copied()
}
#[must_use]
pub fn resolve_expression(&self, name: &str) -> Option<ExpressionHandle> {
self.expressions.get(&fold_name(name)).copied()
}
#[must_use]
pub fn resolve_function(&self, name: &str) -> Option<FunctionHandle> {
self.functions.get(&fold_name(name)).copied()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Column, Function, Hierarchy, Measure, SharedExpression, Table};
use rstest::rstest;
fn column(name: &str) -> Column {
Column {
name: name.to_string(),
..Default::default()
}
}
fn measure(name: &str) -> Measure {
Measure {
name: name.to_string(),
expression: "0".to_string(),
..Default::default()
}
}
fn model() -> TabularDatabase {
TabularDatabase {
name: Some("Contoso".to_string()),
tables: vec![
Table {
name: "Sales".to_string(),
columns: vec![column("Amount"), column("Beløb")],
measures: vec![measure("Total Sales"), measure("Antal")],
..Default::default()
},
Table {
name: "Dato".to_string(),
columns: vec![column("Måned"), column("Antal")],
measures: vec![measure("Omsætning")],
hierarchies: vec![Hierarchy {
name: "Kalender".to_string(),
..Default::default()
}],
..Default::default()
},
Table {
name: "sales".to_string(),
columns: vec![column("Amount")],
..Default::default()
},
],
expressions: vec![
SharedExpression {
name: "Server".to_string(),
expression: "\"contoso.database.windows.net\"".to_string(),
..Default::default()
},
SharedExpression {
name: "Database".to_string(),
expression: "\"AdventureWorks\"".to_string(),
..Default::default()
},
],
functions: vec![Function {
name: "Sales.NetPrice".to_string(),
expression: "(price: SCALAR) => price * 0.75".to_string(),
is_hidden: false,
}],
..Default::default()
}
}
fn index() -> ModelIndex {
ModelIndex::build(&model())
}
fn column_handle(table: usize, column: usize) -> Resolved {
Resolved::Column(ColumnHandle { table, column })
}
fn measure_handle(table: usize, measure: usize) -> Resolved {
Resolved::Measure(MeasureHandle { table, measure })
}
mod resolve_table {
use super::*;
#[rstest]
#[case::upper("SALES", 0)]
#[case::as_written("Sales", 0)]
#[case::danish_lower("dato", 1)]
#[case::danish_upper("DATO", 1)]
fn finds_a_table_ignoring_case(#[case] name: &str, #[case] expected: usize) {
assert_eq!(index().resolve_table(name), Some(TableHandle(expected)));
}
#[rstest]
#[case::misspelled("Salez")]
#[case::empty("")]
fn returns_none_for_an_unknown_name(#[case] name: &str) {
assert_eq!(index().resolve_table(name), None);
}
}
mod resolve_qualified {
use super::*;
#[rstest]
#[case::lower_table_upper_column("sales", "AMOUNT", 0, 0)]
#[case::upper_table_danish_column("DATO", "måned", 1, 0)]
#[case::danish_column_upper("Dato", "MÅNED", 1, 0)]
fn finds_a_column_on_the_named_table(
#[case] table: &str,
#[case] name: &str,
#[case] expected_table: usize,
#[case] expected_column: usize,
) {
assert_eq!(
index().resolve_qualified(table, name),
Some(column_handle(expected_table, expected_column))
);
}
#[rstest]
#[case::on_its_home_table("SALES", "total sales", 0, 0)]
#[case::danish_on_its_home_table("Dato", "OMSÆTNING", 1, 0)]
#[case::under_a_wrong_table_prefix("Dato", "Total Sales", 0, 0)]
#[case::under_a_nonexistent_table_prefix("Ukendt Tabel", "omsætning", 1, 0)]
fn finds_a_measure(
#[case] table: &str,
#[case] name: &str,
#[case] expected_table: usize,
#[case] expected_measure: usize,
) {
assert_eq!(
index().resolve_qualified(table, name),
Some(measure_handle(expected_table, expected_measure))
);
}
#[rstest]
#[case::column_of_a_different_table("Dato", "Amount")]
#[case::unknown_name("Sales", "Nope")]
#[case::unknown_table_and_name("Ukendt Tabel", "Måned")]
fn returns_none_when_nothing_matches(#[case] table: &str, #[case] name: &str) {
assert_eq!(index().resolve_qualified(table, name), None);
}
#[test]
fn binds_to_the_named_tables_column_when_both_exist() {
assert_eq!(
index().resolve_qualified("Dato", "Antal"),
Some(column_handle(1, 1))
);
}
#[test]
fn binds_to_the_measure_when_the_named_table_has_no_such_column() {
assert_eq!(
index().resolve_qualified("Sales", "antal"),
Some(measure_handle(0, 1))
);
}
}
mod resolve_unqualified {
use super::*;
#[test]
fn finds_a_global_measure_without_a_home_table() {
assert_eq!(
index().resolve_unqualified("OMSÆTNING", None).measure,
Some(MeasureHandle {
table: 1,
measure: 0
})
);
}
#[test]
fn offers_no_column_candidate_without_a_home_table() {
assert_eq!(index().resolve_unqualified("OMSÆTNING", None).column, None);
}
#[test]
fn returns_the_measure_candidate_for_an_ambiguous_name() {
assert_eq!(
index().resolve_unqualified("antal", Some("Dato")).measure,
Some(MeasureHandle {
table: 0,
measure: 1
})
);
}
#[test]
fn returns_the_column_candidate_for_an_ambiguous_name() {
assert_eq!(
index().resolve_unqualified("antal", Some("Dato")).column,
Some(ColumnHandle {
table: 1,
column: 1
})
);
}
#[test]
fn drops_the_column_candidate_when_there_is_no_home_table() {
assert_eq!(index().resolve_unqualified("antal", None).column, None);
}
#[test]
fn finds_a_column_of_the_home_table() {
assert_eq!(
index().resolve_unqualified("BELØB", Some("sales")).column,
Some(ColumnHandle {
table: 0,
column: 1
})
);
}
#[test]
fn offers_no_measure_candidate_for_a_column_only_name() {
assert_eq!(
index().resolve_unqualified("BELØB", Some("sales")).measure,
None
);
}
#[test]
fn matches_nothing_against_the_wrong_home_table() {
let found = index().resolve_unqualified("Beløb", Some("Dato"));
assert!(found.is_empty(), "expected no candidates, got {found:?}");
}
#[rstest]
#[case::known_home_table(Some("Sales"))]
#[case::unknown_home_table(Some("Ukendt Tabel"))]
#[case::no_home_table(None)]
fn is_empty_for_an_unknown_name(#[case] home_table: Option<&str>) {
let found = index().resolve_unqualified("Ukendt", home_table);
assert!(found.is_empty(), "expected no candidates, got {found:?}");
}
#[test]
fn primary_prefers_the_measure_when_a_name_is_ambiguous() {
assert_eq!(
index().resolve_unqualified("antal", Some("Dato")).primary(),
Some(measure_handle(0, 1))
);
}
#[test]
fn primary_returns_the_column_when_there_is_no_measure() {
assert_eq!(
index()
.resolve_unqualified("BELØB", Some("sales"))
.primary(),
Some(column_handle(0, 1))
);
}
#[test]
fn primary_is_none_for_an_unknown_name() {
assert_eq!(
index()
.resolve_unqualified("Ukendt", Some("Sales"))
.primary(),
None
);
}
}
mod resolve_hierarchy {
use super::*;
#[rstest]
#[case::lower_table_upper_name("dato", "KALENDER")]
#[case::upper_table_as_written("DATO", "Kalender")]
fn finds_a_hierarchy_ignoring_case(#[case] table: &str, #[case] name: &str) {
assert_eq!(
index().resolve_hierarchy(table, name),
Some(HierarchyHandle {
table: 1,
hierarchy: 0
})
);
}
#[rstest]
#[case::another_table("Sales", "Kalender")]
#[case::a_column_name("Dato", "Måned")]
#[case::unknown_table("Ukendt Tabel", "Kalender")]
fn returns_none(#[case] table: &str, #[case] name: &str) {
assert_eq!(index().resolve_hierarchy(table, name), None);
}
}
mod resolve_expression {
use super::*;
#[rstest]
#[case::upper("SERVER", 0)]
#[case::as_written("Server", 0)]
#[case::lower("database", 1)]
fn finds_an_expression_ignoring_case(#[case] name: &str, #[case] expected: usize) {
assert_eq!(
index().resolve_expression(name),
Some(ExpressionHandle(expected))
);
}
#[test]
fn returns_none_for_an_unknown_name() {
assert_eq!(index().resolve_expression("Ukendt"), None);
}
}
mod resolve_function {
use super::*;
#[rstest]
#[case::as_written("Sales.NetPrice")]
#[case::upper("SALES.NETPRICE")]
#[case::lower("sales.netprice")]
fn finds_a_function_ignoring_case(#[case] name: &str) {
assert_eq!(index().resolve_function(name), Some(FunctionHandle(0)));
}
#[rstest]
#[case::a_shared_expression_name("Server")]
#[case::a_measure_name("Total Sales")]
#[case::unknown("Ukendt")]
fn returns_none(#[case] name: &str) {
assert_eq!(index().resolve_function(name), None);
}
}
mod build_with_duplicate_names {
use super::*;
#[rstest]
#[case::as_written("Sales")]
#[case::as_the_duplicate_is_spelled("sales")]
fn resolves_the_table_to_the_first_occurrence(#[case] name: &str) {
assert_eq!(index().resolve_table(name), Some(TableHandle(0)));
}
#[test]
fn resolves_a_shared_column_name_to_the_first_tables_column() {
assert_eq!(
index().resolve_qualified("Sales", "Amount"),
Some(column_handle(0, 0))
);
}
#[test]
fn resolves_a_shared_column_name_unqualified_to_the_first_tables_column() {
assert_eq!(
index().resolve_unqualified("Amount", Some("SALES")).column,
Some(ColumnHandle {
table: 0,
column: 0
})
);
}
}
mod accessors {
use super::*;
#[test]
fn a_resolved_table_handle_reaches_its_table() {
let db = model();
let handle = ModelIndex::build(&db).resolve_table("SALES").unwrap();
assert_eq!(db.table(handle).unwrap().name, "Sales");
}
#[test]
fn a_resolved_column_handle_reaches_its_column() {
let db = model();
let Some(Resolved::Column(handle)) =
ModelIndex::build(&db).resolve_qualified("sales", "BELØB")
else {
panic!("expected 'sales'[BELØB] to resolve to a column");
};
assert_eq!(db.column(handle).unwrap().name, "Beløb");
}
#[test]
fn a_resolved_measure_handle_reaches_its_measure() {
let db = model();
let handle = ModelIndex::build(&db)
.resolve_unqualified("omsætning", None)
.measure
.unwrap();
assert_eq!(db.measure(handle).unwrap().name, "Omsætning");
}
#[test]
fn a_resolved_hierarchy_handle_reaches_its_hierarchy() {
let db = model();
let handle = ModelIndex::build(&db)
.resolve_hierarchy("DATO", "kalender")
.unwrap();
assert_eq!(db.hierarchy(handle).unwrap().name, "Kalender");
}
#[test]
fn a_resolved_expression_handle_reaches_its_expression() {
let db = model();
let handle = ModelIndex::build(&db)
.resolve_expression("DATABASE")
.unwrap();
assert_eq!(db.shared_expression(handle).unwrap().name, "Database");
}
#[test]
fn a_stale_table_handle_is_none() {
assert!(model().table(TableHandle(9)).is_none());
}
#[test]
fn a_column_handle_with_an_out_of_range_table_is_none() {
let handle = ColumnHandle {
table: 9,
column: 9,
};
assert!(model().column(handle).is_none());
}
#[test]
fn a_column_handle_with_an_out_of_range_column_is_none() {
let handle = ColumnHandle {
table: 0,
column: 9,
};
assert!(model().column(handle).is_none());
}
#[test]
fn a_measure_handle_with_an_out_of_range_table_is_none() {
let handle = MeasureHandle {
table: 9,
measure: 0,
};
assert!(model().measure(handle).is_none());
}
#[test]
fn a_measure_handle_into_a_table_without_measures_is_none() {
let handle = MeasureHandle {
table: 2,
measure: 0,
};
assert!(model().measure(handle).is_none());
}
#[test]
fn a_hierarchy_handle_into_a_table_without_hierarchies_is_none() {
let handle = HierarchyHandle {
table: 0,
hierarchy: 0,
};
assert!(model().hierarchy(handle).is_none());
}
#[test]
fn a_stale_expression_handle_is_none() {
assert!(model().shared_expression(ExpressionHandle(9)).is_none());
}
#[test]
fn a_resolved_function_handle_reaches_its_function() {
let db = model();
let handle = ModelIndex::build(&db)
.resolve_function("SALES.NETPRICE")
.unwrap();
assert_eq!(db.function(handle).unwrap().name, "Sales.NetPrice");
}
#[test]
fn a_stale_function_handle_is_none() {
assert!(model().function(FunctionHandle(9)).is_none());
}
#[test]
fn an_object_id_for_a_stale_column_handle_is_none() {
assert!(model().object_id(column_handle(9, 9)).is_none());
}
#[test]
fn an_object_id_for_a_stale_measure_handle_is_none() {
assert!(model().object_id(measure_handle(9, 9)).is_none());
}
}
mod object_id {
use super::*;
#[test]
fn carries_the_models_casing_for_a_measure() {
let db = model();
let resolved = ModelIndex::build(&db)
.resolve_qualified("SALES", "TOTAL SALES")
.unwrap();
assert_eq!(
db.object_id(resolved).unwrap().to_string(),
"'Sales'[Total Sales]"
);
}
#[test]
fn carries_the_models_casing_for_a_danish_column() {
let db = model();
let resolved = ModelIndex::build(&db)
.resolve_qualified("DATO", "MÅNED")
.unwrap();
assert_eq!(db.object_id(resolved).unwrap().to_string(), "'Dato'[Måned]");
}
#[test]
fn distinguishes_a_column_from_a_measure_of_the_same_name() {
let db = model();
assert_ne!(
db.object_id(column_handle(1, 1)).unwrap(),
db.object_id(measure_handle(0, 1)).unwrap()
);
}
#[rstest]
#[case::the_dato_column(column_handle(1, 1), "'Dato'[Antal]")]
#[case::the_sales_measure(measure_handle(0, 1), "'Sales'[Antal]")]
fn renders_each_antal_under_its_own_table(
#[case] resolved: Resolved,
#[case] expected: &str,
) {
assert_eq!(model().object_id(resolved).unwrap().to_string(), expected);
}
}
}