ripbi_core/dax.rs
1//! DAX lexing and reference resolution.
2//!
3//! Tokenizes the DAX expressions carried by the AST (measures, calculated
4//! columns and tables, RLS filters, calculation items, user-defined functions)
5//! and extracts the object references they contain — enough to build the
6//! dependency graph, not a full parse tree.
7//!
8//! The tokenizer is a Rust port of SQLBI Whiteboard's `DaxLexer`
9//! (<https://github.com/sql-bi/SQLBI-Whiteboard>), © SQLBI, MIT licensed — thank
10//! you for battle-testing the fiddly corners of DAX lexing (doubled-delimiter
11//! escapes, dot-absorbing identifiers, the `Sales[E]` exponent trap). Reduced
12//! here to what reference extraction needs: no case normalization, no comment
13//! attachment, no formatter. Everything `DaxPrinter`-shaped upstream was
14//! deliberately not ported, and definition-name extraction (`DefinedObjectName`,
15//! `IsQuery`) is unnecessary because ingestion already knows each expression's
16//! owner.
17//!
18//! The design rule is **conservative**: zero false positives. Anything the lexer
19//! cannot prove is treated as a use — an unqualified `[Name]` keeps every
20//! candidate alive, a bare identifier is a candidate table, and a call is a
21//! candidate user-defined function. Over-marking costs precision; under-marking
22//! deletes live code. Resolution failures are data, never errors: the graph
23//! layer decides what an unresolvable reference means.
24//!
25//! ```
26//! use ripbi_core::dax;
27//!
28//! // Lexing is purely syntactic: no model needed.
29//! let refs = dax::references("SUM('Sales Header'[Net Price])");
30//! assert_eq!(refs.len(), 2); // the SUM call candidate + the field reference
31//!
32//! // ...but materializing a FieldRef and resolving against a model is one call.
33//! let field = refs[1].to_field_ref().expect("a field reference");
34//! assert_eq!(field.to_string(), "'Sales Header'[Net Price]");
35//! ```
36
37pub mod lexer;
38pub mod refs;
39
40pub use lexer::{Token, TokenKind, tokenize};
41pub use refs::{RawRef, quoted_names, references, unescape_name};
42
43use crate::identity::{NameKey, ObjectId};
44use crate::model::TabularDatabase;
45use crate::model::index::{ModelIndex, Resolved};
46
47/// What one [`RawRef`] bound to in the model — the graph-ready outcome of
48/// resolution, as data.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Binding<'a> {
51 /// The reference matched at least one model object. `targets` holds the
52 /// graph-node identities of **every** candidate: an unqualified `[Name]`
53 /// can bind a measure and a home-table column at once, because a lexer
54 /// cannot tell row context from filter context.
55 Bound {
56 /// The reference as written.
57 raw: RawRef<'a>,
58 /// The objects the reference keeps alive.
59 targets: Vec<ObjectId>,
60 },
61 /// The reference matched nothing — a stale expression, a typo, a renamed
62 /// object, or a built-in function. Never an error: the graph layer decides
63 /// what an unresolvable reference means (typically: ignore).
64 Unresolved {
65 /// The reference as written, for diagnostics.
66 raw: RawRef<'a>,
67 },
68}
69
70impl Binding<'_> {
71 /// The bound objects, empty for an unresolved reference.
72 #[must_use]
73 pub fn targets(&self) -> &[ObjectId] {
74 match self {
75 Binding::Bound { targets, .. } => targets,
76 Binding::Unresolved { .. } => &[],
77 }
78 }
79
80 /// True when the reference matched nothing.
81 #[must_use]
82 pub fn is_unresolved(&self) -> bool {
83 matches!(self, Binding::Unresolved { .. })
84 }
85}
86
87/// Resolves one extracted reference against the model.
88///
89/// `home_table` is the row-context table of the expression the reference was
90/// found in — [`DaxExpressionRef::home_table`](crate::DaxExpressionRef) carries
91/// it. Resolution rules live in [`ModelIndex`]; this wrapper only turns its
92/// answers into graph-ready data:
93///
94/// - qualified `'Table'[Name]` → the table's column, falling back to the
95/// model-global measure (`resolve_qualified`);
96/// - unqualified `[Name]` → the measure **and** the home-table column, both
97/// kept alive (`resolve_unqualified`);
98/// - bare table → the table (`resolve_table`);
99/// - a call whose name is a user-defined function → that function
100/// (`resolve_function`); built-ins resolve to nothing and stay unresolved.
101///
102/// ```
103/// use ripbi_core::{Column, Measure, ModelIndex, Table, TabularDatabase, dax};
104///
105/// let db = TabularDatabase {
106/// tables: vec![
107/// Table {
108/// name: "Sales".to_string(),
109/// measures: vec![Measure {
110/// name: "Antal".to_string(),
111/// expression: "0".to_string(),
112/// ..Default::default()
113/// }],
114/// ..Default::default()
115/// },
116/// Table {
117/// name: "Dato".to_string(),
118/// columns: vec![Column {
119/// name: "Antal".to_string(),
120/// ..Default::default()
121/// }],
122/// ..Default::default()
123/// },
124/// ],
125/// ..Default::default()
126/// };
127/// let index = ModelIndex::build(&db);
128///
129/// // `[Antal]` in a `Dato` row context is ambiguous: measure and column both.
130/// let raw = dax::references("[Antal]").remove(0);
131/// let binding = dax::bind(&db, &index, Some("Dato"), raw);
132/// assert_eq!(binding.targets().len(), 2);
133///
134/// // An unknown name is data, not an error.
135/// let raw = dax::references("[Ukendt]").remove(0);
136/// assert!(dax::bind(&db, &index, Some("Dato"), raw).is_unresolved());
137/// ```
138#[must_use]
139pub fn bind<'a>(
140 db: &TabularDatabase,
141 index: &ModelIndex,
142 home_table: Option<&str>,
143 raw: RawRef<'a>,
144) -> Binding<'a> {
145 let mut targets = Vec::new();
146 match raw {
147 RawRef::Field {
148 table: Some(table),
149 name,
150 ..
151 } => {
152 // Resolution is by logical name: `''` escapes inside quoted names are
153 // stripped first, or `'It''s'[X]` could never find the table `It's`.
154 let table = unescape_name(table);
155 let name = unescape_name(name);
156 if let Some(id) = index
157 .resolve_qualified(&table, &name)
158 .and_then(|r| db.object_id(r))
159 {
160 targets.push(id);
161 }
162 }
163 RawRef::Field {
164 table: None, name, ..
165 } => {
166 let name = unescape_name(name);
167 let matches = index.resolve_unqualified(&name, home_table);
168 for resolved in [
169 matches.measure.map(Resolved::Measure),
170 matches.column.map(Resolved::Column),
171 ] {
172 if let Some(id) = resolved.and_then(|r| db.object_id(r)) {
173 targets.push(id);
174 }
175 }
176 }
177 RawRef::Table { name, .. } => {
178 let name = unescape_name(name);
179 if let Some(table) = index.resolve_table(&name).and_then(|h| db.table(h)) {
180 targets.push(ObjectId::Table {
181 table: NameKey::new(table.name.as_str()),
182 });
183 }
184 }
185 RawRef::Function { name, .. } => {
186 if let Some(function) = index.resolve_function(name).and_then(|h| db.function(h)) {
187 targets.push(ObjectId::Function {
188 name: NameKey::new(function.name.as_str()),
189 });
190 }
191 }
192 }
193
194 if targets.is_empty() {
195 Binding::Unresolved { raw }
196 } else {
197 Binding::Bound { raw, targets }
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use crate::identity::FieldRef;
205 use crate::model::{Column, Function, Measure, Table};
206
207 /// Fixture with hand-checked positions:
208 ///
209 /// ```text
210 /// table 0 "Sales" columns 0 "Amount" 1 "Beløb" measures 0 "Total Sales" 1 "Antal"
211 /// table 1 "Dato" columns 0 "Måned" 1 "Antal"
212 /// functions 0 "MyFunc"
213 /// ```
214 ///
215 /// "Antal" is deliberately both a measure and a column: that is the
216 /// ambiguity the zero-false-positive rule exists for.
217 fn model() -> TabularDatabase {
218 let column = |name: &str| Column {
219 name: name.to_string(),
220 ..Default::default()
221 };
222 let measure = |name: &str| Measure {
223 name: name.to_string(),
224 expression: "0".to_string(),
225 ..Default::default()
226 };
227 TabularDatabase {
228 tables: vec![
229 Table {
230 name: "Sales".to_string(),
231 columns: vec![column("Amount"), column("Beløb")],
232 measures: vec![measure("Total Sales"), measure("Antal")],
233 ..Default::default()
234 },
235 Table {
236 name: "Dato".to_string(),
237 columns: vec![column("Måned"), column("Antal")],
238 ..Default::default()
239 },
240 ],
241 functions: vec![Function {
242 name: "MyFunc".to_string(),
243 expression: "1".to_string(),
244 is_hidden: false,
245 }],
246 ..Default::default()
247 }
248 }
249
250 /// Binds every reference in `expression` and returns their targets.
251 fn bound<'a>(expression: &'a str, home_table: Option<&str>) -> Vec<Binding<'a>> {
252 let db = model();
253 let index = ModelIndex::build(&db);
254 references(expression)
255 .into_iter()
256 .map(|raw| bind(&db, &index, home_table, raw))
257 .collect()
258 }
259
260 #[test]
261 fn a_qualified_reference_resolves_to_the_column_first() {
262 let bindings = bound("'Sales'[Amount]", None);
263 assert_eq!(bindings.len(), 1);
264 assert_eq!(
265 bindings[0].targets(),
266 [ObjectId::Column {
267 table: NameKey::new("Sales"),
268 column: NameKey::new("Amount"),
269 }]
270 );
271 }
272
273 #[test]
274 fn a_stale_qualifier_still_keeps_the_measure_alive() {
275 // Measure names are model-global: a wrong or renamed table prefix must
276 // not orphan them.
277 let bindings = bound("'No Such Table'[TOTAL SALES]", None);
278 assert_eq!(
279 bindings[0].targets(),
280 [ObjectId::Measure {
281 table: NameKey::new("Sales"),
282 measure: NameKey::new("Total Sales"),
283 }]
284 );
285 }
286
287 #[test]
288 fn an_ambiguous_unqualified_reference_keeps_every_candidate_alive() {
289 // "Antal" is a measure on Sales and a column on Dato; inside a Dato row
290 // context both are live.
291 let bindings = bound("[Antal]", Some("Dato"));
292 assert_eq!(bindings[0].targets().len(), 2);
293 assert!(bindings[0].targets().contains(&ObjectId::Measure {
294 table: NameKey::new("Sales"),
295 measure: NameKey::new("Antal"),
296 }));
297 assert!(bindings[0].targets().contains(&ObjectId::Column {
298 table: NameKey::new("Dato"),
299 column: NameKey::new("Antal"),
300 }));
301
302 // With no row context there is no column candidate to consider.
303 let bindings = bound("[Antal]", None);
304 assert_eq!(bindings[0].targets().len(), 1);
305 }
306
307 #[test]
308 fn resolution_is_case_insensitive_end_to_end() {
309 let bindings = bound("[total sales]", Some("dato"));
310 assert_eq!(
311 bindings[0].targets(),
312 [ObjectId::Measure {
313 table: NameKey::new("Sales"),
314 measure: NameKey::new("Total Sales"),
315 }]
316 );
317 }
318
319 #[test]
320 fn an_unknown_name_binds_to_nothing_and_stays_data() {
321 let bindings = bound("[Ukendt] + 'X'[Y]", Some("Sales"));
322 assert_eq!(bindings.len(), 2);
323 assert!(bindings.iter().all(Binding::is_unresolved));
324 assert!(bindings.iter().all(|b| b.targets().is_empty()));
325 }
326
327 #[test]
328 fn a_bare_table_use_resolves_to_the_table() {
329 // Refs: two COUNTROWS calls (unresolved built-ins), 'Sales' (a table
330 // use), and Missing (a table use that matches nothing).
331 let bindings = bound("COUNTROWS('Sales') + COUNTROWS(Missing)", Some("Sales"));
332 assert_eq!(bindings.len(), 4);
333 assert!(
334 bindings[0].is_unresolved(),
335 "COUNTROWS is not a model function"
336 );
337 assert_eq!(
338 bindings[1].targets(),
339 [ObjectId::Table {
340 table: NameKey::new("Sales"),
341 }]
342 );
343 assert!(bindings[2].is_unresolved());
344 assert!(bindings[3].is_unresolved());
345 }
346
347 #[test]
348 fn a_user_defined_function_resolves_and_built_ins_do_not() {
349 // Refs: MyFunc(, [Amount], SUM(, [Amount].
350 let bindings = bound("MyFunc([Amount]) + SUM([Amount])", Some("Sales"));
351 assert_eq!(bindings.len(), 4);
352 assert_eq!(
353 bindings[0].targets(),
354 [ObjectId::Function {
355 name: NameKey::new("MyFunc"),
356 }]
357 );
358 assert_eq!(
359 bindings[1].targets(),
360 [ObjectId::Column {
361 table: NameKey::new("Sales"),
362 column: NameKey::new("Amount"),
363 }]
364 );
365 assert!(
366 bindings[2].is_unresolved(),
367 "built-in SUM matches no model function"
368 );
369 assert!(!bindings[3].is_unresolved());
370 }
371
372 #[test]
373 fn materialized_field_refs_display_valid_dax() {
374 let db = model();
375 let index = ModelIndex::build(&db);
376 let raw = references("'It''s'[X]").remove(0);
377 let field = raw.to_field_ref().expect("a field reference");
378 assert_eq!(
379 field,
380 FieldRef {
381 table: Some(NameKey::new("It's")),
382 name: NameKey::new("X"),
383 }
384 );
385 // Display re-applies the escaping the lexer stripped.
386 assert_eq!(field.to_string(), "'It''s'[X]");
387 // Unresolvable here — the fixture has no such table — but well-formed.
388 assert!(bind(&db, &index, None, raw).is_unresolved());
389 }
390
391 /// Escaped quotes must be stripped before resolution, or a reference to a
392 /// table actually named `It's` can never bind.
393 #[test]
394 fn an_escaped_qualifier_resolves_to_the_escaped_name() {
395 let db = TabularDatabase {
396 tables: vec![Table {
397 name: "It's".to_string(),
398 columns: vec![Column {
399 name: "X".to_string(),
400 ..Default::default()
401 }],
402 ..Default::default()
403 }],
404 ..Default::default()
405 };
406 let index = ModelIndex::build(&db);
407
408 let raw = references("'It''s'[X]").remove(0);
409 assert_eq!(
410 bind(&db, &index, None, raw).targets(),
411 [ObjectId::Column {
412 table: NameKey::new("It's"),
413 column: NameKey::new("X"),
414 }]
415 );
416
417 // The bare table use escapes too.
418 let raw = references("COUNTROWS('It''s')").remove(1);
419 assert_eq!(
420 bind(&db, &index, None, raw).targets(),
421 [ObjectId::Table {
422 table: NameKey::new("It's"),
423 }]
424 );
425 }
426
427 #[test]
428 fn the_tricky_sample_binds_only_its_real_reference() {
429 // Ported from the SQLBI smoke tests: variables and the definition name
430 // resolve to nothing in a model without such tables.
431 let source = concat!(
432 "Tricky :=\n",
433 "-- a real comment\n",
434 "VAR Year = 2024\n",
435 "VAR Note = \"-- not a comment\"\n",
436 "RETURN Year & Note & Sales[Amount]\n",
437 );
438 let bindings = bound(source, Some("Sales"));
439 assert_eq!(
440 bindings.len(),
441 9,
442 "every bare word is a conservative candidate"
443 );
444 assert_eq!(
445 bindings.iter().filter(|b| !b.is_unresolved()).count(),
446 1,
447 "only Sales[Amount] resolves"
448 );
449 assert_eq!(bindings.last().expect("field ref last").targets().len(), 1);
450 }
451}