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                ..Default::default()
285            }],
286            ..Default::default()
287        }
288    }
289
290    /// Binds every reference in `expression` and flattens their targets.
291    fn targets_of(expression: &str) -> Vec<ObjectId> {
292        let db = model();
293        let index = ModelIndex::build(&db);
294        bindings(&db, &index, expression)
295            .iter()
296            .flat_map(|binding| binding.targets().iter().cloned())
297            .collect()
298    }
299
300    #[test]
301    fn a_qualified_reference_names_the_table_and_the_column() {
302        // `Antal` is also a Sales measure. DAX's binder keeps the measure
303        // alive on a stale qualifier; M cannot reference measures at all, so
304        // the qualified form names exactly the table plus its column.
305        let targets = targets_of(r#"#"Sales"[Antal]"#);
306        assert_eq!(
307            targets,
308            [
309                ObjectId::Table {
310                    table: NameKey::new("Sales"),
311                },
312                ObjectId::Column {
313                    table: NameKey::new("Sales"),
314                    column: NameKey::new("Antal"),
315                },
316            ]
317        );
318
319        // The same name unqualified names both tables' columns — and still no
320        // measure, and no table: `[Antal]` does not say which query it reads.
321        let targets = targets_of("[Antal]");
322        assert_eq!(targets.len(), 2);
323        assert!(
324            !targets
325                .iter()
326                .any(|t| matches!(t, ObjectId::Measure { .. } | ObjectId::Table { .. }))
327        );
328    }
329
330    #[test]
331    fn a_column_string_keeps_every_column_of_that_name_alive() {
332        // M string arguments carry no row context, so every candidate counts.
333        let targets = targets_of(r#"Table.SelectColumns(Source, {"Antal"})"#);
334        assert_eq!(targets.len(), 2);
335
336        let targets = targets_of(r#"Table.ExpandTableColumn(Source, "Amount")"#);
337        assert_eq!(
338            targets,
339            [ObjectId::Column {
340                table: NameKey::new("Sales"),
341                column: NameKey::new("Amount"),
342            }]
343        );
344    }
345
346    #[test]
347    fn a_name_resolves_to_the_table_or_shared_expression_of_that_name() {
348        let targets = targets_of("let Source = Sales in Source");
349        assert!(targets.contains(&ObjectId::Table {
350            table: NameKey::new("Sales"),
351        }));
352
353        let targets = targets_of("Sql.Database(ServerName)");
354        assert_eq!(
355            targets,
356            [ObjectId::Expression {
357                name: NameKey::new("ServerName"),
358            }]
359        );
360    }
361
362    #[test]
363    fn resolution_is_case_insensitive_end_to_end() {
364        let targets = targets_of(r#"#"SALES"[amount]"#);
365        assert_eq!(
366            targets,
367            [
368                ObjectId::Table {
369                    table: NameKey::new("Sales"),
370                },
371                ObjectId::Column {
372                    table: NameKey::new("Sales"),
373                    column: NameKey::new("Amount"),
374                },
375            ]
376        );
377    }
378
379    #[test]
380    fn an_escaped_qualifier_resolves_to_the_escaped_name() {
381        let db = TabularDatabase {
382            tables: vec![Table {
383                name: "It\"s".to_string(),
384                columns: vec![Column {
385                    name: "X".to_string(),
386                    ..Default::default()
387                }],
388                ..Default::default()
389            }],
390            ..Default::default()
391        };
392        let index = ModelIndex::build(&db);
393        let raw = references(r#"#"It""s"[X]"#).remove(0);
394        assert_eq!(
395            bind(&db, &index, raw).targets(),
396            [
397                ObjectId::Table {
398                    table: NameKey::new("It\"s"),
399                },
400                ObjectId::Column {
401                    table: NameKey::new("It\"s"),
402                    column: NameKey::new("X"),
403                },
404            ]
405        );
406    }
407
408    #[test]
409    fn an_unknown_name_binds_to_nothing_and_stays_data() {
410        let db = model();
411        let index = ModelIndex::build(&db);
412        let bindings_found = bindings(&db, &index, "let Source = Gone in Source[Gone]");
413        assert_eq!(bindings_found.len(), 3);
414        assert!(bindings_found.iter().all(Binding::is_unresolved));
415        assert!(bindings_found.iter().all(|b| b.targets().is_empty()));
416    }
417}