use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use crate::identity::fold_name;
use crate::ingest::{SkipKind, SkipNotice};
use crate::model::{
CalculationGroup, CalculationItem, Calendar, Column, ColumnKind, Function, Hierarchy,
HierarchyLevel, HierarchyRef, Kpi, Measure, Partition, PartitionSource, Relationship, Role,
SharedExpression, Table, TablePermission, TabularDatabase, Variation,
};
use crate::{Error, Result};
const IGNORED_KEYS: &[&str] = &[
"lineageTag",
"sourceLineageTag",
"changedProperty",
"description",
"annotation",
"extendedProperty",
"dataType",
"isNullable",
"formatString",
"summarizeBy",
"sourceColumn",
"dataCategory",
"isKey",
"isNameInferred",
"isDataTypeInferred",
"isUnique",
"isDefaultLabel",
"isDefaultImage",
"isAvailableInMdx",
"keepUniqueRows",
"tableDetailPosition",
"displayFolder",
"excludeFromModelRefresh",
"showAsVariationsOnly",
"culture",
"sourceQueryCulture",
"defaultPowerBIDataSourceVersion",
"discourageImplicitMeasures",
"dataAccessOptions",
"compatibilityLevel",
"createOrReplace",
"retainDataTillForceCalculate",
"queryGroup",
"cultureInfo",
"linguisticMetadata",
"contentType",
"precedence",
"mode",
"modelPermission",
"crossFilteringBehavior",
"fromCardinality",
"toCardinality",
"joinOnDateBehavior",
"hideArrows",
"securityFilteringBehavior",
"reliability",
"ordinal",
"timeUnit",
"statusGraphic",
];
const NAMED_DESCRIPTORS: &[&str] = &[
"annotation",
"calculationItem",
"calendar",
"column",
"dataSource",
"expression",
"extendedProperty",
"function",
"hierarchy",
"level",
"measure",
"model",
"partition",
"perspective",
"relationship",
"role",
"table",
"tablePermission",
"variation",
];
pub(super) fn load_database(
definition: &Path,
name: Option<String>,
skips: &mut Vec<SkipNotice>,
) -> Result<TabularDatabase> {
let mut loader = Loader::new(name);
let mut entries: Vec<(String, PathBuf, bool)> = Vec::new();
for entry in fs::read_dir(definition)? {
let entry = entry?;
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
entries.push((
entry.file_name().to_string_lossy().into_owned(),
entry.path(),
is_dir,
));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (file_name, path, is_dir) in &entries {
if *is_dir {
match file_name.as_str() {
"tables" => loader.load_tables_dir(path, skips)?,
"roles" => loader.load_roles_dir(path, skips)?,
"cultures" => {}
other => notice(
skips,
path,
None,
SkipKind::UnknownObject,
format!("unknown directory '{other}' in definition/"),
),
}
continue;
}
match file_name.as_str() {
"database.tmdl" => loader.load_database_tmdl(path, skips)?,
"model.tmdl" => loader.load_model_tmdl(path, skips)?,
"relationships.tmdl" => loader.load_relationships_tmdl(path, skips)?,
"expressions.tmdl" => loader.load_expressions_tmdl(path, skips)?,
file_name if file_name.ends_with(".tmdl") => loader.load_extra_tmdl(path, skips)?,
other => notice(
skips,
path,
None,
SkipKind::UnknownObject,
format!("unexpected file '{other}' in definition/"),
),
}
}
Ok(loader.finish(definition, skips))
}
struct Loader {
database: TabularDatabase,
tables: Vec<Table>,
seen_tables: HashSet<String>,
table_refs: Vec<(String, usize)>,
model_tmdl: Option<PathBuf>,
}
impl Loader {
fn new(name: Option<String>) -> Self {
Self {
database: TabularDatabase {
name,
..Default::default()
},
tables: Vec::new(),
seen_tables: HashSet::new(),
table_refs: Vec::new(),
model_tmdl: None,
}
}
fn load_database_tmdl(&mut self, path: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for root in &parse_file(path)? {
match root.key.as_str() {
"database" => {
for child in &root.children {
if is_ignored(child) {
continue;
}
notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{}' on database", child.key),
);
}
}
"annotation" | "extendedProperty" => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("'{other}' does not belong in database.tmdl"),
),
}
}
Ok(())
}
fn load_model_tmdl(&mut self, path: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
self.model_tmdl = Some(path.to_path_buf());
for root in &parse_file(path)? {
match root.key.as_str() {
"model" => {
for child in &root.children {
if is_ignored(child) {
continue;
}
notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{}' on model", child.key),
);
}
}
"ref" => match root.name.as_deref() {
Some("table") => {
let referenced = root.tail.as_deref().map(unquote).unwrap_or_default();
self.table_refs.push((referenced, root.line));
}
Some("cultureInfo") | Some("expression") => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("unknown ref type '{other:?}' in model.tmdl"),
),
},
"table" => self.add_table(path, root.line, map_table(root, path, skips), skips),
"relationship" => self.add_relationship(root, path, skips),
"role" => self.database.roles.push(map_role(root, path, skips)),
"expression" => self
.database
.expressions
.push(map_expression(root, path, skips)),
"function" => self
.database
.functions
.push(map_function(root, path, skips)),
"annotation" | "extendedProperty" | "dataSource" | "perspective" => {}
"queryGroup" => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("unknown object '{other}' in model.tmdl"),
),
}
}
Ok(())
}
fn load_relationships_tmdl(&mut self, path: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for root in &parse_file(path)? {
match root.key.as_str() {
"relationship" => self.add_relationship(root, path, skips),
"annotation" | "extendedProperty" => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("'{other}' does not belong in relationships.tmdl"),
),
}
}
Ok(())
}
fn load_expressions_tmdl(&mut self, path: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for root in &parse_file(path)? {
match root.key.as_str() {
"expression" => self
.database
.expressions
.push(map_expression(root, path, skips)),
"function" => self
.database
.functions
.push(map_function(root, path, skips)),
"annotation" | "extendedProperty" => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("'{other}' does not belong in expressions.tmdl"),
),
}
}
Ok(())
}
fn load_extra_tmdl(&mut self, path: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for root in &parse_file(path)? {
match root.key.as_str() {
"table" => self.add_table(path, root.line, map_table(root, path, skips), skips),
"relationship" => self.add_relationship(root, path, skips),
"role" => self.database.roles.push(map_role(root, path, skips)),
"expression" => self
.database
.expressions
.push(map_expression(root, path, skips)),
"function" => self
.database
.functions
.push(map_function(root, path, skips)),
"annotation" | "extendedProperty" | "dataSource" | "perspective" => {}
other => notice(
skips,
path,
Some(root.line),
SkipKind::UnknownObject,
format!("unknown object '{other}'"),
),
}
}
Ok(())
}
fn load_tables_dir(&mut self, dir: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for (file_name, path, is_dir) in sorted_entries(dir)? {
if is_dir {
notice(
skips,
&path,
None,
SkipKind::UnknownObject,
format!("unexpected directory '{file_name}' in tables/"),
);
continue;
}
if !file_name.ends_with(".tmdl") {
notice(
skips,
&path,
None,
SkipKind::UnknownObject,
format!("unexpected file '{file_name}' in tables/"),
);
continue;
}
for root in &parse_file(&path)? {
match root.key.as_str() {
"table" => {
self.add_table(&path, root.line, map_table(root, &path, skips), skips)
}
"annotation" | "extendedProperty" => {}
other => notice(
skips,
&path,
Some(root.line),
SkipKind::UnknownObject,
format!("'{other}' does not belong in a table file"),
),
}
}
}
Ok(())
}
fn load_roles_dir(&mut self, dir: &Path, skips: &mut Vec<SkipNotice>) -> Result<()> {
for (file_name, path, is_dir) in sorted_entries(dir)? {
if is_dir {
notice(
skips,
&path,
None,
SkipKind::UnknownObject,
format!("unexpected directory '{file_name}' in roles/"),
);
continue;
}
if !file_name.ends_with(".tmdl") {
notice(
skips,
&path,
None,
SkipKind::UnknownObject,
format!("unexpected file '{file_name}' in roles/"),
);
continue;
}
for root in &parse_file(&path)? {
match root.key.as_str() {
"role" => self.database.roles.push(map_role(root, &path, skips)),
"annotation" | "extendedProperty" => {}
other => notice(
skips,
&path,
Some(root.line),
SkipKind::UnknownObject,
format!("'{other}' does not belong in a role file"),
),
}
}
}
Ok(())
}
fn add_table(&mut self, path: &Path, line: usize, table: Table, skips: &mut Vec<SkipNotice>) {
if self.seen_tables.insert(fold_name(&table.name)) {
self.tables.push(table);
} else {
notice(
skips,
path,
Some(line),
SkipKind::MalformedValue,
format!("duplicate table '{}'", table.name),
);
}
}
fn add_relationship(&mut self, root: &Node, path: &Path, skips: &mut Vec<SkipNotice>) {
if let Some(relationship) = map_relationship(root, path, skips) {
self.database.relationships.push(relationship);
}
}
fn finish(mut self, definition: &Path, skips: &mut Vec<SkipNotice>) -> TabularDatabase {
let folded: Vec<String> = self.tables.iter().map(|t| fold_name(&t.name)).collect();
let mut slots: Vec<Option<Table>> = self.tables.drain(..).map(Some).collect();
for (reference, line) in &self.table_refs {
let wanted = fold_name(reference);
let mut found = None;
for (index, slot) in slots.iter().enumerate() {
if slot.is_some() && folded[index] == wanted {
found = Some(index);
break;
}
}
match found {
Some(index) => {
if let Some(table) = slots[index].take() {
self.database.tables.push(table);
}
}
None => notice(
skips,
self.model_tmdl.as_deref().unwrap_or(definition),
Some(*line),
SkipKind::UnknownObject,
format!("ref table '{reference}' has no matching table definition"),
),
}
}
for table in slots.into_iter().flatten() {
self.database.tables.push(table);
}
self.database
}
}
fn sorted_entries(dir: &Path) -> Result<Vec<(String, PathBuf, bool)>> {
let mut entries: Vec<(String, PathBuf, bool)> = Vec::new();
for entry in fs::read_dir(dir)? {
let entry = entry?;
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
entries.push((
entry.file_name().to_string_lossy().into_owned(),
entry.path(),
is_dir,
));
}
entries.sort_by(|a, b| a.0.cmp(&b.0));
Ok(entries)
}
fn parse_file(path: &Path) -> Result<Vec<Node>> {
let text = fs::read_to_string(path)?;
let nodes = parse_document(&text, path)?;
validate(&nodes, path)?;
Ok(nodes)
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
struct Node {
key: String,
name: Option<String>,
tail: Option<String>,
value: NodeValue,
line: usize,
children: Vec<Node>,
}
impl Node {
fn text(&self) -> Option<&str> {
match &self.value {
NodeValue::Inline(text) | NodeValue::Block(text) => Some(text),
NodeValue::None => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
enum NodeValue {
#[default]
None,
Inline(String),
Block(String),
}
fn parse_document(text: &str, path: &Path) -> Result<Vec<Node>> {
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
let lines: Vec<&str> = text
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.collect();
let mut builder = TreeBuilder {
roots: Vec::new(),
stack: Vec::new(),
block: None,
};
builder.run(&lines, path)?;
Ok(builder.roots)
}
struct TreeBuilder {
roots: Vec<Node>,
stack: Vec<(usize, Node)>,
block: Option<Block>,
}
struct Block {
indent: usize,
body: Vec<String>,
prefix: Option<String>,
}
impl TreeBuilder {
fn run(&mut self, lines: &[&str], path: &Path) -> Result<()> {
for (index, raw) in lines.iter().enumerate() {
let depth = tab_depth(raw);
let absorbed = match self.block.as_mut() {
Some(block) if raw.trim().is_empty() => {
block.body.push(String::new());
true
}
Some(block) if depth >= block.indent => {
block.body.push(raw[block.indent..].to_string());
true
}
_ => false,
};
if absorbed {
continue;
}
if self.block.is_some() {
self.close_block();
}
let content = raw[depth..].trim_start();
if content.is_empty() || content.starts_with("///") {
continue;
}
let line = tokenize(content).map_err(|malformed| {
Error::Tmdl(format!(
"{}: line {}: {malformed}",
path.display(),
index + 1
))
})?;
let node = Node {
key: line.key.to_string(),
name: line.name.map(str::to_string),
tail: line.tail.map(str::to_string),
value: match line.value {
Some(value) => NodeValue::Inline(value.to_string()),
None => NodeValue::None,
},
line: index + 1,
children: Vec::new(),
};
if line.equals_form {
let value = line.value.unwrap_or_default();
let continuation = !value.is_empty();
let next = lines[index + 1..]
.iter()
.find(|probe| !probe.trim().is_empty());
let opens = match next {
Some(next) if continuation => tab_depth(next) > depth + 1,
Some(next) => tab_depth(next) > depth,
None => false,
};
if opens && let Some(next) = next {
let indent = tab_depth(next);
let prefix = continuation.then(|| value.to_string());
self.open(node, depth);
self.block = Some(Block {
indent,
body: Vec::new(),
prefix,
});
continue;
}
}
self.open(node, depth);
}
if self.block.is_some() {
self.close_block();
}
while let Some((_, node)) = self.stack.pop() {
self.attach(node);
}
Ok(())
}
fn open(&mut self, node: Node, depth: usize) {
while self.stack.last().is_some_and(|(open, _)| *open >= depth) {
let Some((_, popped)) = self.stack.pop() else {
break;
};
self.attach(popped);
}
self.stack.push((depth, node));
}
fn attach(&mut self, node: Node) {
match self.stack.last_mut() {
Some((_, parent)) => parent.children.push(node),
None => self.roots.push(node),
}
}
fn close_block(&mut self) {
let Some(block) = self.block.take() else {
return;
};
let mut body = block.body;
while body.last().is_some_and(String::is_empty) {
body.pop();
}
if let Some((_, node)) = self.stack.last_mut() {
node.value = match block.prefix {
Some(prefix) => NodeValue::Block(format!("{prefix}\n{}", body.join("\n"))),
None => NodeValue::Block(body.join("\n")),
};
}
}
}
fn tab_depth(line: &str) -> usize {
line.bytes().take_while(|b| *b == b'\t').count()
}
struct Line<'a> {
key: &'a str,
name: Option<&'a str>,
tail: Option<&'a str>,
value: Option<&'a str>,
equals_form: bool,
}
fn tokenize(content: &str) -> std::result::Result<Line<'_>, String> {
let key_end = content
.find(|c: char| c.is_whitespace() || c == ':' || c == '=')
.unwrap_or(content.len());
let key = &content[..key_end];
if key.is_empty() {
return Err(format!("unparsable line {content:?}"));
}
let rest = content[key_end..].trim_start();
let (name, rest) = read_name(rest);
let rest = rest.trim_start();
if let Some(value) = rest.strip_prefix(':') {
return Ok(Line {
key,
name,
tail: None,
value: Some(value.trim_start()),
equals_form: false,
});
}
if let Some(value) = rest.strip_prefix('=') {
return Ok(Line {
key,
name,
tail: None,
value: Some(value.trim_start()),
equals_form: true,
});
}
if !rest.is_empty() {
return Ok(Line {
key,
name,
tail: Some(rest.trim_end()),
value: None,
equals_form: false,
});
}
Ok(Line {
key,
name,
tail: None,
value: None,
equals_form: false,
})
}
fn read_name(rest: &str) -> (Option<&str>, &str) {
if let Some(quoted) = rest.strip_prefix('\'') {
let bytes = quoted.as_bytes();
let mut cursor = 0;
while cursor < quoted.len() {
if bytes[cursor] == b'\'' {
if bytes.get(cursor + 1) == Some(&b'\'') {
cursor += 2;
continue;
}
let end = cursor + 2;
return (Some(&rest[..end]), &rest[end..]);
}
cursor += 1;
}
return (Some(rest), "");
}
if rest.is_empty() || rest.starts_with(':') || rest.starts_with('=') {
return (None, rest);
}
let end = rest
.find(|c: char| c.is_whitespace() || c == ':' || c == '=')
.unwrap_or(rest.len());
(Some(&rest[..end]), &rest[end..])
}
fn validate(nodes: &[Node], path: &Path) -> Result<()> {
for node in nodes {
if NAMED_DESCRIPTORS.contains(&node.key.as_str())
&& node.name.is_none()
&& node.value == NodeValue::None
{
return Err(Error::Tmdl(format!(
"{}: line {}: '{}' requires a name",
path.display(),
node.line,
node.key
)));
}
if node.key == "ref" && (node.name.is_none() || node.tail.is_none()) {
return Err(Error::Tmdl(format!(
"{}: line {}: ref requires a type and a target",
path.display(),
node.line
)));
}
validate(&node.children, path)?;
}
Ok(())
}
fn notice(
skips: &mut Vec<SkipNotice>,
path: &Path,
line: Option<usize>,
kind: SkipKind,
detail: String,
) {
skips.push(SkipNotice {
path: path.to_path_buf(),
location: line.map(|line| format!("line {line}")),
kind,
detail,
});
}
fn is_ignored(node: &Node) -> bool {
IGNORED_KEYS.contains(&node.key.as_str())
}
fn unquote(name: &str) -> String {
let Some(inner) = name.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) else {
return name.to_string();
};
inner.replace("''", "'")
}
fn parse_column_ref(text: &str) -> Option<(Option<String>, String)> {
let text = text.trim();
if let Some(rest) = text.strip_prefix('\'') {
let bytes = rest.as_bytes();
let mut cursor = 0;
while cursor < rest.len() {
if bytes[cursor] == b'\'' {
if bytes.get(cursor + 1) == Some(&b'\'') {
cursor += 2;
continue;
}
let remainder = &rest[cursor + 1..];
if !remainder.starts_with('.') {
return (!rest[..cursor].is_empty()).then(|| (None, unquote(text)));
}
let table = rest[..cursor].replace("''", "'");
return Some((Some(table), unquote(&remainder[1..])));
}
cursor += 1;
}
return None;
}
if let Some((table, column)) = text.split_once('.') {
return Some((Some(table.to_string()), unquote(column)));
}
(!text.is_empty()).then(|| (None, unquote(text)))
}
fn parse_bool(value: Option<&str>) -> Option<bool> {
match value {
Some("true") => Some(true),
Some("false") => Some(false),
_ => None,
}
}
fn map_table(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Table {
let mut table = Table {
name: unquote(node.name.as_deref().unwrap_or_default()),
..Default::default()
};
for child in &node.children {
if child.key == "annotation" {
if let (Some(name), Some("true")) = (child.name.as_deref(), child.text()) {
match name {
"__PBI_LocalDateTable" => table.is_local_date_table = true,
"__PBI_TemplateDateTable" => table.is_template_date_table = true,
_ => {}
}
}
continue;
}
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"measure" => table.measures.push(map_measure(child, path, skips)),
"column" => table.columns.push(map_column(child, path, skips)),
"hierarchy" => table.hierarchies.push(map_hierarchy(child, path, skips)),
"partition" => table.partitions.push(map_partition(child, path, skips)),
"calendar" => table.calendars.push(map_calendar(child, path, skips)),
"calculationGroup" => {
table.calculation_group = Some(map_calculation_group(child, path, skips));
}
"isHidden" => table.is_hidden = true,
"isPrivate" => table.is_private = true,
"defaultDetailRowsDefinition" => {
table.detail_rows_expression = child.text().map(str::to_string);
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on table '{}'", table.name),
),
}
}
table.is_local_date_table |= table.name.starts_with("LocalDateTable_");
table.is_template_date_table |= table.name.starts_with("DateTableTemplate_");
table
}
fn map_measure(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Measure {
let name = unquote(node.name.as_deref().unwrap_or_default());
if node.text().is_none() {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("measure '{name}' has no expression"),
);
}
let mut measure = Measure {
name,
expression: node.text().unwrap_or_default().to_string(),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"isHidden" => measure.is_hidden = true,
"formatStringDefinition" => {
measure.format_string_expression = child.text().map(str::to_string);
}
"detailRowsDefinition" => {
measure.detail_rows_expression = child.text().map(str::to_string);
}
"kpi" => measure.kpi = Some(map_kpi(child, path, skips)),
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on measure '{}'", measure.name),
),
}
}
measure
}
fn map_kpi(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Kpi {
let mut kpi = Kpi::default();
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"targetExpression" => {
kpi.target_expression = child.text().map(str::to_string);
}
"statusExpression" => {
kpi.status_expression = child.text().map(str::to_string);
}
"trendExpression" => {
kpi.trend_expression = child.text().map(str::to_string);
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on kpi"),
),
}
}
kpi
}
fn map_column(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Column {
let kind = match node.text() {
Some(expression) => ColumnKind::Calculated {
expression: expression.to_string(),
},
None => ColumnKind::Data,
};
let mut column = Column {
name: unquote(node.name.as_deref().unwrap_or_default()),
kind,
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"isHidden" => column.is_hidden = true,
"sortByColumn" => column.sort_by_column = child.text().map(unquote),
"variation" => column.variations.push(map_variation(child, path, skips)),
"relatedColumnDetails" => {
for detail in &child.children {
if detail.key == "groupByColumn" {
if let Some(text) = detail.text() {
column.group_by_columns.push(unquote(text));
}
} else if !is_ignored(detail) {
notice(
skips,
path,
Some(detail.line),
SkipKind::UnknownProperty,
format!("unknown property '{}' on relatedColumnDetails", detail.key),
);
}
}
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on column '{}'", column.name),
),
}
}
column
}
fn map_variation(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Variation {
let mut variation = Variation {
name: unquote(node.name.as_deref().unwrap_or_default()),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"isDefault" => variation.is_default = true,
"relationship" => variation.relationship = child.text().map(str::to_string),
"defaultHierarchy" => {
variation.default_hierarchy = child.text().and_then(|text| {
parse_column_ref(text).map(|(table, hierarchy)| HierarchyRef {
table: table.unwrap_or_default(),
hierarchy,
})
});
if variation.default_hierarchy.is_none() {
notice(
skips,
path,
Some(child.line),
SkipKind::MalformedValue,
format!(
"variation '{}' has an unreadable defaultHierarchy",
variation.name
),
);
}
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!(
"unknown property '{other}' on variation '{}'",
variation.name
),
),
}
}
variation
}
fn map_hierarchy(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Hierarchy {
let mut hierarchy = Hierarchy {
name: unquote(node.name.as_deref().unwrap_or_default()),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"isHidden" => hierarchy.is_hidden = true,
"level" => {
let name = unquote(child.name.as_deref().unwrap_or_default());
let column = child
.children
.iter()
.find(|sub| sub.key == "column")
.and_then(Node::text)
.map(unquote);
let Some(column) = column else {
notice(
skips,
path,
Some(child.line),
SkipKind::MalformedValue,
format!("level '{name}' has no column"),
);
hierarchy.levels.push(HierarchyLevel {
name,
column: String::new(),
});
continue;
};
hierarchy.levels.push(HierarchyLevel { name, column });
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!(
"unknown property '{other}' on hierarchy '{}'",
hierarchy.name
),
),
}
}
hierarchy
}
fn map_calendar(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Calendar {
let mut calendar = Calendar {
name: unquote(node.name.as_deref().unwrap_or_default()),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"calendarColumnGroup" => {
for property in &child.children {
match property.key.as_str() {
"column" | "primaryColumn" | "associatedColumn" => {
if let Some(text) = property.text() {
calendar.columns.push(unquote(text));
}
}
other if !is_ignored(property) => notice(
skips,
path,
Some(property.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on calendarColumnGroup"),
),
_ => {}
}
}
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on calendar '{}'", calendar.name),
),
}
}
calendar
}
fn map_partition(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Partition {
let name = unquote(node.name.as_deref().unwrap_or_default());
let kind = node.text();
let source = node
.children
.iter()
.find(|child| child.key == "source")
.and_then(Node::text);
let expression = match (kind, source) {
(Some("m" | "calculated" | "query"), Some(text)) => text.to_string(),
(Some("m" | "calculated" | "query"), None) => {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("partition '{name}' has no source"),
);
String::new()
}
_ => String::new(),
};
let source = match kind {
Some("m") => PartitionSource::M { expression },
Some("calculated") => PartitionSource::Calculated { expression },
Some("query") => PartitionSource::Query { query: expression },
Some(other) => {
PartitionSource::Other {
kind: Some(other.to_string()),
}
}
None => {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("partition '{name}' has no kind"),
);
PartitionSource::Other { kind: None }
}
};
for child in &node.children {
if is_ignored(child) || child.key == "source" {
continue;
}
notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!(
"unknown property '{child}' on partition '{name}'",
child = child.key
),
);
}
Partition { name, source }
}
fn map_calculation_group(
node: &Node,
path: &Path,
skips: &mut Vec<SkipNotice>,
) -> CalculationGroup {
let mut group = CalculationGroup::default();
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"calculationItem" => group.items.push(map_calculation_item(child, path, skips)),
"noSelectionExpression" => {
group.no_selection_expression = child.text().map(str::to_string);
group.no_selection_format_string_expression = nested_format_string(child);
}
"multipleOrEmptySelectionExpression" => {
group.multiple_or_empty_selection_expression = child.text().map(str::to_string);
group.multiple_or_empty_selection_format_string_expression =
nested_format_string(child);
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on calculation group"),
),
}
}
group
}
fn nested_format_string(node: &Node) -> Option<String> {
node.children
.iter()
.find(|child| child.key == "formatStringDefinition")
.and_then(Node::text)
.map(str::to_string)
}
fn map_calculation_item(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> CalculationItem {
let name = unquote(node.name.as_deref().unwrap_or_default());
if node.text().is_none() {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("calculation item '{name}' has no expression"),
);
}
let mut item = CalculationItem {
name,
expression: node.text().unwrap_or_default().to_string(),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"formatStringDefinition" => {
item.format_string_expression = child.text().map(str::to_string);
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!(
"unknown property '{other}' on calculation item '{}'",
item.name
),
),
}
}
item
}
fn map_relationship(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Option<Relationship> {
let mut relationship = Relationship {
name: node.name.as_deref().map(unquote),
..Default::default()
};
let mut from: Option<(Option<String>, String)> = None;
let mut to: Option<(Option<String>, String)> = None;
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"fromColumn" => from = child.text().and_then(parse_column_ref),
"toColumn" => to = child.text().and_then(parse_column_ref),
"isActive" => match parse_bool(child.text()) {
Some(value) => relationship.is_active = value,
None => notice(
skips,
path,
Some(child.line),
SkipKind::MalformedValue,
format!(
"cannot parse isActive '{}'",
child.text().unwrap_or_default()
),
),
},
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on relationship"),
),
}
}
let (from, to) = match (from, to) {
(Some(from), Some(to)) => (from, to),
_ => {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!(
"relationship '{}' is missing or unreadable fromColumn/toColumn",
node.name.as_deref().unwrap_or_default()
),
);
return None;
}
};
let (Some(from_table), from_column) = from else {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
"fromColumn is not table-qualified".to_string(),
);
return None;
};
let (Some(to_table), to_column) = to else {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
"toColumn is not table-qualified".to_string(),
);
return None;
};
relationship.from_table = from_table;
relationship.from_column = from_column;
relationship.to_table = to_table;
relationship.to_column = to_column;
Some(relationship)
}
fn map_role(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Role {
let mut role = Role {
name: unquote(node.name.as_deref().unwrap_or_default()),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"tablePermission" => {
let table = unquote(child.name.as_deref().unwrap_or_default());
let filter_expression = child.text().map(str::to_string).or_else(|| {
child
.children
.iter()
.find(|sub| sub.key == "filterExpression")
.and_then(Node::text)
.map(str::to_string)
});
role.table_permissions.push(TablePermission {
table,
filter_expression,
});
}
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on role '{}'", role.name),
),
}
}
role
}
fn map_expression(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> SharedExpression {
let name = unquote(node.name.as_deref().unwrap_or_default());
if node.text().is_none() {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("expression '{name}' has no body"),
);
}
for child in &node.children {
if !is_ignored(child) {
notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{}' on expression '{name}'", child.key),
);
}
}
SharedExpression {
name,
expression: node.text().unwrap_or_default().to_string(),
}
}
fn map_function(node: &Node, path: &Path, skips: &mut Vec<SkipNotice>) -> Function {
let name = unquote(node.name.as_deref().unwrap_or_default());
if node.text().is_none() {
notice(
skips,
path,
Some(node.line),
SkipKind::MalformedValue,
format!("function '{name}' has no body"),
);
}
let mut function = Function {
name,
expression: node.text().unwrap_or_default().to_string(),
..Default::default()
};
for child in &node.children {
if is_ignored(child) {
continue;
}
match child.key.as_str() {
"isHidden" => function.is_hidden = true,
other => notice(
skips,
path,
Some(child.line),
SkipKind::UnknownProperty,
format!("unknown property '{other}' on function '{}'", function.name),
),
}
}
function
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
fn parse(text: &str) -> Vec<Node> {
parse_document(text, Path::new("test.tmdl")).expect("valid document")
}
fn map_one(text: &str, key: &str) -> Node {
parse(text)
.into_iter()
.find(|node| node.key == key)
.unwrap_or_else(|| panic!("no '{key}' root in document"))
}
fn key_at<'a>(node: &'a Node, key: &str) -> &'a Node {
node.children
.iter()
.find(|child| child.key == key)
.unwrap_or_else(|| panic!("no '{key}' child"))
}
mod scanner {
use super::*;
#[test]
fn nests_children_by_tab_depth() {
let roots = parse("table Sales\n\tmeasure M = 1\n\t\tisHidden\n\tcolumn C\n");
assert_eq!(roots.len(), 1);
let table = &roots[0];
assert_eq!(table.key, "table");
assert_eq!(table.name.as_deref(), Some("Sales"));
assert_eq!(table.line, 1);
assert_eq!(table.children.len(), 2);
assert_eq!(table.children[0].key, "measure");
assert_eq!(table.children[1].key, "column");
let measure = &table.children[0];
assert_eq!(measure.value, NodeValue::Inline("1".to_string()));
assert_eq!(measure.children[0].key, "isHidden");
assert_eq!(measure.children[0].value, NodeValue::None);
}
#[rstest]
#[case("flag", None, NodeValue::None)]
#[case("key: value", None, NodeValue::Inline("value".to_string()))]
#[case("key = raw", None, NodeValue::Inline("raw".to_string()))]
#[case("annotation N = v", Some("N"), NodeValue::Inline("v".to_string()))]
fn reads_line_forms(
#[case] text: &str,
#[case] name: Option<&str>,
#[case] value: NodeValue,
) {
let roots = parse(text);
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].name.as_deref(), name);
assert_eq!(roots[0].value, value);
}
#[test]
fn reads_ref_directives_with_their_target_as_tail() {
let roots = parse("ref table 'Sales Order'\n");
assert_eq!(roots[0].key, "ref");
assert_eq!(roots[0].name.as_deref(), Some("table"));
assert_eq!(roots[0].tail.as_deref(), Some("'Sales Order'"));
}
#[test]
fn keeps_quoted_names_raw_with_escapes() {
let roots = parse("measure 'It''s' = 1\n");
assert_eq!(roots[0].name.as_deref(), Some("'It''s'"));
}
#[test]
fn stops_names_at_equals_without_space() {
let roots = parse("measure M= 1\n");
assert_eq!(roots[0].name.as_deref(), Some("M"));
assert_eq!(roots[0].value, NodeValue::Inline("1".to_string()));
}
#[test]
fn captures_blocks_dedented_with_inner_blank_lines() {
let roots = parse(
"expression E =\n\t\tlet\n\t\t x = 1\n\n\t\tin\n\t\t x\n\nannotation A = 1\n",
);
assert_eq!(roots.len(), 2);
assert_eq!(
roots[0].value,
NodeValue::Block("let\n x = 1\n\nin\n x".to_string())
);
assert_eq!(roots[1].key, "annotation");
}
#[test]
fn closes_a_block_at_a_shallower_sibling_property() {
let roots = parse("expression E =\n\t\tbody\n\tlineageTag: t\n");
assert_eq!(roots[0].value, NodeValue::Block("body".to_string()));
assert_eq!(roots[0].children.len(), 1);
assert_eq!(roots[0].children[0].key, "lineageTag");
}
#[test]
fn closes_a_block_at_a_sibling_object_header() {
let roots = parse("measure M =\n\t\tVAR x = 1\n\t\tRETURN x\nmeasure N = 2\n");
assert_eq!(roots.len(), 2);
assert_eq!(
roots[0].value,
NodeValue::Block("VAR x = 1\nRETURN x".to_string())
);
assert_eq!(roots[1].value, NodeValue::Inline("2".to_string()));
}
#[test]
fn continues_an_inline_expression_below_property_depth() {
let roots =
parse("measure M = ```\n\t\t\tVAR x = 1\n\t\t\tRETURN x\n\t\tformatString: 0\n");
assert_eq!(
roots[0].value,
NodeValue::Block("```\nVAR x = 1\nRETURN x".to_string())
);
assert_eq!(roots[0].children.len(), 1);
assert_eq!(roots[0].children[0].key, "formatString");
}
#[test]
fn does_not_continue_an_inline_value_at_property_depth() {
let roots = parse("measure M = 1 + 1\n\tformatString: 0\n");
assert_eq!(roots[0].value, NodeValue::Inline("1 + 1".to_string()));
assert_eq!(roots[0].children.len(), 1);
}
#[test]
fn keeps_an_empty_value_when_equals_has_no_block() {
let roots = parse("source =\nsource2 = x\n");
assert_eq!(roots[0].value, NodeValue::Inline(String::new()));
}
#[test]
fn rejects_unparsable_lines() {
let error = parse_document("= broken\n", Path::new("test.tmdl"));
assert!(error.is_err());
}
#[test]
fn numbers_lines_from_one() {
let roots = parse("\n\ntable Sales\n");
assert_eq!(roots[0].line, 3);
}
}
mod unquote {
use super::*;
#[rstest]
#[case("Sales", "Sales")]
#[case("'Sales Order'", "Sales Order")]
#[case("'It''s quoted'", "It's quoted")]
#[case("'A.B: C'", "A.B: C")]
#[case("'", "'")]
fn strips_quotes_and_unescapes(#[case] raw: &str, #[case] expected: &str) {
assert_eq!(unquote(raw), expected);
}
}
mod column_refs {
use super::*;
#[rstest]
#[case("Sales.CustomerKey", Some((Some("Sales".into()), "CustomerKey".into())))]
#[case(
"'Sales Order'.SalesOrderLineKey",
Some((Some("Sales Order".into()), "SalesOrderLineKey".into()))
)]
#[case(
"'T'.'Col Name'",
Some((Some("T".into()), "Col Name".into()))
)]
#[case(
"Sales.'Order Quantity'",
Some((Some("Sales".into()), "Order Quantity".into()))
)]
#[case("'Mth of year'", Some((None, "Mth of year".into())))]
#[case("Plain", Some((None, "Plain".into())))]
#[case("", None)]
#[case("'Unterminated", None)]
#[case("'TableOnly'", Some((None, "TableOnly".into())))]
fn parses_written_forms(
#[case] text: &str,
#[case] expected: Option<(Option<String>, String)>,
) {
assert_eq!(parse_column_ref(text), expected);
}
}
mod validation {
use super::*;
#[test]
fn rejects_a_table_without_a_name() {
let error = validate(
&[Node {
key: "table".to_string(),
..Default::default()
}],
Path::new("test.tmdl"),
);
assert!(error.is_err());
}
#[test]
fn rejects_an_incomplete_ref() {
let error = validate(
&[Node {
key: "ref".to_string(),
name: Some("table".to_string()),
..Default::default()
}],
Path::new("test.tmdl"),
);
assert!(error.is_err());
}
#[test]
fn accepts_nameless_calculation_groups() {
let nodes = parse("calculationGroup\n\tprecedence: 1\n");
assert!(validate(&nodes, Path::new("test.tmdl")).is_ok());
}
}
mod mapping {
use super::*;
#[test]
fn notices_an_unknown_property_with_its_line() {
let mut skips = Vec::new();
let node = map_one(
"table Sales\n\tcolumn Amount\n\t\tdataType: double\n\t\taiHint: smart\n",
"table",
);
let table = map_table(&node, Path::new("tables/Sales.tmdl"), &mut skips);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::UnknownProperty);
assert_eq!(skips[0].location.as_deref(), Some("line 4"));
assert!(skips[0].detail.contains("aiHint"));
assert_eq!(skips[0].path, Path::new("tables/Sales.tmdl"));
assert_eq!(table.columns[0].name, "Amount");
}
#[test]
fn ignores_deliberately_unmodeled_metadata_silently() {
let mut skips = Vec::new();
let node = map_one(
"table Sales\n\tlineageTag: g\n\tsourceLineageTag: s\n\tisHidden\n\texcludeFromModelRefresh\n\tcolumn Amount\n\t\tdataType: double\n\t\tisNullable: false\n\t\tformatString: 0\n\t\tsummarizeBy: sum\n\t\tsourceColumn: Amount\n\t\tsourceLineageTag: c\n\t\tchangedProperty = IsHidden\n\t\tannotation SetBy = User\n\tmeasure M = 1\n\t\tdisplayFolder: Core\n",
"table",
);
let table = map_table(&node, Path::new("t"), &mut skips);
assert!(
skips.is_empty(),
"unmodeled metadata must be silent: {skips:?}"
);
assert!(table.is_hidden);
assert_eq!(table.columns.len(), 1);
assert_eq!(table.measures.len(), 1);
}
#[test]
fn the_engine_annotations_mark_a_local_date_table() {
let mut skips = Vec::new();
let node = map_one(
"table 'LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228'\n\tisHidden\n\tshowAsVariationsOnly\n\tannotation __PBI_LocalDateTable = true\n\tannotation SummarizationSetBy = Automatic\n",
"table",
);
let table = map_table(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:?}");
assert!(table.is_hidden);
assert!(table.is_local_date_table);
assert!(!table.is_template_date_table);
assert!(!table.is_private);
}
#[test]
fn the_engine_annotation_and_isprivate_mark_the_template_table() {
let mut skips = Vec::new();
let node = map_one(
"table 'DateTableTemplate_0039983e-de71-45fb-bd88-812f61c0ff38'\n\tisHidden\n\tisPrivate\n\tannotation __PBI_TemplateDateTable = true\n",
"table",
);
let table = map_table(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:?}");
assert!(table.is_private);
assert!(table.is_template_date_table);
assert!(!table.is_local_date_table);
}
#[test]
fn the_name_prefixes_fall_back_when_no_metadata_names_the_machinery() {
let mut skips = Vec::new();
let local = map_one("table LocalDateTable_deadbeef-1\n", "table");
let local = map_table(&local, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert!(local.is_local_date_table);
assert!(!local.is_template_date_table);
let template = map_one("table DateTableTemplate_deadbeef-1\n", "table");
let template = map_table(&template, Path::new("t"), &mut skips);
assert!(template.is_template_date_table);
let plain = map_one("table Sales\n", "table");
let plain = map_table(&plain, Path::new("t"), &mut skips);
assert!(!plain.is_local_date_table);
assert!(!plain.is_template_date_table);
}
#[test]
fn a_private_user_table_is_not_date_machinery() {
let mut skips = Vec::new();
let node = map_one("table 'ClusterMappingTable 2'\n\tisPrivate\n", "table");
let table = map_table(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:?}");
assert!(table.is_private);
assert!(!table.is_local_date_table);
assert!(!table.is_template_date_table);
}
#[test]
fn a_variation_declares_its_relationship_and_default_hierarchy() {
let mut skips = Vec::new();
let node = map_one(
"column Date\n\tisHidden\n\tvariation Variation\n\t\tisDefault\n\t\trelationship: b10a0bfa-b7fe-4437-8b2d-85624b0f085f\n\t\tdefaultHierarchy: LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228.'Date Hierarchy'\n",
"column",
);
let column = map_column(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:?}");
assert_eq!(column.variations.len(), 1);
let variation = &column.variations[0];
assert_eq!(variation.name, "Variation");
assert!(variation.is_default);
assert_eq!(
variation.relationship.as_deref(),
Some("b10a0bfa-b7fe-4437-8b2d-85624b0f085f")
);
assert_eq!(
variation.default_hierarchy,
Some(HierarchyRef {
table: "LocalDateTable_9e0bbdfc-9803-41d0-b204-481ce398f228".to_string(),
hierarchy: "Date Hierarchy".to_string(),
})
);
}
#[test]
fn an_unmarked_variation_is_not_the_default() {
let mut skips = Vec::new();
let node = map_one(
"column Date\n\tvariation Variation\n\t\trelationship: r1\n",
"column",
);
let column = map_column(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:?}");
assert!(!column.variations[0].is_default);
assert_eq!(column.variations[0].relationship.as_deref(), Some("r1"));
assert_eq!(column.variations[0].default_hierarchy, None);
}
#[test]
fn an_unknown_variation_child_is_drift() {
let mut skips = Vec::new();
let node = map_one(
"column Date\n\tvariation Variation\n\t\tdefaultHierarchy: 'D'.'Date Hierarchy'\n\t\taiHint: x\n",
"column",
);
let column = map_column(&node, Path::new("t"), &mut skips);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::UnknownProperty);
assert!(skips[0].detail.contains("aiHint"));
assert_eq!(
column.variations[0].default_hierarchy,
Some(HierarchyRef {
table: "D".to_string(),
hierarchy: "Date Hierarchy".to_string(),
})
);
}
#[test]
fn query_group_membership_is_deliberately_unmodeled() {
let mut skips = Vec::new();
let expression = map_one(
"expression E =\n\t\tlet\n\t\t S = 1\n\t\tin\n\t\t S\n\tlineageTag: t\n\tqueryGroup: 'Extract Tables\\\\e_X'\n",
"expression",
);
let expression = map_expression(&expression, Path::new("t"), &mut skips);
let partition = map_one(
"partition P = m\n\tmode: import\n\tqueryGroup: 'Extract Tables\\\\e_X'\n\tsource =\n\t\tlet\n\t\t S = 1\n\t\tin\n\t\t S\n",
"partition",
);
let partition = map_partition(&partition, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "query groups must be silent: {skips:?}");
assert!(expression.expression.contains("S = 1"));
assert!(matches!(partition.source, PartitionSource::M { .. }));
}
#[test]
fn maps_a_measure_with_kpi_and_dynamic_format_string() {
let mut skips = Vec::new();
let node = map_one(
"measure 'Growth %' =\n\t\tVAR p = 1\n\t\tRETURN p\n\tisHidden\n\tformatStringDefinition = \"0%\"\n\tdetailRowsDefinition = DR\n\tkpi\n\t\tstatusGraphic: Three Traffic Lights\n\t\ttargetExpression = [B]\n\t\tstatusExpression = IF(1, 1, -1)\n\t\ttrendExpression = [T]\n",
"measure",
);
let measure = map_measure(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert!(measure.is_hidden);
assert_eq!(measure.expression, "VAR p = 1\nRETURN p");
assert_eq!(measure.format_string_expression.as_deref(), Some("\"0%\""));
assert_eq!(measure.detail_rows_expression.as_deref(), Some("DR"));
let kpi = measure.kpi.expect("kpi mapped");
assert_eq!(kpi.target_expression.as_deref(), Some("[B]"));
assert_eq!(kpi.status_expression.as_deref(), Some("IF(1, 1, -1)"));
assert_eq!(kpi.trend_expression.as_deref(), Some("[T]"));
}
#[test]
fn notices_the_invalid_short_kpi_spellings() {
let mut skips = Vec::new();
let node = map_one("measure M = 1\n\tkpi\n\t\ttarget = [B]\n", "measure");
let measure = map_measure(&node, Path::new("t"), &mut skips);
let kpi = measure.kpi.expect("kpi block still maps");
assert_eq!(kpi.target_expression, None);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::UnknownProperty);
}
#[test]
fn notices_a_measure_without_an_expression() {
let mut skips = Vec::new();
let node = map_one("measure M\n", "measure");
let measure = map_measure(&node, Path::new("t"), &mut skips);
assert_eq!(measure.expression, "");
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::MalformedValue);
}
#[test]
fn maps_calculated_columns_sort_and_group_by() {
let mut skips = Vec::new();
let node = map_one(
"column 'Margin %' = DIVIDE([Sales Amount], 10)\n\tsortByColumn: 'Sales Amount'\n\trelatedColumnDetails\n\t\tgroupByColumn: 'A'\n\t\tgroupByColumn: B\n",
"column",
);
let column = map_column(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert_eq!(
column.kind,
ColumnKind::Calculated {
expression: "DIVIDE([Sales Amount], 10)".to_string()
}
);
assert_eq!(column.sort_by_column.as_deref(), Some("Sales Amount"));
assert_eq!(column.group_by_columns, ["A", "B"]);
}
#[test]
fn maps_hierarchies_with_quoted_level_columns() {
let mut skips = Vec::new();
let node = map_one(
"hierarchy Fiscal\n\tlevel 'Month Name'\n\t\tcolumn: 'Mth of year'\n\tlevel Year\n\t\tcolumn: YearNum\n",
"hierarchy",
);
let hierarchy = map_hierarchy(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert_eq!(
hierarchy.levels,
[
HierarchyLevel {
name: "Month Name".to_string(),
column: "Mth of year".to_string(),
},
HierarchyLevel {
name: "Year".to_string(),
column: "YearNum".to_string(),
}
]
);
}
#[test]
fn notices_a_level_without_a_column() {
let mut skips = Vec::new();
let node = map_one("hierarchy H\n\tlevel Year\n", "hierarchy");
let hierarchy = map_hierarchy(&node, Path::new("t"), &mut skips);
assert_eq!(hierarchy.levels[0].column, "");
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::MalformedValue);
}
#[test]
fn maps_calendar_column_groups() {
let mut skips = Vec::new();
let node = map_one(
"calendar 'Fiscal Calendar'\n\tlineageTag: x\n\tcalendarColumnGroup\n\t\tcolumn: Year\n\t\tcolumn: 'Month Name'\n",
"calendar",
);
let calendar = map_calendar(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:#?}");
assert_eq!(calendar.name, "Fiscal Calendar");
assert_eq!(calendar.columns, ["Year", "Month Name"]);
}
#[test]
fn maps_a_time_unit_association_group() {
let mut skips = Vec::new();
let node = map_one(
"calendar 'Fiscal Calendar'\n\tcalendarColumnGroup = month\n\t\tprimaryColumn: Month Num\n\t\tassociatedColumn: 'Month Name'\n",
"calendar",
);
let calendar = map_calendar(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "{skips:#?}");
assert_eq!(calendar.columns, ["Month Num", "Month Name"]);
}
#[test]
fn notices_unknown_calendar_properties_without_losing_the_group() {
let mut skips = Vec::new();
let node = map_one(
"calendar C\n\tcalendarColumnGroup\n\t\tcolumn: Year\n\t\tmystery: 1\n\tcalendarColumnGroup\n\t\tcolumn: Month\n",
"calendar",
);
let calendar = map_calendar(&node, Path::new("t"), &mut skips);
assert_eq!(calendar.columns, ["Year", "Month"]);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::UnknownProperty);
}
#[rstest]
#[case::m("m", PartitionSource::M { expression: "let x = 1 in x".to_string() })]
#[case::calculated(
"calculated",
PartitionSource::Calculated { expression: "TOPN(10, T)".to_string() }
)]
#[case::query("query", PartitionSource::Query { query: "SELECT 1".to_string() })]
#[case::calculation_group(
"calculationGroup",
PartitionSource::Other { kind: Some("calculationGroup".to_string()) }
)]
#[case::entity(
"entity",
PartitionSource::Other { kind: Some("entity".to_string()) }
)]
fn maps_partition_kinds(#[case] kind: &str, #[case] expected: PartitionSource) {
let mut skips = Vec::new();
let text = match kind {
"calculationGroup" | "entity" => format!("partition P = {kind}\n"),
_ => format!(
"partition P = {kind}\n\tmode: import\n\tsource = {}\n",
match kind {
"m" => "let x = 1 in x",
"calculated" => "TOPN(10, T)",
_ => "SELECT 1",
}
),
};
let node = map_one(&text, "partition");
let partition = map_partition(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert_eq!(partition.source, expected);
}
#[test]
fn notices_an_m_partition_without_a_source() {
let mut skips = Vec::new();
let node = map_one("partition P = m\n\tmode: import\n", "partition");
let partition = map_partition(&node, Path::new("t"), &mut skips);
assert_eq!(
partition.source,
PartitionSource::M {
expression: String::new()
}
);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::MalformedValue);
}
#[test]
fn maps_relationships_with_quoted_tables_and_defaults_active() {
let mut skips = Vec::new();
let node = map_one(
"relationship guid-1\n\tfromColumn: Sales.Key\n\ttoColumn: 'Sales Order'.Key\n",
"relationship",
);
let relationship = map_relationship(&node, Path::new("t"), &mut skips).expect("mapped");
assert!(skips.is_empty());
assert!(relationship.is_active);
assert_eq!(relationship.from_table, "Sales");
assert_eq!(relationship.to_table, "Sales Order");
assert_eq!(relationship.name.as_deref(), Some("guid-1"));
}
#[test]
fn maps_inactive_relationships_and_ignores_unmodeled_flags() {
let mut skips = Vec::new();
let node = map_one(
"relationship g\n\tisActive: false\n\tcrossFilteringBehavior: bothDirections\n\tfromColumn: A.X\n\ttoColumn: B.Y\n",
"relationship",
);
let relationship = map_relationship(&node, Path::new("t"), &mut skips).expect("mapped");
assert!(skips.is_empty());
assert!(!relationship.is_active);
}
#[test]
fn drops_a_relationship_with_missing_references() {
let mut skips = Vec::new();
let node = map_one("relationship g\n\tfromColumn: A.X\n", "relationship");
assert!(map_relationship(&node, Path::new("t"), &mut skips).is_none());
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::MalformedValue);
}
#[test]
fn notices_an_unparseable_is_active() {
let mut skips = Vec::new();
let node = map_one(
"relationship g\n\tisActive: maybe\n\tfromColumn: A.X\n\ttoColumn: B.Y\n",
"relationship",
);
let relationship = map_relationship(&node, Path::new("t"), &mut skips).expect("mapped");
assert!(
relationship.is_active,
"malformed value keeps the TOM default"
);
assert_eq!(skips.len(), 1);
assert_eq!(skips[0].kind, SkipKind::MalformedValue);
}
#[test]
fn maps_roles_with_block_and_metadata_only_permissions() {
let mut skips = Vec::new();
let node = map_one(
"role Admin\n\tmodelPermission: read\n\ttablePermission Sales =\n\t\tFILTER('Sales', [Amount] > 0)\n\ttablePermission 'Sales Order'\n",
"role",
);
let role = map_role(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert_eq!(role.name, "Admin");
assert_eq!(
role.table_permissions,
[
TablePermission {
table: "Sales".to_string(),
filter_expression: Some("FILTER('Sales', [Amount] > 0)".to_string()),
},
TablePermission {
table: "Sales Order".to_string(),
filter_expression: None,
},
]
);
}
#[test]
fn maps_calculation_groups_with_selection_expressions() {
let mut skips = Vec::new();
let node = map_one(
"calculationGroup\n\tprecedence: 100\n\tcalculationItem Current = SELECTEDMEASURE()\n\tcalculationItem 'YoY %' = 1\n\t\tformatStringDefinition = \"0%\"\n\tnoSelectionExpression = SELECTEDMEASURE()\n\t\tformatStringDefinition = F()\n\tmultipleOrEmptySelectionExpression = ERROR(\"x\")\n\t\tformatStringDefinition = \"G\"\n",
"calculationGroup",
);
let group = map_calculation_group(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty());
assert_eq!(group.items.len(), 2);
assert_eq!(
group.items[1].format_string_expression.as_deref(),
Some("\"0%\"")
);
assert_eq!(
group.no_selection_expression.as_deref(),
Some("SELECTEDMEASURE()")
);
assert_eq!(
group.no_selection_format_string_expression.as_deref(),
Some("F()")
);
assert_eq!(
group.multiple_or_empty_selection_expression.as_deref(),
Some("ERROR(\"x\")")
);
assert_eq!(
group
.multiple_or_empty_selection_format_string_expression
.as_deref(),
Some("\"G\"")
);
}
}
mod ignore_list {
use super::*;
#[test]
fn silences_a_column_carrying_all_universal_metadata() {
let mut skips = Vec::new();
let text = "column C\n".to_string()
+ &[
"dataType",
"formatString",
"summarizeBy",
"sourceColumn",
"dataCategory",
"isKey",
"isNameInferred",
"isDataTypeInferred",
"isUnique",
"isDefaultLabel",
"isDefaultImage",
"isAvailableInMdx",
"keepUniqueRows",
"lineageTag",
"tableDetailPosition",
]
.iter()
.map(|key| format!("\t{key}: x\n"))
.collect::<String>()
+ "\tvariation V\n\t\tdefaultHierarchy: H\n\t\tisDefault\n\tshowAsVariationsOnly\n";
let node = map_one(&text, "column");
let column = map_column(&node, Path::new("t"), &mut skips);
assert!(skips.is_empty(), "metadata keys must be silent: {skips:?}");
assert!(key_at(&node, "dataType").value == NodeValue::Inline("x".to_string()));
let variation = &column.variations[0];
assert!(variation.is_default);
assert_eq!(
variation
.default_hierarchy
.as_ref()
.map(|h| h.hierarchy.as_str()),
Some("H")
);
}
}
}