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}