1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
29pub enum BrokenReason {
30 TableNotFound,
32 FieldNotFound,
34 MeasureNotFound,
36 HierarchyNotFound,
39 LevelNotFound,
42 BoundArtifactBroken {
46 artifact: ObjectId,
48 },
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct BrokenBinding {
56 pub edge: BindingEdge,
59 pub target: FieldTarget,
61 pub reason: BrokenReason,
63}
64
65type 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 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
94pub(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 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 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
172fn 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 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
216mod constructors {
219 pub const COLUMN_NAMES: [&str; 11] = [
221 "value", "value1", "value2", "value3", "value4", "value5", "value6", "value7", "value8",
222 "value9", "value10",
223 ];
224}
225
226pub(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
249pub(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
272fn 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 extend_table_columns(db, index, &unescape_name(qualifier), &mut candidates);
305 } else if !dax::bind(db, index, None, raw.clone()).is_unresolved() {
306 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
320fn 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
332pub(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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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}