pbiplint 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pbiplint.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// ../core/src/version.ts
|
|
4
|
-
var VERSION = "0.2.
|
|
4
|
+
var VERSION = "0.2.2";
|
|
5
5
|
|
|
6
6
|
// ../core/src/engine/config.ts
|
|
7
7
|
var ConfigError = class extends Error {
|
|
8
8
|
};
|
|
9
9
|
function bindConfig(config, rules) {
|
|
10
10
|
const ruleByUpper = new Map(rules.map((r) => [r.id.toUpperCase(), r]));
|
|
11
|
-
const
|
|
11
|
+
const bound2 = {
|
|
12
12
|
disabled: /* @__PURE__ */ new Set(),
|
|
13
13
|
severity: /* @__PURE__ */ new Map(),
|
|
14
14
|
options: /* @__PURE__ */ new Map(),
|
|
@@ -18,12 +18,12 @@ function bindConfig(config, rules) {
|
|
|
18
18
|
for (const id of config.disabled) {
|
|
19
19
|
const real = ruleByUpper.get(id.toUpperCase())?.id;
|
|
20
20
|
if (real === void 0) unknownRules.push(id);
|
|
21
|
-
else
|
|
21
|
+
else bound2.disabled.add(real);
|
|
22
22
|
}
|
|
23
23
|
for (const [id, severity] of config.severity) {
|
|
24
24
|
const real = ruleByUpper.get(id.toUpperCase())?.id;
|
|
25
25
|
if (real === void 0) unknownRules.push(id);
|
|
26
|
-
else
|
|
26
|
+
else bound2.severity.set(real, severity);
|
|
27
27
|
}
|
|
28
28
|
for (const [id, options] of config.options) {
|
|
29
29
|
const rule = ruleByUpper.get(id.toUpperCase());
|
|
@@ -51,9 +51,9 @@ function bindConfig(config, rules) {
|
|
|
51
51
|
`pbiplint.config.json: rules["${real}"].${name} must be one of ${decl.values.join(", ")}`
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
|
-
|
|
54
|
+
bound2.options.set(real, { ...options });
|
|
55
55
|
}
|
|
56
|
-
return { config:
|
|
56
|
+
return { config: bound2, unknownRules: [...new Set(unknownRules)] };
|
|
57
57
|
}
|
|
58
58
|
var SEVERITY_BY_NAME = { info: 1, warning: 2, error: 3 };
|
|
59
59
|
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -205,12 +205,14 @@ function buildColumn(c, table) {
|
|
|
205
205
|
}
|
|
206
206
|
function buildMeasure(x, table) {
|
|
207
207
|
const p = x.props;
|
|
208
|
+
const kpi = x.children.find((c) => c.type === "kpi")?.props;
|
|
208
209
|
return {
|
|
209
210
|
...named(x),
|
|
210
211
|
table,
|
|
211
212
|
expression: x.value ?? "",
|
|
212
213
|
formatString: str(p.formatstring),
|
|
213
214
|
formatStringDefinition: str(p.formatstringdefinition),
|
|
215
|
+
kpiExpressions: kpi && [kpi.targetexpression, kpi.statusexpression, kpi.trendexpression].map(str).filter((e) => e !== void 0),
|
|
214
216
|
isHidden: flag(p.ishidden),
|
|
215
217
|
displayFolder: str(p.displayfolder)
|
|
216
218
|
};
|
|
@@ -586,48 +588,215 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
586
588
|
};
|
|
587
589
|
}
|
|
588
590
|
|
|
589
|
-
// ../core/src/
|
|
590
|
-
var
|
|
591
|
-
var
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
591
|
+
// ../core/src/dax/tokenize.ts
|
|
592
|
+
var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
|
|
593
|
+
var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
594
|
+
var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
|
|
595
|
+
var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
596
|
+
var DIGIT = /[0-9]/;
|
|
597
|
+
var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
|
|
598
|
+
var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
|
|
599
|
+
function quoted(text2, from, close) {
|
|
600
|
+
let value = "";
|
|
601
|
+
let j = from + 1;
|
|
602
|
+
while (j < text2.length) {
|
|
603
|
+
const c = text2[j];
|
|
604
|
+
if (c === close) {
|
|
605
|
+
if (text2[j + 1] !== close) return { value, end: j + 1 };
|
|
606
|
+
j++;
|
|
607
|
+
}
|
|
608
|
+
value += c;
|
|
609
|
+
j++;
|
|
610
|
+
}
|
|
611
|
+
return { value, end: text2.length };
|
|
612
|
+
}
|
|
613
|
+
function tokenizeDax(expression) {
|
|
614
|
+
const s = expression;
|
|
615
|
+
const tokens = [];
|
|
616
|
+
const push2 = (kind, text2, start, end) => {
|
|
617
|
+
tokens.push({ kind, text: text2, start, end, depth: 0 });
|
|
618
|
+
};
|
|
619
|
+
let i = 0;
|
|
620
|
+
while (i < s.length) {
|
|
621
|
+
const c = s[i];
|
|
622
|
+
if (/\s/.test(c)) {
|
|
623
|
+
i++;
|
|
624
|
+
} else if (s.startsWith("//", i) || s.startsWith("--", i)) {
|
|
625
|
+
const nl = s.indexOf("\n", i);
|
|
626
|
+
i = nl === -1 ? s.length : nl;
|
|
627
|
+
} else if (s.startsWith("/*", i)) {
|
|
628
|
+
const close = s.indexOf("*/", i + 2);
|
|
629
|
+
i = close === -1 ? s.length : close + 2;
|
|
630
|
+
} else if (c === '"') {
|
|
631
|
+
const q2 = quoted(s, i, '"');
|
|
632
|
+
push2("string", q2.value, i, q2.end);
|
|
633
|
+
i = q2.end;
|
|
634
|
+
} else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
|
|
635
|
+
const q2 = quoted(s, i + 2, '"');
|
|
636
|
+
push2("date", q2.value, i, q2.end);
|
|
637
|
+
i = q2.end;
|
|
638
|
+
} else if (c === "'" || c === "[") {
|
|
639
|
+
const q2 = quoted(s, i, c === "'" ? "'" : "]");
|
|
640
|
+
push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
|
|
641
|
+
i = q2.end;
|
|
642
|
+
} else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
|
|
643
|
+
NUMBER.lastIndex = i;
|
|
644
|
+
const text2 = NUMBER.exec(s)[0];
|
|
645
|
+
push2("number", text2, i, i + text2.length);
|
|
646
|
+
i += text2.length;
|
|
647
|
+
} else {
|
|
648
|
+
IDENTIFIER.lastIndex = i;
|
|
649
|
+
const id = IDENTIFIER.exec(s)?.[0];
|
|
650
|
+
const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
|
|
651
|
+
const text2 = id ?? op ?? c;
|
|
652
|
+
push2(
|
|
653
|
+
id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
|
|
654
|
+
text2,
|
|
655
|
+
i,
|
|
656
|
+
i + text2.length
|
|
657
|
+
);
|
|
658
|
+
i += text2.length;
|
|
659
|
+
}
|
|
599
660
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
661
|
+
annotate(tokens);
|
|
662
|
+
return tokens;
|
|
663
|
+
}
|
|
664
|
+
function annotate(tokens) {
|
|
665
|
+
const open = [];
|
|
666
|
+
const args = [];
|
|
667
|
+
const place = (t) => {
|
|
668
|
+
t.depth = open.length;
|
|
669
|
+
if (open.length === 0) return;
|
|
670
|
+
t.parent = open[open.length - 1];
|
|
671
|
+
t.arg = args[args.length - 1];
|
|
672
|
+
};
|
|
673
|
+
tokens.forEach((t, k) => {
|
|
674
|
+
if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
|
|
675
|
+
place(t);
|
|
676
|
+
const before = tokens[k - 1];
|
|
677
|
+
if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
|
|
678
|
+
open.push(k);
|
|
679
|
+
args.push(0);
|
|
680
|
+
} else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
|
|
681
|
+
const o = open.pop();
|
|
682
|
+
args.pop();
|
|
683
|
+
if (o !== void 0) {
|
|
684
|
+
tokens[o].close = k;
|
|
685
|
+
t.open = o;
|
|
686
|
+
}
|
|
687
|
+
place(t);
|
|
688
|
+
} else {
|
|
689
|
+
place(t);
|
|
690
|
+
if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
function opensBlock(tokens, k) {
|
|
695
|
+
if (isWord(tokens[k - 1], "RETURN")) return true;
|
|
696
|
+
const eq = tokens[k - 1];
|
|
697
|
+
return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
|
|
698
|
+
}
|
|
699
|
+
function daxVariables(tokens) {
|
|
700
|
+
const out = [];
|
|
701
|
+
tokens.forEach((t, k) => {
|
|
702
|
+
const name = tokens[k + 1];
|
|
703
|
+
const eq = tokens[k + 2];
|
|
704
|
+
if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
|
|
705
|
+
if (eq.text !== "=") return;
|
|
706
|
+
const from = k + 3;
|
|
707
|
+
let to = from;
|
|
708
|
+
let nested = 0;
|
|
709
|
+
for (; to < tokens.length; to++) {
|
|
710
|
+
const x = tokens[to];
|
|
711
|
+
if (x.depth < t.depth) break;
|
|
712
|
+
if (x.depth !== t.depth) continue;
|
|
713
|
+
if (isWord(x, "VAR")) {
|
|
714
|
+
if (opensBlock(tokens, to)) nested++;
|
|
715
|
+
else if (nested === 0) break;
|
|
716
|
+
} else if (isWord(x, "RETURN")) {
|
|
717
|
+
if (nested === 0) break;
|
|
718
|
+
nested--;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
let blockEnd2 = to;
|
|
722
|
+
for (; blockEnd2 < tokens.length; blockEnd2++) {
|
|
723
|
+
const x = tokens[blockEnd2];
|
|
724
|
+
if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
|
|
725
|
+
}
|
|
726
|
+
out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
|
|
727
|
+
});
|
|
728
|
+
for (const v of out)
|
|
729
|
+
for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
function variableAt(vars, name, use) {
|
|
733
|
+
const key4 = name.toUpperCase();
|
|
734
|
+
let found;
|
|
735
|
+
for (const v of vars)
|
|
736
|
+
if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
|
|
737
|
+
found = v;
|
|
738
|
+
return found;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ../core/src/index/references.ts
|
|
742
|
+
var CREATES_COLUMNS = /* @__PURE__ */ new Set([
|
|
743
|
+
"ADDCOLUMNS",
|
|
744
|
+
"SELECTCOLUMNS",
|
|
745
|
+
"SUMMARIZE",
|
|
746
|
+
"SUMMARIZECOLUMNS",
|
|
747
|
+
"ROW",
|
|
748
|
+
"DATATABLE"
|
|
749
|
+
]);
|
|
750
|
+
function createdColumns(tokens) {
|
|
751
|
+
const out = /* @__PURE__ */ new Map();
|
|
752
|
+
for (const t of tokens) {
|
|
753
|
+
if (t.kind !== "string" || t.parent === void 0) continue;
|
|
754
|
+
const open = tokens[t.parent];
|
|
755
|
+
if (open.call === void 0 || !CREATES_COLUMNS.has(open.call)) continue;
|
|
756
|
+
const name = t.text.toLowerCase();
|
|
757
|
+
out.set(name, [...out.get(name) ?? [], [t.parent, open.close ?? tokens.length]]);
|
|
603
758
|
}
|
|
604
759
|
return out;
|
|
605
760
|
}
|
|
761
|
+
function refsInTokens(tokens) {
|
|
762
|
+
const created = createdColumns(tokens);
|
|
763
|
+
const out = [];
|
|
764
|
+
tokens.forEach((t, k) => {
|
|
765
|
+
if (t.kind !== "column") return;
|
|
766
|
+
const before = tokens[k - 1];
|
|
767
|
+
if (isPunctuation(before, ".") && tokens[k - 2]?.kind === "column") return;
|
|
768
|
+
if (before?.kind === "table" || before?.kind === "identifier" && before.end === t.start) {
|
|
769
|
+
out.push({ table: before.text, name: t.text, qualified: true });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const calls = created.get(t.text.toLowerCase());
|
|
773
|
+
if (calls?.every(([open, close]) => k < open || k > close))
|
|
774
|
+
out.push({ name: t.text, qualified: false, created: true });
|
|
775
|
+
else out.push({ name: t.text, qualified: false });
|
|
776
|
+
});
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
606
779
|
var lower2 = (s) => s.toLowerCase();
|
|
607
780
|
var key = (table, name) => `${lower2(table)} ${lower2(name)}`;
|
|
608
|
-
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
609
781
|
function functionCallReader(functions) {
|
|
610
782
|
if (functions.length === 0) return () => [];
|
|
611
783
|
const byName2 = /* @__PURE__ */ new Map();
|
|
612
784
|
for (const f of functions) if (!byName2.has(lower2(f.name))) byName2.set(lower2(f.name), f);
|
|
613
|
-
const call = new RegExp(
|
|
614
|
-
`(^|[^\\p{L}\\p{N}_.])(${[...byName2.keys()].map(escapeRegExp).join("|")})(?=\\s*\\()`,
|
|
615
|
-
"giu"
|
|
616
|
-
);
|
|
617
785
|
const order3 = new Map(functions.map((f, i) => [f, i]));
|
|
618
|
-
return (
|
|
786
|
+
return (tokens) => {
|
|
619
787
|
const found = /* @__PURE__ */ new Set();
|
|
620
|
-
|
|
621
|
-
const f = byName2.get(lower2(
|
|
788
|
+
tokens.forEach((t, k) => {
|
|
789
|
+
const f = t.call === void 0 ? void 0 : byName2.get(lower2(tokens[k - 1].text));
|
|
622
790
|
if (f) found.add(f);
|
|
623
|
-
}
|
|
791
|
+
});
|
|
624
792
|
return [...found].sort((a, b) => order3.get(a) - order3.get(b));
|
|
625
793
|
};
|
|
626
794
|
}
|
|
627
|
-
function resolveBareName(
|
|
795
|
+
function resolveBareName(ref, owner, lookup) {
|
|
796
|
+
const { name } = ref;
|
|
628
797
|
const measure = lookup.measureNamed(name);
|
|
629
798
|
if (measure !== void 0) return { kind: "measure", measure };
|
|
630
|
-
if (owner.kind === "calculationItem") return { kind: "none" };
|
|
799
|
+
if (ref.created || owner.kind === "calculationItem") return { kind: "none" };
|
|
631
800
|
if (owner.kind === "function") {
|
|
632
801
|
const columns2 = lookup.tables.flatMap((t) => lookup.columnOf(t, name) ?? []);
|
|
633
802
|
return columns2.length > 0 ? { kind: "columns", columns: columns2 } : { kind: "none" };
|
|
@@ -664,7 +833,7 @@ function buildReferenceIndex(model) {
|
|
|
664
833
|
if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
|
|
665
834
|
return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
666
835
|
}
|
|
667
|
-
const bare = resolveBareName(raw
|
|
836
|
+
const bare = resolveBareName(raw, { kind: ownerKind, table: ownerTable }, lookup);
|
|
668
837
|
if (bare.kind === "measure")
|
|
669
838
|
return {
|
|
670
839
|
kind: "measure",
|
|
@@ -686,19 +855,26 @@ function buildReferenceIndex(model) {
|
|
|
686
855
|
const byObject = /* @__PURE__ */ new Map();
|
|
687
856
|
const add = (of, ownerTable, ...expressions) => {
|
|
688
857
|
const expression = expressions.filter((e) => e !== void 0).join("\n");
|
|
858
|
+
const tokens = tokenizeDax(expression);
|
|
689
859
|
const owner = {
|
|
690
860
|
...of,
|
|
691
861
|
ownerTable,
|
|
692
862
|
expression,
|
|
693
|
-
refs:
|
|
694
|
-
calls: callsIn(
|
|
863
|
+
refs: refsInTokens(tokens).flatMap((r) => resolve4(r, ownerTable, of.kind)),
|
|
864
|
+
calls: callsIn(tokens)
|
|
695
865
|
};
|
|
696
866
|
owners.push(owner);
|
|
697
867
|
byObject.set(of.object, owner);
|
|
698
868
|
};
|
|
699
869
|
for (const t of model.tables) {
|
|
700
870
|
for (const m of t.measures)
|
|
701
|
-
add(
|
|
871
|
+
add(
|
|
872
|
+
{ kind: "measure", object: m },
|
|
873
|
+
t,
|
|
874
|
+
m.expression,
|
|
875
|
+
m.formatStringDefinition,
|
|
876
|
+
...m.kpiExpressions ?? []
|
|
877
|
+
);
|
|
702
878
|
for (const c of t.columns)
|
|
703
879
|
if (c.kind === "calculated") add({ kind: "calculatedColumn", object: c }, t, c.expression);
|
|
704
880
|
if (t.kind === "calculated")
|
|
@@ -932,9 +1108,14 @@ function buildReportReferenceIndex(report, model) {
|
|
|
932
1108
|
return inModel ? { table: inModel.table.name, name: inModel.name } : reportMeasuresByName.get(lower3(name));
|
|
933
1109
|
}
|
|
934
1110
|
};
|
|
1111
|
+
const callsIn = functionCallReader(model?.functions ?? []);
|
|
1112
|
+
const functionCalls = [];
|
|
935
1113
|
for (const m of report.measures) {
|
|
936
1114
|
const owner = { kind: "reportMeasure", object: m };
|
|
937
|
-
|
|
1115
|
+
const tokens = tokenizeDax(m.expression);
|
|
1116
|
+
const calls = callsIn(tokens);
|
|
1117
|
+
if (calls.length > 0) functionCalls.push({ measure: m, calls });
|
|
1118
|
+
for (const raw of refsInTokens(tokens)) {
|
|
938
1119
|
if (raw.qualified) {
|
|
939
1120
|
const t = tables.get(lower3(raw.table));
|
|
940
1121
|
const kind = t && measureOf(t, raw.name) || reportMeasures.has(`${lower3(raw.table)}\0${lower3(raw.name)}`) ? "measure" : "column";
|
|
@@ -942,7 +1123,7 @@ function buildReportReferenceIndex(report, model) {
|
|
|
942
1123
|
continue;
|
|
943
1124
|
}
|
|
944
1125
|
const bare = resolveBareName(
|
|
945
|
-
raw
|
|
1126
|
+
raw,
|
|
946
1127
|
{ kind: "reportMeasure", table: tables.get(lower3(m.table)) },
|
|
947
1128
|
bareLookup
|
|
948
1129
|
);
|
|
@@ -970,19 +1151,8 @@ function buildReportReferenceIndex(report, model) {
|
|
|
970
1151
|
});
|
|
971
1152
|
}
|
|
972
1153
|
}
|
|
973
|
-
const callsIn = functionCallReader(model?.functions ?? []);
|
|
974
|
-
const functionCalls = report.measures.map((measure) => ({ measure, calls: callsIn(measure.expression) })).filter((c) => c.calls.length > 0);
|
|
975
|
-
const byTarget = /* @__PURE__ */ new Map();
|
|
976
|
-
for (const r of refs) {
|
|
977
|
-
const target = r.resolution.kind === "column" ? r.resolution.column : r.resolution.kind === "measure" ? r.resolution.measure : void 0;
|
|
978
|
-
if (!target) continue;
|
|
979
|
-
const arr = byTarget.get(target) ?? [];
|
|
980
|
-
arr.push(r);
|
|
981
|
-
byTarget.set(target, arr);
|
|
982
|
-
}
|
|
983
1154
|
return {
|
|
984
1155
|
refs,
|
|
985
|
-
referencedBy: (target) => byTarget.get(target) ?? [],
|
|
986
1156
|
unresolved: () => refs.filter((r) => r.resolution.kind === "unresolved"),
|
|
987
1157
|
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v),
|
|
988
1158
|
functionCalls
|
|
@@ -1037,7 +1207,7 @@ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
1037
1207
|
var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : "a boolean";
|
|
1038
1208
|
var CONFLICT_MARKER = /^(?:<{7}|={7}|>{7})(?:\s|$)/;
|
|
1039
1209
|
var LINE_BREAK = /\r\n?|\n/;
|
|
1040
|
-
function
|
|
1210
|
+
function quoted2(line) {
|
|
1041
1211
|
if (line === void 0 || line.length <= 120) return line ?? "";
|
|
1042
1212
|
const text2 = line.trimStart();
|
|
1043
1213
|
if (text2.length <= 120) return text2;
|
|
@@ -1086,7 +1256,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1086
1256
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
1087
1257
|
const lines = body.split(LINE_BREAK);
|
|
1088
1258
|
const issues = lines.flatMap(
|
|
1089
|
-
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text:
|
|
1259
|
+
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted2(line), reason: "merge conflict marker" }] : []
|
|
1090
1260
|
);
|
|
1091
1261
|
if (issues.length > 0) return { json: void 0, issues };
|
|
1092
1262
|
let json;
|
|
@@ -1098,7 +1268,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1098
1268
|
const flat = message.replace(/\s+/g, " ");
|
|
1099
1269
|
return {
|
|
1100
1270
|
json: void 0,
|
|
1101
|
-
issues: [{ file, line, text:
|
|
1271
|
+
issues: [{ file, line, text: quoted2(lines[line - 1]), reason: `not valid JSON (${flat})` }]
|
|
1102
1272
|
};
|
|
1103
1273
|
}
|
|
1104
1274
|
const deep = tooDeepAt(body);
|
|
@@ -1110,7 +1280,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1110
1280
|
{
|
|
1111
1281
|
file,
|
|
1112
1282
|
line,
|
|
1113
|
-
text:
|
|
1283
|
+
text: quoted2(lines[line - 1]),
|
|
1114
1284
|
reason: `nested more than ${MAX_DEPTH} levels deep`
|
|
1115
1285
|
}
|
|
1116
1286
|
]
|
|
@@ -1125,7 +1295,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1125
1295
|
{
|
|
1126
1296
|
file,
|
|
1127
1297
|
line: start + 1,
|
|
1128
|
-
text:
|
|
1298
|
+
text: quoted2(lines[start]),
|
|
1129
1299
|
reason: `not a JSON object (the file holds ${kindOf(json)})`
|
|
1130
1300
|
}
|
|
1131
1301
|
]
|
|
@@ -1357,13 +1527,16 @@ function filtersOf(filterConfig, file, pointer) {
|
|
|
1357
1527
|
if (!isRecord4(f)) return [];
|
|
1358
1528
|
const p = `${pointer}/filters/${i}`;
|
|
1359
1529
|
const field = collectFieldRefs(f.field, `${p}/field`)[0];
|
|
1530
|
+
const where = isRecord4(f.filter) && Array.isArray(f.filter.Where) ? f.filter.Where : void 0;
|
|
1360
1531
|
return [
|
|
1361
1532
|
{
|
|
1362
1533
|
name: str2(f.name) ?? String(i),
|
|
1363
1534
|
type: str2(f.type),
|
|
1535
|
+
howCreated: str2(f.howCreated),
|
|
1364
1536
|
...field ? { field } : {},
|
|
1365
1537
|
refs: collectFieldRefs(f, p),
|
|
1366
1538
|
applied: isRecord4(f.filter),
|
|
1539
|
+
...where ? { where } : {},
|
|
1367
1540
|
file,
|
|
1368
1541
|
pointer: p
|
|
1369
1542
|
}
|
|
@@ -1533,6 +1706,8 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1533
1706
|
visual,
|
|
1534
1707
|
pointer: `/explorationState/sections/${escapePointer(page)}/visualContainers/${escapePointer(visual)}`
|
|
1535
1708
|
});
|
|
1709
|
+
const options = isRecord4(json.options) ? json.options : {};
|
|
1710
|
+
const names = Array.isArray(options.targetVisualNames) ? options.targetVisualNames : [];
|
|
1536
1711
|
return {
|
|
1537
1712
|
id: str2(json.name) ?? id,
|
|
1538
1713
|
displayName: str2(json.displayName) ?? id,
|
|
@@ -1541,6 +1716,11 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1541
1716
|
...str2(state.activeSection) !== void 0 ? { activePage: str2(state.activeSection) } : {},
|
|
1542
1717
|
pages: Object.keys(sections),
|
|
1543
1718
|
visuals,
|
|
1719
|
+
...options.applyOnlyToTargetVisuals === true ? {
|
|
1720
|
+
targetVisuals: names.flatMap(
|
|
1721
|
+
(visual, i) => typeof visual === "string" ? [{ visual, pointer: `/options/targetVisualNames/${i}` }] : []
|
|
1722
|
+
)
|
|
1723
|
+
} : {},
|
|
1544
1724
|
refs: collectFieldRefs(state, "/explorationState")
|
|
1545
1725
|
};
|
|
1546
1726
|
}
|
|
@@ -1893,7 +2073,7 @@ var dataType = (c) => (c.dataType ?? "").toLowerCase();
|
|
|
1893
2073
|
var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
|
|
1894
2074
|
var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
|
|
1895
2075
|
var isBlank = (s) => s === void 0 || s.trim() === "";
|
|
1896
|
-
var
|
|
2076
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1897
2077
|
var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
|
|
1898
2078
|
var modelPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropObjects));
|
|
1899
2079
|
var tablesPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropTableLine));
|
|
@@ -1986,6 +2166,13 @@ var finding = {
|
|
|
1986
2166
|
location: e.location,
|
|
1987
2167
|
object: e
|
|
1988
2168
|
}),
|
|
2169
|
+
/** A user-defined function, named bare (`Local.AddTax`) as Tabular Editor names it. */
|
|
2170
|
+
function: (f) => ({
|
|
2171
|
+
objectType: "Function",
|
|
2172
|
+
objectName: f.name,
|
|
2173
|
+
location: f.location,
|
|
2174
|
+
object: f
|
|
2175
|
+
}),
|
|
1989
2176
|
dataSource: (d) => ({
|
|
1990
2177
|
objectType: "DataSource",
|
|
1991
2178
|
objectName: d.name,
|
|
@@ -2059,6 +2246,11 @@ var reportMeasureLabel = (m) => `[${m.name}] (report)`;
|
|
|
2059
2246
|
var pageFilterLabel = (p) => `Page filter on "${p.displayName}"`;
|
|
2060
2247
|
var REPORT_LABEL = "Report";
|
|
2061
2248
|
var REPORT_FILTER_LABEL = "Report filter";
|
|
2249
|
+
var fieldLabel = (ref) => {
|
|
2250
|
+
const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
|
|
2251
|
+
const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
|
|
2252
|
+
return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
|
|
2253
|
+
};
|
|
2062
2254
|
|
|
2063
2255
|
// ../core/src/rules/report-helpers.ts
|
|
2064
2256
|
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -3304,7 +3496,7 @@ var RULE_SUMMARIES = {
|
|
|
3304
3496
|
"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY": "Regular tables that carry a row-level security filter in any role and take part in a many-to-many relationship.",
|
|
3305
3497
|
AVOID_USING_THE_IFERROR_FUNCTION: "Measures and calculated columns that call IFERROR.",
|
|
3306
3498
|
BROKEN_ACTION_TARGET: "Buttons, shapes, and images whose page navigation, drillthrough, or bookmark action names a page or a bookmark the report does not have, matched against the `name` in each page.json and bookmark file.",
|
|
3307
|
-
BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report,
|
|
3499
|
+
BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report, which capture a visual that is not on its page, or which apply only to selected visuals and name one that is not on their active page, matched against the `name` in each page.json and visual.json.",
|
|
3308
3500
|
BROKEN_FIELD_REFERENCE: "References in the report to a table, column, measure, hierarchy, or hierarchy level that the model does not have, wherever the report names a field: a visual's wells, formatting, and sort, a filter on a visual, a page, or the whole report, a drillthrough or tooltip page's fields, and a bookmark.",
|
|
3309
3501
|
CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: "Calculation groups that contain no calculation items.",
|
|
3310
3502
|
"CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": "Every relationship that is bi-directional, many-to-many, or both. This is a review list at info severity, not a defect.",
|
|
@@ -3328,6 +3520,7 @@ var RULE_SUMMARIES = {
|
|
|
3328
3520
|
FIX_REFERENTIAL_INTEGRITY_VIOLATIONS: "Relationships where the many side holds key values that do not exist on the one side. The count of offending rows is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
|
|
3329
3521
|
"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS": "Visible columns whose name starts with Is and whose type is whole number, and visible columns whose name ends with the word Flag after a space and whose type is not text.",
|
|
3330
3522
|
HARDCODED_PERIOD_IN_DAX: "Measures, calculated columns, and calculation items whose DAX fixes a year or a date, and date tables whose CALENDAR ends on a fixed date.",
|
|
3523
|
+
HARDCODED_YEAR_IN_FILTER: "Filters in the Filters pane, on a visual, a page, or all pages, that hold a year column to fixed years: years picked in Basic filtering, a year set with Advanced filtering's is, years kept up to one with is less than or is less than or equal to, and a range between two years.",
|
|
3331
3524
|
HIDDEN_VISUAL_WITH_FIELDS: "Visuals hidden in the Selection pane, with their own eye icon or with that of a group they sit in, that still have fields in their wells.",
|
|
3332
3525
|
HIDE_FACT_TABLE_COLUMNS: "Visible numeric columns that a measure aggregates directly with a fully qualified reference, such as `SUM('Sales'[Amount])`. COUNT, COUNTBLANK, SUM, AVERAGE, MIN, MAX, DISTINCTCOUNT, VALUES, DISTINCT, and the A-suffixed COUNTA, AVERAGEA, MAXA, and MINA count as aggregations.",
|
|
3333
3526
|
HIDE_FOREIGN_KEYS: "Visible columns whose name matches the from column of a relationship whose from side is many. Only the from cardinality is tested, so a many-to-many relationship counts here too, not just many-to-one.",
|
|
@@ -3382,6 +3575,9 @@ var RULE_SUMMARIES = {
|
|
|
3382
3575
|
SPLIT_DATE_AND_TIME: "DateTime columns holding values that are not at midnight. Whether any row carries a time is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
|
|
3383
3576
|
TAB_ORDER_FOLLOWS_LAYOUT: "Pages whose tab order, the order keyboard users move through the visuals in, disagrees with the order the layout reads in: rows from top to bottom, and left to right within a row. The rule checks this only when the project's policy asks for tab order to follow the layout, and reports nothing without it.",
|
|
3384
3577
|
TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
|
|
3578
|
+
UDF_NOT_CALLED: "User-defined functions that no measure, calculated column, calculated table, calculation item, row-level security filter, format string expression, KPI, or other function in the model calls. The functions of a DAX Lib package, which share one `DAXLIB_PackageId` annotation, count as one: the package is reported once, on its first function, when nothing outside it calls any of them.",
|
|
3579
|
+
UDF_USE_COMPOUND_NAMES: "User-defined functions whose name holds neither a dot nor an underscore, such as `AddTax`. Tabular Editor 3 has a built-in rule with the same test.",
|
|
3580
|
+
UDF_WITHOUT_DESCRIPTION: "User-defined functions with no description, or one of only spaces, other than functions installed from a DAX Lib package. Tabular Editor 3 has a built-in rule with the same test, which also reports package functions.",
|
|
3385
3581
|
UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, row-level security filter, or object-level security rule.",
|
|
3386
3582
|
UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
|
|
3387
3583
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": "Tables that have a numeric column for each of Jan, Feb, Mar, Apr, May, and Jun, matched as substrings of the column names.",
|
|
@@ -3413,6 +3609,7 @@ var SCOPE_MAP = {
|
|
|
3413
3609
|
NamedExpression: "NamedExpression",
|
|
3414
3610
|
ProviderDataSource: "DataSource",
|
|
3415
3611
|
StructuredDataSource: "DataSource",
|
|
3612
|
+
UserDefinedFunction: "Function",
|
|
3416
3613
|
KPI: null
|
|
3417
3614
|
};
|
|
3418
3615
|
function mapScope(scope) {
|
|
@@ -3580,7 +3777,7 @@ var HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
|
|
|
3580
3777
|
return columns(m, (c) => {
|
|
3581
3778
|
if (c.isHidden || !isNumericType(c)) return false;
|
|
3582
3779
|
const re = new RegExp(
|
|
3583
|
-
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${
|
|
3780
|
+
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp(c.table.name)}'*\\[${escapeRegExp(c.name)}\\]\\s*\\)`,
|
|
3584
3781
|
"i"
|
|
3585
3782
|
);
|
|
3586
3783
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -3925,7 +4122,7 @@ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
|
3925
4122
|
return m.relationships.filter((r) => {
|
|
3926
4123
|
if (r.isActive) return false;
|
|
3927
4124
|
const re = new RegExp(
|
|
3928
|
-
`USERELATIONSHIP\\s*\\(\\s*'*${
|
|
4125
|
+
`USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp(r.fromTable)}'*\\[${escapeRegExp(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp(r.toTable)}'*\\[${escapeRegExp(r.toColumn)}\\]`,
|
|
3929
4126
|
"i"
|
|
3930
4127
|
);
|
|
3931
4128
|
return !expressions.some((e) => re.test(e));
|
|
@@ -4107,7 +4304,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
|
|
|
4107
4304
|
return tablesInScope(m).filter((t) => {
|
|
4108
4305
|
if (!permissions.some((tp) => tp.table === t.name && tp.filter !== void 0)) return false;
|
|
4109
4306
|
const re = new RegExp(
|
|
4110
|
-
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${
|
|
4307
|
+
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp(t.name)}'*\\[`,
|
|
4111
4308
|
"i"
|
|
4112
4309
|
);
|
|
4113
4310
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -4489,404 +4686,34 @@ function pbiplintRule(spec) {
|
|
|
4489
4686
|
};
|
|
4490
4687
|
}
|
|
4491
4688
|
|
|
4492
|
-
// ../core/src/rules/pbiplint/
|
|
4493
|
-
var
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
v,
|
|
4521
|
-
a.pointer,
|
|
4522
|
-
`${checked.action} action points at ${checked.object} "${a.target}", which does not exist`
|
|
4523
|
-
)
|
|
4524
|
-
];
|
|
4525
|
-
})
|
|
4526
|
-
);
|
|
4527
|
-
}
|
|
4528
|
-
});
|
|
4529
|
-
var ACTION_WITHOUT_DESTINATION = pbiplintRule({
|
|
4530
|
-
id: "ACTION_WITHOUT_DESTINATION",
|
|
4531
|
-
name: "Action has no destination",
|
|
4532
|
-
category: "Report Design",
|
|
4533
|
-
severity: 2,
|
|
4534
|
-
scope: ["Visual"],
|
|
4535
|
-
layer: "report",
|
|
4536
|
-
// The same three types as BROKEN_ACTION_TARGET, on any visual, hidden or not. The pointer is the
|
|
4537
|
-
// destination property when it is there and empty, else the entry's properties.
|
|
4538
|
-
check: ({ report }) => report ? allVisuals(report).flatMap(
|
|
4539
|
-
(v) => v.actions.flatMap((a) => {
|
|
4540
|
-
const checked = CHECKED.get(a.type.toLowerCase());
|
|
4541
|
-
if (!checked || !a.on || a.target !== void 0 || a.conditional) return [];
|
|
4542
|
-
return [
|
|
4543
|
-
reportFinding.visual(v, a.pointer, `${checked.action} action has no destination`)
|
|
4544
|
-
];
|
|
4545
|
-
})
|
|
4546
|
-
) : []
|
|
4547
|
-
});
|
|
4548
|
-
var SECTIONS = "/explorationState/sections";
|
|
4549
|
-
var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
4550
|
-
id: "BROKEN_BOOKMARK_REFERENCE",
|
|
4551
|
-
name: "Bookmark refers to a missing page or visual",
|
|
4552
|
-
category: "Error Prevention",
|
|
4553
|
-
severity: 2,
|
|
4554
|
-
scope: ["Bookmark"],
|
|
4555
|
-
layer: "report",
|
|
4556
|
-
// Groups are captured apart from visuals and are not read, nor is the list of target visuals.
|
|
4557
|
-
// A page or a visual whose own file could not be read is there, under the folder name Desktop
|
|
4558
|
-
// gives it, so it is never reported missing, and neither is one a folder that could not be read
|
|
4559
|
-
// could hold.
|
|
4560
|
-
check: ({ report }) => {
|
|
4561
|
-
if (!report) return [];
|
|
4562
|
-
const pages = new Map(report.pages.map((p) => [p.id, p]));
|
|
4563
|
-
const missing = (id) => !pages.has(id) && !pageUnread(report, id);
|
|
4564
|
-
return report.bookmarks.flatMap((b) => {
|
|
4565
|
-
const out = [];
|
|
4566
|
-
if (b.activePage !== void 0 && missing(b.activePage))
|
|
4567
|
-
out.push(
|
|
4568
|
-
reportFinding.bookmark(
|
|
4569
|
-
b,
|
|
4570
|
-
`active page "${b.activePage}" does not exist`,
|
|
4571
|
-
"/explorationState/activeSection"
|
|
4572
|
-
)
|
|
4573
|
-
);
|
|
4574
|
-
for (const id of b.pages)
|
|
4575
|
-
if (id !== b.activePage && missing(id))
|
|
4576
|
-
out.push(
|
|
4577
|
-
reportFinding.bookmark(
|
|
4578
|
-
b,
|
|
4579
|
-
`captured page "${id}" does not exist`,
|
|
4580
|
-
`${SECTIONS}/${escapePointer(id)}`
|
|
4581
|
-
)
|
|
4582
|
-
);
|
|
4583
|
-
for (const { page, visual, pointer } of b.visuals) {
|
|
4584
|
-
const p = pages.get(page);
|
|
4585
|
-
if (p && !p.visuals.some((v) => v.id === visual) && !visualUnread(report, p, visual))
|
|
4586
|
-
out.push(
|
|
4587
|
-
reportFinding.bookmark(
|
|
4588
|
-
b,
|
|
4589
|
-
`captured visual "${visual}" is not on page "${p.displayName}"`,
|
|
4590
|
-
pointer
|
|
4591
|
-
)
|
|
4592
|
-
);
|
|
4593
|
-
}
|
|
4594
|
-
return out;
|
|
4595
|
-
});
|
|
4596
|
-
}
|
|
4597
|
-
});
|
|
4598
|
-
var actionRules = [
|
|
4599
|
-
BROKEN_ACTION_TARGET,
|
|
4600
|
-
ACTION_WITHOUT_DESTINATION,
|
|
4601
|
-
BROKEN_BOOKMARK_REFERENCE
|
|
4602
|
-
];
|
|
4603
|
-
|
|
4604
|
-
// ../core/src/rules/pbiplint/measures.ts
|
|
4605
|
-
var REPORT_LEVEL_MEASURES = pbiplintRule({
|
|
4606
|
-
id: "REPORT_LEVEL_MEASURES",
|
|
4607
|
-
name: "Measure defined in the report",
|
|
4608
|
-
category: "Maintenance",
|
|
4609
|
-
severity: 2,
|
|
4610
|
-
scope: ["ReportMeasure"],
|
|
4611
|
-
layer: "report",
|
|
4612
|
-
// Its findings are report objects, yet it reports only beside the model the report reads, so a
|
|
4613
|
-
// run without the model skips it rather than counting it as run.
|
|
4614
|
-
needs: ["model", "report"],
|
|
4615
|
-
// The detail says where the measure lives; the rule's page gives the fix.
|
|
4616
|
-
check: (project) => reportMeasuresToMove(project).map(
|
|
4617
|
-
(m) => reportFinding.reportMeasure(m, `defined in the report on table "${m.table}"`)
|
|
4618
|
-
)
|
|
4619
|
-
});
|
|
4620
|
-
var measureRules2 = [REPORT_LEVEL_MEASURES];
|
|
4621
|
-
|
|
4622
|
-
// ../core/src/rules/pbiplint/opening.ts
|
|
4623
|
-
var LANDING_PAGE_NOT_SET = pbiplintRule({
|
|
4624
|
-
id: "LANDING_PAGE_NOT_SET",
|
|
4625
|
-
name: "No landing page set",
|
|
4626
|
-
category: "Report Design",
|
|
4627
|
-
severity: 1,
|
|
4628
|
-
scope: ["Report"],
|
|
4629
|
-
layer: "report",
|
|
4630
|
-
check: ({ report }) => {
|
|
4631
|
-
const opens = report && landingPageNotSet(report) ? openingPage(report) : void 0;
|
|
4632
|
-
if (!report || !opens) return [];
|
|
4633
|
-
const pointer = opens.by === "active" ? "/activePageName" : void 0;
|
|
4634
|
-
if (opens.page === void 0 && !opens.unread)
|
|
4635
|
-
return [
|
|
4636
|
-
reportFinding.pagesHeader(
|
|
4637
|
-
report,
|
|
4638
|
-
pointer,
|
|
4639
|
-
`no landing page set; the active page "${opens.name}" does not exist`
|
|
4640
|
-
)
|
|
4641
|
-
];
|
|
4642
|
-
const name = opens.page?.displayName ?? opens.name;
|
|
4643
|
-
const which = opens.by === "first" ? "the first page" : "the page open when it was saved";
|
|
4644
|
-
return [reportFinding.pagesHeader(report, pointer, `opens on "${name}", ${which}`)];
|
|
4645
|
-
}
|
|
4646
|
-
});
|
|
4647
|
-
var OPENING_PAGE_INVALID = pbiplintRule({
|
|
4648
|
-
id: "OPENING_PAGE_INVALID",
|
|
4649
|
-
name: "Opening page missing or hidden",
|
|
4650
|
-
category: "Error Prevention",
|
|
4651
|
-
severity: 3,
|
|
4652
|
-
scope: ["Report"],
|
|
4653
|
-
layer: "report",
|
|
4654
|
-
check: ({ report }) => {
|
|
4655
|
-
const opens = report && openingPage(report);
|
|
4656
|
-
if (!report || !opens || !openingPageInvalid(opens)) return [];
|
|
4657
|
-
const pointer = opens.by === "landing" ? "/landingPageName" : "/activePageName";
|
|
4658
|
-
const detail = opens.page === void 0 ? `${opens.by} page "${opens.name}" does not exist` : `active page "${opens.page.displayName}" is hidden from readers`;
|
|
4659
|
-
return [reportFinding.pagesHeader(report, pointer, detail)];
|
|
4660
|
-
}
|
|
4661
|
-
});
|
|
4662
|
-
var FILTERS_PANE_STATE = pbiplintRule({
|
|
4663
|
-
id: "FILTERS_PANE_STATE",
|
|
4664
|
-
name: "Filters pane state differs from policy",
|
|
4665
|
-
category: "Report Design",
|
|
4666
|
-
severity: 2,
|
|
4667
|
-
scope: ["Report"],
|
|
4668
|
-
layer: "report",
|
|
4669
|
-
options: [{ name: "expect", type: "string", values: ["open", "closed"] }],
|
|
4670
|
-
check: ({ report }, ctx) => {
|
|
4671
|
-
const expect = ctx.options.expect;
|
|
4672
|
-
if (!report || expect === void 0) return [];
|
|
4673
|
-
const pane = filtersPaneState(report);
|
|
4674
|
-
if (!pane || pane.state === expect) return [];
|
|
4675
|
-
const { state, recordedAt } = pane;
|
|
4676
|
-
const saved = recordedAt === void 0 ? `not recorded, read as ${state}` : `saved ${state}`;
|
|
4677
|
-
return [
|
|
4678
|
-
reportFinding.report(report, `${saved}; the policy expects ${expect}`, "report", recordedAt)
|
|
4679
|
-
];
|
|
4680
|
-
}
|
|
4681
|
-
});
|
|
4682
|
-
var openingRules = [LANDING_PAGE_NOT_SET, OPENING_PAGE_INVALID, FILTERS_PANE_STATE];
|
|
4683
|
-
|
|
4684
|
-
// ../core/src/rules/pbiplint/pages.ts
|
|
4685
|
-
var NEW_PAGE = "is the name Power BI Desktop gives a new page";
|
|
4686
|
-
var DUPLICATE = "is the name Power BI Desktop gives a duplicated page";
|
|
4687
|
-
var DEFAULT_NAMES = [
|
|
4688
|
-
[/^(?:Page|Seite|Página|Pagina|ページ) [1-9]\d*$/, NEW_PAGE],
|
|
4689
|
-
[/^(?:Duplicate of|Duplicado de|Doublon de|Duplicata de|Duplikat av) .+$/, DUPLICATE],
|
|
4690
|
-
[/^Duplikat von ".+"$/, DUPLICATE],
|
|
4691
|
-
[/^.+ \(copy\)$/, "is named as a copy"]
|
|
4692
|
-
];
|
|
4693
|
-
var recordedName = (p) => {
|
|
4694
|
-
const name = p.json?.displayName;
|
|
4695
|
-
return typeof name === "string" ? name : void 0;
|
|
4696
|
-
};
|
|
4697
|
-
var DEFAULT_PAGE_NAME = pbiplintRule({
|
|
4698
|
-
id: "DEFAULT_PAGE_NAME",
|
|
4699
|
-
name: "Page keeps its default name",
|
|
4700
|
-
category: "Report Design",
|
|
4701
|
-
severity: 2,
|
|
4702
|
-
scope: ["Page"],
|
|
4703
|
-
layer: "report",
|
|
4704
|
-
check: ({ report }) => report ? report.pages.flatMap((p) => {
|
|
4705
|
-
const name = recordedName(p);
|
|
4706
|
-
const match = name === void 0 ? void 0 : DEFAULT_NAMES.find(([re]) => re.test(name));
|
|
4707
|
-
return match ? [reportFinding.page(p, "/displayName", `"${name}" ${match[1]}`)] : [];
|
|
4708
|
-
}) : []
|
|
4709
|
-
});
|
|
4710
|
-
var pageRules2 = [DEFAULT_PAGE_NAME];
|
|
4711
|
-
|
|
4712
|
-
// ../core/src/dax/tokenize.ts
|
|
4713
|
-
var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
|
|
4714
|
-
var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
4715
|
-
var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
|
|
4716
|
-
var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
4717
|
-
var DIGIT = /[0-9]/;
|
|
4718
|
-
var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
|
|
4719
|
-
var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
|
|
4720
|
-
function quoted2(text2, from, close) {
|
|
4721
|
-
let value = "";
|
|
4722
|
-
let j = from + 1;
|
|
4723
|
-
while (j < text2.length) {
|
|
4724
|
-
const c = text2[j];
|
|
4725
|
-
if (c === close) {
|
|
4726
|
-
if (text2[j + 1] !== close) return { value, end: j + 1 };
|
|
4727
|
-
j++;
|
|
4728
|
-
}
|
|
4729
|
-
value += c;
|
|
4730
|
-
j++;
|
|
4731
|
-
}
|
|
4732
|
-
return { value, end: text2.length };
|
|
4733
|
-
}
|
|
4734
|
-
function tokenizeDax(expression) {
|
|
4735
|
-
const s = expression;
|
|
4736
|
-
const tokens = [];
|
|
4737
|
-
const push2 = (kind, text2, start, end) => {
|
|
4738
|
-
tokens.push({ kind, text: text2, start, end, depth: 0 });
|
|
4739
|
-
};
|
|
4740
|
-
let i = 0;
|
|
4741
|
-
while (i < s.length) {
|
|
4742
|
-
const c = s[i];
|
|
4743
|
-
if (/\s/.test(c)) {
|
|
4744
|
-
i++;
|
|
4745
|
-
} else if (s.startsWith("//", i) || s.startsWith("--", i)) {
|
|
4746
|
-
const nl = s.indexOf("\n", i);
|
|
4747
|
-
i = nl === -1 ? s.length : nl;
|
|
4748
|
-
} else if (s.startsWith("/*", i)) {
|
|
4749
|
-
const close = s.indexOf("*/", i + 2);
|
|
4750
|
-
i = close === -1 ? s.length : close + 2;
|
|
4751
|
-
} else if (c === '"') {
|
|
4752
|
-
const q2 = quoted2(s, i, '"');
|
|
4753
|
-
push2("string", q2.value, i, q2.end);
|
|
4754
|
-
i = q2.end;
|
|
4755
|
-
} else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
|
|
4756
|
-
const q2 = quoted2(s, i + 2, '"');
|
|
4757
|
-
push2("date", q2.value, i, q2.end);
|
|
4758
|
-
i = q2.end;
|
|
4759
|
-
} else if (c === "'" || c === "[") {
|
|
4760
|
-
const q2 = quoted2(s, i, c === "'" ? "'" : "]");
|
|
4761
|
-
push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
|
|
4762
|
-
i = q2.end;
|
|
4763
|
-
} else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
|
|
4764
|
-
NUMBER.lastIndex = i;
|
|
4765
|
-
const text2 = NUMBER.exec(s)[0];
|
|
4766
|
-
push2("number", text2, i, i + text2.length);
|
|
4767
|
-
i += text2.length;
|
|
4768
|
-
} else {
|
|
4769
|
-
IDENTIFIER.lastIndex = i;
|
|
4770
|
-
const id = IDENTIFIER.exec(s)?.[0];
|
|
4771
|
-
const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
|
|
4772
|
-
const text2 = id ?? op ?? c;
|
|
4773
|
-
push2(
|
|
4774
|
-
id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
|
|
4775
|
-
text2,
|
|
4776
|
-
i,
|
|
4777
|
-
i + text2.length
|
|
4778
|
-
);
|
|
4779
|
-
i += text2.length;
|
|
4780
|
-
}
|
|
4781
|
-
}
|
|
4782
|
-
annotate(tokens);
|
|
4783
|
-
return tokens;
|
|
4784
|
-
}
|
|
4785
|
-
function annotate(tokens) {
|
|
4786
|
-
const open = [];
|
|
4787
|
-
const args = [];
|
|
4788
|
-
const place = (t) => {
|
|
4789
|
-
t.depth = open.length;
|
|
4790
|
-
if (open.length === 0) return;
|
|
4791
|
-
t.parent = open[open.length - 1];
|
|
4792
|
-
t.arg = args[args.length - 1];
|
|
4793
|
-
};
|
|
4794
|
-
tokens.forEach((t, k) => {
|
|
4795
|
-
if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
|
|
4796
|
-
place(t);
|
|
4797
|
-
const before = tokens[k - 1];
|
|
4798
|
-
if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
|
|
4799
|
-
open.push(k);
|
|
4800
|
-
args.push(0);
|
|
4801
|
-
} else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
|
|
4802
|
-
const o = open.pop();
|
|
4803
|
-
args.pop();
|
|
4804
|
-
if (o !== void 0) {
|
|
4805
|
-
tokens[o].close = k;
|
|
4806
|
-
t.open = o;
|
|
4807
|
-
}
|
|
4808
|
-
place(t);
|
|
4809
|
-
} else {
|
|
4810
|
-
place(t);
|
|
4811
|
-
if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
|
|
4812
|
-
}
|
|
4813
|
-
});
|
|
4814
|
-
}
|
|
4815
|
-
function opensBlock(tokens, k) {
|
|
4816
|
-
if (isWord(tokens[k - 1], "RETURN")) return true;
|
|
4817
|
-
const eq = tokens[k - 1];
|
|
4818
|
-
return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
|
|
4819
|
-
}
|
|
4820
|
-
function daxVariables(tokens) {
|
|
4821
|
-
const out = [];
|
|
4822
|
-
tokens.forEach((t, k) => {
|
|
4823
|
-
const name = tokens[k + 1];
|
|
4824
|
-
const eq = tokens[k + 2];
|
|
4825
|
-
if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
|
|
4826
|
-
if (eq.text !== "=") return;
|
|
4827
|
-
const from = k + 3;
|
|
4828
|
-
let to = from;
|
|
4829
|
-
let nested = 0;
|
|
4830
|
-
for (; to < tokens.length; to++) {
|
|
4831
|
-
const x = tokens[to];
|
|
4832
|
-
if (x.depth < t.depth) break;
|
|
4833
|
-
if (x.depth !== t.depth) continue;
|
|
4834
|
-
if (isWord(x, "VAR")) {
|
|
4835
|
-
if (opensBlock(tokens, to)) nested++;
|
|
4836
|
-
else if (nested === 0) break;
|
|
4837
|
-
} else if (isWord(x, "RETURN")) {
|
|
4838
|
-
if (nested === 0) break;
|
|
4839
|
-
nested--;
|
|
4840
|
-
}
|
|
4841
|
-
}
|
|
4842
|
-
let blockEnd2 = to;
|
|
4843
|
-
for (; blockEnd2 < tokens.length; blockEnd2++) {
|
|
4844
|
-
const x = tokens[blockEnd2];
|
|
4845
|
-
if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
|
|
4846
|
-
}
|
|
4847
|
-
out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
|
|
4848
|
-
});
|
|
4849
|
-
for (const v of out)
|
|
4850
|
-
for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
|
|
4851
|
-
return out;
|
|
4852
|
-
}
|
|
4853
|
-
function variableAt(vars, name, use) {
|
|
4854
|
-
const key4 = name.toUpperCase();
|
|
4855
|
-
let found;
|
|
4856
|
-
for (const v of vars)
|
|
4857
|
-
if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
|
|
4858
|
-
found = v;
|
|
4859
|
-
return found;
|
|
4860
|
-
}
|
|
4861
|
-
|
|
4862
|
-
// ../core/src/rules/pbiplint/period-words.ts
|
|
4863
|
-
var YEAR_WORDS = /* @__PURE__ */ new Set([
|
|
4864
|
-
"year",
|
|
4865
|
-
"years",
|
|
4866
|
-
"yr",
|
|
4867
|
-
"yrs",
|
|
4868
|
-
"a\xF1o",
|
|
4869
|
-
"a\xF1os",
|
|
4870
|
-
"ano",
|
|
4871
|
-
"anos",
|
|
4872
|
-
"anio",
|
|
4873
|
-
"jahr",
|
|
4874
|
-
"ann\xE9e",
|
|
4875
|
-
"annee",
|
|
4876
|
-
"anno",
|
|
4877
|
-
"jaar",
|
|
4878
|
-
"\xE5r",
|
|
4879
|
-
"rok",
|
|
4880
|
-
"vuosi",
|
|
4881
|
-
"ejercicio",
|
|
4882
|
-
"exercice",
|
|
4883
|
-
"fy",
|
|
4884
|
-
"ay",
|
|
4885
|
-
"cy",
|
|
4886
|
-
"ly",
|
|
4887
|
-
"py",
|
|
4888
|
-
"yyyy",
|
|
4889
|
-
"y"
|
|
4689
|
+
// ../core/src/rules/pbiplint/period-words.ts
|
|
4690
|
+
var YEAR_WORDS = /* @__PURE__ */ new Set([
|
|
4691
|
+
"year",
|
|
4692
|
+
"years",
|
|
4693
|
+
"yr",
|
|
4694
|
+
"yrs",
|
|
4695
|
+
"a\xF1o",
|
|
4696
|
+
"a\xF1os",
|
|
4697
|
+
"ano",
|
|
4698
|
+
"anos",
|
|
4699
|
+
"anio",
|
|
4700
|
+
"jahr",
|
|
4701
|
+
"ann\xE9e",
|
|
4702
|
+
"annee",
|
|
4703
|
+
"anno",
|
|
4704
|
+
"jaar",
|
|
4705
|
+
"\xE5r",
|
|
4706
|
+
"rok",
|
|
4707
|
+
"vuosi",
|
|
4708
|
+
"ejercicio",
|
|
4709
|
+
"exercice",
|
|
4710
|
+
"fy",
|
|
4711
|
+
"ay",
|
|
4712
|
+
"cy",
|
|
4713
|
+
"ly",
|
|
4714
|
+
"py",
|
|
4715
|
+
"yyyy",
|
|
4716
|
+
"y"
|
|
4890
4717
|
]);
|
|
4891
4718
|
var MONTH_WORDS = /* @__PURE__ */ new Set([
|
|
4892
4719
|
"month",
|
|
@@ -4933,6 +4760,7 @@ function nameClass(name) {
|
|
|
4933
4760
|
// ../core/src/rules/pbiplint/period-forms.ts
|
|
4934
4761
|
var FIRST_YEAR = 1950;
|
|
4935
4762
|
var LAST_YEAR = 2049;
|
|
4763
|
+
var inYearRange = (year) => year >= FIRST_YEAR && year <= LAST_YEAR;
|
|
4936
4764
|
var COMPARISONS = /* @__PURE__ */ new Set(["=", "==", "<>"]);
|
|
4937
4765
|
var WRAPPERS = /* @__PURE__ */ new Set([
|
|
4938
4766
|
"SELECTEDVALUE",
|
|
@@ -4966,7 +4794,7 @@ function yearIn(t, strings) {
|
|
|
4966
4794
|
const text2 = t?.kind === "number" ? t.text : strings && t?.kind === "string" ? t.text.trim() : void 0;
|
|
4967
4795
|
if (text2 === void 0 || !/^\d{4}$/.test(text2)) return void 0;
|
|
4968
4796
|
const year = Number(text2);
|
|
4969
|
-
return year
|
|
4797
|
+
return inYearRange(year) ? year : void 0;
|
|
4970
4798
|
}
|
|
4971
4799
|
function argumentsOf(tokens, open) {
|
|
4972
4800
|
const end = tokens[open]?.close ?? tokens.length;
|
|
@@ -5066,14 +4894,14 @@ function expressionPeriods(expression) {
|
|
|
5066
4894
|
}
|
|
5067
4895
|
if (t.call === "DATE" && !isBound(tokens, k) && !isYearFreeFormat(tokens, k)) {
|
|
5068
4896
|
const [y, m, d] = argumentsOf(tokens, k).map((span) => only(tokens, span));
|
|
5069
|
-
const
|
|
5070
|
-
if (
|
|
4897
|
+
const fixed2 = yearIn(y, false);
|
|
4898
|
+
if (fixed2 !== void 0) {
|
|
5071
4899
|
if (isWhole(m) && isWhole(d)) {
|
|
5072
4900
|
const [month, day] = [Number(m.text), Number(d.text)];
|
|
5073
|
-
const epoch =
|
|
5074
|
-
const date = daxDate(
|
|
5075
|
-
if (!epoch && date) found.push({ at: tokens[k - 1].start, year:
|
|
5076
|
-
} else year(y,
|
|
4901
|
+
const epoch = fixed2 === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
|
|
4902
|
+
const date = daxDate(fixed2, month, day);
|
|
4903
|
+
if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed2, date });
|
|
4904
|
+
} else year(y, fixed2);
|
|
5077
4905
|
}
|
|
5078
4906
|
}
|
|
5079
4907
|
});
|
|
@@ -5234,27 +5062,417 @@ function periodFindings(model) {
|
|
|
5234
5062
|
)
|
|
5235
5063
|
);
|
|
5236
5064
|
}
|
|
5237
|
-
return out;
|
|
5238
|
-
}
|
|
5239
|
-
var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
|
|
5240
|
-
id: "HARDCODED_PERIOD_IN_DAX",
|
|
5241
|
-
name: "Hardcoded period in DAX",
|
|
5242
|
-
category: "DAX Expressions",
|
|
5243
|
-
severity: 1,
|
|
5244
|
-
scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
|
|
5245
|
-
layer: "model",
|
|
5246
|
-
// No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
|
|
5247
|
-
// parser could not read can hide an object from the rule, never put a period in one.
|
|
5248
|
-
check: ({ model }) => model ? periodFindings(model) : []
|
|
5065
|
+
return out;
|
|
5066
|
+
}
|
|
5067
|
+
var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
|
|
5068
|
+
id: "HARDCODED_PERIOD_IN_DAX",
|
|
5069
|
+
name: "Hardcoded period in DAX",
|
|
5070
|
+
category: "DAX Expressions",
|
|
5071
|
+
severity: 1,
|
|
5072
|
+
scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
|
|
5073
|
+
layer: "model",
|
|
5074
|
+
// No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
|
|
5075
|
+
// parser could not read can hide an object from the rule, never put a period in one.
|
|
5076
|
+
check: ({ model }) => model ? periodFindings(model) : []
|
|
5077
|
+
});
|
|
5078
|
+
var periodRules = [HARDCODED_PERIOD_IN_DAX];
|
|
5079
|
+
|
|
5080
|
+
// ../core/src/rules/pbiplint/actions.ts
|
|
5081
|
+
var CHECKED = /* @__PURE__ */ new Map([
|
|
5082
|
+
["pagenavigation", { action: "Page navigation", object: "page" }],
|
|
5083
|
+
["drillthrough", { action: "Drillthrough", object: "page" }],
|
|
5084
|
+
["bookmark", { action: "Bookmark", object: "bookmark" }]
|
|
5085
|
+
]);
|
|
5086
|
+
var BROKEN_ACTION_TARGET = pbiplintRule({
|
|
5087
|
+
id: "BROKEN_ACTION_TARGET",
|
|
5088
|
+
name: "Action points at nothing",
|
|
5089
|
+
category: "Error Prevention",
|
|
5090
|
+
severity: 3,
|
|
5091
|
+
scope: ["Visual"],
|
|
5092
|
+
layer: "report",
|
|
5093
|
+
check: ({ report }) => {
|
|
5094
|
+
if (!report) return [];
|
|
5095
|
+
const pages = new Set(report.pages.map((p) => p.id));
|
|
5096
|
+
const bookmarks = new Set(report.bookmarks.map((b) => b.id));
|
|
5097
|
+
const exists = {
|
|
5098
|
+
page: (name) => pages.has(name) || pageUnread(report, name),
|
|
5099
|
+
bookmark: (name) => bookmarks.has(name) || bookmarkUnread(report, name)
|
|
5100
|
+
};
|
|
5101
|
+
return allVisuals(report).flatMap(
|
|
5102
|
+
(v) => v.actions.flatMap((a) => {
|
|
5103
|
+
const checked = CHECKED.get(a.type.toLowerCase());
|
|
5104
|
+
if (!checked || !a.on || a.target === void 0) return [];
|
|
5105
|
+
if (exists[checked.object](a.target)) return [];
|
|
5106
|
+
return [
|
|
5107
|
+
reportFinding.visual(
|
|
5108
|
+
v,
|
|
5109
|
+
a.pointer,
|
|
5110
|
+
`${checked.action} action points at ${checked.object} "${a.target}", which does not exist`
|
|
5111
|
+
)
|
|
5112
|
+
];
|
|
5113
|
+
})
|
|
5114
|
+
);
|
|
5115
|
+
}
|
|
5116
|
+
});
|
|
5117
|
+
var ACTION_WITHOUT_DESTINATION = pbiplintRule({
|
|
5118
|
+
id: "ACTION_WITHOUT_DESTINATION",
|
|
5119
|
+
name: "Action has no destination",
|
|
5120
|
+
category: "Report Design",
|
|
5121
|
+
severity: 2,
|
|
5122
|
+
scope: ["Visual"],
|
|
5123
|
+
layer: "report",
|
|
5124
|
+
// The same three types as BROKEN_ACTION_TARGET, on any visual, hidden or not. The pointer is the
|
|
5125
|
+
// destination property when it is there and empty, else the entry's properties.
|
|
5126
|
+
check: ({ report }) => report ? allVisuals(report).flatMap(
|
|
5127
|
+
(v) => v.actions.flatMap((a) => {
|
|
5128
|
+
const checked = CHECKED.get(a.type.toLowerCase());
|
|
5129
|
+
if (!checked || !a.on || a.target !== void 0 || a.conditional) return [];
|
|
5130
|
+
return [
|
|
5131
|
+
reportFinding.visual(v, a.pointer, `${checked.action} action has no destination`)
|
|
5132
|
+
];
|
|
5133
|
+
})
|
|
5134
|
+
) : []
|
|
5135
|
+
});
|
|
5136
|
+
var SECTIONS = "/explorationState/sections";
|
|
5137
|
+
var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
5138
|
+
id: "BROKEN_BOOKMARK_REFERENCE",
|
|
5139
|
+
name: "Bookmark refers to a missing page or visual",
|
|
5140
|
+
category: "Error Prevention",
|
|
5141
|
+
severity: 2,
|
|
5142
|
+
scope: ["Bookmark"],
|
|
5143
|
+
layer: "report",
|
|
5144
|
+
// Groups, captured apart from visuals under `visualContainerGroups`, are not read. A page or a
|
|
5145
|
+
// visual whose own file could not be read is there, under the folder name Desktop gives it, so
|
|
5146
|
+
// it is never reported missing, and neither is one a folder that could not be read could hold.
|
|
5147
|
+
check: ({ report }) => {
|
|
5148
|
+
if (!report) return [];
|
|
5149
|
+
const pages = new Map(report.pages.map((p) => [p.id, p]));
|
|
5150
|
+
const missing = (id) => !pages.has(id) && !pageUnread(report, id);
|
|
5151
|
+
const notOn = (p, id) => !p.visuals.some((v) => v.id === id) && !visualUnread(report, p, id);
|
|
5152
|
+
return report.bookmarks.flatMap((b) => {
|
|
5153
|
+
const out = [];
|
|
5154
|
+
if (b.activePage !== void 0 && missing(b.activePage))
|
|
5155
|
+
out.push(
|
|
5156
|
+
reportFinding.bookmark(
|
|
5157
|
+
b,
|
|
5158
|
+
`active page "${b.activePage}" does not exist`,
|
|
5159
|
+
"/explorationState/activeSection"
|
|
5160
|
+
)
|
|
5161
|
+
);
|
|
5162
|
+
for (const id of b.pages)
|
|
5163
|
+
if (id !== b.activePage && missing(id))
|
|
5164
|
+
out.push(
|
|
5165
|
+
reportFinding.bookmark(
|
|
5166
|
+
b,
|
|
5167
|
+
`captured page "${id}" does not exist`,
|
|
5168
|
+
`${SECTIONS}/${escapePointer(id)}`
|
|
5169
|
+
)
|
|
5170
|
+
);
|
|
5171
|
+
for (const { page, visual, pointer } of b.visuals) {
|
|
5172
|
+
const p = pages.get(page);
|
|
5173
|
+
if (p && notOn(p, visual))
|
|
5174
|
+
out.push(
|
|
5175
|
+
reportFinding.bookmark(
|
|
5176
|
+
b,
|
|
5177
|
+
`captured visual "${visual}" is not on page "${p.displayName}"`,
|
|
5178
|
+
pointer
|
|
5179
|
+
)
|
|
5180
|
+
);
|
|
5181
|
+
}
|
|
5182
|
+
const active = b.activePage === void 0 ? void 0 : pages.get(b.activePage);
|
|
5183
|
+
const stale = active ? (b.targetVisuals ?? []).filter((t) => notOn(active, t.visual)) : [];
|
|
5184
|
+
if (active && stale.length > 0) {
|
|
5185
|
+
const many = stale.length > 1;
|
|
5186
|
+
const names = englishList(stale.map((t) => `"${t.visual}"`));
|
|
5187
|
+
out.push(
|
|
5188
|
+
reportFinding.bookmark(
|
|
5189
|
+
b,
|
|
5190
|
+
`target visual${many ? "s" : ""} ${names} ${many ? "are" : "is"} not on page "${active.displayName}"`,
|
|
5191
|
+
stale[0].pointer
|
|
5192
|
+
)
|
|
5193
|
+
);
|
|
5194
|
+
}
|
|
5195
|
+
return out;
|
|
5196
|
+
});
|
|
5197
|
+
}
|
|
5198
|
+
});
|
|
5199
|
+
var actionRules = [
|
|
5200
|
+
BROKEN_ACTION_TARGET,
|
|
5201
|
+
ACTION_WITHOUT_DESTINATION,
|
|
5202
|
+
BROKEN_BOOKMARK_REFERENCE
|
|
5203
|
+
];
|
|
5204
|
+
|
|
5205
|
+
// ../core/src/rules/pbiplint/filters.ts
|
|
5206
|
+
var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5207
|
+
function yearOf(e) {
|
|
5208
|
+
const value = isRecord7(e) && isRecord7(e.Literal) ? e.Literal.Value : void 0;
|
|
5209
|
+
const m = typeof value === "string" ? /^(?:(\d{4})L|'(\d{4})')$/.exec(value) : null;
|
|
5210
|
+
const year = m ? Number(m[1] ?? m[2]) : void 0;
|
|
5211
|
+
return year !== void 0 && inYearRange(year) ? year : void 0;
|
|
5212
|
+
}
|
|
5213
|
+
function isYearColumn(e) {
|
|
5214
|
+
if (!isRecord7(e)) return false;
|
|
5215
|
+
const name = isRecord7(e.Column) ? e.Column.Property : isRecord7(e.HierarchyLevel) ? e.HierarchyLevel.Level : void 0;
|
|
5216
|
+
return typeof name === "string" && nameClass(name) === "year";
|
|
5217
|
+
}
|
|
5218
|
+
var fixed = (years) => `fixed year${years.length > 1 ? "s" : ""} ${englishList(years.map(String))}`;
|
|
5219
|
+
function keptYears(condition, at2) {
|
|
5220
|
+
if (!isRecord7(condition)) return void 0;
|
|
5221
|
+
const { In: inList, Comparison: compared2 } = condition;
|
|
5222
|
+
if (isRecord7(inList) && Array.isArray(inList.Expressions) && inList.Expressions.length === 1 && isYearColumn(inList.Expressions[0]) && Array.isArray(inList.Values)) {
|
|
5223
|
+
const found = inList.Values.flatMap((row, i) => {
|
|
5224
|
+
const year = Array.isArray(row) && row.length === 1 ? yearOf(row[0]) : void 0;
|
|
5225
|
+
return year === void 0 ? [] : [{ year, at: `${at2}/In/Values/${i}/0/Literal/Value` }];
|
|
5226
|
+
});
|
|
5227
|
+
if (found.length === 0) return void 0;
|
|
5228
|
+
const years = [...new Set(found.map((f) => f.year))];
|
|
5229
|
+
return { years, detail: fixed(years), at: found[0].at };
|
|
5230
|
+
}
|
|
5231
|
+
if (isRecord7(compared2) && compared2.ComparisonKind === 0 && isYearColumn(compared2.Left)) {
|
|
5232
|
+
const year = yearOf(compared2.Right);
|
|
5233
|
+
if (year === void 0) return void 0;
|
|
5234
|
+
return { years: [year], detail: fixed([year]), at: `${at2}/Comparison/Right/Literal/Value` };
|
|
5235
|
+
}
|
|
5236
|
+
return void 0;
|
|
5237
|
+
}
|
|
5238
|
+
function bound(condition, at2) {
|
|
5239
|
+
const compared2 = isRecord7(condition) ? condition.Comparison : void 0;
|
|
5240
|
+
if (!isRecord7(compared2) || !isYearColumn(compared2.Left)) return void 0;
|
|
5241
|
+
const year = yearOf(compared2.Right);
|
|
5242
|
+
if (year === void 0) return void 0;
|
|
5243
|
+
const literal2 = `${at2}/Comparison/Right/Literal/Value`;
|
|
5244
|
+
switch (compared2.ComparisonKind) {
|
|
5245
|
+
case 1:
|
|
5246
|
+
return { side: "lower", year: year + 1, at: literal2 };
|
|
5247
|
+
case 2:
|
|
5248
|
+
return { side: "lower", year, at: literal2 };
|
|
5249
|
+
case 3:
|
|
5250
|
+
return { side: "upper", year: year - 1, at: literal2 };
|
|
5251
|
+
case 4:
|
|
5252
|
+
return { side: "upper", year, at: literal2 };
|
|
5253
|
+
default:
|
|
5254
|
+
return void 0;
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
function yearsUpTo(condition, at2) {
|
|
5258
|
+
const alone = bound(condition, at2);
|
|
5259
|
+
if (alone?.side === "upper")
|
|
5260
|
+
return { years: [alone.year], detail: `years up to ${alone.year}`, at: alone.at };
|
|
5261
|
+
const both = isRecord7(condition) ? condition.And : void 0;
|
|
5262
|
+
if (!isRecord7(both)) return void 0;
|
|
5263
|
+
const sides = [bound(both.Left, `${at2}/And/Left`), bound(both.Right, `${at2}/And/Right`)];
|
|
5264
|
+
const lower4 = sides.find((b) => b?.side === "lower");
|
|
5265
|
+
const upper = sides.find((b) => b?.side === "upper");
|
|
5266
|
+
if (!lower4 || !upper) return void 0;
|
|
5267
|
+
return {
|
|
5268
|
+
years: [lower4.year, upper.year],
|
|
5269
|
+
detail: `years ${lower4.year} to ${upper.year}`,
|
|
5270
|
+
at: upper.at
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
function yearFinding(f, names, make) {
|
|
5274
|
+
if (f.howCreated === "Drillthrough" || f.howCreated === "Drill") return [];
|
|
5275
|
+
const kept = (f.where ?? []).map((w, i) => {
|
|
5276
|
+
if (!isRecord7(w)) return void 0;
|
|
5277
|
+
const at2 = `${f.pointer}/filter/Where/${i}/Condition`;
|
|
5278
|
+
return keptYears(w.Condition, at2) ?? yearsUpTo(w.Condition, at2);
|
|
5279
|
+
}).find((k) => k !== void 0);
|
|
5280
|
+
if (!kept || kept.years.some((y) => names.some((n2) => n2 !== void 0 && namesYear(n2, y))))
|
|
5281
|
+
return [];
|
|
5282
|
+
return [make(kept.at, f.field ? `${kept.detail} on ${fieldLabel(f.field)}` : kept.detail)];
|
|
5283
|
+
}
|
|
5284
|
+
function yearFindings(report) {
|
|
5285
|
+
const out = report.filters.flatMap(
|
|
5286
|
+
(f) => yearFinding(f, [], (at2, detail) => reportFinding.reportFilter(report, at2, detail))
|
|
5287
|
+
);
|
|
5288
|
+
for (const p of report.pages) {
|
|
5289
|
+
for (const f of p.filters)
|
|
5290
|
+
out.push(...yearFinding(f, [p.displayName], (at2, d) => reportFinding.pageFilter(p, at2, d)));
|
|
5291
|
+
for (const v of p.visuals)
|
|
5292
|
+
for (const f of v.filters)
|
|
5293
|
+
out.push(
|
|
5294
|
+
...yearFinding(f, [p.displayName, v.title], (at2, d) => reportFinding.visual(v, at2, d))
|
|
5295
|
+
);
|
|
5296
|
+
}
|
|
5297
|
+
return out;
|
|
5298
|
+
}
|
|
5299
|
+
var HARDCODED_YEAR_IN_FILTER = pbiplintRule({
|
|
5300
|
+
id: "HARDCODED_YEAR_IN_FILTER",
|
|
5301
|
+
name: "Hardcoded year in a filter",
|
|
5302
|
+
category: "Report Design",
|
|
5303
|
+
severity: 1,
|
|
5304
|
+
scope: ["Visual", "Page", "Report"],
|
|
5305
|
+
layer: "report",
|
|
5306
|
+
// No skipWhenUnread: a report file that could not be read hides its filters from the rule,
|
|
5307
|
+
// never adds one.
|
|
5308
|
+
check: ({ report }) => report ? yearFindings(report) : []
|
|
5309
|
+
});
|
|
5310
|
+
var filterRules = [HARDCODED_YEAR_IN_FILTER];
|
|
5311
|
+
|
|
5312
|
+
// ../core/src/rules/pbiplint/functions.ts
|
|
5313
|
+
var packageOf = (f) => f.annotations.DAXLIB_PackageId;
|
|
5314
|
+
function notCalled(model, references) {
|
|
5315
|
+
const calledBy = (f) => references.functionCalledBy(f);
|
|
5316
|
+
const out = [];
|
|
5317
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5318
|
+
for (const f of model.functions) {
|
|
5319
|
+
const pkg = packageOf(f);
|
|
5320
|
+
if (pkg === void 0) {
|
|
5321
|
+
if (calledBy(f).length === 0) out.push(finding.function(f));
|
|
5322
|
+
continue;
|
|
5323
|
+
}
|
|
5324
|
+
if (seen.has(pkg)) continue;
|
|
5325
|
+
seen.add(pkg);
|
|
5326
|
+
const members = model.functions.filter((g) => packageOf(g) === pkg);
|
|
5327
|
+
const fromOutside = members.some(
|
|
5328
|
+
(g) => calledBy(g).some((o) => !(o.kind === "function" && members.includes(o.object)))
|
|
5329
|
+
);
|
|
5330
|
+
if (fromOutside) continue;
|
|
5331
|
+
const detail = members.length === 1 ? `package ${pkg}: its one function is not called` : `package ${pkg}: none of its ${members.length} functions is called`;
|
|
5332
|
+
out.push({ ...finding.function(f), detail });
|
|
5333
|
+
}
|
|
5334
|
+
return out;
|
|
5335
|
+
}
|
|
5336
|
+
var UDF_NOT_CALLED = pbiplintRule({
|
|
5337
|
+
id: "UDF_NOT_CALLED",
|
|
5338
|
+
name: "User-defined function nothing calls",
|
|
5339
|
+
category: "Maintenance",
|
|
5340
|
+
severity: 1,
|
|
5341
|
+
scope: ["Function"],
|
|
5342
|
+
layer: "model",
|
|
5343
|
+
// A model file pbiplint could not fully read may hold the call.
|
|
5344
|
+
skipWhenModelUnread: modelPartlyRead,
|
|
5345
|
+
check: ({ model }, { indexes: { references } }) => model ? notCalled(model, references) : []
|
|
5346
|
+
});
|
|
5347
|
+
var UDF_USE_COMPOUND_NAMES = pbiplintRule({
|
|
5348
|
+
id: "UDF_USE_COMPOUND_NAMES",
|
|
5349
|
+
name: "User-defined function with a one-word name",
|
|
5350
|
+
category: "Error Prevention",
|
|
5351
|
+
severity: 1,
|
|
5352
|
+
scope: ["Function"],
|
|
5353
|
+
layer: "model",
|
|
5354
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => !f.name.includes(".") && !f.name.includes("_")).map(finding.function)
|
|
5355
|
+
});
|
|
5356
|
+
var UDF_WITHOUT_DESCRIPTION = pbiplintRule({
|
|
5357
|
+
id: "UDF_WITHOUT_DESCRIPTION",
|
|
5358
|
+
name: "User-defined function with no description",
|
|
5359
|
+
category: "Maintenance",
|
|
5360
|
+
severity: 1,
|
|
5361
|
+
scope: ["Function"],
|
|
5362
|
+
layer: "model",
|
|
5363
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => packageOf(f) === void 0 && isBlank(f.description)).map(finding.function)
|
|
5364
|
+
});
|
|
5365
|
+
var functionRules = [UDF_NOT_CALLED, UDF_USE_COMPOUND_NAMES, UDF_WITHOUT_DESCRIPTION];
|
|
5366
|
+
|
|
5367
|
+
// ../core/src/rules/pbiplint/measures.ts
|
|
5368
|
+
var REPORT_LEVEL_MEASURES = pbiplintRule({
|
|
5369
|
+
id: "REPORT_LEVEL_MEASURES",
|
|
5370
|
+
name: "Measure defined in the report",
|
|
5371
|
+
category: "Maintenance",
|
|
5372
|
+
severity: 2,
|
|
5373
|
+
scope: ["ReportMeasure"],
|
|
5374
|
+
layer: "report",
|
|
5375
|
+
// Its findings are report objects, yet it reports only beside the model the report reads, so a
|
|
5376
|
+
// run without the model skips it rather than counting it as run.
|
|
5377
|
+
needs: ["model", "report"],
|
|
5378
|
+
// The detail says where the measure lives; the rule's page gives the fix.
|
|
5379
|
+
check: (project) => reportMeasuresToMove(project).map(
|
|
5380
|
+
(m) => reportFinding.reportMeasure(m, `defined in the report on table "${m.table}"`)
|
|
5381
|
+
)
|
|
5382
|
+
});
|
|
5383
|
+
var measureRules2 = [REPORT_LEVEL_MEASURES];
|
|
5384
|
+
|
|
5385
|
+
// ../core/src/rules/pbiplint/opening.ts
|
|
5386
|
+
var LANDING_PAGE_NOT_SET = pbiplintRule({
|
|
5387
|
+
id: "LANDING_PAGE_NOT_SET",
|
|
5388
|
+
name: "No landing page set",
|
|
5389
|
+
category: "Report Design",
|
|
5390
|
+
severity: 1,
|
|
5391
|
+
scope: ["Report"],
|
|
5392
|
+
layer: "report",
|
|
5393
|
+
check: ({ report }) => {
|
|
5394
|
+
const opens = report && landingPageNotSet(report) ? openingPage(report) : void 0;
|
|
5395
|
+
if (!report || !opens) return [];
|
|
5396
|
+
const pointer = opens.by === "active" ? "/activePageName" : void 0;
|
|
5397
|
+
if (opens.page === void 0 && !opens.unread)
|
|
5398
|
+
return [
|
|
5399
|
+
reportFinding.pagesHeader(
|
|
5400
|
+
report,
|
|
5401
|
+
pointer,
|
|
5402
|
+
`no landing page set; the active page "${opens.name}" does not exist`
|
|
5403
|
+
)
|
|
5404
|
+
];
|
|
5405
|
+
const name = opens.page?.displayName ?? opens.name;
|
|
5406
|
+
const which = opens.by === "first" ? "the first page" : "the page open when it was saved";
|
|
5407
|
+
return [reportFinding.pagesHeader(report, pointer, `opens on "${name}", ${which}`)];
|
|
5408
|
+
}
|
|
5409
|
+
});
|
|
5410
|
+
var OPENING_PAGE_INVALID = pbiplintRule({
|
|
5411
|
+
id: "OPENING_PAGE_INVALID",
|
|
5412
|
+
name: "Opening page missing or hidden",
|
|
5413
|
+
category: "Error Prevention",
|
|
5414
|
+
severity: 3,
|
|
5415
|
+
scope: ["Report"],
|
|
5416
|
+
layer: "report",
|
|
5417
|
+
check: ({ report }) => {
|
|
5418
|
+
const opens = report && openingPage(report);
|
|
5419
|
+
if (!report || !opens || !openingPageInvalid(opens)) return [];
|
|
5420
|
+
const pointer = opens.by === "landing" ? "/landingPageName" : "/activePageName";
|
|
5421
|
+
const detail = opens.page === void 0 ? `${opens.by} page "${opens.name}" does not exist` : `active page "${opens.page.displayName}" is hidden from readers`;
|
|
5422
|
+
return [reportFinding.pagesHeader(report, pointer, detail)];
|
|
5423
|
+
}
|
|
5249
5424
|
});
|
|
5250
|
-
var
|
|
5425
|
+
var FILTERS_PANE_STATE = pbiplintRule({
|
|
5426
|
+
id: "FILTERS_PANE_STATE",
|
|
5427
|
+
name: "Filters pane state differs from policy",
|
|
5428
|
+
category: "Report Design",
|
|
5429
|
+
severity: 2,
|
|
5430
|
+
scope: ["Report"],
|
|
5431
|
+
layer: "report",
|
|
5432
|
+
options: [{ name: "expect", type: "string", values: ["open", "closed"] }],
|
|
5433
|
+
check: ({ report }, ctx) => {
|
|
5434
|
+
const expect = ctx.options.expect;
|
|
5435
|
+
if (!report || expect === void 0) return [];
|
|
5436
|
+
const pane = filtersPaneState(report);
|
|
5437
|
+
if (!pane || pane.state === expect) return [];
|
|
5438
|
+
const { state, recordedAt } = pane;
|
|
5439
|
+
const saved = recordedAt === void 0 ? `not recorded, read as ${state}` : `saved ${state}`;
|
|
5440
|
+
return [
|
|
5441
|
+
reportFinding.report(report, `${saved}; the policy expects ${expect}`, "report", recordedAt)
|
|
5442
|
+
];
|
|
5443
|
+
}
|
|
5444
|
+
});
|
|
5445
|
+
var openingRules = [LANDING_PAGE_NOT_SET, OPENING_PAGE_INVALID, FILTERS_PANE_STATE];
|
|
5251
5446
|
|
|
5252
|
-
// ../core/src/rules/pbiplint/
|
|
5253
|
-
var
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
|
|
5447
|
+
// ../core/src/rules/pbiplint/pages.ts
|
|
5448
|
+
var NEW_PAGE = "is the name Power BI Desktop gives a new page";
|
|
5449
|
+
var DUPLICATE = "is the name Power BI Desktop gives a duplicated page";
|
|
5450
|
+
var DEFAULT_NAMES = [
|
|
5451
|
+
[/^(?:Page|Seite|Página|Pagina|ページ) [1-9]\d*$/, NEW_PAGE],
|
|
5452
|
+
[/^(?:Duplicate of|Duplicado de|Doublon de|Duplicata de|Duplikat av) .+$/, DUPLICATE],
|
|
5453
|
+
[/^Duplikat von ".+"$/, DUPLICATE],
|
|
5454
|
+
[/^.+ \(copy\)$/, "is named as a copy"]
|
|
5455
|
+
];
|
|
5456
|
+
var recordedName = (p) => {
|
|
5457
|
+
const name = p.json?.displayName;
|
|
5458
|
+
return typeof name === "string" ? name : void 0;
|
|
5257
5459
|
};
|
|
5460
|
+
var DEFAULT_PAGE_NAME = pbiplintRule({
|
|
5461
|
+
id: "DEFAULT_PAGE_NAME",
|
|
5462
|
+
name: "Page keeps its default name",
|
|
5463
|
+
category: "Report Design",
|
|
5464
|
+
severity: 2,
|
|
5465
|
+
scope: ["Page"],
|
|
5466
|
+
layer: "report",
|
|
5467
|
+
check: ({ report }) => report ? report.pages.flatMap((p) => {
|
|
5468
|
+
const name = recordedName(p);
|
|
5469
|
+
const match = name === void 0 ? void 0 : DEFAULT_NAMES.find(([re]) => re.test(name));
|
|
5470
|
+
return match ? [reportFinding.page(p, "/displayName", `"${name}" ${match[1]}`)] : [];
|
|
5471
|
+
}) : []
|
|
5472
|
+
});
|
|
5473
|
+
var pageRules2 = [DEFAULT_PAGE_NAME];
|
|
5474
|
+
|
|
5475
|
+
// ../core/src/rules/pbiplint/references.ts
|
|
5258
5476
|
var firstPerObjectAndField = (findings) => {
|
|
5259
5477
|
const seen = /* @__PURE__ */ new Set();
|
|
5260
5478
|
return findings.filter((f) => {
|
|
@@ -5525,6 +5743,8 @@ var pbiplintRules = [
|
|
|
5525
5743
|
...pageRules2,
|
|
5526
5744
|
...measureRules2,
|
|
5527
5745
|
...periodRules,
|
|
5746
|
+
...filterRules,
|
|
5747
|
+
...functionRules,
|
|
5528
5748
|
...actionRules,
|
|
5529
5749
|
...tabOrderRules
|
|
5530
5750
|
];
|
|
@@ -7512,17 +7732,19 @@ Why it matters
|
|
|
7512
7732
|
|
|
7513
7733
|
A bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.
|
|
7514
7734
|
|
|
7515
|
-
|
|
7735
|
+
With Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by name. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a name of its own, which the list does not hold, so the bookmark leaves it alone.
|
|
7736
|
+
|
|
7737
|
+
Microsoft's PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark's configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop's saved files do keep bookmarks whose active page is gone. Power BI Desktop's saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark's captured visuals.
|
|
7516
7738
|
|
|
7517
7739
|
How to fix it
|
|
7518
7740
|
|
|
7519
|
-
In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.
|
|
7741
|
+
In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark's More options (...) menu.
|
|
7520
7742
|
|
|
7521
|
-
In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
|
|
7743
|
+
In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A target visual is a string in options.targetVisualNames: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
|
|
7522
7744
|
|
|
7523
7745
|
When to ignore it
|
|
7524
7746
|
|
|
7525
|
-
There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
|
|
7747
|
+
There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
|
|
7526
7748
|
|
|
7527
7749
|
This rule reports on bookmarks, and a bookmark's file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set "BROKEN_BOOKMARK_REFERENCE": "off" under rules in pbiplint.config.json.
|
|
7528
7750
|
|
|
@@ -7530,14 +7752,16 @@ Quirks
|
|
|
7530
7752
|
|
|
7531
7753
|
- A bookmark captures one page. Power BI Desktop's saved files write one key in sections, the active page's, so a missing page is reported once, at activeSection. A sections key that names a different missing page is reported on its own line.
|
|
7532
7754
|
- The rule reports a missing active page whether or not the bookmark's Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.
|
|
7533
|
-
- A captured visual is checked against the page it is captured under, and only when that page exists
|
|
7534
|
-
-
|
|
7755
|
+
- A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.
|
|
7756
|
+
- The list of target visuals is checked only when Selected visuals is on, which the file records as applyOnlyToTargetVisuals set to true beside the list. Power BI Desktop's saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.
|
|
7757
|
+
- A bookmark's stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.
|
|
7758
|
+
- A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under visualContainerGroups, and the visuals listed as each group's children, are not checked.
|
|
7535
7759
|
- Pages and visuals are matched by name, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a name is supported, and that Power BI Desktop keeps the original folder names when it saves.
|
|
7536
7760
|
- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft's PBIR documentation says is a page's or a visual's name by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file's own PARSE_ISSUE finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.
|
|
7537
7761
|
- bookmarks.json, which holds the bookmarks' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.
|
|
7538
7762
|
|
|
7539
7763
|
Read more: https://pbiplint.com/rules/broken-bookmark-reference`,
|
|
7540
|
-
markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and only when that page exists
|
|
7764
|
+
markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nWith Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by `name`. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a `name` of its own, which the list does not hold, so the bookmark leaves it alone.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone. Power BI Desktop\'s saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark\'s captured visuals.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark\'s More options (...) menu.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A target visual is a string in `options.targetVisualNames`: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.\n- The list of target visuals is checked only when Selected visuals is on, which the file records as `applyOnlyToTargetVisuals` set to `true` beside the list. Power BI Desktop\'s saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.\n- A bookmark\'s stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.\n- A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under `visualContainerGroups`, and the visuals listed as each group\'s `children`, are not checked.\n- Pages and visuals are matched by `name`, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a `name` is supported, and that Power BI Desktop keeps the original folder names when it saves.\n- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft\'s PBIR documentation says is a page\'s or a visual\'s `name` by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.\n- bookmarks.json, which holds the bookmarks\' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.\n\nRead more: https://pbiplint.com/rules/broken-bookmark-reference'
|
|
7541
7765
|
},
|
|
7542
7766
|
BROKEN_FIELD_REFERENCE: {
|
|
7543
7767
|
text: `Example
|
|
@@ -8081,7 +8305,7 @@ Put the table name in front of every column reference:
|
|
|
8081
8305
|
|
|
8082
8306
|
Total Sales = SUM ( 'Sales'[Amount] )
|
|
8083
8307
|
|
|
8084
|
-
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role.
|
|
8308
|
+
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
|
|
8085
8309
|
|
|
8086
8310
|
When to ignore it
|
|
8087
8311
|
|
|
@@ -8094,13 +8318,14 @@ Quirks
|
|
|
8094
8318
|
- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.
|
|
8095
8319
|
- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.
|
|
8096
8320
|
- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.
|
|
8097
|
-
-
|
|
8098
|
-
-
|
|
8321
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ). Inside a call that creates the name, such as SELECTCOLUMNS ( 'Sales', "Region", [Region] ), the name is read as any other bare name is, since a call cannot read a column it is creating.
|
|
8322
|
+
- DAX is read token by token, so a bare [Column] inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as 'Date'[Date].[Year].
|
|
8323
|
+
- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
|
|
8099
8324
|
- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.
|
|
8100
8325
|
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
8101
8326
|
|
|
8102
8327
|
Read more: https://pbiplint.com/rules/dax-columns-fully-qualified`,
|
|
8103
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nIn DAX a bare `[Name]` is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\n### How to fix it\n\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n-
|
|
8328
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nIn DAX a bare `[Name]` is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\n### How to fix it\n\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), \"Share\", [Total] ), [Share] )`. Inside a call that creates the name, such as `SELECTCOLUMNS ( 'Sales', \"Region\", [Region] )`, the name is read as any other bare name is, since a call cannot read a column it is creating.\n- DAX is read token by token, so a bare `[Column]` inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as `'Date'[Date].[Year]`.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified"
|
|
8104
8329
|
},
|
|
8105
8330
|
DAX_MEASURES_UNQUALIFIED: {
|
|
8106
8331
|
text: `Example
|
|
@@ -8143,7 +8368,7 @@ Delete the table name from the reference and leave the brackets:
|
|
|
8143
8368
|
|
|
8144
8369
|
Average Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )
|
|
8145
8370
|
|
|
8146
|
-
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group.
|
|
8371
|
+
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
|
|
8147
8372
|
|
|
8148
8373
|
When to ignore it
|
|
8149
8374
|
|
|
@@ -8153,14 +8378,14 @@ To ignore this rule on one object, add annotation pbiplint.ignore = DAX_MEASURES
|
|
|
8153
8378
|
|
|
8154
8379
|
Quirks
|
|
8155
8380
|
|
|
8156
|
-
-
|
|
8157
|
-
- A measure's dynamic format string
|
|
8381
|
+
- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's "'Sales'[Total Sales]", is not reported.
|
|
8382
|
+
- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so 'Sales'[Total Sales] written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
|
|
8158
8383
|
- The reference has to resolve. 'Sales'[Total Sales] written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.
|
|
8159
8384
|
- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.
|
|
8160
8385
|
- The table name may be written bare or in single quotes; both forms are matched.
|
|
8161
8386
|
|
|
8162
8387
|
Read more: https://pbiplint.com/rules/dax-measures-unqualified`,
|
|
8163
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n-
|
|
8388
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's `\"'Sales'[Total Sales]\"`, is not reported.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so `'Sales'[Total Sales]` written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- The reference has to resolve. `'Sales'[Total Sales]` written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.\n- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.\n- The table name may be written bare or in single quotes; both forms are matched.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
|
|
8164
8389
|
},
|
|
8165
8390
|
DEFAULT_PAGE_NAME: {
|
|
8166
8391
|
text: `Example
|
|
@@ -9075,6 +9300,134 @@ Quirks
|
|
|
9075
9300
|
Read more: https://pbiplint.com/rules/hardcoded-period-in-dax`,
|
|
9076
9301
|
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = 2025)\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = YEAR(TODAY()))\n formatString: #,0\n```\n\n### Why it matters\n\nA year typed into DAX is right for the year it was written in and quietly wrong after it. A measure named Current Year Sales that filters on 2025 still shows 2025's sales all through 2026, under the same name, with no error, and a reader has no way to tell.\n\nA date table built with `CALENDAR(DATE(2020, 1, 1), DATE(2026, 12, 31))` has no rows after December 31, 2026. From January 1, 2027, new rows in a table related to it find no date there. A visual that groups by the date table's columns shows them under a blank value: the [blank virtual row](https://learn.microsoft.com/power-bi/transform-model/desktop-relationships-understand#regular-relationships) Power BI adds when a value on a relationship's many side has no match on its one side. A filter or slicer on the date table leaves them out, and time intelligence stops at the table's last day. [Microsoft's guidance on date tables](https://learn.microsoft.com/power-bi/guidance/model-date-tables#generate-with-dax) says CALENDAR's start and end can come from other DAX functions, like `MAX(Sales[OrderDate])`. An end taken from the data moves with it.\n\n### How to fix it\n\nTake the period from something that moves with time.\n\n- For the current period, use `TODAY()`: `YEAR(TODAY())` for this year, as the fixed example does, or `TODAY()` itself for an as-of date.\n- For the latest period in the data, which stays right when a refresh runs late, take it from the fact table: `YEAR(MAX(Sales[Order Date]))`. Inside a measure, MAX reads only the dates the visual's filters leave, so write `CALCULATE(MAX(Sales[Order Date]), REMOVEFILTERS())` when the measure needs the latest date in all the data.\n- When a report reader should choose the period, add a parameter: on the Modeling tab, select New parameter, then Numeric range, and set its Minimum and Maximum to the first and last years it should offer. Power BI Desktop creates the parameter and, with it, a measure that gives the parameter's current value ([what-if parameters](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#create-a-parameter)), and your measure compares with that measure instead of a number. Parameters are [designed for measures](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#considerations-and-limitations), so this route suits a measure, not a calculated column.\n\nA filter argument of CALCULATE written as a comparison, as in the example, [can't reference a measure or use a nested CALCULATE](https://learn.microsoft.com/dax/calculate-function-dax#boolean-filter-expressions), so put the latest year or the parameter's value in a variable first:\n\n```\nLatest Year Sales =\nVAR LatestYear = YEAR ( CALCULATE ( MAX ( Sales[Order Date] ), REMOVEFILTERS () ) )\nRETURN\n CALCULATE ( SUM ( Sales[Amount] ), YEAR ( Sales[Order Date] ) = LatestYear )\n```\n\nFor a date table, end CALENDAR on the data rather than on a day:\n\n```\nDate = CALENDAR ( DATE ( 2020, 1, 1 ), DATE ( YEAR ( MAX ( Sales[Order Date] ) ), 12, 31 ) )\n```\n\nThis ends on the last day of the latest year in Sales, so the table spans full years, as [Microsoft's guidance](https://learn.microsoft.com/power-bi/guidance/model-date-tables) asks of a date table, and grows when a refresh brings a new year. [CALENDARAUTO](https://learn.microsoft.com/dax/calendarauto-function-dax#remarks) does the same from every date in the model outside calculated columns and tables, so a birth date or a placeholder such as December 31, 9999 stretches it too. To reach the end of the current year whether or not the data gets there yet, end CALENDAR on `DATE ( YEAR ( TODAY () ), 12, 31 )` instead.\n\nIn Power BI Desktop, select a measure, calculated column, or calculated table in the Data pane and edit its DAX in the formula bar. A calculation item is edited in Model view: select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), then select the calculation item under its calculation group, and its DAX opens in the DAX formula bar ([calculation groups](https://learn.microsoft.com/power-bi/transform-model/calculation-groups#add-a-new-calculation-group-in-model-view)). In TMDL, edit the expression after the object's `=`, or, for a date table, the `source` of its `calculated` partition.\n\n### When to ignore it\n\nA fixed period is sometimes the point: a baseline year a measure compares against, a known event such as a change of data source or a day of bad data, a cohort such as customers whose first purchase was in 2023, a rule that changed in a given year, or sample data that never changes. A date table can end on purpose too, such as one that must stop at a contract's last day.\n\nOften the better move is a name that says so. An object whose name carries its year, such as `Sales 2024` or `Growth from 19/20`, reads as deliberate to anyone who opens the model, and the rule leaves it alone.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HARDCODED_PERIOD_IN_DAX` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HARDCODED_PERIOD_IN_DAX\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- An object whose name carries one of the years it fixes, as four digits (`Sales 2024`) or as the year's last two digits with no digit beside them (`Jan-24`, `19/20`), is left out, since such an object is almost always meant to fix its year. Two digits can match by chance, as `Top 20` beside a fixed 2020 does, which hides that one object.\n- A date string at a date table's end whose day and month read either way, such as `\"01/02/2026\"`, is quoted as written: DAX reads it by the model's culture, which pbiplint does not settle.\n- A year outside 1950 to 2049 is left alone, such as `DATE(9999, 12, 31)` as an open end or `DATE(1900, 1, 1)` as a default, and so is the Unix epoch, `DATE(1970, 1, 1)`, the base of a conversion such as `DATE(1970, 1, 1) + 'Log'[UnixTime] / 86400`. A date table's end is reported whatever its year or day.\n- A year compared with `<`, `<=`, `>`, or `>=` is left out, since in real models it is usually a cut-off or a cohort, and so are year-month keys such as `202306` and date strings outside a date table's end, which never went stale there. Year arithmetic such as `[Year] - 2025` and fiscal-year labels such as `\"2024/25\"` are left out too, since each came up in too few models to judge. A `DATE()` with a fixed year is read whatever it is compared with, though, so `'Sales'[Order Date] >= DATE(2024, 1, 1)` is reported.\n- From a calculated table, the rule reads only where its CALENDAR calls end, so a year compared inside a date table's ADDCOLUMNS is not reported. The rest of a calculated table's DAX, user-defined functions, and row-level security filters are left out, since in real models they mostly hold inline data, sample generators, or deliberate cut-offs. Format string expressions are not read either, since they choose how a value is shown rather than which rows are counted, and fixed dates in Power Query (M) are left to pbiplint's Power Query rules, which are still to come. A date table end reached through a measure or written with arithmetic, such as `DATE(2026, 12, 31) + 1`, is not taken for a fixed end, and a `DATE()` inside a `CALENDAR` or `GENERATESERIES` call in a measure, a calculated column, or a calculation item is taken for a table's bound and left alone.\n- A month or a quarter compared with a number, a year-end date such as `\"6/30\"` given to DATESYTD, and a `DATE()` given straight to FORMAT with a format that shows no year, as in `FORMAT(DATE(2000, [Month], 1), \"mmmm\")` for a month's name, never fire: none of them goes stale. The rule counts a format as showing the year when it has a `y` in it or is one of the named formats General Date, Long Date, Medium Date, and Short Date, so `FORMAT(DATE(2024, 12, 31), \"Long Date\")` is reported. A `DATE()` inside another call within FORMAT is reported whatever the format, as in `FORMAT(EOMONTH(DATE(2000, [Month], 1), 0), \"mmmm\")`.\n- Year names are read in several languages (year, a\xF1o, anio, jahr, ann\xE9e, anno, jaar, \xE5r, and more), whatever the model's culture.\n- Desktop's own auto date/time tables are left alone, since Desktop builds them itself.\n\nRead more: https://pbiplint.com/rules/hardcoded-period-in-dax"
|
|
9077
9302
|
},
|
|
9303
|
+
HARDCODED_YEAR_IN_FILTER: {
|
|
9304
|
+
text: `Example
|
|
9305
|
+
|
|
9306
|
+
Fires the rule in page.json
|
|
9307
|
+
|
|
9308
|
+
{
|
|
9309
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9310
|
+
"name": "3cea48e58036b1654474",
|
|
9311
|
+
"displayName": "Overview",
|
|
9312
|
+
"displayOption": "FitToPage",
|
|
9313
|
+
"height": 720,
|
|
9314
|
+
"width": 1280,
|
|
9315
|
+
"filterConfig": {
|
|
9316
|
+
"filters": [
|
|
9317
|
+
{
|
|
9318
|
+
"name": "21d1dc168996da6409ea",
|
|
9319
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },
|
|
9320
|
+
"type": "Categorical",
|
|
9321
|
+
"filter": {
|
|
9322
|
+
"Version": 2,
|
|
9323
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9324
|
+
"Where": [
|
|
9325
|
+
{
|
|
9326
|
+
"Condition": {
|
|
9327
|
+
"In": {
|
|
9328
|
+
"Expressions": [
|
|
9329
|
+
{ "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }
|
|
9330
|
+
],
|
|
9331
|
+
"Values": [[{ "Literal": { "Value": "2025L" } }]]
|
|
9332
|
+
}
|
|
9333
|
+
}
|
|
9334
|
+
}
|
|
9335
|
+
]
|
|
9336
|
+
},
|
|
9337
|
+
"howCreated": "User"
|
|
9338
|
+
}
|
|
9339
|
+
]
|
|
9340
|
+
}
|
|
9341
|
+
}
|
|
9342
|
+
|
|
9343
|
+
After the fix in page.json
|
|
9344
|
+
|
|
9345
|
+
{
|
|
9346
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9347
|
+
"name": "3cea48e58036b1654474",
|
|
9348
|
+
"displayName": "Overview",
|
|
9349
|
+
"displayOption": "FitToPage",
|
|
9350
|
+
"height": 720,
|
|
9351
|
+
"width": 1280,
|
|
9352
|
+
"filterConfig": {
|
|
9353
|
+
"filters": [
|
|
9354
|
+
{
|
|
9355
|
+
"name": "f7a189206cee6051586c",
|
|
9356
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },
|
|
9357
|
+
"type": "RelativeDate",
|
|
9358
|
+
"filter": {
|
|
9359
|
+
"Version": 2,
|
|
9360
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9361
|
+
"Where": [
|
|
9362
|
+
{
|
|
9363
|
+
"Condition": {
|
|
9364
|
+
"Comparison": {
|
|
9365
|
+
"ComparisonKind": 0,
|
|
9366
|
+
"Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },
|
|
9367
|
+
"Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }
|
|
9368
|
+
}
|
|
9369
|
+
}
|
|
9370
|
+
}
|
|
9371
|
+
]
|
|
9372
|
+
},
|
|
9373
|
+
"howCreated": "User"
|
|
9374
|
+
}
|
|
9375
|
+
]
|
|
9376
|
+
}
|
|
9377
|
+
}
|
|
9378
|
+
|
|
9379
|
+
The Overview page was saved with 2025 picked for 'Date'[Year] under Filters on this page, so the finding reads Page filter on "Overview" with fixed year 2025 on 'Date'[Year]. The fix filters 'Date'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.
|
|
9380
|
+
|
|
9381
|
+
Why it matters
|
|
9382
|
+
|
|
9383
|
+
A filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" (Reset to default values (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.
|
|
9384
|
+
|
|
9385
|
+
A filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year's data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft's words, "If you hide the filter, they can't even see it" (Lock or hide filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).
|
|
9386
|
+
|
|
9387
|
+
How to fix it
|
|
9388
|
+
|
|
9389
|
+
Filter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:
|
|
9390
|
+
|
|
9391
|
+
1. Drag the date column, such as 'Date'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).
|
|
9392
|
+
2. On the date column's filter card, select Relative date from the Filter type drop-down (Create the relative date range filter (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).
|
|
9393
|
+
3. Under Show items when the value, choose is in this, then year.
|
|
9394
|
+
4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual's own fields cannot be deleted, since the visual refers to it, so clear it instead (Types of filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).
|
|
9395
|
+
|
|
9396
|
+
A relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column's Year level, filter that date column itself (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).
|
|
9397
|
+
|
|
9398
|
+
When the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.
|
|
9399
|
+
|
|
9400
|
+
When the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column's DAX in the formula bar (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):
|
|
9401
|
+
|
|
9402
|
+
Is Latest Year = 'Date'[Year] = YEAR ( MAX ( Sales[Order Date] ) )
|
|
9403
|
+
|
|
9404
|
+
Then drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar's current year, write YEAR ( TODAY () ) in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).
|
|
9405
|
+
|
|
9406
|
+
In the report's files, the filter is an entry in filterConfig in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year's entry with one on the date column, as the example does.
|
|
9407
|
+
|
|
9408
|
+
When to ignore it
|
|
9409
|
+
|
|
9410
|
+
A fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.
|
|
9411
|
+
|
|
9412
|
+
Often the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as Sales 2024 or Review FY24, reads as deliberate to every reader, and the rule leaves that filter alone.
|
|
9413
|
+
|
|
9414
|
+
To ignore this rule on one page or visual, add { "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" } to the annotations array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "HARDCODED_YEAR_IN_FILTER": "off" under rules in pbiplint.config.json.
|
|
9415
|
+
|
|
9416
|
+
Quirks
|
|
9417
|
+
|
|
9418
|
+
- A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.
|
|
9419
|
+
- Years are counted whole, so is less than 2026 reads as years up to 2025, and a range from is greater than 2017 reads as starting in 2018.
|
|
9420
|
+
- An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.
|
|
9421
|
+
- A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.
|
|
9422
|
+
- Filters that drilling sets are left alone: a drillthrough page's field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.
|
|
9423
|
+
- A filter hidden from readers or locked is reported like any other, since it still filters.
|
|
9424
|
+
- Only the Filters pane is read. A year saved as a slicer's selection is SLICER_SELECTION_SAVED's to report, and bookmarks are not read.
|
|
9425
|
+
- A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.
|
|
9426
|
+
- In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.
|
|
9427
|
+
|
|
9428
|
+
Read more: https://pbiplint.com/rules/hardcoded-year-in-filter`,
|
|
9429
|
+
markdown: '### Example\n\n**Fires the rule in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "21d1dc168996da6409ea",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },\n "type": "Categorical",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }\n ],\n "Values": [[{ "Literal": { "Value": "2025L" } }]]\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\n**After the fix in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "f7a189206cee6051586c",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },\n "type": "RelativeDate",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "Comparison": {\n "ComparisonKind": 0,\n "Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },\n "Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\nThe Overview page was saved with 2025 picked for \'Date\'[Year] under Filters on this page, so the finding reads `Page filter on "Overview"` with `fixed year 2025 on \'Date\'[Year]`. The fix filters \'Date\'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.\n\n### Why it matters\n\nA filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" ([Reset to default values](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.\n\nA filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year\'s data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft\'s words, "If you hide the filter, they can\'t even see it" ([Lock or hide filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).\n\n### How to fix it\n\nFilter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:\n\n1. Drag the date column, such as \'Date\'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).\n2. On the date column\'s filter card, select Relative date from the Filter type drop-down ([Create the relative date range filter](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).\n3. Under Show items when the value, choose is in this, then year.\n4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual\'s own fields cannot be deleted, since the visual refers to it, so clear it instead ([Types of filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).\n\nA relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column\'s Year level, filter that date column itself ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).\n\nWhen the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.\n\nWhen the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column\'s DAX in the formula bar ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):\n\n```\nIs Latest Year = \'Date\'[Year] = YEAR ( MAX ( Sales[Order Date] ) )\n```\n\nThen drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar\'s current year, write `YEAR ( TODAY () )` in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).\n\nIn the report\'s files, the filter is an entry in `filterConfig` in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year\'s entry with one on the date column, as the example does.\n\n### When to ignore it\n\nA fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.\n\nOften the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as `Sales 2024` or `Review FY24`, reads as deliberate to every reader, and the rule leaves that filter alone.\n\nTo ignore this rule on one page or visual, add `{ "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" }` to the `annotations` array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"HARDCODED_YEAR_IN_FILTER": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.\n- Years are counted whole, so `is less than 2026` reads as `years up to 2025`, and a range from `is greater than 2017` reads as starting in 2018.\n- An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.\n- A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.\n- Filters that drilling sets are left alone: a drillthrough page\'s field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.\n- A filter hidden from readers or locked is reported like any other, since it still filters.\n- Only the Filters pane is read. A year saved as a slicer\'s selection is `SLICER_SELECTION_SAVED`\'s to report, and bookmarks are not read.\n- A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.\n- In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.\n\nRead more: https://pbiplint.com/rules/hardcoded-year-in-filter'
|
|
9430
|
+
},
|
|
9078
9431
|
HIDDEN_VISUAL_WITH_FIELDS: {
|
|
9079
9432
|
text: `Example
|
|
9080
9433
|
|
|
@@ -10435,15 +10788,16 @@ Quirks
|
|
|
10435
10788
|
- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
|
|
10436
10789
|
- Calculated tables whose names start with LocalDateTable_ or DateTableTemplate_, which pbiplint reads as Power BI Desktop's auto date/time tables the way REMOVE_AUTO-DATE_TABLE does, are left out of the findings, reached or not. So is a composite model's copy of one: a LocalDateTable_ table whose entity partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with showAsVariationsOnly, so it is shown only through a date column's hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and REMOVE_AUTO-DATE_TABLE reports them; the table a copy reads is calculated in the model it extends, and that model's own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.
|
|
10437
10790
|
- UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model's own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.
|
|
10438
|
-
- DAX
|
|
10791
|
+
- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.
|
|
10792
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ) does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.
|
|
10439
10793
|
- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.
|
|
10440
|
-
- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is
|
|
10794
|
+
- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.
|
|
10441
10795
|
- The rule compares the report with its model, so it runs only when both are in the input.
|
|
10442
10796
|
- The rule also needs every file it reads the report's fields from: report.json (the report's filters), reportExtensions.json (the report's own measures), each page.json (a page's filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, a report file could not be read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual's mobile.json, which hold the report's format version, the order of its pages, the order and groups of its bookmarks, and a visual's mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.
|
|
10443
10797
|
- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt table, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, a model file could not be fully read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A /// description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.
|
|
10444
10798
|
|
|
10445
10799
|
Read more: https://pbiplint.com/rules/not-reached-from-report`,
|
|
10446
|
-
markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX references are found by pattern, the way the model rules find them, so a field named inside a string or a comment of a reached measure counts as reached.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is found by pattern too: the function\'s name, in any letter case, followed by an opening parenthesis, with no letter, digit, underscore, or dot just before the name.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
|
|
10800
|
+
markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( \'Sales\'[Region] ), "Share", [Total] ), [Share] )` does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function\'s whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
|
|
10447
10801
|
},
|
|
10448
10802
|
NUMERIC_COLUMN_SUMMARIZE_BY: {
|
|
10449
10803
|
text: `Example
|
|
@@ -12831,7 +13185,7 @@ To ignore this rule on one visual, add { "name": "pbiplint.ignore", "value": "SL
|
|
|
12831
13185
|
|
|
12832
13186
|
Quirks
|
|
12833
13187
|
|
|
12834
|
-
- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and
|
|
13188
|
+
- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and this rule does not report it. HARDCODED_YEAR_IN_FILTER reports one that holds a year column to fixed years.
|
|
12835
13189
|
- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.
|
|
12836
13190
|
- In Power BI Desktop's saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.
|
|
12837
13191
|
- Each synced copy of a slicer is reported on its own page, since Power BI Desktop's saved files write the selection into every copy.
|
|
@@ -12840,7 +13194,7 @@ Quirks
|
|
|
12840
13194
|
- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.
|
|
12841
13195
|
|
|
12842
13196
|
Read more: https://pbiplint.com/rules/slicer-selection-saved`,
|
|
12843
|
-
markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and
|
|
13197
|
+
markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and this rule does not report it. `HARDCODED_YEAR_IN_FILTER` reports one that holds a year column to fixed years.\n- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.\n- In Power BI Desktop\'s saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.\n- Each synced copy of a slicer is reported on its own page, since Power BI Desktop\'s saved files write the selection into every copy.\n- A hidden slicer is reported like a visible one. Microsoft notes that slicers continue to filter a report page whether or not they are visible.\n- A selection is read by where it sits, not by the visual\'s type, so custom slicers from AppSource, such as the Chiclet Slicer, the Hierarchy Slicer, and the Text Filter, are reported the same way as the slicers built into Power BI. Microsoft\'s guidance for building Power BI visuals says a visual that filters through the Visual Filters API declares a `filter` in the `general` section of its capabilities and applies its filter there. Separately, Power BI Desktop\'s saved files keep these slicers\' selections in that `filter` under `objects.general`, and in those files every kind of visual that carries that `filter` is one that filters the report.\n- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.\n\nRead more: https://pbiplint.com/rules/slicer-selection-saved'
|
|
12844
13198
|
},
|
|
12845
13199
|
SNOWFLAKE_SCHEMA_ARCHITECTURE: {
|
|
12846
13200
|
text: `Example
|
|
@@ -13233,6 +13587,181 @@ Quirks
|
|
|
13233
13587
|
Read more: https://pbiplint.com/rules/trim-object-names`,
|
|
13234
13588
|
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID '\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n### Why it matters\n\nA leading or trailing space is invisible in the field list but part of the name, so \"Sales \" and \"Sales\" are two objects to the engine, and a DAX reference, a visual binding, or a deployment script that uses the trimmed name fails against something that looks correct. The space usually arrives with a source column name or a paste. `OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE` reports the same names at error severity for a narrower set of object types.\n\n### How to fix it\n\nIn Power BI Desktop, double-click the field in the Data pane and retype the name without the space, or select the object and clear the space from Name in the Properties pane. In the TMDL file the name is on the declaration line, so `column 'Order ID '` becomes `column 'Order ID'`. A data column carries its own `sourceColumn`, so the model name and the source column name are independent and trimming the model name does not change what the refresh loads. Renaming in Desktop updates the visuals bound to the field; a hand edit in the TMDL file does not, so open the report and check its visuals after one. Where the space arrives with a source column name, trim it in Power Query as well, so the next column you add from that query comes in clean.\n\n### When to ignore it\n\nNo name should start or end with a space. A leading space is sometimes used to push a measure to the top of the field list; a display folder gathers the measure with the ones it belongs beside, and it does that without hiding a character in the name.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = TRIM_OBJECT_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"TRIM_OBJECT_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is for a space character at the start or the end of the name. A name padded with a tab is not reported here; `SPECIAL_CHARS_IN_OBJECT_NAMES` covers that one.\n- The model object is in scope, and a finding on the model is always named `Model` whatever the model is called, so a stray space in the model's own name gives a finding with nothing in the line to show it. The model's name is on the `model` line in the TMDL file.\n\nRead more: https://pbiplint.com/rules/trim-object-names"
|
|
13235
13589
|
},
|
|
13590
|
+
UDF_NOT_CALLED: {
|
|
13591
|
+
text: `Example
|
|
13592
|
+
|
|
13593
|
+
Fires the rule
|
|
13594
|
+
|
|
13595
|
+
table Sales
|
|
13596
|
+
column Amount
|
|
13597
|
+
dataType: decimal
|
|
13598
|
+
sourceColumn: Amount
|
|
13599
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13600
|
+
formatString: #,0
|
|
13601
|
+
|
|
13602
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13603
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13604
|
+
|
|
13605
|
+
/// Adds 20 percent VAT to an amount.
|
|
13606
|
+
function 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2
|
|
13607
|
+
|
|
13608
|
+
After the fix
|
|
13609
|
+
|
|
13610
|
+
table Sales
|
|
13611
|
+
column Amount
|
|
13612
|
+
dataType: decimal
|
|
13613
|
+
sourceColumn: Amount
|
|
13614
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13615
|
+
formatString: #,0
|
|
13616
|
+
|
|
13617
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13618
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13619
|
+
|
|
13620
|
+
Why it matters
|
|
13621
|
+
|
|
13622
|
+
A function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.
|
|
13623
|
+
|
|
13624
|
+
It also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS pass over everything the dead function names. Deleting the function brings those to light.
|
|
13625
|
+
|
|
13626
|
+
A DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.
|
|
13627
|
+
|
|
13628
|
+
How to fix it
|
|
13629
|
+
|
|
13630
|
+
Delete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.
|
|
13631
|
+
|
|
13632
|
+
In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from definition/functions.tmdl: its /// description lines, its function line, and the lines indented under it. For a package, delete each function that carries its DAXLIB_PackageId annotation.
|
|
13633
|
+
|
|
13634
|
+
When to ignore it
|
|
13635
|
+
|
|
13636
|
+
When something outside the model's own DAX calls the function:
|
|
13637
|
+
|
|
13638
|
+
- A DAX query, such as a test harness that runs a model's test functions from outside it.
|
|
13639
|
+
- A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).
|
|
13640
|
+
- A visual calculation, which pbiplint does not read.
|
|
13641
|
+
|
|
13642
|
+
Or when the function is kept on purpose, such as one written ahead of the measures that will call it.
|
|
13643
|
+
|
|
13644
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_NOT_CALLED under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_NOT_CALLED": "off" under rules in pbiplint.config.json.
|
|
13645
|
+
|
|
13646
|
+
Quirks
|
|
13647
|
+
|
|
13648
|
+
- A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so MySales.Tax( is not a call to Sales.Tax. A call written inside a string or a comment is not a call.
|
|
13649
|
+
- The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.
|
|
13650
|
+
- A package is known by the DAXLIB_PackageId annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.
|
|
13651
|
+
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
13652
|
+
|
|
13653
|
+
Read more: https://pbiplint.com/rules/udf-not-called`,
|
|
13654
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n\n/// Adds 20 percent VAT to an amount.\nfunction 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.\n\nIt also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` pass over everything the dead function names. Deleting the function brings those to light.\n\nA DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.\n\n### How to fix it\n\nDelete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from `definition/functions.tmdl`: its `///` description lines, its `function` line, and the lines indented under it. For a package, delete each function that carries its `DAXLIB_PackageId` annotation.\n\n### When to ignore it\n\nWhen something outside the model's own DAX calls the function:\n\n- A DAX query, such as a test harness that runs a model's test functions from outside it.\n- A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).\n- A visual calculation, which pbiplint does not read.\n\nOr when the function is kept on purpose, such as one written ahead of the measures that will call it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_NOT_CALLED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_NOT_CALLED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so `MySales.Tax(` is not a call to `Sales.Tax`. A call written inside a string or a comment is not a call.\n- The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.\n- A package is known by the `DAXLIB_PackageId` annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/udf-not-called"
|
|
13655
|
+
},
|
|
13656
|
+
UDF_USE_COMPOUND_NAMES: {
|
|
13657
|
+
text: `Example
|
|
13658
|
+
|
|
13659
|
+
Fires the rule
|
|
13660
|
+
|
|
13661
|
+
table Sales
|
|
13662
|
+
column Amount
|
|
13663
|
+
dataType: decimal
|
|
13664
|
+
sourceColumn: Amount
|
|
13665
|
+
measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))
|
|
13666
|
+
formatString: #,0
|
|
13667
|
+
|
|
13668
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13669
|
+
function AddTax = (amount: NUMERIC) => amount * 1.1
|
|
13670
|
+
|
|
13671
|
+
After the fix
|
|
13672
|
+
|
|
13673
|
+
table Sales
|
|
13674
|
+
column Amount
|
|
13675
|
+
dataType: decimal
|
|
13676
|
+
sourceColumn: Amount
|
|
13677
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13678
|
+
formatString: #,0
|
|
13679
|
+
|
|
13680
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13681
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13682
|
+
|
|
13683
|
+
Why it matters
|
|
13684
|
+
|
|
13685
|
+
Most of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name "Must not conflict with built-in DAX functions" (Define and manage user-defined functions (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that "the built-in function takes precedence and your UDF will stop working" (Use compound names for user-defined functions (https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.
|
|
13686
|
+
|
|
13687
|
+
A dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a Local. prefix for a model's own functions "to avoid conflicts with future DAX function names" (Function names (https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.
|
|
13688
|
+
|
|
13689
|
+
How to fix it
|
|
13690
|
+
|
|
13691
|
+
Rename the function to a compound name, such as Local.AddTax for a function of the model's own, or a prefix for your organization or library.
|
|
13692
|
+
|
|
13693
|
+
In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.
|
|
13694
|
+
|
|
13695
|
+
In TMDL, change the name on the function's line in definition/functions.tmdl, in single quotes when it holds a dot (function 'Local.AddTax' =), and at every call in the files under definition/, where it is written without quotes (Local.AddTax(). Searching those files for the old name followed by an opening parenthesis finds the calls.
|
|
13696
|
+
|
|
13697
|
+
When to ignore it
|
|
13698
|
+
|
|
13699
|
+
When the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.
|
|
13700
|
+
|
|
13701
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_USE_COMPOUND_NAMES": "off" under rules in pbiplint.config.json.
|
|
13702
|
+
|
|
13703
|
+
Quirks
|
|
13704
|
+
|
|
13705
|
+
- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so _toggleButton passes, and so does add_tax.
|
|
13706
|
+
- A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as INFO.USERDEFINEDFUNCTIONS (https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).
|
|
13707
|
+
- A function from a DAX Lib package is checked as any other, since its name is what callers write.
|
|
13708
|
+
|
|
13709
|
+
Read more: https://pbiplint.com/rules/udf-use-compound-names`,
|
|
13710
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction AddTax = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nMost of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name \"Must not conflict with built-in DAX functions\" ([Define and manage user-defined functions](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that \"the built-in function takes precedence and your UDF will stop working\" ([Use compound names for user-defined functions](https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.\n\nA dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a `Local.` prefix for a model's own functions \"to avoid conflicts with future DAX function names\" ([Function names](https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.\n\n### How to fix it\n\nRename the function to a compound name, such as `Local.AddTax` for a function of the model's own, or a prefix for your organization or library.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.\n\nIn TMDL, change the name on the function's line in `definition/functions.tmdl`, in single quotes when it holds a dot (`function 'Local.AddTax' =`), and at every call in the files under `definition/`, where it is written without quotes (`Local.AddTax(`). Searching those files for the old name followed by an opening parenthesis finds the calls.\n\n### When to ignore it\n\nWhen the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_USE_COMPOUND_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so `_toggleButton` passes, and so does `add_tax`.\n- A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as [`INFO.USERDEFINEDFUNCTIONS`](https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).\n- A function from a DAX Lib package is checked as any other, since its name is what callers write.\n\nRead more: https://pbiplint.com/rules/udf-use-compound-names"
|
|
13711
|
+
},
|
|
13712
|
+
UDF_WITHOUT_DESCRIPTION: {
|
|
13713
|
+
text: `Example
|
|
13714
|
+
|
|
13715
|
+
Fires the rule
|
|
13716
|
+
|
|
13717
|
+
table Sales
|
|
13718
|
+
column Amount
|
|
13719
|
+
dataType: decimal
|
|
13720
|
+
sourceColumn: Amount
|
|
13721
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13722
|
+
formatString: #,0
|
|
13723
|
+
|
|
13724
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13725
|
+
|
|
13726
|
+
After the fix
|
|
13727
|
+
|
|
13728
|
+
table Sales
|
|
13729
|
+
column Amount
|
|
13730
|
+
dataType: decimal
|
|
13731
|
+
sourceColumn: Amount
|
|
13732
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13733
|
+
formatString: #,0
|
|
13734
|
+
|
|
13735
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13736
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13737
|
+
|
|
13738
|
+
Why it matters
|
|
13739
|
+
|
|
13740
|
+
A function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with /// lines, and it notes that single-line (//) or multi-line (/* */) comments "will not appear in IntelliSense function descriptions" (General form (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.
|
|
13741
|
+
|
|
13742
|
+
How to fix it
|
|
13743
|
+
|
|
13744
|
+
Write a sentence or two on what the function returns and what each parameter expects.
|
|
13745
|
+
|
|
13746
|
+
In Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write /// lines directly above its FUNCTION line and select Update model with changes (Saving to the model (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the /// syntax serves "both measure and function descriptions" (Add measure descriptions (https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the /// lines directly above the function's function line in definition/functions.tmdl, with no blank line between the last of them and the declaration.
|
|
13747
|
+
|
|
13748
|
+
Microsoft says parameter descriptions are not supported (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; @param and @returns tags are optional.
|
|
13749
|
+
|
|
13750
|
+
When to ignore it
|
|
13751
|
+
|
|
13752
|
+
A function whose name and parameters already say everything, such as Local.Double(amount), gains little from a sentence repeating them. A function nothing calls is better deleted than described.
|
|
13753
|
+
|
|
13754
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_WITHOUT_DESCRIPTION": "off" under rules in pbiplint.config.json.
|
|
13755
|
+
|
|
13756
|
+
Quirks
|
|
13757
|
+
|
|
13758
|
+
- Functions with a DAXLIB_PackageId annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version (Submitting a library to DAX Lib (https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.
|
|
13759
|
+
- A description of only spaces or tabs counts as none.
|
|
13760
|
+
- Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.
|
|
13761
|
+
|
|
13762
|
+
Read more: https://pbiplint.com/rules/udf-without-description`,
|
|
13763
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with `///` lines, and it notes that single-line (`//`) or multi-line (`/* */`) comments \"will not appear in IntelliSense function descriptions\" ([General form](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.\n\n### How to fix it\n\nWrite a sentence or two on what the function returns and what each parameter expects.\n\nIn Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write `///` lines directly above its `FUNCTION` line and select Update model with changes ([Saving to the model](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the `///` syntax serves \"both measure and function descriptions\" ([Add measure descriptions](https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the `///` lines directly above the function's `function` line in `definition/functions.tmdl`, with no blank line between the last of them and the declaration.\n\nMicrosoft says parameter descriptions are not supported ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; `@param` and `@returns` tags are optional.\n\n### When to ignore it\n\nA function whose name and parameters already say everything, such as `Local.Double(amount)`, gains little from a sentence repeating them. A function nothing calls is better deleted than described.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_WITHOUT_DESCRIPTION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Functions with a `DAXLIB_PackageId` annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version ([Submitting a library to DAX Lib](https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.\n- A description of only spaces or tabs counts as none.\n- Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.\n\nRead more: https://pbiplint.com/rules/udf-without-description"
|
|
13764
|
+
},
|
|
13236
13765
|
UNNECESSARY_COLUMNS: {
|
|
13237
13766
|
text: `Example
|
|
13238
13767
|
|
|
@@ -13285,17 +13814,18 @@ To ignore this rule on one object, add annotation pbiplint.ignore = UNNECESSARY_
|
|
|
13285
13814
|
|
|
13286
13815
|
Quirks
|
|
13287
13816
|
|
|
13288
|
-
- DAX
|
|
13817
|
+
- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, 'Date'[Date].[Year], only 'Date'[Date] is. A bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.
|
|
13818
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden 'Archive'[DueDate] that DAX names only as [DueDate] outside SUMMARIZE ( 'Invoices', 'Invoices'[Key], "DueDate", MAX ( 'Invoices'[DueDate] ) ) is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the [Qty] in SUMX ( 'Sales', [Qty] ) is not a use of 'Sales'[Qty] either.
|
|
13289
13819
|
- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.
|
|
13290
13820
|
- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside SUMX ( 'Sales', [Handling Fee] ) but reported the column a function names in MAX ( [Tax Rate] ), though deleting it would break the function.
|
|
13291
13821
|
- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its groupByColumn under relatedColumnDetails, and the parameter stops working without it. The source rule does not test groupByColumn, so Tabular Editor reports that column.
|
|
13292
13822
|
- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.
|
|
13293
13823
|
- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS does read variations.
|
|
13294
|
-
- Row-level security
|
|
13824
|
+
- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: Table[Column] or 'Table'[Column] in any role's filter, or [Column] in a filter on the column's own table, counts as a use even inside a comment or a string there.
|
|
13295
13825
|
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
13296
13826
|
|
|
13297
13827
|
Read more: https://pbiplint.com/rules/unnecessary-columns`,
|
|
13298
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX
|
|
13828
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, `'Date'[Date].[Year]`, only `'Date'[Date]` is. A bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden `'Archive'[DueDate]` that DAX names only as `[DueDate]` outside `SUMMARIZE ( 'Invoices', 'Invoices'[Key], \"DueDate\", MAX ( 'Invoices'[DueDate] ) )` is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the `[Qty]` in `SUMX ( 'Sales', [Qty] )` is not a use of `'Sales'[Qty]` either.\n- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports that column.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: `Table[Column]` or `'Table'[Column]` in any role's filter, or `[Column]` in a filter on the column's own table, counts as a use even inside a comment or a string there.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
|
|
13299
13829
|
},
|
|
13300
13830
|
UNNECESSARY_MEASURES: {
|
|
13301
13831
|
text: `Example
|
|
@@ -13330,7 +13860,7 @@ A hidden measure that no other measure uses can only be reached by a report that
|
|
|
13330
13860
|
|
|
13331
13861
|
How to fix it
|
|
13332
13862
|
|
|
13333
|
-
In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures
|
|
13863
|
+
In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.
|
|
13334
13864
|
|
|
13335
13865
|
When to ignore it
|
|
13336
13866
|
|
|
@@ -13344,11 +13874,11 @@ Quirks
|
|
|
13344
13874
|
- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.
|
|
13345
13875
|
- A row-level security filter counts as a DAX expression, so a measure named in one is used.
|
|
13346
13876
|
- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, NOT_REACHED_FROM_REPORT follows the calls, so it reports a measure that only an uncalled function uses.
|
|
13347
|
-
- A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives.
|
|
13877
|
+
- A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.
|
|
13348
13878
|
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
13349
13879
|
|
|
13350
13880
|
Read more: https://pbiplint.com/rules/unnecessary-measures`,
|
|
13351
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\n### How to fix it\n\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures
|
|
13881
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\n### How to fix it\n\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden measure that a visual or a report-level filter binds to directly is in use, but this rule reads the model only, as the source rule does, so it reports that measure all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete a measure. A measure you have written for a calculation item or a measure you have not finished is a fair thing to leave for as long as that lasts. A hidden measure nobody can name a caller for is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- References from calculation items and from other hidden measures count as usage.\n- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.\n- A row-level security filter counts as a DAX expression, so a measure named in one is used.\n- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, `NOT_REACHED_FROM_REPORT` follows the calls, so it reports a measure that only an uncalled function uses.\n- A bare `[Measure]` reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures"
|
|
13352
13882
|
},
|
|
13353
13883
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": {
|
|
13354
13884
|
text: `Example
|
|
@@ -13743,7 +14273,7 @@ var statOf = (p) => statSync(p, { throwIfNoEntry: false });
|
|
|
13743
14273
|
var isDir = (p) => statOf(p)?.isDirectory() ?? false;
|
|
13744
14274
|
var isFile = (p) => statOf(p)?.isFile() ?? false;
|
|
13745
14275
|
var byName = (a, b) => a.localeCompare(b, "en");
|
|
13746
|
-
var
|
|
14276
|
+
var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13747
14277
|
function folderAt(p) {
|
|
13748
14278
|
try {
|
|
13749
14279
|
const stat = statOf(p);
|
|
@@ -13930,9 +14460,9 @@ function walked(input, base, read) {
|
|
|
13930
14460
|
}
|
|
13931
14461
|
function reportsNamed(name, text2) {
|
|
13932
14462
|
const json = readJson(name, text2).json;
|
|
13933
|
-
if (!
|
|
14463
|
+
if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
|
|
13934
14464
|
return json.artifacts.flatMap(
|
|
13935
|
-
(a) =>
|
|
14465
|
+
(a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
|
|
13936
14466
|
);
|
|
13937
14467
|
}
|
|
13938
14468
|
function resolvePbip(input, path) {
|
|
@@ -14106,7 +14636,7 @@ function readFolder(w, input, path, preferred) {
|
|
|
14106
14636
|
}
|
|
14107
14637
|
|
|
14108
14638
|
// src/main.ts
|
|
14109
|
-
var VERSION2 = true ? "0.2.
|
|
14639
|
+
var VERSION2 = true ? "0.2.2" : "0.0.0-dev";
|
|
14110
14640
|
function listRules() {
|
|
14111
14641
|
const width = Math.max(...defaultRules.map((r) => r.id.length));
|
|
14112
14642
|
return defaultRules.map(
|