Skip to main content

ripbi_core/dax/
refs.rs

1//! Reference extraction: adjacency rules over the DAX token stream.
2//!
3//! A reference in DAX is never a single token — `'Sales'[Amount]` is a quoted
4//! table followed by a bracketed name — so extraction walks the token stream and
5//! merges adjacent tokens by shape. The tokenizer emits no whitespace tokens, so
6//! "adjacent" means exactly "the next token in the stream".
7
8use std::borrow::Cow;
9use std::ops::Range;
10
11use super::lexer::{Token, TokenKind, tokenize};
12use crate::identity::{FieldRef, NameKey};
13
14/// One object reference found in an expression, exactly as written.
15///
16/// Names are raw source slices: delimiters are stripped, but quote escapes are
17/// left intact (`'It''s'` yields `"It''s"`). [`unescape_name`] and
18/// [`RawRef::to_field_ref`] produce the logical names that [`FieldRef`] and the
19/// rest of the identity layer expect.
20///
21/// Extraction is purely syntactic and knows nothing about any model: whether a
22/// bracketed name is a column or a measure, and whether an identifier names a
23/// real table or function at all, is decided by resolution, not here.
24#[derive(Debug, Clone, PartialEq, Eq, Hash)]
25pub enum RawRef<'a> {
26    /// A column or measure reference: `'Table'[Name]`, `Table[Name]`, or an
27    /// unqualified `[Name]`. `table` is `None` exactly when the reference was
28    /// written unqualified.
29    Field {
30        /// Qualifying table name as written, delimiters stripped.
31        table: Option<&'a str>,
32        /// The name inside the brackets, as written.
33        name: &'a str,
34        /// Byte range of the whole reference, qualifier included.
35        span: Range<usize>,
36    },
37    /// A bare table use: `COUNTROWS(Sales)`, `EVALUATE Sales`. Detected so that a
38    /// table referenced only this way is never reported unused.
39    Table {
40        /// The table name as written, delimiters stripped.
41        name: &'a str,
42        /// Byte range of the name.
43        span: Range<usize>,
44    },
45    /// A function call: an identifier followed by `(`. Whether this is a
46    /// user-defined function of the model (a real dependency) or a built-in
47    /// (`SUM`, `COUNTROWS`, …) is decided by resolution.
48    Function {
49        /// The function name as written.
50        name: &'a str,
51        /// Byte range of the name.
52        span: Range<usize>,
53    },
54}
55
56/// Every reference in a DAX expression, in source order.
57///
58/// Strings, comments, numbers, `dt"…"` literals, and `@parameters` never produce
59/// references; malformed input yields the references it can, never an error.
60///
61/// ```
62/// use ripbi_core::dax::RawRef;
63///
64/// let refs = ripbi_core::dax::references("DIVIDE([Sales Amount], 10)");
65/// // The DIVIDE call is a function candidate; [Sales Amount] is the field ref.
66/// assert_eq!(
67///     refs[1],
68///     RawRef::Field { table: None, name: "Sales Amount", span: 7..21 }
69/// );
70/// ```
71#[must_use]
72pub fn references(text: &str) -> Vec<RawRef<'_>> {
73    extract(&tokenize(text))
74}
75
76/// The content of every string literal in a DAX expression, escapes resolved —
77/// the names query-time extension columns are introduced by
78/// (`ADDCOLUMNS(t, "@Krav", …)`, `SELECTCOLUMNS(t, "Ordning", …)`, `ROW("X", …)`).
79/// Comments never reach the token stream, so their contents are excluded, and
80/// date literals (`dt"…"`) are dates, not names, so they are excluded too.
81///
82/// ```
83/// use ripbi_core::dax;
84///
85/// let names = dax::quoted_names("ADDCOLUMNS(t, \"@Krav\", [X]) -- \"gone\"");
86/// assert_eq!(names, ["@Krav"]);
87/// ```
88#[must_use]
89pub fn quoted_names(text: &str) -> Vec<Cow<'_, str>> {
90    tokenize(text)
91        .into_iter()
92        .filter(|token| token.kind == TokenKind::String)
93        .map(|token| string_inner(token.text))
94        .collect()
95}
96
97/// Strips the doubled-quote escapes from a name as written inside single quotes:
98/// `"It''s"` → `"It's"`. Borrows when there is nothing to unescape.
99///
100/// Bracketed names need no unescaping: `]` cannot appear in an Analysis
101/// Services object name, so a bracketed slice is already the logical name.
102#[must_use]
103pub fn unescape_name(name: &str) -> Cow<'_, str> {
104    if name.contains("''") {
105        Cow::Owned(name.replace("''", "'"))
106    } else {
107        Cow::Borrowed(name)
108    }
109}
110
111impl RawRef<'_> {
112    /// The logical [`FieldRef`] of a [`RawRef::Field`] — delimiters stripped and
113    /// quote escapes resolved, per the identity layer's producer contract.
114    /// `None` for [`RawRef::Table`] and [`RawRef::Function`], which name no
115    /// column or measure.
116    #[must_use]
117    pub fn to_field_ref(&self) -> Option<FieldRef> {
118        let RawRef::Field { table, name, .. } = self else {
119            return None;
120        };
121        Some(FieldRef {
122            table: table.map(|table| NameKey::new(unescape_name(table).as_ref())),
123            name: NameKey::new(unescape_name(name).as_ref()),
124        })
125    }
126}
127
128fn extract<'a>(tokens: &[Token<'a>]) -> Vec<RawRef<'a>> {
129    let mut out = Vec::new();
130    let mut index = 0usize;
131
132    while index < tokens.len() {
133        let token = &tokens[index];
134        match token.kind {
135            TokenKind::QuotedTable => {
136                let name = quoted_inner(token.text);
137                // 'Table'[Name] — the qualifier merges with the bracketed name.
138                if let Some(bracket) = bracket_at(tokens, index + 1) {
139                    out.push(RawRef::Field {
140                        table: Some(name),
141                        name: quoted_inner(bracket.text),
142                        span: token.start..bracket.end(),
143                    });
144                    index += 2;
145                } else {
146                    out.push(RawRef::Table {
147                        name,
148                        span: token.start..token.end(),
149                    });
150                    index += 1;
151                }
152            }
153            TokenKind::Identifier => {
154                match tokens.get(index + 1).map(|t| t.kind) {
155                    // Sales[Name] — an unquoted table qualifier.
156                    Some(TokenKind::BracketName) => {
157                        let bracket = &tokens[index + 1];
158                        out.push(RawRef::Field {
159                            table: Some(token.text),
160                            name: quoted_inner(bracket.text),
161                            span: token.start..bracket.end(),
162                        });
163                        index += 2;
164                    }
165                    // A call — a user-defined function candidate. Built-ins
166                    // resolve to nothing and stay behind as unresolved data.
167                    Some(TokenKind::OpenParen) => {
168                        out.push(RawRef::Function {
169                            name: token.text,
170                            span: token.start..token.end(),
171                        });
172                        index += 1;
173                    }
174                    // A bare name — conservatively a table use. A variable or
175                    // keyword that collides with a table name can only mark an
176                    // object used that truly is reachable; never the reverse.
177                    _ => {
178                        out.push(RawRef::Table {
179                            name: token.text,
180                            span: token.start..token.end(),
181                        });
182                        index += 1;
183                    }
184                }
185            }
186            // [Name] — unqualified: a measure, or a column of the row context.
187            TokenKind::BracketName => {
188                out.push(RawRef::Field {
189                    table: None,
190                    name: quoted_inner(token.text),
191                    span: token.start..token.end(),
192                });
193                index += 1;
194            }
195            _ => index += 1,
196        }
197    }
198
199    out
200}
201
202/// The bracketed token directly after `index`, if there is one.
203fn bracket_at<'a, 'b>(tokens: &'a [Token<'b>], index: usize) -> Option<&'a Token<'b>> {
204    let token = tokens.get(index)?;
205    (token.kind == TokenKind::BracketName).then_some(token)
206}
207
208/// The name inside a quoted-table or bracketed-name token, delimiters stripped.
209/// Unterminated input has no closing delimiter; the opening one is still dropped.
210fn quoted_inner(text: &str) -> &str {
211    let bytes = text.as_bytes();
212    let closed = text.len() >= 2
213        && match bytes[0] {
214            b'\'' => bytes[text.len() - 1] == b'\'',
215            b'[' => bytes[text.len() - 1] == b']',
216            _ => false,
217        };
218    if closed {
219        &text[1..text.len() - 1]
220    } else {
221        &text[1..]
222    }
223}
224
225/// The content of a string-literal token: delimiters stripped and doubled-quote
226/// escapes resolved. Unterminated input has no closing delimiter; the opening
227/// one is still dropped.
228fn string_inner(text: &str) -> Cow<'_, str> {
229    let bytes = text.as_bytes();
230    let closed = text.len() >= 2 && bytes[0] == b'"' && bytes[text.len() - 1] == b'"';
231    let inner = if closed {
232        &text[1..text.len() - 1]
233    } else {
234        &text[1..]
235    };
236    if inner.contains("\"\"") {
237        Cow::Owned(inner.replace("\"\"", "\""))
238    } else {
239        Cow::Borrowed(inner)
240    }
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    fn refs(text: &str) -> Vec<RawRef<'_>> {
248        references(text)
249    }
250
251    fn names(text: &str) -> Vec<String> {
252        quoted_names(text)
253            .into_iter()
254            .map(|n| n.into_owned())
255            .collect()
256    }
257
258    /// The qualified/unqualified shape of every `Field` ref, as `(table, name)`.
259    fn fields(text: &str) -> Vec<(Option<&str>, &str)> {
260        refs(text)
261            .into_iter()
262            .filter_map(|r| match r {
263                RawRef::Field { table, name, .. } => Some((table, name)),
264                _ => None,
265            })
266            .collect()
267    }
268
269    /// The names of every `Table` ref.
270    fn tables(text: &str) -> Vec<&str> {
271        refs(text)
272            .into_iter()
273            .filter_map(|r| match r {
274                RawRef::Table { name, .. } => Some(name),
275                _ => None,
276            })
277            .collect()
278    }
279
280    /// The names of every `Function` ref.
281    fn functions(text: &str) -> Vec<&str> {
282        refs(text)
283            .into_iter()
284            .filter_map(|r| match r {
285                RawRef::Function { name, .. } => Some(name),
286                _ => None,
287            })
288            .collect()
289    }
290
291    #[test]
292    fn finds_the_three_reference_forms() {
293        assert_eq!(
294            fields("'Sales Header'[Net Price] + Sales[Amount] - [Total]"),
295            [
296                (Some("Sales Header"), "Net Price"),
297                (Some("Sales"), "Amount"),
298                (None, "Total"),
299            ]
300        );
301    }
302
303    #[test]
304    fn spans_cover_the_whole_reference() {
305        let text = "SUM('Sales Header'[Net Price])";
306        let found = refs(text);
307        assert_eq!(found.len(), 2, "the SUM call plus its one argument");
308        let RawRef::Field { span, .. } = &found[1] else {
309            panic!("the second ref is the qualified field");
310        };
311        assert_eq!(&text[span.clone()], "'Sales Header'[Net Price]");
312    }
313
314    #[test]
315    fn every_span_is_a_valid_source_subslice() {
316        let text = "'It''s'[X] + COUNTROWS(Sales) + [Total] * MyFunc([Amount])";
317        for found in refs(text) {
318            let span = match found {
319                RawRef::Field { span, .. }
320                | RawRef::Table { span, .. }
321                | RawRef::Function { span, .. } => span,
322            };
323            assert!(!span.is_empty());
324            assert!(
325                text.get(span.clone()).is_some(),
326                "span {span:?} must be inside the source"
327            );
328        }
329    }
330
331    #[test]
332    fn whitespace_between_qualifier_and_name_still_qualifies() {
333        assert_eq!(
334            fields("'Sales' [Amount] + Dato [Måned]"),
335            [(Some("Sales"), "Amount"), (Some("Dato"), "Måned"),]
336        );
337    }
338
339    #[test]
340    fn quoted_tables_with_escaped_quotes_carry_raw_names() {
341        let found = refs("'It''s'[X]");
342        assert_eq!(found.len(), 1);
343        match &found[0] {
344            RawRef::Field {
345                table: Some(table),
346                name,
347                ..
348            } => {
349                assert_eq!(*table, "It''s");
350                assert_eq!(*name, "X");
351            }
352            other => panic!("expected a qualified field ref, got {other:?}"),
353        }
354        // ...and unescaping produces the logical name.
355        let field = found[0].to_field_ref().expect("field ref");
356        assert_eq!(field.table.as_ref().map(NameKey::as_str), Some("It's"));
357        assert_eq!(field.name.as_str(), "X");
358    }
359
360    #[test]
361    fn bare_table_uses_are_detected() {
362        assert_eq!(
363            tables("COUNTROWS(Sales) + COUNTROWS('Sales Order')"),
364            ["Sales", "Sales Order"]
365        );
366        // Even the keyword is a candidate: it resolves to nothing against any
367        // model, which is exactly the conservative direction.
368        assert_eq!(tables("EVALUATE Sales"), ["EVALUATE", "Sales"]);
369    }
370
371    #[test]
372    fn calls_become_function_candidates() {
373        assert_eq!(functions("SUMX(VALUES(T), [M])"), ["SUMX", "VALUES"]);
374        assert_eq!(fields("SUMX(VALUES(T), [M])"), [(None, "M"),]);
375        assert_eq!(tables("SUMX(VALUES(T), [M])"), ["T"]);
376    }
377
378    #[test]
379    fn a_reference_followed_by_a_call_is_not_a_call() {
380        // [Total] ( … ) is the implicit-CALCULATE measure call form.
381        assert!(functions("[Total]([Amount])").is_empty());
382        assert_eq!(
383            fields("[Total]([Amount])"),
384            [(None, "Total"), (None, "Amount")]
385        );
386    }
387
388    #[test]
389    fn refs_inside_strings_and_comments_do_not_count() {
390        let text = concat!(
391            "\"[In String] 'Table'[X]\"\n",
392            "// [In Line Comment] 'Q'[Y]\n",
393            "-- [Dash Comment]\n",
394            "/* [Block] [Comment] */\n",
395            "dt\"[Date Literal]\"\n",
396            "[Real]",
397        );
398        assert_eq!(fields(text), [(None, "Real")]);
399        assert!(tables(text).is_empty());
400        assert!(functions(text).is_empty());
401    }
402
403    #[test]
404    fn comments_are_skipped_but_the_code_around_them_is_not() {
405        assert_eq!(
406            fields("[A] /* [B] */ [C] -- [D]\n[E] // [F]"),
407            [(None, "A"), (None, "C"), (None, "E"),]
408        );
409    }
410
411    #[test]
412    fn the_tricky_sample_from_the_sqlbi_smoke_tests_yields_one_field_ref() {
413        // Ported from SQLBI.Whiteboard.Core.SmokeTests: exactly one comment, the
414        // string is not a comment, and the only field reference is
415        // Sales[Amount]. The bare words (Tricky, VAR, Year, Note, RETURN) are
416        // conservative table candidates — 8 of them — never field references.
417        let source = concat!(
418            "Tricky :=\n",
419            "-- a real comment\n",
420            "VAR Year = 2024\n",
421            "VAR Note = \"-- not a comment\"\n",
422            "RETURN Year & Note & Sales[Amount]\n",
423        );
424        assert_eq!(refs(source).len(), 9);
425        assert_eq!(fields(source), [(Some("Sales"), "Amount")]);
426        assert_eq!(tables(source).len(), 8);
427        assert!(functions(source).is_empty());
428
429        let tokens = crate::dax::lexer::tokenize(source);
430        assert_eq!(
431            tokens
432                .iter()
433                .filter(|t| t.kind == TokenKind::Comment)
434                .count(),
435            1,
436            "the real comment, and only it, is a comment"
437        );
438    }
439
440    #[test]
441    fn the_sumx_definition_sample_yields_three_field_refs() {
442        // Ingestion strips definition headers before the lexer sees an
443        // expression, so the body alone yields the bare table plus two
444        // qualified field references.
445        assert_eq!(
446            fields("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
447            [(Some("Sales"), "Quantity"), (Some("Sales"), "Net Price")]
448        );
449        assert_eq!(
450            tables("SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )"),
451            ["Sales"]
452        );
453
454        // With the header included anyway, the lexer cannot know `[Sales
455        // Amount]` names the object being defined — it is an unqualified field
456        // reference like any other. Ownership is the AST's business, not the
457        // lexer's.
458        let header = "[Sales Amount] = SUMX ( Sales, Sales[Quantity] * Sales[Net Price] )";
459        assert_eq!(
460            fields(header),
461            [
462                (None, "Sales Amount"),
463                (Some("Sales"), "Quantity"),
464                (Some("Sales"), "Net Price"),
465            ]
466        );
467    }
468
469    #[test]
470    fn function_bodies_keep_their_own_references() {
471        // SELECTCOLUMNS('Sales', "Key", [SalesOrderLineKey]) — the string
472        // "Key" must not become a reference; 'Sales' is a bare table use.
473        let source = "SELECTCOLUMNS('Sales', \"Key\", [SalesOrderLineKey])";
474        assert_eq!(fields(source), [(None, "SalesOrderLineKey")]);
475        assert_eq!(tables(source), ["Sales"]);
476    }
477
478    #[test]
479    fn hierarchy_level_syntax_lexes_as_independent_refs() {
480        // Not valid in DAX model expressions, but must not corrupt extraction.
481        assert_eq!(
482            fields("Product[Category].[Subcategory]"),
483            [(Some("Product"), "Category"), (None, "Subcategory"),]
484        );
485    }
486
487    #[test]
488    fn unterminated_tokens_still_produce_conservative_refs() {
489        // `'Sales [Amount]` never closes, so everything is one quoted table —
490        // an unresolved table use, never a lost reference.
491        assert_eq!(tables("'Sales [Amount]"), ["Sales [Amount]"]);
492        // `[Col` without a closing bracket is still an unqualified field.
493        assert_eq!(fields("[Col"), [(None, "Col")]);
494    }
495
496    #[test]
497    fn keywords_and_variables_are_conservative_table_candidates() {
498        // TRUE resolves to nothing against any model; a variable named like a
499        // table can only over-mark usage, which is the safe direction.
500        assert_eq!(tables("TRUE && FALSE"), ["TRUE", "FALSE"]);
501    }
502
503    #[test]
504    fn numbers_and_parameters_are_never_refs() {
505        assert!(refs("1.5E+10 + @Risk + .5").is_empty());
506    }
507
508    /// The string-literal contents — the names extension columns are
509    /// introduced by — come back escapes resolved, with comment and date
510    /// literals excluded.
511    #[test]
512    fn quoted_names_returns_string_contents() {
513        assert_eq!(
514            names("ADDCOLUMNS(t, \"@Krav\", \"It\"\"s\") -- \"gone\" dt\"2024-01-01\""),
515            ["@Krav".to_string(), "It\"s".to_string()]
516        );
517        assert!(names("[No string here] + 1").is_empty());
518    }
519
520    #[test]
521    fn empty_input_has_no_references() {
522        assert!(refs("").is_empty());
523        assert!(refs("   \n\t  ").is_empty());
524    }
525
526    #[test]
527    fn unescape_name_borrows_without_escapes_and_resolves_with_them() {
528        assert!(matches!(unescape_name("Plain"), Cow::Borrowed("Plain")));
529        assert!(matches!(unescape_name("It''s"), Cow::Owned(_)));
530        assert_eq!(unescape_name("It''s''''s").as_ref(), "It's''s");
531        // Bracketed names never carry escapes to begin with.
532        assert!(matches!(
533            unescape_name("Net Price"),
534            Cow::Borrowed("Net Price")
535        ));
536    }
537
538    #[test]
539    fn to_field_ref_is_none_for_table_and_function_refs() {
540        let text = "COUNTROWS(Sales)";
541        for found in &refs(text) {
542            match found {
543                RawRef::Table { .. } | RawRef::Function { .. } => {
544                    assert!(found.to_field_ref().is_none());
545                }
546                RawRef::Field { .. } => assert!(found.to_field_ref().is_some()),
547            }
548        }
549    }
550}