pub mod lexer;
pub mod refs;
pub use lexer::{Token, TokenKind, tokenize};
pub use refs::{RawRef, quoted_names, references, unescape_name};
use crate::identity::{NameKey, ObjectId};
use crate::model::TabularDatabase;
use crate::model::index::{ModelIndex, Resolved};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Binding<'a> {
Bound {
raw: RawRef<'a>,
targets: Vec<ObjectId>,
},
Unresolved {
raw: RawRef<'a>,
},
}
impl Binding<'_> {
#[must_use]
pub fn targets(&self) -> &[ObjectId] {
match self {
Binding::Bound { targets, .. } => targets,
Binding::Unresolved { .. } => &[],
}
}
#[must_use]
pub fn is_unresolved(&self) -> bool {
matches!(self, Binding::Unresolved { .. })
}
}
#[must_use]
pub fn bind<'a>(
db: &TabularDatabase,
index: &ModelIndex,
home_table: Option<&str>,
raw: RawRef<'a>,
) -> Binding<'a> {
let mut targets = Vec::new();
match raw {
RawRef::Field {
table: Some(table),
name,
..
} => {
let table = unescape_name(table);
let name = unescape_name(name);
if let Some(id) = index
.resolve_qualified(&table, &name)
.and_then(|r| db.object_id(r))
{
targets.push(id);
}
}
RawRef::Field {
table: None, name, ..
} => {
let name = unescape_name(name);
let matches = index.resolve_unqualified(&name, home_table);
for resolved in [
matches.measure.map(Resolved::Measure),
matches.column.map(Resolved::Column),
] {
if let Some(id) = resolved.and_then(|r| db.object_id(r)) {
targets.push(id);
}
}
}
RawRef::Table { name, .. } => {
let name = unescape_name(name);
if let Some(table) = index.resolve_table(&name).and_then(|h| db.table(h)) {
targets.push(ObjectId::Table {
table: NameKey::new(table.name.as_str()),
});
}
}
RawRef::Function { name, .. } => {
if let Some(function) = index.resolve_function(name).and_then(|h| db.function(h)) {
targets.push(ObjectId::Function {
name: NameKey::new(function.name.as_str()),
});
}
}
}
if targets.is_empty() {
Binding::Unresolved { raw }
} else {
Binding::Bound { raw, targets }
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::FieldRef;
use crate::model::{Column, Function, Measure, Table};
fn model() -> TabularDatabase {
let column = |name: &str| Column {
name: name.to_string(),
..Default::default()
};
let measure = |name: &str| Measure {
name: name.to_string(),
expression: "0".to_string(),
..Default::default()
};
TabularDatabase {
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")],
..Default::default()
},
],
functions: vec![Function {
name: "MyFunc".to_string(),
expression: "1".to_string(),
is_hidden: false,
}],
..Default::default()
}
}
fn bound<'a>(expression: &'a str, home_table: Option<&str>) -> Vec<Binding<'a>> {
let db = model();
let index = ModelIndex::build(&db);
references(expression)
.into_iter()
.map(|raw| bind(&db, &index, home_table, raw))
.collect()
}
#[test]
fn a_qualified_reference_resolves_to_the_column_first() {
let bindings = bound("'Sales'[Amount]", None);
assert_eq!(bindings.len(), 1);
assert_eq!(
bindings[0].targets(),
[ObjectId::Column {
table: NameKey::new("Sales"),
column: NameKey::new("Amount"),
}]
);
}
#[test]
fn a_stale_qualifier_still_keeps_the_measure_alive() {
let bindings = bound("'No Such Table'[TOTAL SALES]", None);
assert_eq!(
bindings[0].targets(),
[ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Total Sales"),
}]
);
}
#[test]
fn an_ambiguous_unqualified_reference_keeps_every_candidate_alive() {
let bindings = bound("[Antal]", Some("Dato"));
assert_eq!(bindings[0].targets().len(), 2);
assert!(bindings[0].targets().contains(&ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Antal"),
}));
assert!(bindings[0].targets().contains(&ObjectId::Column {
table: NameKey::new("Dato"),
column: NameKey::new("Antal"),
}));
let bindings = bound("[Antal]", None);
assert_eq!(bindings[0].targets().len(), 1);
}
#[test]
fn resolution_is_case_insensitive_end_to_end() {
let bindings = bound("[total sales]", Some("dato"));
assert_eq!(
bindings[0].targets(),
[ObjectId::Measure {
table: NameKey::new("Sales"),
measure: NameKey::new("Total Sales"),
}]
);
}
#[test]
fn an_unknown_name_binds_to_nothing_and_stays_data() {
let bindings = bound("[Ukendt] + 'X'[Y]", Some("Sales"));
assert_eq!(bindings.len(), 2);
assert!(bindings.iter().all(Binding::is_unresolved));
assert!(bindings.iter().all(|b| b.targets().is_empty()));
}
#[test]
fn a_bare_table_use_resolves_to_the_table() {
let bindings = bound("COUNTROWS('Sales') + COUNTROWS(Missing)", Some("Sales"));
assert_eq!(bindings.len(), 4);
assert!(
bindings[0].is_unresolved(),
"COUNTROWS is not a model function"
);
assert_eq!(
bindings[1].targets(),
[ObjectId::Table {
table: NameKey::new("Sales"),
}]
);
assert!(bindings[2].is_unresolved());
assert!(bindings[3].is_unresolved());
}
#[test]
fn a_user_defined_function_resolves_and_built_ins_do_not() {
let bindings = bound("MyFunc([Amount]) + SUM([Amount])", Some("Sales"));
assert_eq!(bindings.len(), 4);
assert_eq!(
bindings[0].targets(),
[ObjectId::Function {
name: NameKey::new("MyFunc"),
}]
);
assert_eq!(
bindings[1].targets(),
[ObjectId::Column {
table: NameKey::new("Sales"),
column: NameKey::new("Amount"),
}]
);
assert!(
bindings[2].is_unresolved(),
"built-in SUM matches no model function"
);
assert!(!bindings[3].is_unresolved());
}
#[test]
fn materialized_field_refs_display_valid_dax() {
let db = model();
let index = ModelIndex::build(&db);
let raw = references("'It''s'[X]").remove(0);
let field = raw.to_field_ref().expect("a field reference");
assert_eq!(
field,
FieldRef {
table: Some(NameKey::new("It's")),
name: NameKey::new("X"),
}
);
assert_eq!(field.to_string(), "'It''s'[X]");
assert!(bind(&db, &index, None, raw).is_unresolved());
}
#[test]
fn an_escaped_qualifier_resolves_to_the_escaped_name() {
let db = TabularDatabase {
tables: vec![Table {
name: "It's".to_string(),
columns: vec![Column {
name: "X".to_string(),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let index = ModelIndex::build(&db);
let raw = references("'It''s'[X]").remove(0);
assert_eq!(
bind(&db, &index, None, raw).targets(),
[ObjectId::Column {
table: NameKey::new("It's"),
column: NameKey::new("X"),
}]
);
let raw = references("COUNTROWS('It''s')").remove(1);
assert_eq!(
bind(&db, &index, None, raw).targets(),
[ObjectId::Table {
table: NameKey::new("It's"),
}]
);
}
#[test]
fn the_tricky_sample_binds_only_its_real_reference() {
let source = concat!(
"Tricky :=\n",
"-- a real comment\n",
"VAR Year = 2024\n",
"VAR Note = \"-- not a comment\"\n",
"RETURN Year & Note & Sales[Amount]\n",
);
let bindings = bound(source, Some("Sales"));
assert_eq!(
bindings.len(),
9,
"every bare word is a conservative candidate"
);
assert_eq!(
bindings.iter().filter(|b| !b.is_unresolved()).count(),
1,
"only Sales[Amount] resolves"
);
assert_eq!(bindings.last().expect("field ref last").targets().len(), 1);
}
}