Skip to main content

ripbi_core/graph/
broken.rs

1//! Broken report bindings: written field references that resolve to nothing
2//! in the model, and bindings that land on artifacts whose own expressions no
3//! longer resolve (issue #60).
4//!
5//! The graph builder swallows resolution misses — "an unresolvable name is
6//! data, not an error" (`docs/name-resolution.md`). This module is the second
7//! reader of that same data: where the builder asks *what stays alive*, it
8//! asks *did the written reference name anything at all*. A miss says the
9//! visual is broken — the table is gone, or the column/measure/hierarchy is
10//! gone — and every direction of the claim is conservative, mirroring the
11//! liveness rule inverted: over-keeping does not apply here, under-claiming
12//! does. A false "broken" is itself a breakage claim, so anything the
13//! machinery might resolve is treated as resolved.
14
15use std::collections::{HashMap, HashSet};
16
17use crate::dax::{self, RawRef, unescape_name};
18use crate::identity::{NameKey, ObjectId, fold_name};
19use crate::model::index::ModelIndex;
20use crate::model::{PartitionSource, Table, TabularDatabase};
21use crate::report::{FieldTarget, ReportModel};
22
23use super::builder::table_struct;
24use super::provenance::BindingEdge;
25
26/// Why one report binding is broken — the static approximation of the error
27/// state the engine would render.
28#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum BrokenReason {
30    /// The written qualifier names a table the model does not have.
31    TableNotFound,
32    /// The table exists but the written column does not.
33    FieldNotFound,
34    /// The written measure matches no report measure and no model measure.
35    MeasureNotFound,
36    /// The table exists but carries no such hierarchy, and no column
37    /// variation resolves the reference.
38    HierarchyNotFound,
39    /// The hierarchy exists but the written level (or the column it drills
40    /// through) does not.
41    LevelNotFound,
42    /// The binding resolves, but to an artifact whose own DAX binds at least
43    /// one field reference to nothing — the visual inherits the artifact's
44    /// error state.
45    BoundArtifactBroken {
46        /// The broken artifact the binding lands on.
47        artifact: ObjectId,
48    },
49}
50
51/// One report binding that is broken: where it lives, what it wrote, and why
52/// it fails to resolve. A broken binding has no [`ObjectId`] of its own — the
53/// written target plus the binding's provenance identify it.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BrokenBinding {
56    /// The binding's site: report, page, visual, bookmark, layout, and what
57    /// kind of binding it is.
58    pub edge: BindingEdge,
59    /// The written field reference that failed, as the report stated it.
60    pub target: FieldTarget,
61    /// Why the reference does not resolve.
62    pub reason: BrokenReason,
63}
64
65/// The sort key of a broken binding: where the binding lives (report, page,
66/// visual, bookmark, layout), then the written target, then the reason —
67/// display strings, not object identity, since there is no [`ObjectId`] to
68/// sort by.
69type SortKey<'a> = (
70    Option<&'a NameKey>,
71    Option<&'a NameKey>,
72    Option<&'a NameKey>,
73    Option<&'a NameKey>,
74    bool,
75    String,
76    &'a BrokenReason,
77);
78
79impl BrokenBinding {
80    /// The deterministic order, per [`SortKey`].
81    pub(super) fn sort_key(&self) -> SortKey<'_> {
82        (
83            self.edge.report.as_ref(),
84            self.edge.page.as_ref(),
85            self.edge.visual.as_ref(),
86            self.edge.bookmark.as_ref(),
87            self.edge.mobile,
88            self.target.to_string(),
89            &self.reason,
90        )
91    }
92}
93
94/// The artifacts — every model and report object owning DAX — whose
95/// expressions bind at least one field reference to nothing: the static
96/// approximation of the engine's error state. Key: the artifact. Value: the
97/// written forms of its unresolved references, sorted and deduplicated.
98///
99/// Built-in function calls and bare table candidates are never breakage (the
100/// lexer emits them conservatively), and a qualified reference naming a
101/// hierarchy or calculation item resolves through the same extended
102/// candidates the graph's liveness uses.
103pub(super) fn broken_artifacts(
104    db: &TabularDatabase,
105    reports: &[&ReportModel],
106    index: &ModelIndex,
107) -> HashMap<ObjectId, Vec<String>> {
108    let mut out: HashMap<ObjectId, Vec<String>> = HashMap::new();
109    let mut scan = |text: &str,
110                    home_table: Option<&str>,
111                    owner: &ObjectId,
112                    report_measures: &HashSet<String>| {
113        // The names query time can introduce: extension columns named by a
114        // string literal (`ADDCOLUMNS(t, "@Krav", …)`, `SELECTCOLUMNS(t,
115        // "Ordning", …)`, `GROUPBY`, `ROW`, `DATATABLE`) and the table
116        // constructor's fixed defaults (`{…}` names its single column
117        // `Value`, row constructors `Value1`, `Value2`, …). Both are
118        // lexically visible without scope analysis, and the
119        // over-approximation is under-claim — the worst case is a typo that
120        // coincides with a string in the same measure going unflagged.
121        let mut query_time: HashSet<String> = dax::quoted_names(text)
122            .into_iter()
123            .map(|name| fold_name(name.as_ref()))
124            .collect();
125        query_time.extend(constructors::COLUMN_NAMES.map(str::to_string));
126        for raw in dax::references(text) {
127            if matches!(raw, RawRef::Field { .. })
128                && !field_resolves(db, index, home_table, &raw, report_measures, &query_time)
129            {
130                let written = raw
131                    .to_field_ref()
132                    .expect("a field reference materializes")
133                    .to_string();
134                out.entry(owner.clone()).or_default().push(written);
135            }
136        }
137    };
138    for expression in db.dax_expressions() {
139        let owner = expression.owner.to_object_id();
140        scan(
141            expression.text,
142            expression.home_table,
143            &owner,
144            &HashSet::new(),
145        );
146    }
147    for report in reports {
148        // A report measure referencing a sibling report measure is the graph's
149        // ordinary report-measure edge (`add_expression_edges`), not breakage.
150        let report_measures: HashSet<String> = report
151            .measures
152            .iter()
153            .map(|measure| fold_name(measure.name.as_str()))
154            .collect();
155        for expression in report.dax_expressions() {
156            let owner = expression.owner.to_object_id();
157            scan(
158                expression.text,
159                expression.home_table,
160                &owner,
161                &report_measures,
162            );
163        }
164    }
165    for refs in out.values_mut() {
166        refs.sort();
167        refs.dedup();
168    }
169    out
170}
171
172/// Whether one field reference resolves, for breakage purposes: the binder's
173/// answer, plus everything the binder cannot know that the engine still
174/// resolves — the extended candidates (a qualified reference may name a
175/// hierarchy or a calculation item), a calculated table's lexical output
176/// schema, the report's own measures for an unqualified name, `@`-prefixed
177/// extension columns, and the query-time column names introduced in the same
178/// expression. Deliberately *not* the qualifying-table fallback — a reference
179/// whose field is missing on an existing declared table is exactly the
180/// breakage this module exists to report.
181fn field_resolves(
182    db: &TabularDatabase,
183    index: &ModelIndex,
184    home_table: Option<&str>,
185    raw: &RawRef<'_>,
186    report_measures: &HashSet<String>,
187    query_time: &HashSet<String>,
188) -> bool {
189    if !dax::bind(db, index, home_table, raw.clone()).is_unresolved() {
190        return true;
191    }
192    let RawRef::Field { table, name, .. } = raw else {
193        return false;
194    };
195    let folded = fold_name(unescape_name(name).as_ref());
196    match table {
197        // Query-time columns are table-less by construction, and the engine
198        // resolves the shapes below without a model object to bind: an `@`
199        // prefix is the convention that keeps extension-column names out of
200        // the model's namespace, a report measure is reachable from its own
201        // report's expressions, and a query-time name comes from this same
202        // expression's string literals or constructor defaults.
203        None => {
204            name.starts_with('@')
205                || report_measures.contains(&folded)
206                || query_time.contains(&folded)
207        }
208        Some(table) => {
209            let table = &unescape_name(table);
210            named_hierarchy_or_item(db, index, table, &folded)
211                || calculated_table_field_resolves(db, index, table, &folded)
212        }
213    }
214}
215
216/// The fixed column names of DAX table constructors: `{1, 2, 3}` names its
217/// single column `Value`; `{(a, b), (c, d)}` names them `Value1`, `Value2`, ….
218mod constructors {
219    /// The folded defaults, widest set a realistic constructor needs.
220    pub const COLUMN_NAMES: [&str; 11] = [
221        "value", "value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8",
222        "value9", "value10",
223    ];
224}
225
226/// Whether `table` carries a hierarchy or calculation item named `folded` —
227/// the extended candidates the binder does not know, shared with the
228/// builder's liveness policy.
229pub(super) fn named_hierarchy_or_item(
230    db: &TabularDatabase,
231    index: &ModelIndex,
232    table: &str,
233    folded: &str,
234) -> bool {
235    let Some(t): Option<&Table> = table_struct(db, index, table) else {
236        return false;
237    };
238    if t.hierarchies.iter().any(|h| fold_name(&h.name) == folded) {
239        return true;
240    }
241    t.calculation_group.as_ref().is_some_and(|group| {
242        group
243            .items
244            .iter()
245            .any(|item| fold_name(&item.name) == folded)
246    })
247}
248
249/// Whether `table` is a calculated table whose partition expression makes
250/// `folded` lexically visible as a field name. A calculated table has no
251/// schema of its own — its columns are whatever the expression returns — so
252/// a qualified miss on it cannot be read as breakage the way a miss on a
253/// declared table can. The lexical candidates stand in for the engine's
254/// output schema: string literals (`DATATABLE` headers, `ADDCOLUMNS`/
255/// `SELECTCOLUMNS` names), the constructor defaults, the columns written in
256/// the expression, and every column of the tables it references (the
257/// wrapped-table shape `FILTER`/`CALCULATETABLE` passes through). An
258/// over-approximation in the safe direction — a rename out of the
259/// expression's visible names still flags.
260pub(super) fn calculated_table_field_resolves(
261    db: &TabularDatabase,
262    index: &ModelIndex,
263    table: &str,
264    folded: &str,
265) -> bool {
266    let Some(candidates) = calculated_table_candidates(db, index, table) else {
267        return false;
268    };
269    candidates.contains(folded)
270}
271
272/// The folded field names a calculated table's partition expression makes
273/// lexically visible, or `None` when the table has no calculated partition.
274/// Only names that resolve are admitted: a stale reference inside the
275/// expression is breakage the artifact pass reports, not a schema the
276/// engine materializes.
277fn calculated_table_candidates(
278    db: &TabularDatabase,
279    index: &ModelIndex,
280    table: &str,
281) -> Option<HashSet<String>> {
282    let t = table_struct(db, index, table)?;
283    let expression = t
284        .partitions
285        .iter()
286        .find_map(|partition| match &partition.source {
287            PartitionSource::Calculated { expression } => Some(expression.as_str()),
288            _ => None,
289        })?;
290    let mut candidates: HashSet<String> = dax::quoted_names(expression)
291        .into_iter()
292        .map(|name| fold_name(name.as_ref()))
293        .collect();
294    candidates.extend(constructors::COLUMN_NAMES.map(str::to_string));
295    for raw in dax::references(expression) {
296        match &raw {
297            RawRef::Field { table, name, .. } => {
298                if let Some(qualifier) = table {
299                    // A written `'X'[C]` admits X's whole column set — the
300                    // shape `FILTER`/`VALUES` passes through. C itself is
301                    // already among them when it exists; when it does not,
302                    // the stale name is the artifact pass's breakage, not a
303                    // candidate.
304                    extend_table_columns(db, index, &unescape_name(qualifier), &mut candidates);
305                } else if !dax::bind(db, index, None, raw.clone()).is_unresolved() {
306                    // An unqualified name only joins the schema when it
307                    // binds — a stale one must stay flaggable.
308                    candidates.insert(fold_name(unescape_name(name).as_ref()));
309                }
310            }
311            RawRef::Table { name, .. } => {
312                extend_table_columns(db, index, &unescape_name(name), &mut candidates);
313            }
314            RawRef::Function { .. } => {}
315        }
316    }
317    Some(candidates)
318}
319
320/// Adds every column of `table` to `candidates`, when the table exists.
321fn extend_table_columns(
322    db: &TabularDatabase,
323    index: &ModelIndex,
324    table: &str,
325    candidates: &mut HashSet<String>,
326) {
327    if let Some(t) = table_struct(db, index, table) {
328        candidates.extend(t.columns.iter().map(|column| fold_name(&column.name)));
329    }
330}
331
332/// The base measure name of a KPI-style synthesized variant, when the name
333/// carries one of the engine's suffixes. A KPI visual binds the goal, status,
334/// and trend variants of its measure — written names the model does not carry
335/// and that resolve to nothing, but that the engine materializes, so they are
336/// resolved, not flagged (issue #60).
337pub(super) fn kpi_variant_base(name: &str) -> Option<&str> {
338    const SUFFIXES: [&str; 4] = ["goal", "status", "trend", "value"];
339    let folded = fold_name(name);
340    let suffix = SUFFIXES
341        .into_iter()
342        .filter_map(|suffix| folded.strip_suffix(suffix))
343        .max_by_key(|rest| rest.len())?;
344    let base = name[..suffix.len()].trim_end();
345    (!base.is_empty()).then_some(base)
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::model::{Column, Measure, Table};
352
353    fn db() -> TabularDatabase {
354        TabularDatabase {
355            tables: vec![Table {
356                name: "Sales".to_string(),
357                columns: vec![
358                    Column {
359                        name: "Amount".to_string(),
360                        ..Default::default()
361                    },
362                    Column {
363                        name: "Region".to_string(),
364                        ..Default::default()
365                    },
366                ],
367                measures: vec![Measure {
368                    name: "Total".to_string(),
369                    expression: "SUM('Sales'[Amount])".to_string(),
370                    ..Default::default()
371                }],
372                ..Default::default()
373            }],
374            ..Default::default()
375        }
376    }
377
378    mod kpi_variants {
379        use super::*;
380
381        #[test]
382        fn the_engine_suffixes_strip_to_the_base_measure() {
383            for (name, base) in [
384                ("Total Goal", "Total"),
385                ("Total Status", "Total"),
386                ("Total Trend", "Total"),
387                ("Total Value", "Total"),
388                ("total goal", "total"),
389            ] {
390                assert_eq!(kpi_variant_base(name), Some(base), "{name}");
391            }
392        }
393
394        #[test]
395        fn names_without_a_suffix_and_bare_suffixes_do_not_strip() {
396            assert_eq!(kpi_variant_base("Total"), None);
397            assert_eq!(kpi_variant_base("Goal"), None, "no base left");
398            assert_eq!(kpi_variant_base("Value"), None);
399            assert_eq!(kpi_variant_base("Sales Value Growth"), None);
400        }
401
402        #[test]
403        fn the_longest_suffix_wins_when_names_stack() {
404            assert_eq!(kpi_variant_base("Sales Goal Value"), Some("Sales Goal"));
405        }
406    }
407
408    mod artifact_pass {
409        use super::*;
410
411        #[test]
412        fn a_measure_referencing_a_missing_column_is_broken() {
413            let mut model = db();
414            model.tables[0].measures.push(Measure {
415                name: "Broken".to_string(),
416                expression: "SUM('Sales'[Nope]) + [Total]".to_string(),
417                ..Default::default()
418            });
419            let index = ModelIndex::build(&model);
420
421            let broken = broken_artifacts(&model, &[], &index);
422
423            let refs = &broken[&ObjectId::Measure {
424                table: NameKey::new("Sales"),
425                measure: NameKey::new("Broken"),
426            }];
427            assert_eq!(refs, &["'Sales'[Nope]".to_string()]);
428        }
429
430        #[test]
431        fn builtins_tables_and_resolving_refs_are_not_breakage() {
432            let mut model = db();
433            model.tables[0].measures.push(Measure {
434                name: "Fine".to_string(),
435                expression: "COUNTROWS(Missing) + SUM('Sales'[Amount])".to_string(),
436                ..Default::default()
437            });
438            let index = ModelIndex::build(&model);
439
440            assert!(broken_artifacts(&model, &[], &index).is_empty());
441        }
442
443        /// A qualified reference naming a hierarchy resolves through the
444        /// extended candidates (`ISINSCOPE('Date'[Calendar])`): the binder
445        /// does not know hierarchies, so treating its miss as breakage would
446        /// flag a healthy measure.
447        #[test]
448        fn a_hierarchy_reference_resolves() {
449            let model = TabularDatabase {
450                tables: vec![Table {
451                    name: "Date".to_string(),
452                    columns: vec![Column {
453                        name: "Year".to_string(),
454                        ..Default::default()
455                    }],
456                    hierarchies: vec![crate::model::Hierarchy {
457                        name: "Calendar".to_string(),
458                        levels: vec![crate::model::HierarchyLevel {
459                            name: "Year".to_string(),
460                            column: "Year".to_string(),
461                        }],
462                        is_hidden: false,
463                    }],
464                    measures: vec![Measure {
465                        name: "In Scope".to_string(),
466                        expression: "ISINSCOPE('Date'[Calendar])".to_string(),
467                        ..Default::default()
468                    }],
469                    ..Default::default()
470                }],
471                ..Default::default()
472            };
473            let index = ModelIndex::build(&model);
474
475            assert!(broken_artifacts(&model, &[], &index).is_empty());
476        }
477
478        /// A reference whose table exists but whose field does not is exactly
479        /// the breakage the qualifying-table fallback must not paper over.
480        #[test]
481        fn a_missing_field_on_a_live_table_is_breakage() {
482            let mut model = db();
483            model.tables[0].measures.push(Measure {
484                name: "Stale".to_string(),
485                expression: "SUM('Sales'[Color])".to_string(),
486                ..Default::default()
487            });
488            let index = ModelIndex::build(&model);
489
490            let broken = broken_artifacts(&model, &[], &index);
491            assert_eq!(broken.len(), 1);
492        }
493
494        #[test]
495        fn a_report_measure_body_is_scanned_too() {
496            let model = db();
497            let index = ModelIndex::build(&model);
498            let mut report = ReportModel::default();
499            report.measures.push(crate::report::ReportMeasure {
500                name: NameKey::new("Local"),
501                expression: "[Total] + [Gone]".to_string(),
502                format_string: None,
503            });
504
505            let broken = broken_artifacts(&model, &[&report], &index);
506
507            let refs = &broken[&ObjectId::ReportMeasure {
508                measure: NameKey::new("Local"),
509            }];
510            assert_eq!(refs, &["[Gone]".to_string()]);
511        }
512
513        /// The kundechef-DB shape: a report measure built on sibling report
514        /// measures. The graph keeps the siblings alive through exactly these
515        /// unqualified names (`add_expression_edges`), so the breakage pass
516        /// must not call them unresolvable.
517        #[test]
518        fn a_report_measure_referencing_a_sibling_resolves() {
519            let model = db();
520            let index = ModelIndex::build(&model);
521            let mut report = ReportModel::default();
522            report.measures.push(crate::report::ReportMeasure {
523                name: NameKey::new("Outer"),
524                expression: "DIVIDE([Inner], [Base], 0)".to_string(),
525                format_string: None,
526            });
527            report.measures.push(crate::report::ReportMeasure {
528                name: NameKey::new("Inner"),
529                expression: "1".to_string(),
530                format_string: None,
531            });
532            report.measures.push(crate::report::ReportMeasure {
533                name: NameKey::new("Base"),
534                expression: "2".to_string(),
535                format_string: None,
536            });
537
538            let broken = broken_artifacts(&model, &[&report], &index);
539
540            assert!(
541                !broken.contains_key(&ObjectId::ReportMeasure {
542                    measure: NameKey::new("Outer"),
543                }),
544                "the siblings resolve: {broken:?}"
545            );
546        }
547
548        /// The SQLBI extension-column pattern: `ADDCOLUMNS` names a
549        /// query-time column `"@Krav"` and the filter reads it back as
550        /// `[@Krav]`. The engine materializes it; only the genuinely missing
551        /// `'Sales'[Nope]` is breakage.
552        #[test]
553        fn an_extension_column_reference_resolves() {
554            let mut model = db();
555            model.tables[0].measures.push(Measure {
556                name: "Kvalificerede".to_string(),
557                expression: "COUNTROWS(FILTER(ADDCOLUMNS(VALUES('Sales'[Amount]), \"@Krav\", [Total]), [@Krav] > 0)) + SUM('Sales'[Nope])"
558                    .to_string(),
559                ..Default::default()
560            });
561            let index = ModelIndex::build(&model);
562
563            let broken = broken_artifacts(&model, &[], &index);
564
565            let refs = &broken[&ObjectId::Measure {
566                table: NameKey::new("Sales"),
567                measure: NameKey::new("Kvalificerede"),
568            }];
569            assert_eq!(refs, &["'Sales'[Nope]".to_string()]);
570        }
571
572        /// The table constructor's default column: `VAR Dele = { … }` names
573        /// its column `Value`, and `[Value]` reads it back through the
574        /// variable — unresolvable to the binder, resolved by the engine.
575        #[test]
576        fn a_constructor_value_column_resolves() {
577            let mut model = db();
578            model.tables[0].measures.push(Measure {
579                name: "Dele".to_string(),
580                expression: "VAR Dele = { \"a\", \"b\" } RETURN CONCATENATEX(FILTER(Dele, NOT ISBLANK([Value])), [Value], \" | \")"
581                    .to_string(),
582                ..Default::default()
583            });
584            let index = ModelIndex::build(&model);
585
586            let broken = broken_artifacts(&model, &[], &index);
587            assert!(
588                !broken.contains_key(&ObjectId::Measure {
589                    table: NameKey::new("Sales"),
590                    measure: NameKey::new("Dele"),
591                }),
592                "the constructor column resolves: {broken:?}"
593            );
594        }
595
596        /// A string-named extension column (`SELECTCOLUMNS`/`GROUPBY`) reads
597        /// back by the same name it was introduced with.
598        #[test]
599        fn a_string_named_extension_column_resolves() {
600            let mut model = db();
601            model.tables[0].measures.push(Measure {
602                name: "Ordninger".to_string(),
603                expression: "COUNTROWS(FILTER(SELECTCOLUMNS('Sales', \"Ordning\", 'Sales'[Amount]), [Ordning] > 0))".to_string(),
604                ..Default::default()
605            });
606            let index = ModelIndex::build(&model);
607
608            assert!(broken_artifacts(&model, &[], &index).is_empty());
609        }
610
611        /// The Bosteder shape: a calculated table whose only schema is its
612        /// `DATATABLE` headers, and a measure reading one of them. The header
613        /// is lexically visible in the partition expression — engine
614        /// materialized, not breakage.
615        #[test]
616        fn a_measure_on_a_calculated_table_reading_a_datatable_header_resolves() {
617            let model = calculated_colaxis("Group");
618            let index = ModelIndex::build(&model);
619
620            assert!(broken_artifacts(&model, &[], &index).is_empty());
621        }
622
623        /// Rename the header and the measure's reference to the old name is
624        /// real breakage — the case a blanket "calculated ⇒ resolved" rule
625        /// would have gone silent on.
626        #[test]
627        fn a_measure_reading_a_renamed_datatable_header_still_flags() {
628            let model = calculated_colaxis("Gruppe");
629            let index = ModelIndex::build(&model);
630
631            let broken = broken_artifacts(&model, &[], &index);
632            let refs = &broken[&ObjectId::Measure {
633                table: NameKey::new("Sales"),
634                measure: NameKey::new("Label"),
635            }];
636            assert_eq!(
637                refs,
638                &["'ColAxis'[Group]".to_string()],
639                "the old header name is no longer visible"
640            );
641        }
642
643        /// The shared fixture: `ColAxis` as a calculated `DATATABLE` table
644        /// with `header` as one of its column names, plus a measure reading
645        /// `'ColAxis'[Group]` — `header` controls whether that reading
646        /// resolves.
647        fn calculated_colaxis(header: &str) -> TabularDatabase {
648            TabularDatabase {
649                tables: vec![
650                    Table {
651                        name: "ColAxis".to_string(),
652                        partitions: vec![crate::model::Partition {
653                            name: "ColAxis".to_string(),
654                            source: crate::model::PartitionSource::Calculated {
655                                expression: format!(
656                                    "DATATABLE(\"{header}\", STRING, {{\"Outlook\"}})"
657                                ),
658                            },
659                        }],
660                        ..Default::default()
661                    },
662                    Table {
663                        name: "Sales".to_string(),
664                        columns: vec![Column {
665                            name: "Amount".to_string(),
666                            ..Default::default()
667                        }],
668                        measures: vec![Measure {
669                            name: "Label".to_string(),
670                            expression: "CONCATENATEX('ColAxis', 'ColAxis'[Group], \" | \")"
671                                .to_string(),
672                            ..Default::default()
673                        }],
674                        ..Default::default()
675                    },
676                ],
677                ..Default::default()
678            }
679        }
680    }
681
682    /// The extended-candidate lookup answers only for hierarchies and
683    /// calculation items — columns and measures are the binder's job, so a
684    /// column name here is deliberately `false`.
685    #[test]
686    fn named_hierarchy_or_item_answers_like_the_builder() {
687        let model = db();
688        let index = ModelIndex::build(&model);
689        assert!(
690            !named_hierarchy_or_item(&model, &index, "Sales", "amount"),
691            "a column is the binder's candidate, not an extended one"
692        );
693        assert!(!named_hierarchy_or_item(&model, &index, "Sales", "nope"));
694        assert!(!named_hierarchy_or_item(&model, &index, "Ghost", "x"));
695    }
696}