Skip to main content

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, 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}