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}