Skip to main content

ripbi_core/
m.rs

1//! M lexing and reference resolution.
2//!
3//! Tokenizes the Power Query (M) expressions carried by the AST — table
4//! partitions and model-level shared expressions — and extracts the object
5//! references they contain: enough to build the dependency graph, not a full
6//! parse tree. See [`crate::m::lexer`] for the tokenizer and
7//! [`crate::m::refs`] for the extraction rules.
8//!
9//! The design rule is the same **conservatism** as the DAX side: zero false
10//! negatives. Anything the extractor cannot rule out is treated as a reference
11//! — an unqualified `[Name]` or a harvested column string names **every**
12//! column of that name in the model, because M string arguments carry no row
13//! context a lexer could consult. Over-marking costs precision; under-marking
14//! deletes live code. Resolution failures are data, never errors: the graph
15//! layer decides what an unresolvable reference means.
16//!
17//! What a reference is *worth* is the graph layer's call, and it is not
18//! uniform: Power Query reads the source and produces the columns the model
19//! maps onto, so a column named in M is the column's **supply chain**, not a
20//! consumer — unloading it cannot break refresh. A table or shared expression
21//! named in M is different: deleting it deletes the query this expression
22//! reads or joins, which breaks refresh. [`crate::graph`] encodes that split.
23//!
24//! ```
25//! use ripbi_core::{Column, ModelIndex, Table, TabularDatabase, m};
26//!
27//! let db = TabularDatabase {
28//!     tables: vec![Table {
29//!         name: "Sales".to_string(),
30//!         columns: vec![
31//!             Column { name: "Amount".to_string(), ..Default::default() },
32//!             Column { name: "Region".to_string(), ..Default::default() },
33//!         ],
34//!         ..Default::default()
35//!     }],
36//!     ..Default::default()
37//! };
38//! let index = ModelIndex::build(&db);
39//!
40//! // The expanded column name resolves to the model column.
41//! let mut refs = m::references(r#"Table.ExpandTableColumn(Source, "Amount")"#);
42//! let binding = m::bind(&db, &index, refs.remove(0));
43//! assert_eq!(binding.targets().len(), 1);
44//!
45//! // An unknown name is data, not an error.
46//! let refs = m::references(r#"Table.SelectColumns(Source, {"Gone"})"#);
47//! assert!(m::bind(&db, &index, refs.into_iter().next().unwrap()).is_unresolved());
48//! ```
49
50pub mod lexer;
51pub mod refs;
52
53pub use lexer::{Token, TokenKind, tokenize};
54pub use refs::{RawRef, references, unescape_name};
55
56use crate::identity::{NameKey, ObjectId};
57use crate::model::TabularDatabase;
58use crate::model::index::{ModelIndex, Resolved};
59
60/// What one [`RawRef`] bound to in the model — the graph-ready outcome of
61/// resolution, as data.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub enum Binding<'a> {
64    /// The reference matched at least one model object. `targets` holds the
65    /// graph-node identities of **every** candidate: an unqualified `[Name]`
66    /// or a column string names every column of that name, because M has no
67    /// row context the lexer could resolve the name against. Naming is not
68    /// keeping alive — what a target is worth (liveness vs supply-chain
69    /// context) is [`crate::graph`]'s decision.
70    Bound {
71        /// The reference as written.
72        raw: RawRef<'a>,
73        /// The objects the reference names.
74        targets: Vec<ObjectId>,
75    },
76    /// The reference matched nothing — a stale expression, a typo, a renamed
77    /// object, or a built-in's non-column argument. Never an error: the graph
78    /// layer decides what an unresolvable reference means (typically: ignore).
79    Unresolved {
80        /// The reference as written, for diagnostics.
81        raw: RawRef<'a>,
82    },
83}
84
85impl Binding<'_> {
86    /// The bound objects, empty for an unresolved reference.
87    #[must_use]
88    pub fn targets(&self) -> &[ObjectId] {
89        match self {
90            Binding::Bound { targets, .. } => targets,
91            Binding::Unresolved { .. } => &[],
92        }
93    }
94
95    /// True when the reference matched nothing.
96    #[must_use]
97    pub fn is_unresolved(&self) -> bool {
98        matches!(self, Binding::Unresolved { .. })
99    }
100}
101
102/// Resolves every reference in one M expression against the model.
103///
104/// The convenience the graph builder uses: [`references`] plus [`bind`] for
105/// each, preserving source order.
106///
107/// ```
108/// use ripbi_core::{Column, ModelIndex, Table, TabularDatabase, m};
109///
110/// let db = TabularDatabase {
111///     tables: vec![Table {
112///         name: "Sales".to_string(),
113///         columns: vec![Column { name: "Amount".to_string(), ..Default::default() }],
114///         ..Default::default()
115///     }],
116///     ..Default::default()
117/// };
118/// let index = ModelIndex::build(&db);
119///
120/// let bindings = m::bindings(&db, &index, r#"Table.SelectColumns(Source, {"Amount"})"#);
121/// assert_eq!(bindings[0].targets().len(), 1);
122/// assert!(bindings[1].is_unresolved(), "`Source` is a variable, not a table");
123/// ```
124#[must_use]
125pub fn bindings<'a>(db: &TabularDatabase, index: &ModelIndex, text: &'a str) -> Vec<Binding<'a>> {
126    references(text)
127        .into_iter()
128        .map(|raw| bind(db, index, raw))
129        .collect()
130}
131
132/// Resolves one extracted reference against the model.
133///
134/// Resolution rules — deliberately narrower than the DAX side where M's
135/// semantics differ:
136///
137/// - qualified `#"Table"[Name]` → that table **and** its column — reading a
138///   field from `Table` requires the `Table` query to exist at refresh — but
139///   **never** a measure fallback: an M expression cannot reference a measure;
140/// - unqualified `[Name]` and a harvested column string → **every** column of
141///   that name in the model: M string arguments carry no row context, so the
142///   conservative direction is to name all candidates;
143/// - a bare or quoted name → the table and/or shared expression of that name
144///   (most bare words are `let` variables and resolve to nothing — data).
145///
146/// ```
147/// use ripbi_core::{Column, Measure, ModelIndex, Table, TabularDatabase, m};
148///
149/// let db = TabularDatabase {
150///     tables: vec![
151///         Table {
152///             name: "Sales".to_string(),
153///             columns: vec![Column { name: "Antal".to_string(), ..Default::default() }],
154///             measures: vec![Measure { name: "Antal".to_string(), expression: "0".to_string(), ..Default::default() }],
155///             ..Default::default()
156///         },
157///         Table {
158///             name: "Dato".to_string(),
159///             columns: vec![Column { name: "Antal".to_string(), ..Default::default() }],
160///             ..Default::default()
161///         },
162///     ],
163///     ..Default::default()
164/// };
165/// let index = ModelIndex::build(&db);
166///
167/// // A qualified name names the table and its column — no measure fallback.
168/// let raw = m::references(r#"#"Sales"[Antal]"#).remove(0);
169/// assert_eq!(m::bind(&db, &index, raw).targets().len(), 2);
170///
171/// // An unqualified name keeps every candidate alive, on every table.
172/// let raw = m::references("[Antal]").remove(0);
173/// assert_eq!(m::bind(&db, &index, raw).targets().len(), 2);
174/// ```
175#[must_use]
176pub fn bind<'a>(db: &TabularDatabase, index: &ModelIndex, raw: RawRef<'a>) -> Binding<'a> {
177    let mut targets = Vec::new();
178    match &raw {
179        RawRef::Field {
180            table: Some(table),
181            name,
182            ..
183        } => {
184            // Resolution is by logical name: `#"` wrappers and `""` escapes
185            // are stripped first, or `#"It""s"[X]` could never find the
186            // table `It"s`.
187            let table = unescape_name(table);
188            let name = unescape_name(name);
189            // The qualifier names a query this expression reads from: the
190            // table node travels with the column so the graph layer can keep
191            // the table alive (deleting it deletes the query).
192            if let Some(handle) = index.resolve_table(&table)
193                && let Some(resolved) = db.table(handle)
194            {
195                targets.push(ObjectId::Table {
196                    table: NameKey::new(resolved.name.as_str()),
197                });
198            }
199            if let Some(handle) = index.resolve_column(&table, &name)
200                && let Some(id) = db.object_id(Resolved::Column(handle))
201            {
202                targets.push(id);
203            }
204        }
205        RawRef::Field {
206            table: None, name, ..
207        }
208        | RawRef::ColumnString { name, .. } => {
209            let name = unescape_name(name);
210            for handle in index.resolve_columns(&name) {
211                if let Some(id) = db.object_id(Resolved::Column(handle)) {
212                    targets.push(id);
213                }
214            }
215        }
216        RawRef::Name { name, .. } => {
217            let name = unescape_name(name);
218            if let Some(handle) = index.resolve_table(&name)
219                && let Some(table) = db.table(handle)
220            {
221                targets.push(ObjectId::Table {
222                    table: NameKey::new(table.name.as_str()),
223                });
224            }
225            if let Some(handle) = index.resolve_expression(&name)
226                && let Some(expression) = db.shared_expression(handle)
227            {
228                targets.push(ObjectId::Expression {
229                    name: NameKey::new(expression.name.as_str()),
230                });
231            }
232        }
233    }
234
235    if targets.is_empty() {
236        Binding::Unresolved { raw }
237    } else {
238        Binding::Bound { raw, targets }
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use crate::model::{Column, Measure, SharedExpression, Table};
246
247    /// Fixture with hand-checked positions:
248    ///
249    /// ```text
250    /// table 0  "Sales"  columns 0 "Amount" 1 "Antal"   measures 0 "Total Sales" 1 "Antal"
251    /// table 1  "Dato"   columns 0 "Måned" 1 "Antal"
252    /// expressions 0 "ServerName"
253    /// ```
254    ///
255    /// "Antal" is deliberately both a measure and two tables' column: that is
256    /// the ambiguity the zero-false-negative rule exists for.
257    fn model() -> TabularDatabase {
258        let column = |name: &str| Column {
259            name: name.to_string(),
260            ..Default::default()
261        };
262        let measure = |name: &str| Measure {
263            name: name.to_string(),
264            expression: "0".to_string(),
265            ..Default::default()
266        };
267        TabularDatabase {
268            tables: vec![
269                Table {
270                    name: "Sales".to_string(),
271                    columns: vec![column("Amount"), column("Antal")],
272                    measures: vec![measure("Total Sales"), measure("Antal")],
273                    ..Default::default()
274                },
275                Table {
276                    name: "Dato".to_string(),
277                    columns: vec![column("Måned"), column("Antal")],
278                    ..Default::default()
279                },
280            ],
281            expressions: vec![SharedExpression {
282                name: "ServerName".to_string(),
283                expression: "\"localhost\"".to_string(),
284            }],
285            ..Default::default()
286        }
287    }
288
289    /// Binds every reference in `expression` and flattens their targets.
290    fn targets_of(expression: &str) -> Vec<ObjectId> {
291        let db = model();
292        let index = ModelIndex::build(&db);
293        bindings(&db, &index, expression)
294            .iter()
295            .flat_map(|binding| binding.targets().iter().cloned())
296            .collect()
297    }
298
299    #[test]
300    fn a_qualified_reference_names_the_table_and_the_column() {
301        // `Antal` is also a Sales measure. DAX's binder keeps the measure
302        // alive on a stale qualifier; M cannot reference measures at all, so
303        // the qualified form names exactly the table plus its column.
304        let targets = targets_of(r#"#"Sales"[Antal]"#);
305        assert_eq!(
306            targets,
307            [
308                ObjectId::Table {
309                    table: NameKey::new("Sales"),
310                },
311                ObjectId::Column {
312                    table: NameKey::new("Sales"),
313                    column: NameKey::new("Antal"),
314                },
315            ]
316        );
317
318        // The same name unqualified names both tables' columns — and still no
319        // measure, and no table: `[Antal]` does not say which query it reads.
320        let targets = targets_of("[Antal]");
321        assert_eq!(targets.len(), 2);
322        assert!(
323            !targets
324                .iter()
325                .any(|t| matches!(t, ObjectId::Measure { .. } | ObjectId::Table { .. }))
326        );
327    }
328
329    #[test]
330    fn a_column_string_keeps_every_column_of_that_name_alive() {
331        // M string arguments carry no row context, so every candidate counts.
332        let targets = targets_of(r#"Table.SelectColumns(Source, {"Antal"})"#);
333        assert_eq!(targets.len(), 2);
334
335        let targets = targets_of(r#"Table.ExpandTableColumn(Source, "Amount")"#);
336        assert_eq!(
337            targets,
338            [ObjectId::Column {
339                table: NameKey::new("Sales"),
340                column: NameKey::new("Amount"),
341            }]
342        );
343    }
344
345    #[test]
346    fn a_name_resolves_to_the_table_or_shared_expression_of_that_name() {
347        let targets = targets_of("let Source = Sales in Source");
348        assert!(targets.contains(&ObjectId::Table {
349            table: NameKey::new("Sales"),
350        }));
351
352        let targets = targets_of("Sql.Database(ServerName)");
353        assert_eq!(
354            targets,
355            [ObjectId::Expression {
356                name: NameKey::new("ServerName"),
357            }]
358        );
359    }
360
361    #[test]
362    fn resolution_is_case_insensitive_end_to_end() {
363        let targets = targets_of(r#"#"SALES"[amount]"#);
364        assert_eq!(
365            targets,
366            [
367                ObjectId::Table {
368                    table: NameKey::new("Sales"),
369                },
370                ObjectId::Column {
371                    table: NameKey::new("Sales"),
372                    column: NameKey::new("Amount"),
373                },
374            ]
375        );
376    }
377
378    #[test]
379    fn an_escaped_qualifier_resolves_to_the_escaped_name() {
380        let db = TabularDatabase {
381            tables: vec![Table {
382                name: "It\"s".to_string(),
383                columns: vec![Column {
384                    name: "X".to_string(),
385                    ..Default::default()
386                }],
387                ..Default::default()
388            }],
389            ..Default::default()
390        };
391        let index = ModelIndex::build(&db);
392        let raw = references(r#"#"It""s"[X]"#).remove(0);
393        assert_eq!(
394            bind(&db, &index, raw).targets(),
395            [
396                ObjectId::Table {
397                    table: NameKey::new("It\"s"),
398                },
399                ObjectId::Column {
400                    table: NameKey::new("It\"s"),
401                    column: NameKey::new("X"),
402                },
403            ]
404        );
405    }
406
407    #[test]
408    fn an_unknown_name_binds_to_nothing_and_stays_data() {
409        let db = model();
410        let index = ModelIndex::build(&db);
411        let bindings_found = bindings(&db, &index, "let Source = Gone in Source[Gone]");
412        assert_eq!(bindings_found.len(), 3);
413        assert!(bindings_found.iter().all(Binding::is_unresolved));
414        assert!(bindings_found.iter().all(|b| b.targets().is_empty()));
415    }
416}