pbiplint 0.2.1 → 0.2.3
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.3";
|
|
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
|
};
|
|
@@ -259,6 +261,17 @@ function buildCalculationGroup(cg, table) {
|
|
|
259
261
|
}
|
|
260
262
|
return group;
|
|
261
263
|
}
|
|
264
|
+
var CALENDAR_COLUMN_PROPS = /* @__PURE__ */ new Set(["primarycolumn", "associatedcolumn", "column"]);
|
|
265
|
+
function buildCalendar(cal, table) {
|
|
266
|
+
const columns2 = [];
|
|
267
|
+
for (const group of cal.children)
|
|
268
|
+
if (group.type === "calendarcolumngroup") {
|
|
269
|
+
for (const p of group.children)
|
|
270
|
+
if (p.kind === "prop" && CALENDAR_COLUMN_PROPS.has(p.type) && p.value !== void 0)
|
|
271
|
+
columns2.push(unquoteName(p.value));
|
|
272
|
+
}
|
|
273
|
+
return { ...named(cal), table, columns: columns2 };
|
|
274
|
+
}
|
|
262
275
|
function buildTable(r, model) {
|
|
263
276
|
let t = model.tables.find((x) => x.name === r.name);
|
|
264
277
|
if (!t) {
|
|
@@ -270,7 +283,8 @@ function buildTable(r, model) {
|
|
|
270
283
|
columns: [],
|
|
271
284
|
measures: [],
|
|
272
285
|
partitions: [],
|
|
273
|
-
hierarchies: []
|
|
286
|
+
hierarchies: [],
|
|
287
|
+
calendars: []
|
|
274
288
|
};
|
|
275
289
|
model.tables.push(t);
|
|
276
290
|
} else {
|
|
@@ -285,6 +299,8 @@ function buildTable(r, model) {
|
|
|
285
299
|
else if (c.kind === "object" && c.type === "partition") t.partitions.push(buildPartition(c, t));
|
|
286
300
|
else if (c.kind === "object" && c.type === "hierarchy")
|
|
287
301
|
t.hierarchies.push(buildHierarchy(c, t));
|
|
302
|
+
else if (c.kind === "object" && c.type === "calendar")
|
|
303
|
+
(t.calendars ??= []).push(buildCalendar(c, t));
|
|
288
304
|
else if (c.type === "calculationgroup") {
|
|
289
305
|
const group = buildCalculationGroup(c, t);
|
|
290
306
|
const first = t.calculationGroup;
|
|
@@ -297,6 +313,34 @@ function buildTable(r, model) {
|
|
|
297
313
|
}
|
|
298
314
|
}
|
|
299
315
|
}
|
|
316
|
+
function buildCulture(r) {
|
|
317
|
+
const block = r.children.find((c) => c.type === "translations");
|
|
318
|
+
if (!block) return named(r);
|
|
319
|
+
const translations = {
|
|
320
|
+
tables: [],
|
|
321
|
+
columns: [],
|
|
322
|
+
measures: [],
|
|
323
|
+
hierarchies: [],
|
|
324
|
+
levels: []
|
|
325
|
+
};
|
|
326
|
+
const captioned = (n2) => (str(n2.props.caption) ?? "").trim() !== "";
|
|
327
|
+
for (const m of objects(block, "model"))
|
|
328
|
+
for (const t of objects(m, "table")) {
|
|
329
|
+
const table = t.name ?? "";
|
|
330
|
+
if (captioned(t)) translations.tables.push(table);
|
|
331
|
+
for (const c of objects(t, "column"))
|
|
332
|
+
if (captioned(c)) translations.columns.push({ table, name: c.name ?? "" });
|
|
333
|
+
for (const x of objects(t, "measure"))
|
|
334
|
+
if (captioned(x)) translations.measures.push({ table, name: x.name ?? "" });
|
|
335
|
+
for (const h of objects(t, "hierarchy")) {
|
|
336
|
+
const hierarchy = h.name ?? "";
|
|
337
|
+
if (captioned(h)) translations.hierarchies.push({ table, name: hierarchy });
|
|
338
|
+
for (const l of objects(h, "level"))
|
|
339
|
+
if (captioned(l)) translations.levels.push({ table, hierarchy, name: l.name ?? "" });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return { ...named(r), translations };
|
|
343
|
+
}
|
|
300
344
|
function buildRelationship(r) {
|
|
301
345
|
const p = r.props;
|
|
302
346
|
const from = splitQualifiedName(str(p.fromcolumn) ?? "");
|
|
@@ -395,7 +439,7 @@ function readDeclaration(r, model) {
|
|
|
395
439
|
break;
|
|
396
440
|
}
|
|
397
441
|
case "cultureinfo":
|
|
398
|
-
model.cultures.push(
|
|
442
|
+
model.cultures.push(buildCulture(r));
|
|
399
443
|
break;
|
|
400
444
|
case "expression":
|
|
401
445
|
model.expressions.push({ ...named(r), expression: r.value ?? "" });
|
|
@@ -519,6 +563,9 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
519
563
|
for (const c of t.columns)
|
|
520
564
|
for (const v of c.variations)
|
|
521
565
|
if (v.defaultColumn) reach(columnOf(v.defaultColumn.table, v.defaultColumn.column), null);
|
|
566
|
+
for (const t of model.tables)
|
|
567
|
+
for (const cal of t.calendars ?? [])
|
|
568
|
+
for (const name of cal.columns) reach(columnOf(t.name, name), null);
|
|
522
569
|
for (const t of model.tables) for (const c of t.columns) if (c.alternateOf) reach(c, null);
|
|
523
570
|
while (queue.length) {
|
|
524
571
|
const n2 = queue.shift();
|
|
@@ -586,48 +633,215 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
586
633
|
};
|
|
587
634
|
}
|
|
588
635
|
|
|
589
|
-
// ../core/src/
|
|
590
|
-
var
|
|
591
|
-
var
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
636
|
+
// ../core/src/dax/tokenize.ts
|
|
637
|
+
var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
|
|
638
|
+
var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
639
|
+
var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
|
|
640
|
+
var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
641
|
+
var DIGIT = /[0-9]/;
|
|
642
|
+
var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
|
|
643
|
+
var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
|
|
644
|
+
function quoted(text2, from, close) {
|
|
645
|
+
let value = "";
|
|
646
|
+
let j = from + 1;
|
|
647
|
+
while (j < text2.length) {
|
|
648
|
+
const c = text2[j];
|
|
649
|
+
if (c === close) {
|
|
650
|
+
if (text2[j + 1] !== close) return { value, end: j + 1 };
|
|
651
|
+
j++;
|
|
652
|
+
}
|
|
653
|
+
value += c;
|
|
654
|
+
j++;
|
|
655
|
+
}
|
|
656
|
+
return { value, end: text2.length };
|
|
657
|
+
}
|
|
658
|
+
function tokenizeDax(expression) {
|
|
659
|
+
const s = expression;
|
|
660
|
+
const tokens = [];
|
|
661
|
+
const push2 = (kind, text2, start, end) => {
|
|
662
|
+
tokens.push({ kind, text: text2, start, end, depth: 0 });
|
|
663
|
+
};
|
|
664
|
+
let i = 0;
|
|
665
|
+
while (i < s.length) {
|
|
666
|
+
const c = s[i];
|
|
667
|
+
if (/\s/.test(c)) {
|
|
668
|
+
i++;
|
|
669
|
+
} else if (s.startsWith("//", i) || s.startsWith("--", i)) {
|
|
670
|
+
const nl = s.indexOf("\n", i);
|
|
671
|
+
i = nl === -1 ? s.length : nl;
|
|
672
|
+
} else if (s.startsWith("/*", i)) {
|
|
673
|
+
const close = s.indexOf("*/", i + 2);
|
|
674
|
+
i = close === -1 ? s.length : close + 2;
|
|
675
|
+
} else if (c === '"') {
|
|
676
|
+
const q2 = quoted(s, i, '"');
|
|
677
|
+
push2("string", q2.value, i, q2.end);
|
|
678
|
+
i = q2.end;
|
|
679
|
+
} 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]))) {
|
|
680
|
+
const q2 = quoted(s, i + 2, '"');
|
|
681
|
+
push2("date", q2.value, i, q2.end);
|
|
682
|
+
i = q2.end;
|
|
683
|
+
} else if (c === "'" || c === "[") {
|
|
684
|
+
const q2 = quoted(s, i, c === "'" ? "'" : "]");
|
|
685
|
+
push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
|
|
686
|
+
i = q2.end;
|
|
687
|
+
} else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
|
|
688
|
+
NUMBER.lastIndex = i;
|
|
689
|
+
const text2 = NUMBER.exec(s)[0];
|
|
690
|
+
push2("number", text2, i, i + text2.length);
|
|
691
|
+
i += text2.length;
|
|
692
|
+
} else {
|
|
693
|
+
IDENTIFIER.lastIndex = i;
|
|
694
|
+
const id = IDENTIFIER.exec(s)?.[0];
|
|
695
|
+
const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
|
|
696
|
+
const text2 = id ?? op ?? c;
|
|
697
|
+
push2(
|
|
698
|
+
id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
|
|
699
|
+
text2,
|
|
700
|
+
i,
|
|
701
|
+
i + text2.length
|
|
702
|
+
);
|
|
703
|
+
i += text2.length;
|
|
704
|
+
}
|
|
599
705
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
706
|
+
annotate(tokens);
|
|
707
|
+
return tokens;
|
|
708
|
+
}
|
|
709
|
+
function annotate(tokens) {
|
|
710
|
+
const open = [];
|
|
711
|
+
const args = [];
|
|
712
|
+
const place = (t) => {
|
|
713
|
+
t.depth = open.length;
|
|
714
|
+
if (open.length === 0) return;
|
|
715
|
+
t.parent = open[open.length - 1];
|
|
716
|
+
t.arg = args[args.length - 1];
|
|
717
|
+
};
|
|
718
|
+
tokens.forEach((t, k) => {
|
|
719
|
+
if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
|
|
720
|
+
place(t);
|
|
721
|
+
const before = tokens[k - 1];
|
|
722
|
+
if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
|
|
723
|
+
open.push(k);
|
|
724
|
+
args.push(0);
|
|
725
|
+
} else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
|
|
726
|
+
const o = open.pop();
|
|
727
|
+
args.pop();
|
|
728
|
+
if (o !== void 0) {
|
|
729
|
+
tokens[o].close = k;
|
|
730
|
+
t.open = o;
|
|
731
|
+
}
|
|
732
|
+
place(t);
|
|
733
|
+
} else {
|
|
734
|
+
place(t);
|
|
735
|
+
if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
function opensBlock(tokens, k) {
|
|
740
|
+
if (isWord(tokens[k - 1], "RETURN")) return true;
|
|
741
|
+
const eq = tokens[k - 1];
|
|
742
|
+
return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
|
|
743
|
+
}
|
|
744
|
+
function daxVariables(tokens) {
|
|
745
|
+
const out = [];
|
|
746
|
+
tokens.forEach((t, k) => {
|
|
747
|
+
const name = tokens[k + 1];
|
|
748
|
+
const eq = tokens[k + 2];
|
|
749
|
+
if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
|
|
750
|
+
if (eq.text !== "=") return;
|
|
751
|
+
const from = k + 3;
|
|
752
|
+
let to = from;
|
|
753
|
+
let nested = 0;
|
|
754
|
+
for (; to < tokens.length; to++) {
|
|
755
|
+
const x = tokens[to];
|
|
756
|
+
if (x.depth < t.depth) break;
|
|
757
|
+
if (x.depth !== t.depth) continue;
|
|
758
|
+
if (isWord(x, "VAR")) {
|
|
759
|
+
if (opensBlock(tokens, to)) nested++;
|
|
760
|
+
else if (nested === 0) break;
|
|
761
|
+
} else if (isWord(x, "RETURN")) {
|
|
762
|
+
if (nested === 0) break;
|
|
763
|
+
nested--;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
let blockEnd2 = to;
|
|
767
|
+
for (; blockEnd2 < tokens.length; blockEnd2++) {
|
|
768
|
+
const x = tokens[blockEnd2];
|
|
769
|
+
if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
|
|
770
|
+
}
|
|
771
|
+
out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
|
|
772
|
+
});
|
|
773
|
+
for (const v of out)
|
|
774
|
+
for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
|
|
775
|
+
return out;
|
|
776
|
+
}
|
|
777
|
+
function variableAt(vars, name, use) {
|
|
778
|
+
const key4 = name.toUpperCase();
|
|
779
|
+
let found;
|
|
780
|
+
for (const v of vars)
|
|
781
|
+
if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
|
|
782
|
+
found = v;
|
|
783
|
+
return found;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ../core/src/index/references.ts
|
|
787
|
+
var CREATES_COLUMNS = /* @__PURE__ */ new Set([
|
|
788
|
+
"ADDCOLUMNS",
|
|
789
|
+
"SELECTCOLUMNS",
|
|
790
|
+
"SUMMARIZE",
|
|
791
|
+
"SUMMARIZECOLUMNS",
|
|
792
|
+
"ROW",
|
|
793
|
+
"DATATABLE"
|
|
794
|
+
]);
|
|
795
|
+
function createdColumns(tokens) {
|
|
796
|
+
const out = /* @__PURE__ */ new Map();
|
|
797
|
+
for (const t of tokens) {
|
|
798
|
+
if (t.kind !== "string" || t.parent === void 0) continue;
|
|
799
|
+
const open = tokens[t.parent];
|
|
800
|
+
if (open.call === void 0 || !CREATES_COLUMNS.has(open.call)) continue;
|
|
801
|
+
const name = t.text.toLowerCase();
|
|
802
|
+
out.set(name, [...out.get(name) ?? [], [t.parent, open.close ?? tokens.length]]);
|
|
603
803
|
}
|
|
604
804
|
return out;
|
|
605
805
|
}
|
|
806
|
+
function refsInTokens(tokens) {
|
|
807
|
+
const created = createdColumns(tokens);
|
|
808
|
+
const out = [];
|
|
809
|
+
tokens.forEach((t, k) => {
|
|
810
|
+
if (t.kind !== "column") return;
|
|
811
|
+
const before = tokens[k - 1];
|
|
812
|
+
if (isPunctuation(before, ".") && tokens[k - 2]?.kind === "column") return;
|
|
813
|
+
if (before?.kind === "table" || before?.kind === "identifier" && before.end === t.start) {
|
|
814
|
+
out.push({ table: before.text, name: t.text, qualified: true });
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
const calls = created.get(t.text.toLowerCase());
|
|
818
|
+
if (calls?.every(([open, close]) => k < open || k > close))
|
|
819
|
+
out.push({ name: t.text, qualified: false, created: true });
|
|
820
|
+
else out.push({ name: t.text, qualified: false });
|
|
821
|
+
});
|
|
822
|
+
return out;
|
|
823
|
+
}
|
|
606
824
|
var lower2 = (s) => s.toLowerCase();
|
|
607
825
|
var key = (table, name) => `${lower2(table)} ${lower2(name)}`;
|
|
608
|
-
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
609
826
|
function functionCallReader(functions) {
|
|
610
827
|
if (functions.length === 0) return () => [];
|
|
611
828
|
const byName2 = /* @__PURE__ */ new Map();
|
|
612
829
|
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
830
|
const order3 = new Map(functions.map((f, i) => [f, i]));
|
|
618
|
-
return (
|
|
831
|
+
return (tokens) => {
|
|
619
832
|
const found = /* @__PURE__ */ new Set();
|
|
620
|
-
|
|
621
|
-
const f = byName2.get(lower2(
|
|
833
|
+
tokens.forEach((t, k) => {
|
|
834
|
+
const f = t.call === void 0 ? void 0 : byName2.get(lower2(tokens[k - 1].text));
|
|
622
835
|
if (f) found.add(f);
|
|
623
|
-
}
|
|
836
|
+
});
|
|
624
837
|
return [...found].sort((a, b) => order3.get(a) - order3.get(b));
|
|
625
838
|
};
|
|
626
839
|
}
|
|
627
|
-
function resolveBareName(
|
|
840
|
+
function resolveBareName(ref, owner, lookup) {
|
|
841
|
+
const { name } = ref;
|
|
628
842
|
const measure = lookup.measureNamed(name);
|
|
629
843
|
if (measure !== void 0) return { kind: "measure", measure };
|
|
630
|
-
if (owner.kind === "calculationItem") return { kind: "none" };
|
|
844
|
+
if (ref.created || owner.kind === "calculationItem") return { kind: "none" };
|
|
631
845
|
if (owner.kind === "function") {
|
|
632
846
|
const columns2 = lookup.tables.flatMap((t) => lookup.columnOf(t, name) ?? []);
|
|
633
847
|
return columns2.length > 0 ? { kind: "columns", columns: columns2 } : { kind: "none" };
|
|
@@ -664,7 +878,7 @@ function buildReferenceIndex(model) {
|
|
|
664
878
|
if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
|
|
665
879
|
return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
666
880
|
}
|
|
667
|
-
const bare = resolveBareName(raw
|
|
881
|
+
const bare = resolveBareName(raw, { kind: ownerKind, table: ownerTable }, lookup);
|
|
668
882
|
if (bare.kind === "measure")
|
|
669
883
|
return {
|
|
670
884
|
kind: "measure",
|
|
@@ -686,19 +900,26 @@ function buildReferenceIndex(model) {
|
|
|
686
900
|
const byObject = /* @__PURE__ */ new Map();
|
|
687
901
|
const add = (of, ownerTable, ...expressions) => {
|
|
688
902
|
const expression = expressions.filter((e) => e !== void 0).join("\n");
|
|
903
|
+
const tokens = tokenizeDax(expression);
|
|
689
904
|
const owner = {
|
|
690
905
|
...of,
|
|
691
906
|
ownerTable,
|
|
692
907
|
expression,
|
|
693
|
-
refs:
|
|
694
|
-
calls: callsIn(
|
|
908
|
+
refs: refsInTokens(tokens).flatMap((r) => resolve4(r, ownerTable, of.kind)),
|
|
909
|
+
calls: callsIn(tokens)
|
|
695
910
|
};
|
|
696
911
|
owners.push(owner);
|
|
697
912
|
byObject.set(of.object, owner);
|
|
698
913
|
};
|
|
699
914
|
for (const t of model.tables) {
|
|
700
915
|
for (const m of t.measures)
|
|
701
|
-
add(
|
|
916
|
+
add(
|
|
917
|
+
{ kind: "measure", object: m },
|
|
918
|
+
t,
|
|
919
|
+
m.expression,
|
|
920
|
+
m.formatStringDefinition,
|
|
921
|
+
...m.kpiExpressions ?? []
|
|
922
|
+
);
|
|
702
923
|
for (const c of t.columns)
|
|
703
924
|
if (c.kind === "calculated") add({ kind: "calculatedColumn", object: c }, t, c.expression);
|
|
704
925
|
if (t.kind === "calculated")
|
|
@@ -932,9 +1153,14 @@ function buildReportReferenceIndex(report, model) {
|
|
|
932
1153
|
return inModel ? { table: inModel.table.name, name: inModel.name } : reportMeasuresByName.get(lower3(name));
|
|
933
1154
|
}
|
|
934
1155
|
};
|
|
1156
|
+
const callsIn = functionCallReader(model?.functions ?? []);
|
|
1157
|
+
const functionCalls = [];
|
|
935
1158
|
for (const m of report.measures) {
|
|
936
1159
|
const owner = { kind: "reportMeasure", object: m };
|
|
937
|
-
|
|
1160
|
+
const tokens = tokenizeDax(m.expression);
|
|
1161
|
+
const calls = callsIn(tokens);
|
|
1162
|
+
if (calls.length > 0) functionCalls.push({ measure: m, calls });
|
|
1163
|
+
for (const raw of refsInTokens(tokens)) {
|
|
938
1164
|
if (raw.qualified) {
|
|
939
1165
|
const t = tables.get(lower3(raw.table));
|
|
940
1166
|
const kind = t && measureOf(t, raw.name) || reportMeasures.has(`${lower3(raw.table)}\0${lower3(raw.name)}`) ? "measure" : "column";
|
|
@@ -942,7 +1168,7 @@ function buildReportReferenceIndex(report, model) {
|
|
|
942
1168
|
continue;
|
|
943
1169
|
}
|
|
944
1170
|
const bare = resolveBareName(
|
|
945
|
-
raw
|
|
1171
|
+
raw,
|
|
946
1172
|
{ kind: "reportMeasure", table: tables.get(lower3(m.table)) },
|
|
947
1173
|
bareLookup
|
|
948
1174
|
);
|
|
@@ -970,19 +1196,8 @@ function buildReportReferenceIndex(report, model) {
|
|
|
970
1196
|
});
|
|
971
1197
|
}
|
|
972
1198
|
}
|
|
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
1199
|
return {
|
|
984
1200
|
refs,
|
|
985
|
-
referencedBy: (target) => byTarget.get(target) ?? [],
|
|
986
1201
|
unresolved: () => refs.filter((r) => r.resolution.kind === "unresolved"),
|
|
987
1202
|
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v),
|
|
988
1203
|
functionCalls
|
|
@@ -996,7 +1211,10 @@ function buildUsageIndex(model) {
|
|
|
996
1211
|
const levelColumns = /* @__PURE__ */ new Set();
|
|
997
1212
|
const variationDefaults = /* @__PURE__ */ new Set();
|
|
998
1213
|
const groupByTargets = /* @__PURE__ */ new Set();
|
|
1214
|
+
const calendarColumns = /* @__PURE__ */ new Set();
|
|
999
1215
|
for (const t of model.tables) {
|
|
1216
|
+
for (const cal of t.calendars ?? [])
|
|
1217
|
+
for (const c of cal.columns) calendarColumns.add(key3(t.name, c));
|
|
1000
1218
|
for (const c of t.columns) {
|
|
1001
1219
|
if (c.sortByColumn !== void 0) sortTargets.add(key3(t.name, c.sortByColumn));
|
|
1002
1220
|
for (const g of c.groupByColumns) groupByTargets.add(key3(t.name, g));
|
|
@@ -1011,7 +1229,8 @@ function buildUsageIndex(model) {
|
|
|
1011
1229
|
usedInSortBy: (c) => sortTargets.has(key3(c.table.name, c.name)),
|
|
1012
1230
|
usedInHierarchies: (c) => levelColumns.has(key3(c.table.name, c.name)),
|
|
1013
1231
|
usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name)),
|
|
1014
|
-
usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name))
|
|
1232
|
+
usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name)),
|
|
1233
|
+
usedInCalendars: (c) => calendarColumns.has(key3(c.table.name, c.name))
|
|
1015
1234
|
};
|
|
1016
1235
|
}
|
|
1017
1236
|
|
|
@@ -1037,7 +1256,7 @@ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
1037
1256
|
var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : "a boolean";
|
|
1038
1257
|
var CONFLICT_MARKER = /^(?:<{7}|={7}|>{7})(?:\s|$)/;
|
|
1039
1258
|
var LINE_BREAK = /\r\n?|\n/;
|
|
1040
|
-
function
|
|
1259
|
+
function quoted2(line) {
|
|
1041
1260
|
if (line === void 0 || line.length <= 120) return line ?? "";
|
|
1042
1261
|
const text2 = line.trimStart();
|
|
1043
1262
|
if (text2.length <= 120) return text2;
|
|
@@ -1086,7 +1305,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1086
1305
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
1087
1306
|
const lines = body.split(LINE_BREAK);
|
|
1088
1307
|
const issues = lines.flatMap(
|
|
1089
|
-
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text:
|
|
1308
|
+
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted2(line), reason: "merge conflict marker" }] : []
|
|
1090
1309
|
);
|
|
1091
1310
|
if (issues.length > 0) return { json: void 0, issues };
|
|
1092
1311
|
let json;
|
|
@@ -1098,7 +1317,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1098
1317
|
const flat = message.replace(/\s+/g, " ");
|
|
1099
1318
|
return {
|
|
1100
1319
|
json: void 0,
|
|
1101
|
-
issues: [{ file, line, text:
|
|
1320
|
+
issues: [{ file, line, text: quoted2(lines[line - 1]), reason: `not valid JSON (${flat})` }]
|
|
1102
1321
|
};
|
|
1103
1322
|
}
|
|
1104
1323
|
const deep = tooDeepAt(body);
|
|
@@ -1110,7 +1329,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1110
1329
|
{
|
|
1111
1330
|
file,
|
|
1112
1331
|
line,
|
|
1113
|
-
text:
|
|
1332
|
+
text: quoted2(lines[line - 1]),
|
|
1114
1333
|
reason: `nested more than ${MAX_DEPTH} levels deep`
|
|
1115
1334
|
}
|
|
1116
1335
|
]
|
|
@@ -1125,7 +1344,7 @@ function readJson(file, text2, options = {}) {
|
|
|
1125
1344
|
{
|
|
1126
1345
|
file,
|
|
1127
1346
|
line: start + 1,
|
|
1128
|
-
text:
|
|
1347
|
+
text: quoted2(lines[start]),
|
|
1129
1348
|
reason: `not a JSON object (the file holds ${kindOf(json)})`
|
|
1130
1349
|
}
|
|
1131
1350
|
]
|
|
@@ -1357,13 +1576,16 @@ function filtersOf(filterConfig, file, pointer) {
|
|
|
1357
1576
|
if (!isRecord4(f)) return [];
|
|
1358
1577
|
const p = `${pointer}/filters/${i}`;
|
|
1359
1578
|
const field = collectFieldRefs(f.field, `${p}/field`)[0];
|
|
1579
|
+
const where = isRecord4(f.filter) && Array.isArray(f.filter.Where) ? f.filter.Where : void 0;
|
|
1360
1580
|
return [
|
|
1361
1581
|
{
|
|
1362
1582
|
name: str2(f.name) ?? String(i),
|
|
1363
1583
|
type: str2(f.type),
|
|
1584
|
+
howCreated: str2(f.howCreated),
|
|
1364
1585
|
...field ? { field } : {},
|
|
1365
1586
|
refs: collectFieldRefs(f, p),
|
|
1366
1587
|
applied: isRecord4(f.filter),
|
|
1588
|
+
...where ? { where } : {},
|
|
1367
1589
|
file,
|
|
1368
1590
|
pointer: p
|
|
1369
1591
|
}
|
|
@@ -1533,6 +1755,8 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1533
1755
|
visual,
|
|
1534
1756
|
pointer: `/explorationState/sections/${escapePointer(page)}/visualContainers/${escapePointer(visual)}`
|
|
1535
1757
|
});
|
|
1758
|
+
const options = isRecord4(json.options) ? json.options : {};
|
|
1759
|
+
const names = Array.isArray(options.targetVisualNames) ? options.targetVisualNames : [];
|
|
1536
1760
|
return {
|
|
1537
1761
|
id: str2(json.name) ?? id,
|
|
1538
1762
|
displayName: str2(json.displayName) ?? id,
|
|
@@ -1541,6 +1765,11 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1541
1765
|
...str2(state.activeSection) !== void 0 ? { activePage: str2(state.activeSection) } : {},
|
|
1542
1766
|
pages: Object.keys(sections),
|
|
1543
1767
|
visuals,
|
|
1768
|
+
...options.applyOnlyToTargetVisuals === true ? {
|
|
1769
|
+
targetVisuals: names.flatMap(
|
|
1770
|
+
(visual, i) => typeof visual === "string" ? [{ visual, pointer: `/options/targetVisualNames/${i}` }] : []
|
|
1771
|
+
)
|
|
1772
|
+
} : {},
|
|
1544
1773
|
refs: collectFieldRefs(state, "/explorationState")
|
|
1545
1774
|
};
|
|
1546
1775
|
}
|
|
@@ -1890,10 +2119,11 @@ var allPartitions = (m) => m.tables.flatMap((t) => t.partitions);
|
|
|
1890
2119
|
var allCalculationItems = (m) => m.tables.flatMap((t) => t.calculationGroup?.items ?? []);
|
|
1891
2120
|
var allTablePermissions = (m) => m.roles.flatMap((r) => r.tablePermissions);
|
|
1892
2121
|
var dataType = (c) => (c.dataType ?? "").toLowerCase();
|
|
2122
|
+
var typeKnown = (c) => dataType(c) !== "";
|
|
1893
2123
|
var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
|
|
1894
2124
|
var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
|
|
1895
2125
|
var isBlank = (s) => s === void 0 || s.trim() === "";
|
|
1896
|
-
var
|
|
2126
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1897
2127
|
var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
|
|
1898
2128
|
var modelPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropObjects));
|
|
1899
2129
|
var tablesPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropTableLine));
|
|
@@ -1986,6 +2216,13 @@ var finding = {
|
|
|
1986
2216
|
location: e.location,
|
|
1987
2217
|
object: e
|
|
1988
2218
|
}),
|
|
2219
|
+
/** A user-defined function, named bare (`Local.AddTax`) as Tabular Editor names it. */
|
|
2220
|
+
function: (f) => ({
|
|
2221
|
+
objectType: "Function",
|
|
2222
|
+
objectName: f.name,
|
|
2223
|
+
location: f.location,
|
|
2224
|
+
object: f
|
|
2225
|
+
}),
|
|
1989
2226
|
dataSource: (d) => ({
|
|
1990
2227
|
objectType: "DataSource",
|
|
1991
2228
|
objectName: d.name,
|
|
@@ -2059,6 +2296,11 @@ var reportMeasureLabel = (m) => `[${m.name}] (report)`;
|
|
|
2059
2296
|
var pageFilterLabel = (p) => `Page filter on "${p.displayName}"`;
|
|
2060
2297
|
var REPORT_LABEL = "Report";
|
|
2061
2298
|
var REPORT_FILTER_LABEL = "Report filter";
|
|
2299
|
+
var fieldLabel = (ref) => {
|
|
2300
|
+
const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
|
|
2301
|
+
const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
|
|
2302
|
+
return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
|
|
2303
|
+
};
|
|
2062
2304
|
|
|
2063
2305
|
// ../core/src/rules/report-helpers.ts
|
|
2064
2306
|
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -3304,16 +3546,17 @@ var RULE_SUMMARIES = {
|
|
|
3304
3546
|
"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
3547
|
AVOID_USING_THE_IFERROR_FUNCTION: "Measures and calculated columns that call IFERROR.",
|
|
3306
3548
|
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,
|
|
3549
|
+
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
3550
|
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
3551
|
CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: "Calculation groups that contain no calculation items.",
|
|
3310
3552
|
"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.",
|
|
3311
3553
|
"CHECK_IF_DYNAMIC_ROW_LEVEL_SECURITY_(RLS)_IS_NECESSARY": "Row-level security filters that call USERNAME or USERPRINCIPALNAME. Reported per table permission, at info severity.",
|
|
3312
3554
|
DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN: "Data columns with no source column. Calculated columns are not checked.",
|
|
3313
|
-
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "Tables with date or calendar in the name that are not marked as a date table, meaning the data category is not Time or no DateTime column is marked as the key.",
|
|
3555
|
+
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "Tables with date or calendar in the name that define no calendar and are not marked as a date table, meaning the data category is not Time or no DateTime column is marked as the key.",
|
|
3314
3556
|
DATECOLUMN_FORMATSTRING: "DateTime columns with date in the name whose format string is not exactly `mm/dd/yyyy`.",
|
|
3315
3557
|
DAX_COLUMNS_FULLY_QUALIFIED: "Measures and row-level security filters that refer to a column by its bare name, `[Column]`, instead of `'Table'[Column]`.",
|
|
3316
3558
|
DAX_MEASURES_UNQUALIFIED: "Measures, calculated columns, calculated tables, and calculation items that refer to a measure with a table prefix, `'Table'[Measure]`.",
|
|
3559
|
+
DECIMAL_COLUMN_WITHOUT_FORMAT_STRING: "Visible Decimal number and Fixed decimal number columns with no format string.",
|
|
3317
3560
|
DEFAULT_PAGE_NAME: "Pages whose display name in page.json has the shape `Page <n>`, `Duplicate of <name>`, or `<name> (copy)`: the names English Power BI Desktop gives a new page and a duplicated one, and a name marked as a copy. The rule also matches the forms Desktop-saved files show for a new or duplicated page in other languages, such as `Seite <n>` and `Doublon de <name>`.",
|
|
3318
3561
|
ENSURE_ALTTEXT: "Visuals other than shapes whose alt text is missing or empty. A visual group is checked by its own alt text, the one set on the group rather than on the visuals inside it.",
|
|
3319
3562
|
ENSURE_PAGES_DO_NOT_SCROLL_VERTICALLY: "Visible pages taller than the threshold, 720 pixels by default.",
|
|
@@ -3328,13 +3571,14 @@ var RULE_SUMMARIES = {
|
|
|
3328
3571
|
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
3572
|
"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
3573
|
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.",
|
|
3574
|
+
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
3575
|
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
3576
|
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
3577
|
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.",
|
|
3334
3578
|
HIDE_TOOLTIP_DRILLTROUGH_PAGES: "Tooltip pages and drillthrough pages that are not hidden.",
|
|
3335
3579
|
INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure, calculation item, or user-defined function activates with USERELATIONSHIP.",
|
|
3336
3580
|
INTEGER_FORMATTING: "Measures whose static format string is not a recognized whole-number, currency, or percentage format. The only format strings the rule accepts are `#,0`, `#,0.0`, and any string containing `$` or `%`. A measure with no format string at all fires too, and that is the common case: the rule reads only the format string, so it cannot tell an unformatted currency or ratio from an unformatted count.",
|
|
3337
|
-
ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, or in a
|
|
3581
|
+
ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, in a variation, or in a calendar, and do not themselves sort by another column.",
|
|
3338
3582
|
LANDING_PAGE_NOT_SET: "A report whose pages.json sets no landing page, so it opens on the page that was active when it was last saved, or, when pages.json records no active page either, on the first page.",
|
|
3339
3583
|
LARGE_TABLES_SHOULD_BE_PARTITIONED: "Tables with more than 25 million rows and a single partition. The row count 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.",
|
|
3340
3584
|
"LIMIT_ROW_LEVEL_SECURITY_(RLS)_LOGIC": "Tables whose row-level security filter, in any role, calls RIGHT, LEFT, UPPER, LOWER, or FIND.",
|
|
@@ -3343,11 +3587,12 @@ var RULE_SUMMARIES = {
|
|
|
3343
3587
|
MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: "Measures whose whole expression is a reference to another measure, such as `[Total Sales]`.",
|
|
3344
3588
|
MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY: "Measures and calculation items that call a time intelligence function, in a model where at least one table is in DirectQuery mode.",
|
|
3345
3589
|
MINIMIZE_POWER_QUERY_TRANSFORMATIONS: "Power Query partitions whose M text contains Table.Combine, Table.Join, Table.NestedJoin, Table.AddColumn, Table.Group, Table.Sort, Table.Pivot, Table.Unpivot, Table.UnpivotOtherColumns, Table.Distinct, a native SQL query, or an OLE DB or ODBC query.",
|
|
3346
|
-
MODEL_SHOULD_HAVE_A_DATE_TABLE: "Models with no table that has the data category Time and a DateTime column marked as the key, which is what Mark as date table sets.",
|
|
3590
|
+
MODEL_SHOULD_HAVE_A_DATE_TABLE: "Models with no table that defines a calendar, and none that has the data category Time and a DateTime column marked as the key, which is what Mark as date table sets.",
|
|
3347
3591
|
MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: "Models that have at least one DirectQuery table, no aggregation table (no column has an alternateOf mapping), and the PowerBI_V3 data source version, which is every project Desktop writes today.",
|
|
3348
3592
|
"MONTH_(AS_A_STRING)_MUST_BE_SORTED": "Text columns with month in the name, but not months, that have no sort-by column.",
|
|
3349
3593
|
MONTHCOLUMN_FORMATSTRING: "DateTime columns with month in the name whose format string is not exactly `MMMM yyyy`.",
|
|
3350
|
-
|
|
3594
|
+
NAME_WITHOUT_TRANSLATION: "Visible tables, columns, measures, and hierarchies, and the levels of visible hierarchies, that a translated culture other than the model's own gives no caption.",
|
|
3595
|
+
NOT_REACHED_FROM_REPORT: "Columns and measures that nothing in the report reaches, directly or through the model. The walk starts from every field the report names, both columns of every relationship except one to an auto date/time table, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of every variation, the columns a calendar names, the columns of an aggregation table (the ones with an `alternateOf` mapping), the fields the report's own measures reference, and the user-defined functions those measures and the security filters call, and it follows DAX references, calls to user-defined functions, sort-by and group-by columns, the detail column or table each mapping names, and calculated tables until nothing new is reached.",
|
|
3351
3596
|
NUMERIC_COLUMN_SUMMARIZE_BY: "Visible whole number, decimal, or double columns whose default summarization is anything other than None.",
|
|
3352
3597
|
OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: "Names that start or end with a space, for the model, tables, measures, hierarchies, perspectives, partitions, data columns, and calculated columns.",
|
|
3353
3598
|
OBJECTS_WITH_NO_DESCRIPTION: "Visible tables, columns, measures, and calculation groups with no description. Visibility is the object's own flag.",
|
|
@@ -3356,7 +3601,7 @@ var RULE_SUMMARIES = {
|
|
|
3356
3601
|
PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: "Regular tables with exactly one partition whose name differs from the table name. Calculated tables and calculation groups are not checked.",
|
|
3357
3602
|
PERCENTAGE_FORMATTING: "Measures with a percent format string other than `#,0.0%;-#,0.0%;#,0.0%`.",
|
|
3358
3603
|
PERSPECTIVES_WITH_NO_OBJECTS: "Perspectives that contain no tables. The rule reads only a perspective's table entries, which is enough: each column, measure, and hierarchy a perspective includes sits under the entry for its table, so with no table entry it includes nothing.",
|
|
3359
|
-
PROVIDE_FORMAT_STRING_FOR_MEASURES: "Visible measures with no format string and no dynamic format string.",
|
|
3604
|
+
PROVIDE_FORMAT_STRING_FOR_MEASURES: "Visible measures with no format string and no dynamic format string, other than those whose DAX plainly returns text.",
|
|
3360
3605
|
REDUCE_ADVANCED_FILTERS: "Pages with more visuals carrying an Advanced filter with a condition applied than the threshold, 4 by default.",
|
|
3361
3606
|
REDUCE_NUMBER_OF_CALCULATED_COLUMNS: "Models with more than five calculated columns across all tables. Columns of calculated tables do not count, and the finding is on the model.",
|
|
3362
3607
|
REDUCE_OBJECTS_WITHIN_VISUALS: "Visuals with more entries in their field wells than the threshold, 6 by default, counting each column, measure, visual calculation, or sparkline in any of the visual's wells as one.",
|
|
@@ -3374,7 +3619,7 @@ var RULE_SUMMARIES = {
|
|
|
3374
3619
|
REMOVE_ROLES_WITH_NO_MEMBERS: "Roles with no members.",
|
|
3375
3620
|
REMOVE_UNUSED_CUSTOM_VISUALS: "Custom visuals from AppSource that the report registers in report.json and that no visual on any page uses.",
|
|
3376
3621
|
REPORT_LEVEL_MEASURES: "Measures defined in the report's reportExtensions.json rather than in the model, reported when the model the report reads is in the input, so that each can move into it.",
|
|
3377
|
-
SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: "Columns with IsAvailableInMdx set to false that are used to sort another column, appear in a hierarchy or a
|
|
3622
|
+
SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: "Columns with IsAvailableInMdx set to false that are used to sort another column, appear in a hierarchy, a variation, or a calendar, or sort by another column.",
|
|
3378
3623
|
SLICER_SEARCH_SAVED: "Slicers saved with a term in their search box: any visual whose visual.json holds a `selfFilter` with a condition under `objects.general`, which is where Power BI Desktop's saved files keep the text typed in a slicer's search box.",
|
|
3379
3624
|
SLICER_SELECTION_SAVED: "Slicers saved with a selection, so that the report opens with it applied: any visual whose visual.json holds a `filter` with a condition under `objects.general`, which is where Power BI Desktop saves the selection of a slicer, button slicer, list slicer, input slicer, or `filterSlicer`, and of a custom slicer from AppSource that filters through the Visual Filters API. It is info without a policy, and a warning when the project's policy expects no saved selections.",
|
|
3380
3625
|
SNOWFLAKE_SCHEMA_ARCHITECTURE: "Tables that are on the from side of one relationship and the to side of another, which is what a dimension related to a sub-dimension looks like.",
|
|
@@ -3382,7 +3627,10 @@ var RULE_SUMMARIES = {
|
|
|
3382
3627
|
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
3628
|
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
3629
|
TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
|
|
3385
|
-
|
|
3630
|
+
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.",
|
|
3631
|
+
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.",
|
|
3632
|
+
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.",
|
|
3633
|
+
UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, calendar, row-level security filter, or object-level security rule.",
|
|
3386
3634
|
UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
|
|
3387
3635
|
"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.",
|
|
3388
3636
|
USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: "Expressions that use the division operator right after a closing bracket or parenthesis, such as `[Sales] / [Cost]` or `SUM(...) / SUM(...)`. A slash that starts a comment is ignored.",
|
|
@@ -3413,6 +3661,7 @@ var SCOPE_MAP = {
|
|
|
3413
3661
|
NamedExpression: "NamedExpression",
|
|
3414
3662
|
ProviderDataSource: "DataSource",
|
|
3415
3663
|
StructuredDataSource: "DataSource",
|
|
3664
|
+
UserDefinedFunction: "Function",
|
|
3416
3665
|
KPI: null
|
|
3417
3666
|
};
|
|
3418
3667
|
function mapScope(scope) {
|
|
@@ -3505,7 +3754,7 @@ var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
|
|
|
3505
3754
|
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3506
3755
|
(m) => columns(
|
|
3507
3756
|
m,
|
|
3508
|
-
(c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && dataType(c) !== "string")
|
|
3757
|
+
(c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && typeKnown(c) && dataType(c) !== "string")
|
|
3509
3758
|
)
|
|
3510
3759
|
);
|
|
3511
3760
|
var DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
|
|
@@ -3518,14 +3767,14 @@ var ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
|
|
|
3518
3767
|
{ skipWhenModelUnread: modelPartlyRead },
|
|
3519
3768
|
(m, { indexes: { usage } }) => columns(
|
|
3520
3769
|
m,
|
|
3521
|
-
(c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && c.sortByColumn === void 0
|
|
3770
|
+
(c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && !usage.usedInCalendars(c) && c.sortByColumn === void 0
|
|
3522
3771
|
)
|
|
3523
3772
|
);
|
|
3524
3773
|
var SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
|
|
3525
3774
|
"SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS",
|
|
3526
3775
|
(m, { indexes: { usage } }) => columns(
|
|
3527
3776
|
m,
|
|
3528
|
-
(c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || c.sortByColumn !== void 0)
|
|
3777
|
+
(c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || usage.usedInCalendars(c) || c.sortByColumn !== void 0)
|
|
3529
3778
|
)
|
|
3530
3779
|
);
|
|
3531
3780
|
var UNNECESSARY_COLUMNS = bpaRule(
|
|
@@ -3539,6 +3788,7 @@ var UNNECESSARY_COLUMNS = bpaRule(
|
|
|
3539
3788
|
if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
|
|
3540
3789
|
if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
|
|
3541
3790
|
if (indexes.usage.usedInGroupBy(c)) return false;
|
|
3791
|
+
if (indexes.usage.usedInCalendars(c)) return false;
|
|
3542
3792
|
const bare = `[${c.name}]`.toLowerCase();
|
|
3543
3793
|
const qualified = [
|
|
3544
3794
|
`${c.table.name}[${c.name}]`.toLowerCase(),
|
|
@@ -3580,7 +3830,7 @@ var HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
|
|
|
3580
3830
|
return columns(m, (c) => {
|
|
3581
3831
|
if (c.isHidden || !isNumericType(c)) return false;
|
|
3582
3832
|
const re = new RegExp(
|
|
3583
|
-
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${
|
|
3833
|
+
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp(c.table.name)}'*\\[${escapeRegExp(c.name)}\\]\\s*\\)`,
|
|
3584
3834
|
"i"
|
|
3585
3835
|
);
|
|
3586
3836
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -3680,11 +3930,50 @@ var patternRule = (id, kinds, patterns) => bpaRule(
|
|
|
3680
3930
|
id,
|
|
3681
3931
|
(m) => expressionObjects(m, kinds).filter((o) => patterns.some((p) => p.test(o.expression))).map((o) => o.finding)
|
|
3682
3932
|
);
|
|
3933
|
+
var TEXT_FUNCTIONS = /* @__PURE__ */ new Set([
|
|
3934
|
+
"FORMAT",
|
|
3935
|
+
"CONCATENATE",
|
|
3936
|
+
"CONCATENATEX",
|
|
3937
|
+
"UNICHAR",
|
|
3938
|
+
"COMBINEVALUES",
|
|
3939
|
+
"LEFT",
|
|
3940
|
+
"RIGHT",
|
|
3941
|
+
"MID",
|
|
3942
|
+
"UPPER",
|
|
3943
|
+
"LOWER",
|
|
3944
|
+
"SUBSTITUTE",
|
|
3945
|
+
"REPT",
|
|
3946
|
+
"TRIM",
|
|
3947
|
+
"FIXED",
|
|
3948
|
+
"REPLACE",
|
|
3949
|
+
"USERPRINCIPALNAME",
|
|
3950
|
+
"USERNAME",
|
|
3951
|
+
"USEROBJECTID",
|
|
3952
|
+
"USERCULTURE",
|
|
3953
|
+
"CUSTOMDATA",
|
|
3954
|
+
"SELECTEDMEASURENAME",
|
|
3955
|
+
"NAMEOF",
|
|
3956
|
+
"TOJSON",
|
|
3957
|
+
"TOCSV"
|
|
3958
|
+
]);
|
|
3959
|
+
function returnsText(expression) {
|
|
3960
|
+
const tokens = tokenizeDax(expression);
|
|
3961
|
+
let from = 0;
|
|
3962
|
+
tokens.forEach((t, i) => {
|
|
3963
|
+
if (t.depth === 0 && isWord(t, "RETURN")) from = i + 1;
|
|
3964
|
+
});
|
|
3965
|
+
const result = tokens.slice(from);
|
|
3966
|
+
const first = result[0];
|
|
3967
|
+
if (first === void 0) return false;
|
|
3968
|
+
if (result.length === 1 && first.kind === "string") return true;
|
|
3969
|
+
if (result.some((t) => t.depth === 0 && t.kind === "operator" && t.text === "&")) return true;
|
|
3970
|
+
return first.kind === "identifier" && TEXT_FUNCTIONS.has(first.text.toUpperCase()) && isPunctuation(result[1], "(");
|
|
3971
|
+
}
|
|
3683
3972
|
var PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
|
|
3684
3973
|
"PROVIDE_FORMAT_STRING_FOR_MEASURES",
|
|
3685
3974
|
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3686
3975
|
(m) => allMeasures(m).filter(
|
|
3687
|
-
(x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition)
|
|
3976
|
+
(x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition) && !returnsText(x.expression)
|
|
3688
3977
|
).map(finding.measure)
|
|
3689
3978
|
);
|
|
3690
3979
|
var formatStringDetail = (x) => {
|
|
@@ -3852,7 +4141,7 @@ var isBidirectional = (r) => r.crossFilteringBehavior === "bothdirections";
|
|
|
3852
4141
|
var RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE = bpaRule(
|
|
3853
4142
|
"RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE",
|
|
3854
4143
|
(m, { indexes: { relationships } }) => allColumns(m).filter(
|
|
3855
|
-
(c) => relationships.forColumn(c.table.name, c.name).length > 0 && dataType(c) !== "int64"
|
|
4144
|
+
(c) => relationships.forColumn(c.table.name, c.name).length > 0 && typeKnown(c) && dataType(c) !== "int64"
|
|
3856
4145
|
).map(finding.column)
|
|
3857
4146
|
);
|
|
3858
4147
|
var HIDE_FOREIGN_KEYS = bpaRule(
|
|
@@ -3909,7 +4198,7 @@ var RELATIONSHIP_COLUMNS_SAME_DATA_TYPE = bpaRule(
|
|
|
3909
4198
|
return m.relationships.filter((r) => {
|
|
3910
4199
|
const from = column(r.fromTable, r.fromColumn);
|
|
3911
4200
|
const to = column(r.toTable, r.toColumn);
|
|
3912
|
-
return from !== void 0 && to !== void 0 && dataType(from) !== dataType(to);
|
|
4201
|
+
return from !== void 0 && to !== void 0 && typeKnown(from) && typeKnown(to) && dataType(from) !== dataType(to);
|
|
3913
4202
|
}).map(finding.relationship);
|
|
3914
4203
|
}
|
|
3915
4204
|
);
|
|
@@ -3925,7 +4214,7 @@ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
|
3925
4214
|
return m.relationships.filter((r) => {
|
|
3926
4215
|
if (r.isActive) return false;
|
|
3927
4216
|
const re = new RegExp(
|
|
3928
|
-
`USERELATIONSHIP\\s*\\(\\s*'*${
|
|
4217
|
+
`USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp(r.fromTable)}'*\\[${escapeRegExp(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp(r.toTable)}'*\\[${escapeRegExp(r.toColumn)}\\]`,
|
|
3929
4218
|
"i"
|
|
3930
4219
|
);
|
|
3931
4220
|
return !expressions.some((e) => re.test(e));
|
|
@@ -3967,18 +4256,19 @@ var relationshipRules = [
|
|
|
3967
4256
|
];
|
|
3968
4257
|
|
|
3969
4258
|
// ../core/src/rules/microsoft-bpa/tables.ts
|
|
3970
|
-
var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && dataType(c) === "datetime");
|
|
4259
|
+
var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && (dataType(c) === "datetime" || !typeKnown(c)));
|
|
4260
|
+
var hasCalendar = (t) => (t.calendars?.length ?? 0) > 0;
|
|
3971
4261
|
var MODEL_SHOULD_HAVE_A_DATE_TABLE = bpaRule(
|
|
3972
4262
|
"MODEL_SHOULD_HAVE_A_DATE_TABLE",
|
|
3973
4263
|
{ skipWhenModelUnread: modelPartlyRead },
|
|
3974
|
-
(m) => m.tables.some((t) => t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
|
|
4264
|
+
(m) => m.tables.some((t) => hasCalendar(t) || t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
|
|
3975
4265
|
);
|
|
3976
4266
|
var DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE = bpaRule(
|
|
3977
4267
|
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
|
|
3978
4268
|
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3979
4269
|
(m) => tablesInScope(m).filter((t) => {
|
|
3980
4270
|
const u = t.name.toUpperCase();
|
|
3981
|
-
return (u.includes("DATE") || u.includes("CALENDAR")) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
|
|
4271
|
+
return (u.includes("DATE") || u.includes("CALENDAR")) && !hasCalendar(t) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
|
|
3982
4272
|
}).map(finding.table)
|
|
3983
4273
|
);
|
|
3984
4274
|
var REMOVE_AUTO_DATE_TABLE = bpaRule(
|
|
@@ -4107,7 +4397,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
|
|
|
4107
4397
|
return tablesInScope(m).filter((t) => {
|
|
4108
4398
|
if (!permissions.some((tp) => tp.table === t.name && tp.filter !== void 0)) return false;
|
|
4109
4399
|
const re = new RegExp(
|
|
4110
|
-
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${
|
|
4400
|
+
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp(t.name)}'*\\[`,
|
|
4111
4401
|
"i"
|
|
4112
4402
|
);
|
|
4113
4403
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -4489,404 +4779,34 @@ function pbiplintRule(spec) {
|
|
|
4489
4779
|
};
|
|
4490
4780
|
}
|
|
4491
4781
|
|
|
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"
|
|
4782
|
+
// ../core/src/rules/pbiplint/period-words.ts
|
|
4783
|
+
var YEAR_WORDS = /* @__PURE__ */ new Set([
|
|
4784
|
+
"year",
|
|
4785
|
+
"years",
|
|
4786
|
+
"yr",
|
|
4787
|
+
"yrs",
|
|
4788
|
+
"a\xF1o",
|
|
4789
|
+
"a\xF1os",
|
|
4790
|
+
"ano",
|
|
4791
|
+
"anos",
|
|
4792
|
+
"anio",
|
|
4793
|
+
"jahr",
|
|
4794
|
+
"ann\xE9e",
|
|
4795
|
+
"annee",
|
|
4796
|
+
"anno",
|
|
4797
|
+
"jaar",
|
|
4798
|
+
"\xE5r",
|
|
4799
|
+
"rok",
|
|
4800
|
+
"vuosi",
|
|
4801
|
+
"ejercicio",
|
|
4802
|
+
"exercice",
|
|
4803
|
+
"fy",
|
|
4804
|
+
"ay",
|
|
4805
|
+
"cy",
|
|
4806
|
+
"ly",
|
|
4807
|
+
"py",
|
|
4808
|
+
"yyyy",
|
|
4809
|
+
"y"
|
|
4890
4810
|
]);
|
|
4891
4811
|
var MONTH_WORDS = /* @__PURE__ */ new Set([
|
|
4892
4812
|
"month",
|
|
@@ -4933,6 +4853,7 @@ function nameClass(name) {
|
|
|
4933
4853
|
// ../core/src/rules/pbiplint/period-forms.ts
|
|
4934
4854
|
var FIRST_YEAR = 1950;
|
|
4935
4855
|
var LAST_YEAR = 2049;
|
|
4856
|
+
var inYearRange = (year) => year >= FIRST_YEAR && year <= LAST_YEAR;
|
|
4936
4857
|
var COMPARISONS = /* @__PURE__ */ new Set(["=", "==", "<>"]);
|
|
4937
4858
|
var WRAPPERS = /* @__PURE__ */ new Set([
|
|
4938
4859
|
"SELECTEDVALUE",
|
|
@@ -4966,7 +4887,7 @@ function yearIn(t, strings) {
|
|
|
4966
4887
|
const text2 = t?.kind === "number" ? t.text : strings && t?.kind === "string" ? t.text.trim() : void 0;
|
|
4967
4888
|
if (text2 === void 0 || !/^\d{4}$/.test(text2)) return void 0;
|
|
4968
4889
|
const year = Number(text2);
|
|
4969
|
-
return year
|
|
4890
|
+
return inYearRange(year) ? year : void 0;
|
|
4970
4891
|
}
|
|
4971
4892
|
function argumentsOf(tokens, open) {
|
|
4972
4893
|
const end = tokens[open]?.close ?? tokens.length;
|
|
@@ -5066,14 +4987,14 @@ function expressionPeriods(expression) {
|
|
|
5066
4987
|
}
|
|
5067
4988
|
if (t.call === "DATE" && !isBound(tokens, k) && !isYearFreeFormat(tokens, k)) {
|
|
5068
4989
|
const [y, m, d] = argumentsOf(tokens, k).map((span) => only(tokens, span));
|
|
5069
|
-
const
|
|
5070
|
-
if (
|
|
4990
|
+
const fixed2 = yearIn(y, false);
|
|
4991
|
+
if (fixed2 !== void 0) {
|
|
5071
4992
|
if (isWhole(m) && isWhole(d)) {
|
|
5072
4993
|
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,
|
|
4994
|
+
const epoch = fixed2 === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
|
|
4995
|
+
const date = daxDate(fixed2, month, day);
|
|
4996
|
+
if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed2, date });
|
|
4997
|
+
} else year(y, fixed2);
|
|
5077
4998
|
}
|
|
5078
4999
|
}
|
|
5079
5000
|
});
|
|
@@ -5157,104 +5078,509 @@ var MONTHS2 = [
|
|
|
5157
5078
|
"November",
|
|
5158
5079
|
"December"
|
|
5159
5080
|
];
|
|
5160
|
-
var shown = (p) => p.ambiguous !== void 0 ? `"${p.ambiguous}"` : p.date ? `${MONTHS2[p.date.month - 1]} ${p.date.day}, ${p.date.year}` : String(p.year);
|
|
5161
|
-
function englishList(items) {
|
|
5162
|
-
const named2 = items.length > 3 ? [...items.slice(0, 3), `${items.length - 3} more`] : [...items];
|
|
5163
|
-
if (named2.length <= 2) return named2.join(" and ");
|
|
5164
|
-
const sep = named2.some((s) => s.includes(",")) ? "; " : ", ";
|
|
5165
|
-
return `${named2.slice(0, -1).join(sep)}${sep}and ${named2.at(-1)}`;
|
|
5166
|
-
}
|
|
5167
|
-
function namesYear(name, year) {
|
|
5168
|
-
const digits = String(year);
|
|
5169
|
-
if (digits.length !== 4) return false;
|
|
5170
|
-
return name.includes(digits) || new RegExp(`(?:^|\\D)${digits.slice(2)}(?:\\D|$)`).test(name);
|
|
5171
|
-
}
|
|
5172
|
-
function lineAt2(node, offset) {
|
|
5173
|
-
if (node?.valueLine === void 0 || node.value === void 0) return void 0;
|
|
5174
|
-
let line = node.valueLine;
|
|
5175
|
-
for (let k = 0; k < offset && k < node.value.length; k++) if (node.value[k] === "\n") line++;
|
|
5176
|
-
return { file: node.file, line };
|
|
5177
|
-
}
|
|
5178
|
-
function fixesDetail(periods) {
|
|
5179
|
-
const names = [...new Set(periods.map(shown))];
|
|
5180
|
-
const days = periods.filter((p) => p.date !== void 0 || p.ambiguous !== void 0).length;
|
|
5181
|
-
const noun = days === 0 ? "year" : days === periods.length ? "date" : "period";
|
|
5182
|
-
return `fixed ${noun}${names.length > 1 ? "s" : ""} ${englishList(names)}`;
|
|
5183
|
-
}
|
|
5184
|
-
function endsDetail(periods) {
|
|
5185
|
-
const names = [...new Set(periods.map(shown))];
|
|
5186
|
-
return names.length === 1 ? `ends on a fixed date, ${names[0]}` : `ends on fixed dates ${englishList(names)}`;
|
|
5187
|
-
}
|
|
5188
|
-
function fixedFinding(base, name, found, detail) {
|
|
5189
|
-
const periods = found.map((f) => f.period);
|
|
5190
|
-
if (periods.length === 0 || periods.some((p) => namesYear(name, p.year))) return [];
|
|
5191
|
-
const where = lineAt2(found[0].node, found[0].period.at);
|
|
5192
|
-
const own = detail(periods);
|
|
5193
|
-
return [
|
|
5194
|
-
{
|
|
5195
|
-
...base,
|
|
5196
|
-
...where ? { location: where } : {},
|
|
5197
|
-
detail: base.detail === void 0 ? own : `${own} in ${base.detail}`
|
|
5198
|
-
}
|
|
5199
|
-
];
|
|
5081
|
+
var shown = (p) => p.ambiguous !== void 0 ? `"${p.ambiguous}"` : p.date ? `${MONTHS2[p.date.month - 1]} ${p.date.day}, ${p.date.year}` : String(p.year);
|
|
5082
|
+
function englishList(items) {
|
|
5083
|
+
const named2 = items.length > 3 ? [...items.slice(0, 3), `${items.length - 3} more`] : [...items];
|
|
5084
|
+
if (named2.length <= 2) return named2.join(" and ");
|
|
5085
|
+
const sep = named2.some((s) => s.includes(",")) ? "; " : ", ";
|
|
5086
|
+
return `${named2.slice(0, -1).join(sep)}${sep}and ${named2.at(-1)}`;
|
|
5087
|
+
}
|
|
5088
|
+
function namesYear(name, year) {
|
|
5089
|
+
const digits = String(year);
|
|
5090
|
+
if (digits.length !== 4) return false;
|
|
5091
|
+
return name.includes(digits) || new RegExp(`(?:^|\\D)${digits.slice(2)}(?:\\D|$)`).test(name);
|
|
5092
|
+
}
|
|
5093
|
+
function lineAt2(node, offset) {
|
|
5094
|
+
if (node?.valueLine === void 0 || node.value === void 0) return void 0;
|
|
5095
|
+
let line = node.valueLine;
|
|
5096
|
+
for (let k = 0; k < offset && k < node.value.length; k++) if (node.value[k] === "\n") line++;
|
|
5097
|
+
return { file: node.file, line };
|
|
5098
|
+
}
|
|
5099
|
+
function fixesDetail(periods) {
|
|
5100
|
+
const names = [...new Set(periods.map(shown))];
|
|
5101
|
+
const days = periods.filter((p) => p.date !== void 0 || p.ambiguous !== void 0).length;
|
|
5102
|
+
const noun = days === 0 ? "year" : days === periods.length ? "date" : "period";
|
|
5103
|
+
return `fixed ${noun}${names.length > 1 ? "s" : ""} ${englishList(names)}`;
|
|
5104
|
+
}
|
|
5105
|
+
function endsDetail(periods) {
|
|
5106
|
+
const names = [...new Set(periods.map(shown))];
|
|
5107
|
+
return names.length === 1 ? `ends on a fixed date, ${names[0]}` : `ends on fixed dates ${englishList(names)}`;
|
|
5108
|
+
}
|
|
5109
|
+
function fixedFinding(base, name, found, detail) {
|
|
5110
|
+
const periods = found.map((f) => f.period);
|
|
5111
|
+
if (periods.length === 0 || periods.some((p) => namesYear(name, p.year))) return [];
|
|
5112
|
+
const where = lineAt2(found[0].node, found[0].period.at);
|
|
5113
|
+
const own = detail(periods);
|
|
5114
|
+
return [
|
|
5115
|
+
{
|
|
5116
|
+
...base,
|
|
5117
|
+
...where ? { location: where } : {},
|
|
5118
|
+
detail: base.detail === void 0 ? own : `${own} in ${base.detail}`
|
|
5119
|
+
}
|
|
5120
|
+
];
|
|
5121
|
+
}
|
|
5122
|
+
var inNode = (node, expression) => expressionPeriods(expression).map((period) => ({ period, node }));
|
|
5123
|
+
function dateTableEnds(t) {
|
|
5124
|
+
if (t.kind !== "calculated" || isAutoDateTable(t)) return [];
|
|
5125
|
+
return t.partitions.filter((p) => p.sourceType === "calculated").flatMap((p) => {
|
|
5126
|
+
const node = p.node?.children.find((c) => c.kind === "expr" && c.type === "source");
|
|
5127
|
+
return calendarEnds(node?.value ?? p.source ?? "").map((period) => ({ period, node }));
|
|
5128
|
+
});
|
|
5129
|
+
}
|
|
5130
|
+
function periodFindings(model) {
|
|
5131
|
+
const out = [];
|
|
5132
|
+
for (const t of model.tables) {
|
|
5133
|
+
out.push(...fixedFinding(finding.table(t), t.name, dateTableEnds(t), endsDetail));
|
|
5134
|
+
for (const x of t.measures)
|
|
5135
|
+
out.push(
|
|
5136
|
+
...fixedFinding(finding.measure(x), x.name, inNode(x.node, x.expression), fixesDetail)
|
|
5137
|
+
);
|
|
5138
|
+
for (const c of t.columns)
|
|
5139
|
+
if (c.kind === "calculated")
|
|
5140
|
+
out.push(
|
|
5141
|
+
...fixedFinding(
|
|
5142
|
+
finding.column(c),
|
|
5143
|
+
c.name,
|
|
5144
|
+
inNode(c.node, c.expression ?? ""),
|
|
5145
|
+
fixesDetail
|
|
5146
|
+
)
|
|
5147
|
+
);
|
|
5148
|
+
for (const i of t.calculationGroup?.items ?? [])
|
|
5149
|
+
out.push(
|
|
5150
|
+
...fixedFinding(
|
|
5151
|
+
finding.calculationItem(i),
|
|
5152
|
+
i.name,
|
|
5153
|
+
inNode(i.node, i.expression),
|
|
5154
|
+
fixesDetail
|
|
5155
|
+
)
|
|
5156
|
+
);
|
|
5157
|
+
}
|
|
5158
|
+
return out;
|
|
5159
|
+
}
|
|
5160
|
+
var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
|
|
5161
|
+
id: "HARDCODED_PERIOD_IN_DAX",
|
|
5162
|
+
name: "Hardcoded period in DAX",
|
|
5163
|
+
category: "DAX Expressions",
|
|
5164
|
+
severity: 1,
|
|
5165
|
+
scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
|
|
5166
|
+
layer: "model",
|
|
5167
|
+
// No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
|
|
5168
|
+
// parser could not read can hide an object from the rule, never put a period in one.
|
|
5169
|
+
check: ({ model }) => model ? periodFindings(model) : []
|
|
5170
|
+
});
|
|
5171
|
+
var periodRules = [HARDCODED_PERIOD_IN_DAX];
|
|
5172
|
+
|
|
5173
|
+
// ../core/src/rules/pbiplint/actions.ts
|
|
5174
|
+
var CHECKED = /* @__PURE__ */ new Map([
|
|
5175
|
+
["pagenavigation", { action: "Page navigation", object: "page" }],
|
|
5176
|
+
["drillthrough", { action: "Drillthrough", object: "page" }],
|
|
5177
|
+
["bookmark", { action: "Bookmark", object: "bookmark" }]
|
|
5178
|
+
]);
|
|
5179
|
+
var BROKEN_ACTION_TARGET = pbiplintRule({
|
|
5180
|
+
id: "BROKEN_ACTION_TARGET",
|
|
5181
|
+
name: "Action points at nothing",
|
|
5182
|
+
category: "Error Prevention",
|
|
5183
|
+
severity: 3,
|
|
5184
|
+
scope: ["Visual"],
|
|
5185
|
+
layer: "report",
|
|
5186
|
+
check: ({ report }) => {
|
|
5187
|
+
if (!report) return [];
|
|
5188
|
+
const pages = new Set(report.pages.map((p) => p.id));
|
|
5189
|
+
const bookmarks = new Set(report.bookmarks.map((b) => b.id));
|
|
5190
|
+
const exists = {
|
|
5191
|
+
page: (name) => pages.has(name) || pageUnread(report, name),
|
|
5192
|
+
bookmark: (name) => bookmarks.has(name) || bookmarkUnread(report, name)
|
|
5193
|
+
};
|
|
5194
|
+
return allVisuals(report).flatMap(
|
|
5195
|
+
(v) => v.actions.flatMap((a) => {
|
|
5196
|
+
const checked = CHECKED.get(a.type.toLowerCase());
|
|
5197
|
+
if (!checked || !a.on || a.target === void 0) return [];
|
|
5198
|
+
if (exists[checked.object](a.target)) return [];
|
|
5199
|
+
return [
|
|
5200
|
+
reportFinding.visual(
|
|
5201
|
+
v,
|
|
5202
|
+
a.pointer,
|
|
5203
|
+
`${checked.action} action points at ${checked.object} "${a.target}", which does not exist`
|
|
5204
|
+
)
|
|
5205
|
+
];
|
|
5206
|
+
})
|
|
5207
|
+
);
|
|
5208
|
+
}
|
|
5209
|
+
});
|
|
5210
|
+
var ACTION_WITHOUT_DESTINATION = pbiplintRule({
|
|
5211
|
+
id: "ACTION_WITHOUT_DESTINATION",
|
|
5212
|
+
name: "Action has no destination",
|
|
5213
|
+
category: "Report Design",
|
|
5214
|
+
severity: 2,
|
|
5215
|
+
scope: ["Visual"],
|
|
5216
|
+
layer: "report",
|
|
5217
|
+
// The same three types as BROKEN_ACTION_TARGET, on any visual, hidden or not. The pointer is the
|
|
5218
|
+
// destination property when it is there and empty, else the entry's properties.
|
|
5219
|
+
check: ({ report }) => report ? allVisuals(report).flatMap(
|
|
5220
|
+
(v) => v.actions.flatMap((a) => {
|
|
5221
|
+
const checked = CHECKED.get(a.type.toLowerCase());
|
|
5222
|
+
if (!checked || !a.on || a.target !== void 0 || a.conditional) return [];
|
|
5223
|
+
return [
|
|
5224
|
+
reportFinding.visual(v, a.pointer, `${checked.action} action has no destination`)
|
|
5225
|
+
];
|
|
5226
|
+
})
|
|
5227
|
+
) : []
|
|
5228
|
+
});
|
|
5229
|
+
var SECTIONS = "/explorationState/sections";
|
|
5230
|
+
var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
5231
|
+
id: "BROKEN_BOOKMARK_REFERENCE",
|
|
5232
|
+
name: "Bookmark refers to a missing page or visual",
|
|
5233
|
+
category: "Error Prevention",
|
|
5234
|
+
severity: 2,
|
|
5235
|
+
scope: ["Bookmark"],
|
|
5236
|
+
layer: "report",
|
|
5237
|
+
// Groups, captured apart from visuals under `visualContainerGroups`, are not read. A page or a
|
|
5238
|
+
// visual whose own file could not be read is there, under the folder name Desktop gives it, so
|
|
5239
|
+
// it is never reported missing, and neither is one a folder that could not be read could hold.
|
|
5240
|
+
check: ({ report }) => {
|
|
5241
|
+
if (!report) return [];
|
|
5242
|
+
const pages = new Map(report.pages.map((p) => [p.id, p]));
|
|
5243
|
+
const missing = (id) => !pages.has(id) && !pageUnread(report, id);
|
|
5244
|
+
const notOn = (p, id) => !p.visuals.some((v) => v.id === id) && !visualUnread(report, p, id);
|
|
5245
|
+
return report.bookmarks.flatMap((b) => {
|
|
5246
|
+
const out = [];
|
|
5247
|
+
if (b.activePage !== void 0 && missing(b.activePage))
|
|
5248
|
+
out.push(
|
|
5249
|
+
reportFinding.bookmark(
|
|
5250
|
+
b,
|
|
5251
|
+
`active page "${b.activePage}" does not exist`,
|
|
5252
|
+
"/explorationState/activeSection"
|
|
5253
|
+
)
|
|
5254
|
+
);
|
|
5255
|
+
for (const id of b.pages)
|
|
5256
|
+
if (id !== b.activePage && missing(id))
|
|
5257
|
+
out.push(
|
|
5258
|
+
reportFinding.bookmark(
|
|
5259
|
+
b,
|
|
5260
|
+
`captured page "${id}" does not exist`,
|
|
5261
|
+
`${SECTIONS}/${escapePointer(id)}`
|
|
5262
|
+
)
|
|
5263
|
+
);
|
|
5264
|
+
for (const { page, visual, pointer } of b.visuals) {
|
|
5265
|
+
const p = pages.get(page);
|
|
5266
|
+
if (p && notOn(p, visual))
|
|
5267
|
+
out.push(
|
|
5268
|
+
reportFinding.bookmark(
|
|
5269
|
+
b,
|
|
5270
|
+
`captured visual "${visual}" is not on page "${p.displayName}"`,
|
|
5271
|
+
pointer
|
|
5272
|
+
)
|
|
5273
|
+
);
|
|
5274
|
+
}
|
|
5275
|
+
const active = b.activePage === void 0 ? void 0 : pages.get(b.activePage);
|
|
5276
|
+
const stale = active ? (b.targetVisuals ?? []).filter((t) => notOn(active, t.visual)) : [];
|
|
5277
|
+
if (active && stale.length > 0) {
|
|
5278
|
+
const many = stale.length > 1;
|
|
5279
|
+
const names = englishList(stale.map((t) => `"${t.visual}"`));
|
|
5280
|
+
out.push(
|
|
5281
|
+
reportFinding.bookmark(
|
|
5282
|
+
b,
|
|
5283
|
+
`target visual${many ? "s" : ""} ${names} ${many ? "are" : "is"} not on page "${active.displayName}"`,
|
|
5284
|
+
stale[0].pointer
|
|
5285
|
+
)
|
|
5286
|
+
);
|
|
5287
|
+
}
|
|
5288
|
+
return out;
|
|
5289
|
+
});
|
|
5290
|
+
}
|
|
5291
|
+
});
|
|
5292
|
+
var actionRules = [
|
|
5293
|
+
BROKEN_ACTION_TARGET,
|
|
5294
|
+
ACTION_WITHOUT_DESTINATION,
|
|
5295
|
+
BROKEN_BOOKMARK_REFERENCE
|
|
5296
|
+
];
|
|
5297
|
+
|
|
5298
|
+
// ../core/src/rules/pbiplint/filters.ts
|
|
5299
|
+
var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5300
|
+
function yearOf(e) {
|
|
5301
|
+
const value = isRecord7(e) && isRecord7(e.Literal) ? e.Literal.Value : void 0;
|
|
5302
|
+
const m = typeof value === "string" ? /^(?:(\d{4})L|'(\d{4})')$/.exec(value) : null;
|
|
5303
|
+
const year = m ? Number(m[1] ?? m[2]) : void 0;
|
|
5304
|
+
return year !== void 0 && inYearRange(year) ? year : void 0;
|
|
5305
|
+
}
|
|
5306
|
+
function isYearColumn(e) {
|
|
5307
|
+
if (!isRecord7(e)) return false;
|
|
5308
|
+
const name = isRecord7(e.Column) ? e.Column.Property : isRecord7(e.HierarchyLevel) ? e.HierarchyLevel.Level : void 0;
|
|
5309
|
+
return typeof name === "string" && nameClass(name) === "year";
|
|
5310
|
+
}
|
|
5311
|
+
var fixed = (years) => `fixed year${years.length > 1 ? "s" : ""} ${englishList(years.map(String))}`;
|
|
5312
|
+
function keptYears(condition, at2) {
|
|
5313
|
+
if (!isRecord7(condition)) return void 0;
|
|
5314
|
+
const { In: inList, Comparison: compared2 } = condition;
|
|
5315
|
+
if (isRecord7(inList) && Array.isArray(inList.Expressions) && inList.Expressions.length === 1 && isYearColumn(inList.Expressions[0]) && Array.isArray(inList.Values)) {
|
|
5316
|
+
const found = inList.Values.flatMap((row, i) => {
|
|
5317
|
+
const year = Array.isArray(row) && row.length === 1 ? yearOf(row[0]) : void 0;
|
|
5318
|
+
return year === void 0 ? [] : [{ year, at: `${at2}/In/Values/${i}/0/Literal/Value` }];
|
|
5319
|
+
});
|
|
5320
|
+
if (found.length === 0) return void 0;
|
|
5321
|
+
const years = [...new Set(found.map((f) => f.year))];
|
|
5322
|
+
return { years, detail: fixed(years), at: found[0].at };
|
|
5323
|
+
}
|
|
5324
|
+
if (isRecord7(compared2) && compared2.ComparisonKind === 0 && isYearColumn(compared2.Left)) {
|
|
5325
|
+
const year = yearOf(compared2.Right);
|
|
5326
|
+
if (year === void 0) return void 0;
|
|
5327
|
+
return { years: [year], detail: fixed([year]), at: `${at2}/Comparison/Right/Literal/Value` };
|
|
5328
|
+
}
|
|
5329
|
+
return void 0;
|
|
5200
5330
|
}
|
|
5201
|
-
|
|
5202
|
-
|
|
5203
|
-
if (
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
|
|
5207
|
-
|
|
5331
|
+
function bound(condition, at2) {
|
|
5332
|
+
const compared2 = isRecord7(condition) ? condition.Comparison : void 0;
|
|
5333
|
+
if (!isRecord7(compared2) || !isYearColumn(compared2.Left)) return void 0;
|
|
5334
|
+
const year = yearOf(compared2.Right);
|
|
5335
|
+
if (year === void 0) return void 0;
|
|
5336
|
+
const literal2 = `${at2}/Comparison/Right/Literal/Value`;
|
|
5337
|
+
switch (compared2.ComparisonKind) {
|
|
5338
|
+
case 1:
|
|
5339
|
+
return { side: "lower", year: year + 1, at: literal2 };
|
|
5340
|
+
case 2:
|
|
5341
|
+
return { side: "lower", year, at: literal2 };
|
|
5342
|
+
case 3:
|
|
5343
|
+
return { side: "upper", year: year - 1, at: literal2 };
|
|
5344
|
+
case 4:
|
|
5345
|
+
return { side: "upper", year, at: literal2 };
|
|
5346
|
+
default:
|
|
5347
|
+
return void 0;
|
|
5348
|
+
}
|
|
5349
|
+
}
|
|
5350
|
+
function yearsUpTo(condition, at2) {
|
|
5351
|
+
const alone = bound(condition, at2);
|
|
5352
|
+
if (alone?.side === "upper")
|
|
5353
|
+
return { years: [alone.year], detail: `years up to ${alone.year}`, at: alone.at };
|
|
5354
|
+
const both = isRecord7(condition) ? condition.And : void 0;
|
|
5355
|
+
if (!isRecord7(both)) return void 0;
|
|
5356
|
+
const sides = [bound(both.Left, `${at2}/And/Left`), bound(both.Right, `${at2}/And/Right`)];
|
|
5357
|
+
const lower4 = sides.find((b) => b?.side === "lower");
|
|
5358
|
+
const upper = sides.find((b) => b?.side === "upper");
|
|
5359
|
+
if (!lower4 || !upper) return void 0;
|
|
5360
|
+
return {
|
|
5361
|
+
years: [lower4.year, upper.year],
|
|
5362
|
+
detail: `years ${lower4.year} to ${upper.year}`,
|
|
5363
|
+
at: upper.at
|
|
5364
|
+
};
|
|
5208
5365
|
}
|
|
5209
|
-
function
|
|
5210
|
-
|
|
5211
|
-
|
|
5212
|
-
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
|
|
5218
|
-
|
|
5366
|
+
function yearFinding(f, names, make) {
|
|
5367
|
+
if (f.howCreated === "Drillthrough" || f.howCreated === "Drill") return [];
|
|
5368
|
+
const kept = (f.where ?? []).map((w, i) => {
|
|
5369
|
+
if (!isRecord7(w)) return void 0;
|
|
5370
|
+
const at2 = `${f.pointer}/filter/Where/${i}/Condition`;
|
|
5371
|
+
return keptYears(w.Condition, at2) ?? yearsUpTo(w.Condition, at2);
|
|
5372
|
+
}).find((k) => k !== void 0);
|
|
5373
|
+
if (!kept || kept.years.some((y) => names.some((n2) => n2 !== void 0 && namesYear(n2, y))))
|
|
5374
|
+
return [];
|
|
5375
|
+
return [make(kept.at, f.field ? `${kept.detail} on ${fieldLabel(f.field)}` : kept.detail)];
|
|
5376
|
+
}
|
|
5377
|
+
function yearFindings(report) {
|
|
5378
|
+
const out = report.filters.flatMap(
|
|
5379
|
+
(f) => yearFinding(f, [], (at2, detail) => reportFinding.reportFilter(report, at2, detail))
|
|
5380
|
+
);
|
|
5381
|
+
for (const p of report.pages) {
|
|
5382
|
+
for (const f of p.filters)
|
|
5383
|
+
out.push(...yearFinding(f, [p.displayName], (at2, d) => reportFinding.pageFilter(p, at2, d)));
|
|
5384
|
+
for (const v of p.visuals)
|
|
5385
|
+
for (const f of v.filters)
|
|
5219
5386
|
out.push(
|
|
5220
|
-
...
|
|
5221
|
-
finding.column(c),
|
|
5222
|
-
c.name,
|
|
5223
|
-
inNode(c.node, c.expression ?? ""),
|
|
5224
|
-
fixesDetail
|
|
5225
|
-
)
|
|
5387
|
+
...yearFinding(f, [p.displayName, v.title], (at2, d) => reportFinding.visual(v, at2, d))
|
|
5226
5388
|
);
|
|
5227
|
-
for (const i of t.calculationGroup?.items ?? [])
|
|
5228
|
-
out.push(
|
|
5229
|
-
...fixedFinding(
|
|
5230
|
-
finding.calculationItem(i),
|
|
5231
|
-
i.name,
|
|
5232
|
-
inNode(i.node, i.expression),
|
|
5233
|
-
fixesDetail
|
|
5234
|
-
)
|
|
5235
|
-
);
|
|
5236
5389
|
}
|
|
5237
5390
|
return out;
|
|
5238
5391
|
}
|
|
5239
|
-
var
|
|
5240
|
-
id: "
|
|
5241
|
-
name: "Hardcoded
|
|
5242
|
-
category: "
|
|
5392
|
+
var HARDCODED_YEAR_IN_FILTER = pbiplintRule({
|
|
5393
|
+
id: "HARDCODED_YEAR_IN_FILTER",
|
|
5394
|
+
name: "Hardcoded year in a filter",
|
|
5395
|
+
category: "Report Design",
|
|
5243
5396
|
severity: 1,
|
|
5244
|
-
scope: ["
|
|
5397
|
+
scope: ["Visual", "Page", "Report"],
|
|
5398
|
+
layer: "report",
|
|
5399
|
+
// No skipWhenUnread: a report file that could not be read hides its filters from the rule,
|
|
5400
|
+
// never adds one.
|
|
5401
|
+
check: ({ report }) => report ? yearFindings(report) : []
|
|
5402
|
+
});
|
|
5403
|
+
var filterRules = [HARDCODED_YEAR_IN_FILTER];
|
|
5404
|
+
|
|
5405
|
+
// ../core/src/rules/pbiplint/formatting.ts
|
|
5406
|
+
var DECIMAL_COLUMN_WITHOUT_FORMAT_STRING = pbiplintRule({
|
|
5407
|
+
id: "DECIMAL_COLUMN_WITHOUT_FORMAT_STRING",
|
|
5408
|
+
name: "Visible decimal column with no format string",
|
|
5409
|
+
category: "Formatting",
|
|
5410
|
+
severity: 1,
|
|
5411
|
+
scope: ["Column", "CalculatedColumn", "CalculatedTableColumn"],
|
|
5245
5412
|
layer: "model",
|
|
5246
|
-
|
|
5247
|
-
|
|
5248
|
-
|
|
5413
|
+
skipWhenModelUnread: tablesPartlyRead,
|
|
5414
|
+
check: ({ model }) => (model ? allColumns(model) : []).filter(
|
|
5415
|
+
(c) => (dataType(c) === "double" || dataType(c) === "decimal") && !hiddenOrTableHidden(c) && isBlank(c.formatString)
|
|
5416
|
+
).map(finding.column)
|
|
5249
5417
|
});
|
|
5250
|
-
var
|
|
5418
|
+
var formattingRules = [DECIMAL_COLUMN_WITHOUT_FORMAT_STRING];
|
|
5251
5419
|
|
|
5252
|
-
// ../core/src/rules/pbiplint/
|
|
5253
|
-
var
|
|
5254
|
-
|
|
5255
|
-
const
|
|
5256
|
-
|
|
5420
|
+
// ../core/src/rules/pbiplint/functions.ts
|
|
5421
|
+
var packageOf = (f) => f.annotations.DAXLIB_PackageId;
|
|
5422
|
+
function notCalled(model, references) {
|
|
5423
|
+
const calledBy = (f) => references.functionCalledBy(f);
|
|
5424
|
+
const out = [];
|
|
5425
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5426
|
+
for (const f of model.functions) {
|
|
5427
|
+
const pkg = packageOf(f);
|
|
5428
|
+
if (pkg === void 0) {
|
|
5429
|
+
if (calledBy(f).length === 0) out.push(finding.function(f));
|
|
5430
|
+
continue;
|
|
5431
|
+
}
|
|
5432
|
+
if (seen.has(pkg)) continue;
|
|
5433
|
+
seen.add(pkg);
|
|
5434
|
+
const members = model.functions.filter((g) => packageOf(g) === pkg);
|
|
5435
|
+
const fromOutside = members.some(
|
|
5436
|
+
(g) => calledBy(g).some((o) => !(o.kind === "function" && members.includes(o.object)))
|
|
5437
|
+
);
|
|
5438
|
+
if (fromOutside) continue;
|
|
5439
|
+
const detail = members.length === 1 ? `package ${pkg}: its one function is not called` : `package ${pkg}: none of its ${members.length} functions is called`;
|
|
5440
|
+
out.push({ ...finding.function(f), detail });
|
|
5441
|
+
}
|
|
5442
|
+
return out;
|
|
5443
|
+
}
|
|
5444
|
+
var UDF_NOT_CALLED = pbiplintRule({
|
|
5445
|
+
id: "UDF_NOT_CALLED",
|
|
5446
|
+
name: "User-defined function nothing calls",
|
|
5447
|
+
category: "Maintenance",
|
|
5448
|
+
severity: 1,
|
|
5449
|
+
scope: ["Function"],
|
|
5450
|
+
layer: "model",
|
|
5451
|
+
// A model file pbiplint could not fully read may hold the call.
|
|
5452
|
+
skipWhenModelUnread: modelPartlyRead,
|
|
5453
|
+
check: ({ model }, { indexes: { references } }) => model ? notCalled(model, references) : []
|
|
5454
|
+
});
|
|
5455
|
+
var UDF_USE_COMPOUND_NAMES = pbiplintRule({
|
|
5456
|
+
id: "UDF_USE_COMPOUND_NAMES",
|
|
5457
|
+
name: "User-defined function with a one-word name",
|
|
5458
|
+
category: "Error Prevention",
|
|
5459
|
+
severity: 1,
|
|
5460
|
+
scope: ["Function"],
|
|
5461
|
+
layer: "model",
|
|
5462
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => !f.name.includes(".") && !f.name.includes("_")).map(finding.function)
|
|
5463
|
+
});
|
|
5464
|
+
var UDF_WITHOUT_DESCRIPTION = pbiplintRule({
|
|
5465
|
+
id: "UDF_WITHOUT_DESCRIPTION",
|
|
5466
|
+
name: "User-defined function with no description",
|
|
5467
|
+
category: "Maintenance",
|
|
5468
|
+
severity: 1,
|
|
5469
|
+
scope: ["Function"],
|
|
5470
|
+
layer: "model",
|
|
5471
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => packageOf(f) === void 0 && isBlank(f.description)).map(finding.function)
|
|
5472
|
+
});
|
|
5473
|
+
var functionRules = [UDF_NOT_CALLED, UDF_USE_COMPOUND_NAMES, UDF_WITHOUT_DESCRIPTION];
|
|
5474
|
+
|
|
5475
|
+
// ../core/src/rules/pbiplint/measures.ts
|
|
5476
|
+
var REPORT_LEVEL_MEASURES = pbiplintRule({
|
|
5477
|
+
id: "REPORT_LEVEL_MEASURES",
|
|
5478
|
+
name: "Measure defined in the report",
|
|
5479
|
+
category: "Maintenance",
|
|
5480
|
+
severity: 2,
|
|
5481
|
+
scope: ["ReportMeasure"],
|
|
5482
|
+
layer: "report",
|
|
5483
|
+
// Its findings are report objects, yet it reports only beside the model the report reads, so a
|
|
5484
|
+
// run without the model skips it rather than counting it as run.
|
|
5485
|
+
needs: ["model", "report"],
|
|
5486
|
+
// The detail says where the measure lives; the rule's page gives the fix.
|
|
5487
|
+
check: (project) => reportMeasuresToMove(project).map(
|
|
5488
|
+
(m) => reportFinding.reportMeasure(m, `defined in the report on table "${m.table}"`)
|
|
5489
|
+
)
|
|
5490
|
+
});
|
|
5491
|
+
var measureRules2 = [REPORT_LEVEL_MEASURES];
|
|
5492
|
+
|
|
5493
|
+
// ../core/src/rules/pbiplint/opening.ts
|
|
5494
|
+
var LANDING_PAGE_NOT_SET = pbiplintRule({
|
|
5495
|
+
id: "LANDING_PAGE_NOT_SET",
|
|
5496
|
+
name: "No landing page set",
|
|
5497
|
+
category: "Report Design",
|
|
5498
|
+
severity: 1,
|
|
5499
|
+
scope: ["Report"],
|
|
5500
|
+
layer: "report",
|
|
5501
|
+
check: ({ report }) => {
|
|
5502
|
+
const opens = report && landingPageNotSet(report) ? openingPage(report) : void 0;
|
|
5503
|
+
if (!report || !opens) return [];
|
|
5504
|
+
const pointer = opens.by === "active" ? "/activePageName" : void 0;
|
|
5505
|
+
if (opens.page === void 0 && !opens.unread)
|
|
5506
|
+
return [
|
|
5507
|
+
reportFinding.pagesHeader(
|
|
5508
|
+
report,
|
|
5509
|
+
pointer,
|
|
5510
|
+
`no landing page set; the active page "${opens.name}" does not exist`
|
|
5511
|
+
)
|
|
5512
|
+
];
|
|
5513
|
+
const name = opens.page?.displayName ?? opens.name;
|
|
5514
|
+
const which = opens.by === "first" ? "the first page" : "the page open when it was saved";
|
|
5515
|
+
return [reportFinding.pagesHeader(report, pointer, `opens on "${name}", ${which}`)];
|
|
5516
|
+
}
|
|
5517
|
+
});
|
|
5518
|
+
var OPENING_PAGE_INVALID = pbiplintRule({
|
|
5519
|
+
id: "OPENING_PAGE_INVALID",
|
|
5520
|
+
name: "Opening page missing or hidden",
|
|
5521
|
+
category: "Error Prevention",
|
|
5522
|
+
severity: 3,
|
|
5523
|
+
scope: ["Report"],
|
|
5524
|
+
layer: "report",
|
|
5525
|
+
check: ({ report }) => {
|
|
5526
|
+
const opens = report && openingPage(report);
|
|
5527
|
+
if (!report || !opens || !openingPageInvalid(opens)) return [];
|
|
5528
|
+
const pointer = opens.by === "landing" ? "/landingPageName" : "/activePageName";
|
|
5529
|
+
const detail = opens.page === void 0 ? `${opens.by} page "${opens.name}" does not exist` : `active page "${opens.page.displayName}" is hidden from readers`;
|
|
5530
|
+
return [reportFinding.pagesHeader(report, pointer, detail)];
|
|
5531
|
+
}
|
|
5532
|
+
});
|
|
5533
|
+
var FILTERS_PANE_STATE = pbiplintRule({
|
|
5534
|
+
id: "FILTERS_PANE_STATE",
|
|
5535
|
+
name: "Filters pane state differs from policy",
|
|
5536
|
+
category: "Report Design",
|
|
5537
|
+
severity: 2,
|
|
5538
|
+
scope: ["Report"],
|
|
5539
|
+
layer: "report",
|
|
5540
|
+
options: [{ name: "expect", type: "string", values: ["open", "closed"] }],
|
|
5541
|
+
check: ({ report }, ctx) => {
|
|
5542
|
+
const expect = ctx.options.expect;
|
|
5543
|
+
if (!report || expect === void 0) return [];
|
|
5544
|
+
const pane = filtersPaneState(report);
|
|
5545
|
+
if (!pane || pane.state === expect) return [];
|
|
5546
|
+
const { state, recordedAt } = pane;
|
|
5547
|
+
const saved = recordedAt === void 0 ? `not recorded, read as ${state}` : `saved ${state}`;
|
|
5548
|
+
return [
|
|
5549
|
+
reportFinding.report(report, `${saved}; the policy expects ${expect}`, "report", recordedAt)
|
|
5550
|
+
];
|
|
5551
|
+
}
|
|
5552
|
+
});
|
|
5553
|
+
var openingRules = [LANDING_PAGE_NOT_SET, OPENING_PAGE_INVALID, FILTERS_PANE_STATE];
|
|
5554
|
+
|
|
5555
|
+
// ../core/src/rules/pbiplint/pages.ts
|
|
5556
|
+
var NEW_PAGE = "is the name Power BI Desktop gives a new page";
|
|
5557
|
+
var DUPLICATE = "is the name Power BI Desktop gives a duplicated page";
|
|
5558
|
+
var DEFAULT_NAMES = [
|
|
5559
|
+
[/^(?:Page|Seite|Página|Pagina|ページ) [1-9]\d*$/, NEW_PAGE],
|
|
5560
|
+
[/^(?:Duplicate of|Duplicado de|Doublon de|Duplicata de|Duplikat av) .+$/, DUPLICATE],
|
|
5561
|
+
[/^Duplikat von ".+"$/, DUPLICATE],
|
|
5562
|
+
[/^.+ \(copy\)$/, "is named as a copy"]
|
|
5563
|
+
];
|
|
5564
|
+
var recordedName = (p) => {
|
|
5565
|
+
const name = p.json?.displayName;
|
|
5566
|
+
return typeof name === "string" ? name : void 0;
|
|
5257
5567
|
};
|
|
5568
|
+
var DEFAULT_PAGE_NAME = pbiplintRule({
|
|
5569
|
+
id: "DEFAULT_PAGE_NAME",
|
|
5570
|
+
name: "Page keeps its default name",
|
|
5571
|
+
category: "Report Design",
|
|
5572
|
+
severity: 2,
|
|
5573
|
+
scope: ["Page"],
|
|
5574
|
+
layer: "report",
|
|
5575
|
+
check: ({ report }) => report ? report.pages.flatMap((p) => {
|
|
5576
|
+
const name = recordedName(p);
|
|
5577
|
+
const match = name === void 0 ? void 0 : DEFAULT_NAMES.find(([re]) => re.test(name));
|
|
5578
|
+
return match ? [reportFinding.page(p, "/displayName", `"${name}" ${match[1]}`)] : [];
|
|
5579
|
+
}) : []
|
|
5580
|
+
});
|
|
5581
|
+
var pageRules2 = [DEFAULT_PAGE_NAME];
|
|
5582
|
+
|
|
5583
|
+
// ../core/src/rules/pbiplint/references.ts
|
|
5258
5584
|
var firstPerObjectAndField = (findings) => {
|
|
5259
5585
|
const seen = /* @__PURE__ */ new Set();
|
|
5260
5586
|
return findings.filter((f) => {
|
|
@@ -5401,7 +5727,75 @@ var TAB_ORDER_FOLLOWS_LAYOUT = pbiplintRule({
|
|
|
5401
5727
|
});
|
|
5402
5728
|
}
|
|
5403
5729
|
});
|
|
5404
|
-
var tabOrderRules = [TAB_ORDER_FOLLOWS_LAYOUT];
|
|
5730
|
+
var tabOrderRules = [TAB_ORDER_FOLLOWS_LAYOUT];
|
|
5731
|
+
|
|
5732
|
+
// ../core/src/rules/pbiplint/translations.ts
|
|
5733
|
+
var listOf3 = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
5734
|
+
function namesWithoutTranslation(model) {
|
|
5735
|
+
const ownCulture = model.props.culture;
|
|
5736
|
+
const own = typeof ownCulture === "string" ? ownCulture.toLowerCase() : void 0;
|
|
5737
|
+
const cultures = model.cultures.filter(
|
|
5738
|
+
(c) => c.translations !== void 0 && c.name.toLowerCase() !== own
|
|
5739
|
+
);
|
|
5740
|
+
if (cultures.length === 0) return [];
|
|
5741
|
+
const out = [];
|
|
5742
|
+
const report = (f, captioned) => {
|
|
5743
|
+
const missing = cultures.filter((c) => !captioned(c.translations)).map((c) => c.name);
|
|
5744
|
+
if (missing.length === 0) return;
|
|
5745
|
+
const cultureDetail = `no caption in ${listOf3(missing)}`;
|
|
5746
|
+
out.push({ ...f, detail: f.detail ? `${f.detail}, ${cultureDetail}` : cultureDetail });
|
|
5747
|
+
};
|
|
5748
|
+
for (const t of model.tables) {
|
|
5749
|
+
if (t.isHidden) continue;
|
|
5750
|
+
report(finding.table(t), (tr) => tr.tables.includes(t.name));
|
|
5751
|
+
for (const c of t.columns)
|
|
5752
|
+
if (!c.isHidden)
|
|
5753
|
+
report(
|
|
5754
|
+
finding.column(c),
|
|
5755
|
+
(tr) => tr.columns.some((x) => x.table === t.name && x.name === c.name)
|
|
5756
|
+
);
|
|
5757
|
+
for (const m of t.measures)
|
|
5758
|
+
if (!m.isHidden)
|
|
5759
|
+
report(
|
|
5760
|
+
finding.measure(m),
|
|
5761
|
+
(tr) => tr.measures.some((x) => x.table === t.name && x.name === m.name)
|
|
5762
|
+
);
|
|
5763
|
+
for (const h of t.hierarchies) {
|
|
5764
|
+
if (h.isHidden) continue;
|
|
5765
|
+
report(
|
|
5766
|
+
finding.hierarchy(h),
|
|
5767
|
+
(tr) => tr.hierarchies.some((x) => x.table === t.name && x.name === h.name)
|
|
5768
|
+
);
|
|
5769
|
+
for (const l of h.levels)
|
|
5770
|
+
report(
|
|
5771
|
+
finding.level(l),
|
|
5772
|
+
(tr) => tr.levels.some((x) => x.table === t.name && x.hierarchy === h.name && x.name === l.name)
|
|
5773
|
+
);
|
|
5774
|
+
}
|
|
5775
|
+
}
|
|
5776
|
+
return out;
|
|
5777
|
+
}
|
|
5778
|
+
var NAME_WITHOUT_TRANSLATION = pbiplintRule({
|
|
5779
|
+
id: "NAME_WITHOUT_TRANSLATION",
|
|
5780
|
+
name: "Visible name with no translation",
|
|
5781
|
+
category: "Naming Conventions",
|
|
5782
|
+
severity: 1,
|
|
5783
|
+
scope: [
|
|
5784
|
+
"Table",
|
|
5785
|
+
"Column",
|
|
5786
|
+
"CalculatedColumn",
|
|
5787
|
+
"CalculatedTable",
|
|
5788
|
+
"CalculatedTableColumn",
|
|
5789
|
+
"CalculationGroupTable",
|
|
5790
|
+
"Measure",
|
|
5791
|
+
"Hierarchy",
|
|
5792
|
+
"Level"
|
|
5793
|
+
],
|
|
5794
|
+
layer: "model",
|
|
5795
|
+
skipWhenModelUnread: modelPartlyRead,
|
|
5796
|
+
check: ({ model }) => model ? namesWithoutTranslation(model) : []
|
|
5797
|
+
});
|
|
5798
|
+
var translationRules = [NAME_WITHOUT_TRANSLATION];
|
|
5405
5799
|
|
|
5406
5800
|
// ../core/src/rules/pbiplint/visuals.ts
|
|
5407
5801
|
var HIDDEN_VISUAL_WITH_FIELDS = pbiplintRule({
|
|
@@ -5525,6 +5919,10 @@ var pbiplintRules = [
|
|
|
5525
5919
|
...pageRules2,
|
|
5526
5920
|
...measureRules2,
|
|
5527
5921
|
...periodRules,
|
|
5922
|
+
...filterRules,
|
|
5923
|
+
...functionRules,
|
|
5924
|
+
...translationRules,
|
|
5925
|
+
...formattingRules,
|
|
5528
5926
|
...actionRules,
|
|
5529
5927
|
...tabOrderRules
|
|
5530
5928
|
];
|
|
@@ -6534,9 +6932,10 @@ Quirks
|
|
|
6534
6932
|
- The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.
|
|
6535
6933
|
- The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.
|
|
6536
6934
|
- Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.
|
|
6935
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
6537
6936
|
|
|
6538
6937
|
Read more: https://pbiplint.com/rules/add-data-category-for-columns`,
|
|
6539
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n summarizeBy: none\n sourceColumn: City\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n dataCategory: City\n summarizeBy: none\n sourceColumn: City\n```\n\n### Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane, open Column tools, and pick the Data category. In the TMDL file, add the property under the column: `dataCategory: City`, `dataCategory: Country`, `dataCategory: Continent`, `dataCategory: Latitude`, or `dataCategory: Longitude`. The property is metadata only, so setting it changes nothing about what the refresh loads or how the column compresses.\n\n### When to ignore it\n\nA column the name test catches that holds no geography is the case to look for first: Country Manager is a person, City Code is an internal code nobody plots, and giving either a data category would be wrong rather than merely unnecessary. A latitude and longitude pair that only ever feeds a distance calculation is in the same position, since nothing binds it to a map. Where the column really is a place name and a report shows it on a map, there is no reason to leave the category off.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ADD_DATA_CATEGORY_FOR_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ADD_DATA_CATEGORY_FOR_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.\n- The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.\n- Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns"
|
|
6938
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n summarizeBy: none\n sourceColumn: City\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer ID\n\n column City\n dataType: string\n dataCategory: City\n summarizeBy: none\n sourceColumn: City\n```\n\n### Why it matters\n\nMap visuals bind a field by its data category, not by its name. Without one, a City column is geocoded by guesswork and can land in the wrong country when names repeat, and Latitude and Longitude are treated as ordinary numbers, so they are summed by default and the map draws a single point in the ocean. The category lives on the model, so every report inherits it once it is set, and it costs nothing at refresh or query time.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane, open Column tools, and pick the Data category. In the TMDL file, add the property under the column: `dataCategory: City`, `dataCategory: Country`, `dataCategory: Continent`, `dataCategory: Latitude`, or `dataCategory: Longitude`. The property is metadata only, so setting it changes nothing about what the refresh loads or how the column compresses.\n\n### When to ignore it\n\nA column the name test catches that holds no geography is the case to look for first: Country Manager is a person, City Code is an internal code nobody plots, and giving either a data category would be wrong rather than merely unnecessary. A latitude and longitude pair that only ever feeds a distance calculation is in the same position, since nothing binds it to a map. Where the column really is a place name and a report shows it on a map, there is no reason to leave the category off.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ADD_DATA_CATEGORY_FOR_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ADD_DATA_CATEGORY_FOR_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The country, continent, and city tests are substrings, matched without regard to letter case, so a text column called City Code or Country Manager is reported. Latitude and longitude must be the whole name, also without regard to case.\n- The type gate goes with the name. A Country column stored as a whole number is not reported, and neither is a Latitude column stored as text, because the rule wants text for the first group and decimal or double for the second.\n- Only the presence of a category is tested, not which one it is, so any value clears the finding. A City column given a Web URL category passes.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/add-data-category-for-columns"
|
|
6540
6939
|
},
|
|
6541
6940
|
"AVOID_BI-DIRECTIONAL_RELATIONSHIPS_AGAINST_HIGH-CARDINALITY_COLUMNS": {
|
|
6542
6941
|
text: `Why it matters
|
|
@@ -6742,9 +7141,10 @@ Quirks
|
|
|
6742
7141
|
- The type name is compared in lower case, so dataType: Double and dataType: double are both reported.
|
|
6743
7142
|
- Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.
|
|
6744
7143
|
- Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.
|
|
7144
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
6745
7145
|
|
|
6746
7146
|
Read more: https://pbiplint.com/rules/avoid-floating-point-data-types`,
|
|
6747
|
-
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: double\n summarizeBy: sum\n sourceColumn: Amount\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 summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent. Fixed Decimal Number stores four decimal places exactly, and Whole Number has no fraction to lose. On an imported column, Fixed Decimal Number can be cheaper as well: the engine holds it as a whole number with the four places assumed, so the engine may be more likely to encode the column by value, where a sum works on the stored numbers without looking each one up, and the column may compress better.\n\n### How to fix it\n\nChange the type where the data is loaded. In Power BI Desktop, choose Transform data, select the column, and pick Fixed decimal number or Whole number from Data Type on the Transform tab; on an import table that converts the values before they reach the model, and on a DirectQuery table the step folds into the query the source runs. You can also set the type in Table view or Report view: select the column, then pick the type from Data type on the Column tools tab. In the TMDL file the property is `dataType: decimal` for Fixed Decimal Number and `dataType: int64` for Whole Number. For a calculated column the type follows the expression, so convert it there: `Unit Price = CURRENCY(DIVIDE('Sales'[Amount], 'Sales'[Quantity]))` returns a fixed decimal whatever the division produced on its own. Best of all, fix the type in the view or the table the query reads, so every model that loads the column starts right.\n\n### When to ignore it\n\nA value that genuinely needs more than four decimal places has to stay Double, and the rule has no way to know which those are. Latitude and longitude are the everyday case: four decimal places is about eleven metres, which is fine for a country map and wrong for a site plan, so a geography column is usually left alone. Scientific readings, unit conversion factors, and exchange rates quoted to six places are the same. Values above the Fixed Decimal Number range are the other case, since it tops out at about 922 trillion. Money is never the exception: if the column holds an amount someone will add up and reconcile, the rounding errors the rule warns about are exactly the ones that end up in a support ticket.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_FLOATING_POINT_DATA_TYPES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_FLOATING_POINT_DATA_TYPES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The type name is compared in lower case, so `dataType: Double` and `dataType: double` are both reported.\n- Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.\n- Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types"
|
|
7147
|
+
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: double\n summarizeBy: sum\n sourceColumn: Amount\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 summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nDouble is binary floating point, so values like 0.1 have no exact representation and sums drift in the last digits. Two totals that should match can differ by a fraction of a cent. Fixed Decimal Number stores four decimal places exactly, and Whole Number has no fraction to lose. On an imported column, Fixed Decimal Number can be cheaper as well: the engine holds it as a whole number with the four places assumed, so the engine may be more likely to encode the column by value, where a sum works on the stored numbers without looking each one up, and the column may compress better.\n\n### How to fix it\n\nChange the type where the data is loaded. In Power BI Desktop, choose Transform data, select the column, and pick Fixed decimal number or Whole number from Data Type on the Transform tab; on an import table that converts the values before they reach the model, and on a DirectQuery table the step folds into the query the source runs. You can also set the type in Table view or Report view: select the column, then pick the type from Data type on the Column tools tab. In the TMDL file the property is `dataType: decimal` for Fixed Decimal Number and `dataType: int64` for Whole Number. For a calculated column the type follows the expression, so convert it there: `Unit Price = CURRENCY(DIVIDE('Sales'[Amount], 'Sales'[Quantity]))` returns a fixed decimal whatever the division produced on its own. Best of all, fix the type in the view or the table the query reads, so every model that loads the column starts right.\n\n### When to ignore it\n\nA value that genuinely needs more than four decimal places has to stay Double, and the rule has no way to know which those are. Latitude and longitude are the everyday case: four decimal places is about eleven metres, which is fine for a country map and wrong for a site plan, so a geography column is usually left alone. Scientific readings, unit conversion factors, and exchange rates quoted to six places are the same. Values above the Fixed Decimal Number range are the other case, since it tops out at about 922 trillion. Money is never the exception: if the column holds an amount someone will add up and reconcile, the rounding errors the rule warns about are exactly the ones that end up in a support ticket.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_FLOATING_POINT_DATA_TYPES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_FLOATING_POINT_DATA_TYPES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The type name is compared in lower case, so `dataType: Double` and `dataType: double` are both reported.\n- Every kind of column is in scope, including calculated columns and the columns of a calculated table, whose type comes from the expression rather than from a load step.\n- Only the declared type is read. A column whose values happen to be whole numbers is reported all the same while the type says Double.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/avoid-floating-point-data-types"
|
|
6748
7148
|
},
|
|
6749
7149
|
AVOID_INVALID_DESCRIPTION_CHARACTERS: {
|
|
6750
7150
|
text: `Example
|
|
@@ -7512,17 +7912,19 @@ Why it matters
|
|
|
7512
7912
|
|
|
7513
7913
|
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
7914
|
|
|
7515
|
-
|
|
7915
|
+
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.
|
|
7916
|
+
|
|
7917
|
+
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
7918
|
|
|
7517
7919
|
How to fix it
|
|
7518
7920
|
|
|
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.
|
|
7921
|
+
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
7922
|
|
|
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.
|
|
7923
|
+
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
7924
|
|
|
7523
7925
|
When to ignore it
|
|
7524
7926
|
|
|
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.
|
|
7927
|
+
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
7928
|
|
|
7527
7929
|
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
7930
|
|
|
@@ -7530,14 +7932,16 @@ Quirks
|
|
|
7530
7932
|
|
|
7531
7933
|
- 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
7934
|
- 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
|
-
-
|
|
7935
|
+
- 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.
|
|
7936
|
+
- 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.
|
|
7937
|
+
- 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.
|
|
7938
|
+
- 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
7939
|
- 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
7940
|
- 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
7941
|
- 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
7942
|
|
|
7539
7943
|
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
|
|
7944
|
+
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
7945
|
},
|
|
7542
7946
|
BROKEN_FIELD_REFERENCE: {
|
|
7543
7947
|
text: `Example
|
|
@@ -7969,28 +8373,30 @@ table Date
|
|
|
7969
8373
|
|
|
7970
8374
|
Why it matters
|
|
7971
8375
|
|
|
7972
|
-
Marking the date table tells the engine which column is the calendar key, and
|
|
8376
|
+
Marking the date table tells the engine which column is the calendar key, and classic time intelligence relies on it (Classic time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)). Where the date table is related to the other tables on a column that is not a date, such as a whole-number date key like 20241231, Microsoft asks for the marking for the time intelligence functions to work: "you need to set your own date table in order use the time intelligence capabilities" (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.
|
|
7973
8377
|
|
|
7974
8378
|
How to fix it
|
|
7975
8379
|
|
|
7976
|
-
In Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is dataCategory: Time on the table and isKey on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the
|
|
8380
|
+
In Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is dataCategory: Time on the table and isKey on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the date table rather than deriving it from a fact table. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, defining a calendar on the table under Calendar options in Table tools clears the finding instead, since its functions need the marking only in the cases Microsoft lists (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)). Where the table is not a date table at all and only the name caught it, rename it or ignore the finding on it.
|
|
7977
8381
|
|
|
7978
8382
|
When to ignore it
|
|
7979
8383
|
|
|
7980
|
-
The name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a
|
|
8384
|
+
The name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a date table is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A date table at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked; a calendar defined on it, which needs no row for every day, clears the finding, and without one the finding stays for as long as the table exists. What is not a legitimate exception is the model's real day-grain date table left unmarked where Microsoft asks for the marking: when the model uses the classic time intelligence functions, relates the date table to other tables on a column that is not a date, or is read with advanced date filters in Excel PivotTables (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
|
|
7981
8385
|
|
|
7982
8386
|
To ignore this rule on one object, add annotation pbiplint.ignore = DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "off" under rules in pbiplint.config.json.
|
|
7983
8387
|
|
|
7984
8388
|
Quirks
|
|
7985
8389
|
|
|
7986
8390
|
- The table name is upper-cased before the test and the match is a substring, so Date, date, and DATE_DIM all count, and so do Updates and Candidates.
|
|
7987
|
-
- The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked.
|
|
7988
|
-
-
|
|
7989
|
-
-
|
|
7990
|
-
-
|
|
8391
|
+
- The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked, so a table without a calendar is still reported.
|
|
8392
|
+
- Without a calendar, the key has to be a DateTime column, and a table keyed on an integer date key is reported however it is categorized.
|
|
8393
|
+
- A table marked as a date table whose key column has no dataType line, as Power BI Desktop saves a CALENDAR table's Date column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" (Mark your date table as the appropriate data type (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.
|
|
8394
|
+
- Calculated tables are in scope and calculation groups are not, so a date table built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.
|
|
8395
|
+
- A table that defines a calendar is not reported, since calendar-based time intelligence works without the table being marked as a date table. The source rule does not read calendars, and Tabular Editor 3 has no version of this rule. The cases where Microsoft still asks for the marking, among them a relationship to the table on a column that is not DateTime, such as an integer date key, are not read here, so a table that defines a calendar is left out even in those cases (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
|
|
8396
|
+
- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, define a calendar on it, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
|
|
7991
8397
|
|
|
7992
8398
|
Read more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table`,
|
|
7993
|
-
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and
|
|
8399
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and classic time intelligence relies on it ([Classic time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)). Where the date table is related to the other tables on a column that is not a date, such as a whole-number date key like 20241231, Microsoft asks for the marking for the time intelligence functions to work: "you need to set your own date table in order use the time intelligence capabilities" ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Marking it also lets you turn off Auto date/time, which otherwise adds a hidden date table for every date column in the model.\n\n### How to fix it\n\nIn Power BI Desktop, select the table in the Data pane, open Table tools, choose Mark as date table, and pick the column that holds the dates. Desktop checks the column before it accepts it: the values must be unique, have no blanks, carry the same time of day throughout, and run without a gap from the first day to the last. In the TMDL file the result is `dataCategory: Time` on the table and `isKey` on that column, and both have to be present before the finding clears. Where the column fails the check, fix it where it is loaded: in Transform data, remove the time part with Date under the Transform tab, drop the duplicate rows, and fill the gaps by generating the date table rather than deriving it from a fact table. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, defining a calendar on the table under Calendar options in Table tools clears the finding instead, since its functions need the marking only in the cases Microsoft lists ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)). Where the table is not a date table at all and only the name caught it, rename it or ignore the finding on it.\n\n### When to ignore it\n\nThe name test is a plain substring, so Updates, Candidates, and Mandates are all reported with no date in them anywhere. Those findings are noise and the rule has no way to see it. A table that holds dates without being a date table is the more interesting case: an Event Calendar of scheduled events, or a Date Changes audit log. Marking either one would be wrong, because it is a fact table and the marking declares a calendar key. A date table at a grain other than the day is the third case, for example a Fiscal Calendar of one row per period; Mark as date table requires one contiguous row per day, so the table cannot be marked; a calendar defined on it, which needs no row for every day, clears the finding, and without one the finding stays for as long as the table exists. What is not a legitimate exception is the model\'s real day-grain date table left unmarked where Microsoft asks for the marking: when the model uses the classic time intelligence functions, relates the date table to other tables on a column that is not a date, or is read with advanced date filters in Excel PivotTables ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The table name is upper-cased before the test and the match is a substring, so `Date`, `date`, and `DATE_DIM` all count, and so do Updates and Candidates.\n- The data category comparison is exact and case-sensitive. A file that says `dataCategory: time` does not count as marked, so a table without a calendar is still reported.\n- Without a calendar, the key has to be a DateTime column, and a table keyed on an integer date key is reported however it is categorized.\n- A table marked as a date table whose key column has no `dataType` line, as Power BI Desktop saves a `CALENDAR` table\'s `Date` column, counts as having its date key, since pbiplint does not know the column\'s type and a marked date table\'s key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" ([Mark your date table as the appropriate data type](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.\n- Calculated tables are in scope and calculation groups are not, so a date table built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.\n- A table that defines a calendar is not reported, since calendar-based time intelligence works without the table being marked as a date table. The source rule does not read calendars, and Tabular Editor 3 has no version of this rule. The cases where Microsoft still asks for the marking, among them a relationship to the table on a column that is not DateTime, such as an integer date key, are not read here, so a table that defines a calendar is left out even in those cases ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n- The rule also needs every part of a table\'s declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could mark the table, hold its key column, define a calendar on it, or make it a calculation group, which the rule leaves out, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file\'s own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table'
|
|
7994
8400
|
},
|
|
7995
8401
|
DATECOLUMN_FORMATSTRING: {
|
|
7996
8402
|
text: `Example
|
|
@@ -8046,9 +8452,10 @@ Quirks
|
|
|
8046
8452
|
- Only the exact string mm/dd/yyyy passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including dd/mm/yyyy and yyyy-mm-dd.
|
|
8047
8453
|
- Hidden columns and columns in hidden tables are in scope.
|
|
8048
8454
|
- Only DateTime columns are read. A date held as text or as an integer date key is not reported here.
|
|
8455
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
8049
8456
|
|
|
8050
8457
|
Read more: https://pbiplint.com/rules/datecolumn-formatstring`,
|
|
8051
|
-
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nA date column with no format string is shown however the viewer\'s locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: mm/dd/yyyy` under the column. The format string changes only how the value is rendered; the stored value and the data type are untouched, so nothing downstream of the model has to change with it.\n\n### When to ignore it\n\nA model whose house convention is a different date order has nothing to gain here: every date column in it fires, and rewriting them all to the US short date is a worse outcome than the finding. Pick the format your readers expect, set it consistently, and treat the rule as house style you have already settled. The other case is a DateTime column that is not a date a reader ever sees, such as a hidden load timestamp caught by the name test, where a format string changes nothing on screen.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATECOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATECOLUMN_FORMATSTRING": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any column containing the letters date is checked, including Update Time and Candidate Start.\n- Only the exact string `mm/dd/yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including `dd/mm/yyyy` and `yyyy-mm-dd`.\n- Hidden columns and columns in hidden tables are in scope.\n- Only DateTime columns are read. A date held as text or as an integer date key is not reported here.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring'
|
|
8458
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column Year\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nA date column with no format string is shown however the viewer\'s locale and the visual decide, so the same column can read 3/4/2026 in one visual and 4 March 2026 in another. A format string on the column fixes the presentation once for every report. The source ruleset picked the US short date as its convention.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: mm/dd/yyyy` under the column. The format string changes only how the value is rendered; the stored value and the data type are untouched, so nothing downstream of the model has to change with it.\n\n### When to ignore it\n\nA model whose house convention is a different date order has nothing to gain here: every date column in it fires, and rewriting them all to the US short date is a worse outcome than the finding. Pick the format your readers expect, set it consistently, and treat the rule as house style you have already settled. The other case is a DateTime column that is not a date a reader ever sees, such as a hidden load timestamp caught by the name test, where a format string changes nothing on screen.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATECOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATECOLUMN_FORMATSTRING": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any column containing the letters date is checked, including Update Time and Candidate Start.\n- Only the exact string `mm/dd/yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported. Every other format fires too, including `dd/mm/yyyy` and `yyyy-mm-dd`.\n- Hidden columns and columns in hidden tables are in scope.\n- Only DateTime columns are read. A date held as text or as an integer date key is not reported here.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column\'s DAX.\n\nRead more: https://pbiplint.com/rules/datecolumn-formatstring'
|
|
8052
8459
|
},
|
|
8053
8460
|
DAX_COLUMNS_FULLY_QUALIFIED: {
|
|
8054
8461
|
text: `Example
|
|
@@ -8081,7 +8488,7 @@ Put the table name in front of every column reference:
|
|
|
8081
8488
|
|
|
8082
8489
|
Total Sales = SUM ( 'Sales'[Amount] )
|
|
8083
8490
|
|
|
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.
|
|
8491
|
+
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
8492
|
|
|
8086
8493
|
When to ignore it
|
|
8087
8494
|
|
|
@@ -8094,13 +8501,14 @@ Quirks
|
|
|
8094
8501
|
- 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
8502
|
- 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
8503
|
- 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
|
-
-
|
|
8504
|
+
- 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.
|
|
8505
|
+
- 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].
|
|
8506
|
+
- 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
8507
|
- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.
|
|
8100
8508
|
- 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
8509
|
|
|
8102
8510
|
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-
|
|
8511
|
+
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
8512
|
},
|
|
8105
8513
|
DAX_MEASURES_UNQUALIFIED: {
|
|
8106
8514
|
text: `Example
|
|
@@ -8143,7 +8551,7 @@ Delete the table name from the reference and leave the brackets:
|
|
|
8143
8551
|
|
|
8144
8552
|
Average Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )
|
|
8145
8553
|
|
|
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.
|
|
8554
|
+
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
8555
|
|
|
8148
8556
|
When to ignore it
|
|
8149
8557
|
|
|
@@ -8153,14 +8561,58 @@ To ignore this rule on one object, add annotation pbiplint.ignore = DAX_MEASURES
|
|
|
8153
8561
|
|
|
8154
8562
|
Quirks
|
|
8155
8563
|
|
|
8156
|
-
-
|
|
8157
|
-
- A measure's dynamic format string
|
|
8564
|
+
- 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.
|
|
8565
|
+
- 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
8566
|
- 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
8567
|
- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.
|
|
8160
8568
|
- The table name may be written bare or in single quotes; both forms are matched.
|
|
8161
8569
|
|
|
8162
8570
|
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-
|
|
8571
|
+
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"
|
|
8572
|
+
},
|
|
8573
|
+
DECIMAL_COLUMN_WITHOUT_FORMAT_STRING: {
|
|
8574
|
+
text: `Example
|
|
8575
|
+
|
|
8576
|
+
Fires the rule
|
|
8577
|
+
|
|
8578
|
+
table Sales
|
|
8579
|
+
column 'Discount Rate'
|
|
8580
|
+
dataType: double
|
|
8581
|
+
summarizeBy: none
|
|
8582
|
+
sourceColumn: Discount Rate
|
|
8583
|
+
|
|
8584
|
+
After the fix
|
|
8585
|
+
|
|
8586
|
+
table Sales
|
|
8587
|
+
column 'Discount Rate'
|
|
8588
|
+
dataType: double
|
|
8589
|
+
formatString: 0.0%
|
|
8590
|
+
summarizeBy: none
|
|
8591
|
+
sourceColumn: Discount Rate
|
|
8592
|
+
|
|
8593
|
+
Why it matters
|
|
8594
|
+
|
|
8595
|
+
A format string says what a column's numbers are. Without one, a share, an amount, and a plain measurement look alike, or as Tabular Editor's guidance for its version of this rule puts it, "Users can't tell if values are currency, percentages, or plain numbers" (Provide format string for numeric and date columns (https://docs.tabulareditor.com/en/kb/bpa-format-string-columns.html#why-this-matters)). Left at General, Power BI Desktop shows the column's values as plain numbers: in a table visual, a discount amount reads 20.30 with no currency symbol, and a share of 0.25 reads 0.25, not 25%. A format set on the column in the model applies wherever the column is used, "unless a visual or element level format string overrides it" (Use custom format strings in Power BI Desktop (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings)), so setting it once spares every report author setting it visual by visual.
|
|
8596
|
+
|
|
8597
|
+
How to fix it
|
|
8598
|
+
|
|
8599
|
+
In Power BI Desktop, select the column in the Data pane and set Format under Column tools, or select it in Model view and set Format in the Properties pane (Add a model level format string (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#add-a-model-level-format-string)). In the column's TMDL file, add a formatString: line under the column, such as formatString: 0.0% for a ratio or formatString: #,0.00 for an amount.
|
|
8600
|
+
|
|
8601
|
+
When to ignore it
|
|
8602
|
+
|
|
8603
|
+
When no reader sees the column as a number: a decimal kept for a relationship or read only by measures is better hidden than formatted, which clears the finding too. Coordinates are the other case: a latitude or longitude column is there to place points on a map, not to be read as a number, so leaving it at General is fine. Otherwise a visible decimal column is one a report author can drop into a visual, where it shows with no format of its own.
|
|
8604
|
+
|
|
8605
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = DECIMAL_COLUMN_WITHOUT_FORMAT_STRING under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "DECIMAL_COLUMN_WITHOUT_FORMAT_STRING": "off" under rules in pbiplint.config.json.
|
|
8606
|
+
|
|
8607
|
+
Quirks
|
|
8608
|
+
|
|
8609
|
+
- Whole-number and date columns are left out, since Power BI Desktop gives each of them a format string by default. The source rule, Tabular Editor 3's built-in, reports them as well.
|
|
8610
|
+
- A column whose TMDL has no dataType line is not read, since pbiplint does not know its type. Power BI Desktop leaves the line out of most calculated columns it saves.
|
|
8611
|
+
- A format string of only spaces counts as none, as it does in PROVIDE_FORMAT_STRING_FOR_MEASURES.
|
|
8612
|
+
- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the column's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
|
|
8613
|
+
|
|
8614
|
+
Read more: https://pbiplint.com/rules/decimal-column-without-format-string`,
|
|
8615
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Discount Rate'\n dataType: double\n summarizeBy: none\n sourceColumn: Discount Rate\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Discount Rate'\n dataType: double\n formatString: 0.0%\n summarizeBy: none\n sourceColumn: Discount Rate\n```\n\n### Why it matters\n\nA format string says what a column's numbers are. Without one, a share, an amount, and a plain measurement look alike, or as Tabular Editor's guidance for its version of this rule puts it, \"Users can't tell if values are currency, percentages, or plain numbers\" ([Provide format string for numeric and date columns](https://docs.tabulareditor.com/en/kb/bpa-format-string-columns.html#why-this-matters)). Left at General, Power BI Desktop shows the column's values as plain numbers: in a table visual, a discount amount reads `20.30` with no currency symbol, and a share of 0.25 reads `0.25`, not 25%. A format set on the column in the model applies wherever the column is used, \"unless a visual or element level format string overrides it\" ([Use custom format strings in Power BI Desktop](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings)), so setting it once spares every report author setting it visual by visual.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools, or select it in Model view and set Format in the Properties pane ([Add a model level format string](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#add-a-model-level-format-string)). In the column's TMDL file, add a `formatString:` line under the column, such as `formatString: 0.0%` for a ratio or `formatString: #,0.00` for an amount.\n\n### When to ignore it\n\nWhen no reader sees the column as a number: a decimal kept for a relationship or read only by measures is better hidden than formatted, which clears the finding too. Coordinates are the other case: a latitude or longitude column is there to place points on a map, not to be read as a number, so leaving it at General is fine. Otherwise a visible decimal column is one a report author can drop into a visual, where it shows with no format of its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DECIMAL_COLUMN_WITHOUT_FORMAT_STRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DECIMAL_COLUMN_WITHOUT_FORMAT_STRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Whole-number and date columns are left out, since Power BI Desktop gives each of them a format string by default. The source rule, Tabular Editor 3's built-in, reports them as well.\n- A column whose TMDL has no `dataType` line is not read, since pbiplint does not know its type. Power BI Desktop leaves the line out of most calculated columns it saves.\n- A format string of only spaces counts as none, as it does in `PROVIDE_FORMAT_STRING_FOR_MEASURES`.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the column's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/decimal-column-without-format-string"
|
|
8164
8616
|
},
|
|
8165
8617
|
DEFAULT_PAGE_NAME: {
|
|
8166
8618
|
text: `Example
|
|
@@ -8992,10 +9444,11 @@ Quirks
|
|
|
8992
9444
|
- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.
|
|
8993
9445
|
- Hidden columns, and columns in hidden tables, are skipped by both halves.
|
|
8994
9446
|
- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.
|
|
9447
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
8995
9448
|
- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
|
|
8996
9449
|
|
|
8997
9450
|
Read more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings`,
|
|
8998
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: int64\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: int64\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: string\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: string\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nThe values have to change, not just the type, so the fix lives upstream of the model. In Power BI Desktop, choose Transform data, select the query, and either add a conditional column that returns Yes or No or use Replace Values on the column itself, then set the column's type to Text in Power Query. Where the query reads a view or a stored procedure, do the same in the select list and the model gets the text column already formed. The Data type box under Column tools changes the type in place on an import model but leaves the values as 0 and 1, so it is not the fix on its own. If a measure counts the flag, keep the numeric column and hide it, which also takes it out of this rule's reach.\n\n### When to ignore it\n\nA column whose type is already boolean is the common false alarm: Power BI shows it as True and False, which reads as well as Yes and No, and the rule reports it only because it tests for text. The name tests catch words that are not flags at all, so a whole-number Issue Count or Isotope Number is noise on the Is side. The case worth acting on is the one the rule was written for: a visible 0 and 1 column that a report author has to decode.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both name tests are case-sensitive. The prefix is the two characters Is, so a whole-number column called Island or Issue Count is reported, and one called isActive is not.\n- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.\n- Hidden columns, and columns in hidden tables, are skipped by both halves.\n- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
|
|
9451
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: int64\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: int64\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: string\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: string\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nThe values have to change, not just the type, so the fix lives upstream of the model. In Power BI Desktop, choose Transform data, select the query, and either add a conditional column that returns Yes or No or use Replace Values on the column itself, then set the column's type to Text in Power Query. Where the query reads a view or a stored procedure, do the same in the select list and the model gets the text column already formed. The Data type box under Column tools changes the type in place on an import model but leaves the values as 0 and 1, so it is not the fix on its own. If a measure counts the flag, keep the numeric column and hide it, which also takes it out of this rule's reach.\n\n### When to ignore it\n\nA column whose type is already boolean is the common false alarm: Power BI shows it as True and False, which reads as well as Yes and No, and the rule reports it only because it tests for text. The name tests catch words that are not flags at all, so a whole-number Issue Count or Isotope Number is noise on the Is side. The case worth acting on is the one the rule was written for: a visible 0 and 1 column that a report author has to decode.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both name tests are case-sensitive. The prefix is the two characters Is, so a whole-number column called Island or Issue Count is reported, and one called isActive is not.\n- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.\n- Hidden columns, and columns in hidden tables, are skipped by both halves.\n- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
|
|
8999
9452
|
},
|
|
9000
9453
|
HARDCODED_PERIOD_IN_DAX: {
|
|
9001
9454
|
text: `Example
|
|
@@ -9075,6 +9528,134 @@ Quirks
|
|
|
9075
9528
|
Read more: https://pbiplint.com/rules/hardcoded-period-in-dax`,
|
|
9076
9529
|
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
9530
|
},
|
|
9531
|
+
HARDCODED_YEAR_IN_FILTER: {
|
|
9532
|
+
text: `Example
|
|
9533
|
+
|
|
9534
|
+
Fires the rule in page.json
|
|
9535
|
+
|
|
9536
|
+
{
|
|
9537
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9538
|
+
"name": "3cea48e58036b1654474",
|
|
9539
|
+
"displayName": "Overview",
|
|
9540
|
+
"displayOption": "FitToPage",
|
|
9541
|
+
"height": 720,
|
|
9542
|
+
"width": 1280,
|
|
9543
|
+
"filterConfig": {
|
|
9544
|
+
"filters": [
|
|
9545
|
+
{
|
|
9546
|
+
"name": "21d1dc168996da6409ea",
|
|
9547
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },
|
|
9548
|
+
"type": "Categorical",
|
|
9549
|
+
"filter": {
|
|
9550
|
+
"Version": 2,
|
|
9551
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9552
|
+
"Where": [
|
|
9553
|
+
{
|
|
9554
|
+
"Condition": {
|
|
9555
|
+
"In": {
|
|
9556
|
+
"Expressions": [
|
|
9557
|
+
{ "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }
|
|
9558
|
+
],
|
|
9559
|
+
"Values": [[{ "Literal": { "Value": "2025L" } }]]
|
|
9560
|
+
}
|
|
9561
|
+
}
|
|
9562
|
+
}
|
|
9563
|
+
]
|
|
9564
|
+
},
|
|
9565
|
+
"howCreated": "User"
|
|
9566
|
+
}
|
|
9567
|
+
]
|
|
9568
|
+
}
|
|
9569
|
+
}
|
|
9570
|
+
|
|
9571
|
+
After the fix in page.json
|
|
9572
|
+
|
|
9573
|
+
{
|
|
9574
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9575
|
+
"name": "3cea48e58036b1654474",
|
|
9576
|
+
"displayName": "Overview",
|
|
9577
|
+
"displayOption": "FitToPage",
|
|
9578
|
+
"height": 720,
|
|
9579
|
+
"width": 1280,
|
|
9580
|
+
"filterConfig": {
|
|
9581
|
+
"filters": [
|
|
9582
|
+
{
|
|
9583
|
+
"name": "f7a189206cee6051586c",
|
|
9584
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },
|
|
9585
|
+
"type": "RelativeDate",
|
|
9586
|
+
"filter": {
|
|
9587
|
+
"Version": 2,
|
|
9588
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9589
|
+
"Where": [
|
|
9590
|
+
{
|
|
9591
|
+
"Condition": {
|
|
9592
|
+
"Comparison": {
|
|
9593
|
+
"ComparisonKind": 0,
|
|
9594
|
+
"Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },
|
|
9595
|
+
"Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }
|
|
9596
|
+
}
|
|
9597
|
+
}
|
|
9598
|
+
}
|
|
9599
|
+
]
|
|
9600
|
+
},
|
|
9601
|
+
"howCreated": "User"
|
|
9602
|
+
}
|
|
9603
|
+
]
|
|
9604
|
+
}
|
|
9605
|
+
}
|
|
9606
|
+
|
|
9607
|
+
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.
|
|
9608
|
+
|
|
9609
|
+
Why it matters
|
|
9610
|
+
|
|
9611
|
+
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.
|
|
9612
|
+
|
|
9613
|
+
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)).
|
|
9614
|
+
|
|
9615
|
+
How to fix it
|
|
9616
|
+
|
|
9617
|
+
Filter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:
|
|
9618
|
+
|
|
9619
|
+
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)).
|
|
9620
|
+
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)).
|
|
9621
|
+
3. Under Show items when the value, choose is in this, then year.
|
|
9622
|
+
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)).
|
|
9623
|
+
|
|
9624
|
+
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)).
|
|
9625
|
+
|
|
9626
|
+
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.
|
|
9627
|
+
|
|
9628
|
+
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)):
|
|
9629
|
+
|
|
9630
|
+
Is Latest Year = 'Date'[Year] = YEAR ( MAX ( Sales[Order Date] ) )
|
|
9631
|
+
|
|
9632
|
+
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)).
|
|
9633
|
+
|
|
9634
|
+
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.
|
|
9635
|
+
|
|
9636
|
+
When to ignore it
|
|
9637
|
+
|
|
9638
|
+
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.
|
|
9639
|
+
|
|
9640
|
+
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.
|
|
9641
|
+
|
|
9642
|
+
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.
|
|
9643
|
+
|
|
9644
|
+
Quirks
|
|
9645
|
+
|
|
9646
|
+
- 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.
|
|
9647
|
+
- 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.
|
|
9648
|
+
- 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.
|
|
9649
|
+
- 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.
|
|
9650
|
+
- 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.
|
|
9651
|
+
- A filter hidden from readers or locked is reported like any other, since it still filters.
|
|
9652
|
+
- 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.
|
|
9653
|
+
- 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.
|
|
9654
|
+
- 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.
|
|
9655
|
+
|
|
9656
|
+
Read more: https://pbiplint.com/rules/hardcoded-year-in-filter`,
|
|
9657
|
+
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'
|
|
9658
|
+
},
|
|
9078
9659
|
HIDDEN_VISUAL_WITH_FIELDS: {
|
|
9079
9660
|
text: `Example
|
|
9080
9661
|
|
|
@@ -9233,9 +9814,10 @@ Quirks
|
|
|
9233
9814
|
- The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.
|
|
9234
9815
|
- Only numeric columns are in scope, so COUNTA('Sales'[Region]) over a text column is not reported.
|
|
9235
9816
|
- A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.
|
|
9817
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
9236
9818
|
|
|
9237
9819
|
Read more: https://pbiplint.com/rules/hide-fact-table-columns`,
|
|
9238
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\n### How to fix it\n\nIn Power BI Desktop, open the model view, select the column, and turn on Is hidden in the Properties pane, or right-click the column in the Data pane of report view and choose Hide. In the TMDL file, add `isHidden` under the column. The column is still loaded, still refreshed, and still available to every measure and relationship; it only leaves the field list.\n\n### When to ignore it\n\nA numeric column readers use as an attribute rather than as a number is the case to keep visible: a Year on a date table, a Unit Price a report author slices by, a Rating people put on an axis. That a measure also aggregates the column somewhere does not make it the wrong field to pick. The rule does not test whether the table is a fact table either, so a dimension attribute that one measure happens to sum is reported on the same terms as a fact column. What to check before hiding is whether a report already binds a visual to the column, because hiding it does not break that visual but does remove the field from the list for whoever edits it next.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HIDE_FACT_TABLE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HIDE_FACT_TABLE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only fully qualified references count, and the quote marks around the table name are optional, so `SUM(Sales[Amount])` matches as well as `SUM('Sales'[Amount])`. A bare `SUM([Amount])` inside a measure on the same table does not.\n- Nothing may come between the column reference and the aggregation's closing parenthesis, so `SUM('Sales'[Amount] * 2)` is not matched. Spaces after the function name and inside the parentheses are allowed, and the function name matches in any letter case.\n- The measure may live on any table in the model, not only on the column's own table.\n- The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.\n- Only numeric columns are in scope, so `COUNTA('Sales'[Region])` over a text column is not reported.\n- A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns"
|
|
9820
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nOnce a measure exists for a column, the column itself is the wrong thing to drag onto a visual: it produces an implicit sum that may not match the measure, ignores whatever logic the measure adds, and sits in the field list right next to the measure under a similar name. Hiding the column leaves one correct choice.\n\n### How to fix it\n\nIn Power BI Desktop, open the model view, select the column, and turn on Is hidden in the Properties pane, or right-click the column in the Data pane of report view and choose Hide. In the TMDL file, add `isHidden` under the column. The column is still loaded, still refreshed, and still available to every measure and relationship; it only leaves the field list.\n\n### When to ignore it\n\nA numeric column readers use as an attribute rather than as a number is the case to keep visible: a Year on a date table, a Unit Price a report author slices by, a Rating people put on an axis. That a measure also aggregates the column somewhere does not make it the wrong field to pick. The rule does not test whether the table is a fact table either, so a dimension attribute that one measure happens to sum is reported on the same terms as a fact column. What to check before hiding is whether a report already binds a visual to the column, because hiding it does not break that visual but does remove the field from the list for whoever edits it next.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HIDE_FACT_TABLE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HIDE_FACT_TABLE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only fully qualified references count, and the quote marks around the table name are optional, so `SUM(Sales[Amount])` matches as well as `SUM('Sales'[Amount])`. A bare `SUM([Amount])` inside a measure on the same table does not.\n- Nothing may come between the column reference and the aggregation's closing parenthesis, so `SUM('Sales'[Amount] * 2)` is not matched. Spaces after the function name and inside the parentheses are allowed, and the function name matches in any letter case.\n- The measure may live on any table in the model, not only on the column's own table.\n- The expression is read as raw text, so an aggregation written inside a string literal or a comment counts.\n- Only numeric columns are in scope, so `COUNTA('Sales'[Region])` over a text column is not reported.\n- A visible column in a hidden table is still reported: the rule tests the column's own visibility, not the table's.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/hide-fact-table-columns"
|
|
9239
9821
|
},
|
|
9240
9822
|
HIDE_FOREIGN_KEYS: {
|
|
9241
9823
|
text: `Example
|
|
@@ -9549,7 +10131,7 @@ Add isAvailableInMdx: false under the column in its table's TMDL file. Power BI
|
|
|
9549
10131
|
|
|
9550
10132
|
When to ignore it
|
|
9551
10133
|
|
|
9552
|
-
Size is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible.
|
|
10134
|
+
Size is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible. A table in Direct Lake storage mode is a judgment of its own: Direct Lake loads a column's data when a query needs it, and its refresh copies only metadata, so the refresh time this rule is about is not spent the same way (see Quirks).
|
|
9553
10135
|
|
|
9554
10136
|
To ignore this rule on one object, add annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS": "off" under rules in pbiplint.config.json.
|
|
9555
10137
|
|
|
@@ -9558,12 +10140,14 @@ Quirks
|
|
|
9558
10140
|
- A column with no isAvailableInMdx line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.
|
|
9559
10141
|
- Visibility is the column's own isHidden or its table's. A visible column in a hidden table is reported.
|
|
9560
10142
|
- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in sortByColumn.
|
|
10143
|
+
- A column a calendar names, as a primary, associated, or time-related column, is not reported: the calendar uses the column for time intelligence, and a primary column for sorting. Tabular Editor 3's built-in version of the rule does not report a primary or associated column a calendar names either. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden.
|
|
9561
10144
|
- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.
|
|
9562
10145
|
- Relationships are not read. A hidden foreign key, which is exactly what HIDE_FOREIGN_KEYS asks you to create, is reported here.
|
|
10146
|
+
- Tables in Direct Lake storage mode are read like any other. Tabular Editor 3's built-in version of the rule skips them, and no source says why. What differs is when the work is done: Direct Lake loads into memory only the column data a query needs, and its refresh copies only metadata (Direct Lake overview (https://learn.microsoft.com/fabric/fundamentals/direct-lake-overview)).
|
|
9563
10147
|
- 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 variation on another table's column that names 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.
|
|
9564
10148
|
|
|
9565
10149
|
Read more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns`,
|
|
9566
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in its table's TMDL file. Power BI Desktop has no setting for the property and never writes it, but it keeps the value once it is in the file, and it keeps it the same way on an import table and a DirectQuery one. The other way to clear a finding is to decide the column should not be hidden after all: clear Is hidden in the Properties pane, or remove `isHidden` from under the column, and the rule stops reading it, as long as its table is visible too. Where a table has dozens of hidden keys, Tabular Editor's property grid sets the property on every selected column in one edit, which is quicker than the same change repeated down a file.\n\n### When to ignore it\n\nSize is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.\n- Visibility is the column's own `isHidden` or its table's. A visible column in a hidden table is reported.\n- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in `sortByColumn`.\n- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.\n- Relationships are not read. A hidden foreign key, which is exactly what `HIDE_FOREIGN_KEYS` asks you to create, is reported here.\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 variation on another table's column that names 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/isavailableinmdx-false-nonattribute-columns"
|
|
10150
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in its table's TMDL file. Power BI Desktop has no setting for the property and never writes it, but it keeps the value once it is in the file, and it keeps it the same way on an import table and a DirectQuery one. The other way to clear a finding is to decide the column should not be hidden after all: clear Is hidden in the Properties pane, or remove `isHidden` from under the column, and the rule stops reading it, as long as its table is visible too. Where a table has dozens of hidden keys, Tabular Editor's property grid sets the property on every selected column in one edit, which is quicker than the same change repeated down a file.\n\n### When to ignore it\n\nSize is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible. A table in Direct Lake storage mode is a judgment of its own: Direct Lake loads a column's data when a query needs it, and its refresh copies only metadata, so the refresh time this rule is about is not spent the same way (see Quirks).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.\n- Visibility is the column's own `isHidden` or its table's. A visible column in a hidden table is reported.\n- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in `sortByColumn`.\n- A column a calendar names, as a primary, associated, or time-related column, is not reported: the calendar uses the column for time intelligence, and a primary column for sorting. Tabular Editor 3's built-in version of the rule does not report a primary or associated column a calendar names either. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden.\n- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.\n- Relationships are not read. A hidden foreign key, which is exactly what `HIDE_FOREIGN_KEYS` asks you to create, is reported here.\n- Tables in Direct Lake storage mode are read like any other. Tabular Editor 3's built-in version of the rule skips them, and no source says why. What differs is when the work is done: Direct Lake loads into memory only the column data a query needs, and its refresh copies only metadata ([Direct Lake overview](https://learn.microsoft.com/fabric/fundamentals/direct-lake-overview)).\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 variation on another table's column that names 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/isavailableinmdx-false-nonattribute-columns"
|
|
9567
10151
|
},
|
|
9568
10152
|
LANDING_PAGE_NOT_SET: {
|
|
9569
10153
|
text: `Example
|
|
@@ -10122,28 +10706,30 @@ table Date
|
|
|
10122
10706
|
|
|
10123
10707
|
Why it matters
|
|
10124
10708
|
|
|
10125
|
-
|
|
10709
|
+
Classic time intelligence, where DATESYTD and the rest are given a date column, needs that column to run without a gap, and the marked date table is where it comes from (Classic time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)); calendar-based time intelligence, a preview, is given a calendar defined on the date table instead. Without a date table that time intelligence can use, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.
|
|
10126
10710
|
|
|
10127
10711
|
How to fix it
|
|
10128
10712
|
|
|
10129
|
-
Add a
|
|
10713
|
+
Add a date table with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a date view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31)); New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the date table from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes dataCategory: Time on the table and isKey on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column date tables stop being built. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, define a calendar on the date table instead, under Calendar options in Table tools, which writes a calendar block under the table in its file (Calendar-based time intelligence (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#calendar-based-time-intelligence-preview)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)).
|
|
10130
10714
|
|
|
10131
10715
|
When to ignore it
|
|
10132
10716
|
|
|
10133
|
-
A model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no
|
|
10717
|
+
A model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no date table would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.
|
|
10134
10718
|
|
|
10135
10719
|
To ignore this rule on one object, add annotation pbiplint.ignore = MODEL_SHOULD_HAVE_A_DATE_TABLE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "MODEL_SHOULD_HAVE_A_DATE_TABLE": "off" under rules in pbiplint.config.json.
|
|
10136
10720
|
|
|
10137
10721
|
Quirks
|
|
10138
10722
|
|
|
10139
|
-
-
|
|
10723
|
+
- A table that defines a calendar satisfies the rule, as it satisfies Tabular Editor 3's built-in version, since calendar-based time intelligence works without a table marked as a date table. The source rule does not read calendars, so Tabular Editor reports a model whose only calendar table is not marked. Microsoft: "You don't need to identify your own date table with the Mark as Date table option if you use the recommended Calendar-based time intelligence in Power BI unless in specific circumstances" (Set and use date tables in Power BI Desktop (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables)). Those circumstances are not read here, so a calendar satisfies the rule even where Microsoft still asks for the marking (When you must mark your date table (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).
|
|
10724
|
+
- Without a calendar, both properties are needed on one table: dataCategory: Time and isKey on one of its DateTime columns. A table with only one of them does not satisfy the rule.
|
|
10725
|
+
- A table marked as a date table whose key column has no dataType line, as Power BI Desktop saves a CALENDAR table's Date column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: "you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date" (Mark your date table as the appropriate data type (https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.
|
|
10140
10726
|
- The data category comparison is exact and case-sensitive, so dataCategory: time leaves the model reported.
|
|
10141
10727
|
- Nothing else about the table is tested. It is not checked for contiguity, for covering the model's date range, or for being related to anything, so a one-row table marked as a date table clears the finding without helping any measure.
|
|
10142
|
-
- Any table can satisfy it, of any kind. A calculated
|
|
10728
|
+
- Any table can satisfy it, of any kind. A calculated date table counts the same as a loaded one.
|
|
10143
10729
|
- 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 date table 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.
|
|
10144
10730
|
|
|
10145
10731
|
Read more: https://pbiplint.com/rules/model-should-have-a-date-table`,
|
|
10146
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\
|
|
10732
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nClassic time intelligence, where DATESYTD and the rest are given a date column, needs that column to run without a gap, and the marked date table is where it comes from ([Classic time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#classic-time-intelligence)); calendar-based time intelligence, a preview, is given a calendar defined on the date table instead. Without a date table that time intelligence can use, the model either leans on Auto date/time, which adds a hidden date table per date column and cannot be extended with fiscal periods or holidays, or does no time intelligence at all. A single shared date table also gives every fact table the same month, quarter, and year attributes, so visuals from different tables line up.\n\n### How to fix it\n\nAdd a date table with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a date view or table in Transform data, so the fiscal periods and holidays live where the rest of the business already agrees on them. Failing that, build one in Power Query from a list of dates, or in Power BI Desktop under Modeling, New table with DAX such as `Date = CALENDAR(DATE(2020, 1, 1), DATE(2030, 12, 31))`; New table needs a table in import storage mode, so a model whose tables are all DirectQuery has to take the date table from the source. Then select the table, open Table tools, choose Mark as date table, and pick the date column, which writes `dataCategory: Time` on the table and `isKey` on that column in the file. Finish by relating each fact table's date column to it in the model view and turning off Auto date/time under File, Options and settings, Options, Data Load, so the hidden per-column date tables stop being built. Where the model uses calendar-based time intelligence, a preview in Power BI Desktop, define a calendar on the date table instead, under Calendar options in Table tools, which writes a `calendar` block under the table in its file ([Calendar-based time intelligence](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#calendar-based-time-intelligence-preview)). Calendar options appears only once the Enhanced DAX Time Intelligence preview is turned on, under File, Options and settings, Options, Preview features ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)).\n\n### When to ignore it\n\nA model with no dates in it is the clean exception: a reference list, a product catalogue published for other models to join to, a survey result set. There is no time intelligence to do and no date table would have anything to cover. A model that answers only current-state questions, such as a stock-on-hand report with no period comparison anywhere, is the same judgment made deliberately rather than by omission. Everything else that shows a trend, a year-to-date figure, or a comparison with last year needs the table, and the reason the rule is at warning is that the alternative, Auto date/time, works well enough in a demo to hide the problem until someone asks for a fiscal year.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MODEL_SHOULD_HAVE_A_DATE_TABLE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MODEL_SHOULD_HAVE_A_DATE_TABLE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A table that defines a calendar satisfies the rule, as it satisfies Tabular Editor 3's built-in version, since calendar-based time intelligence works without a table marked as a date table. The source rule does not read calendars, so Tabular Editor reports a model whose only calendar table is not marked. Microsoft: \"You don't need to identify your own date table with the Mark as Date table option if you use the recommended Calendar-based time intelligence in Power BI unless in specific circumstances\" ([Set and use date tables in Power BI Desktop](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables)). Those circumstances are not read here, so a calendar satisfies the rule even where Microsoft still asks for the marking ([When you must mark your date table](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#when-you-must-mark-your-date-table)).\n- Without a calendar, both properties are needed on one table: `dataCategory: Time` and `isKey` on one of its DateTime columns. A table with only one of them does not satisfy the rule.\n- A table marked as a date table whose key column has no `dataType` line, as Power BI Desktop saves a `CALENDAR` table's `Date` column, counts as having its date key, since pbiplint does not know the column's type and a marked date table's key is meant to be a date: \"you need to make sure the data type is properly set. You want to set the Data type to Date/Time or Date\" ([Mark your date table as the appropriate data type](https://learn.microsoft.com/power-bi/transform-model/desktop-date-tables#mark-your-date-table-as-the-appropriate-data-type)). Tabular Editor reads the type from the DAX that computes the column.\n- The data category comparison is exact and case-sensitive, so `dataCategory: time` leaves the model reported.\n- Nothing else about the table is tested. It is not checked for contiguity, for covering the model's date range, or for being related to anything, so a one-row table marked as a date table clears the finding without helping any measure.\n- Any table can satisfy it, of any kind. A calculated date table counts the same as a loaded one.\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 date table 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/model-should-have-a-date-table"
|
|
10147
10733
|
},
|
|
10148
10734
|
MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: {
|
|
10149
10735
|
text: `Example
|
|
@@ -10291,9 +10877,10 @@ Quirks
|
|
|
10291
10877
|
- Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.
|
|
10292
10878
|
- Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.
|
|
10293
10879
|
- Hidden columns, and columns in hidden tables, are in scope.
|
|
10880
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
10294
10881
|
|
|
10295
10882
|
Read more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted`,
|
|
10296
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sortByColumn: 'Month Number'\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n### Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\n### How to fix it\n\nAdd a month number column to the table, hide it, then in Power BI Desktop select the month name column and pick the number under Sort by column in Column tools. In the TMDL file the property is `sortByColumn: 'Month Number'` under the month name column. Each month name has to map to exactly one number, so a name like January that appears in several years needs a number column at the same grain, 1 through 12, not a year-month key; Power BI rejects the sort otherwise. Where the axis runs across years, sort a Month Year column by a year-month number instead and leave the plain month name for the within-year views.\n\n### When to ignore it\n\nA text column the name test catches that is not a list of month names is noise: a Monthly Target note, a Month Comment, anything where alphabetical order is as good as any other. A month column whose values already sort correctly is the other case, such as one holding 2026-01 and 2026-02, where a sort-by column would only add a second column to maintain. Where the values are month names, there is no reason to leave the sort alphabetical.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTH_(AS_A_STRING)_MUST_BE_SORTED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTH_(AS_A_STRING)_MUST_BE_SORTED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring of the upper-cased name, so Month Name is reported and so is a text column called Monthly Target.\n- The exclusion is the substring MONTHS, not the word, so Months Elapsed passes and so do MonthStart and MonthSort, whose upper-cased names contain MONTHS. Written with a space, Month Start is reported.\n- Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.\n- Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.\n- Hidden columns, and columns in hidden tables, are in scope.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted"
|
|
10883
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sortByColumn: 'Month Number'\n sourceColumn: Month Name\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Month Number\n```\n\n### Why it matters\n\nA text month sorts alphabetically: April, August, December. Every axis and slicer that uses the column shows that order until someone notices, and the fix has to be repeated in each visual unless it is made once on the column.\n\n### How to fix it\n\nAdd a month number column to the table, hide it, then in Power BI Desktop select the month name column and pick the number under Sort by column in Column tools. In the TMDL file the property is `sortByColumn: 'Month Number'` under the month name column. Each month name has to map to exactly one number, so a name like January that appears in several years needs a number column at the same grain, 1 through 12, not a year-month key; Power BI rejects the sort otherwise. Where the axis runs across years, sort a Month Year column by a year-month number instead and leave the plain month name for the within-year views.\n\n### When to ignore it\n\nA text column the name test catches that is not a list of month names is noise: a Monthly Target note, a Month Comment, anything where alphabetical order is as good as any other. A month column whose values already sort correctly is the other case, such as one holding 2026-01 and 2026-02, where a sort-by column would only add a second column to maintain. Where the values are month names, there is no reason to leave the sort alphabetical.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTH_(AS_A_STRING)_MUST_BE_SORTED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTH_(AS_A_STRING)_MUST_BE_SORTED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring of the upper-cased name, so Month Name is reported and so is a text column called Monthly Target.\n- The exclusion is the substring MONTHS, not the word, so Months Elapsed passes and so do MonthStart and MonthSort, whose upper-cased names contain MONTHS. Written with a space, Month Start is reported.\n- Only text columns are read. A month held as a whole number, or as a DateTime, is not reported here.\n- Any sort-by column clears the finding. The rule checks that the property is set, not that it points at a month number.\n- Hidden columns, and columns in hidden tables, are in scope.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/month-as-a-string-must-be-sorted"
|
|
10297
10884
|
},
|
|
10298
10885
|
MONTHCOLUMN_FORMATSTRING: {
|
|
10299
10886
|
text: `Example
|
|
@@ -10349,9 +10936,72 @@ Quirks
|
|
|
10349
10936
|
- Only the exact string MMMM yyyy passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.
|
|
10350
10937
|
- Only DateTime columns are read. A month held as text or as a whole number is not reported here.
|
|
10351
10938
|
- Hidden columns, and columns in hidden tables, are in scope.
|
|
10939
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
10352
10940
|
|
|
10353
10941
|
Read more: https://pbiplint.com/rules/monthcolumn-formatstring`,
|
|
10354
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n### Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The `MMMM yyyy` format shows the month and year, and setting it on the column fixes every visual at once.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: MMMM yyyy` under the column. The column keeps its DateTime type and its value, so it still sorts chronologically and still works in a relationship to the date table; only what a visual prints changes.\n\n### When to ignore it\n\nA DateTime column with month in its name that holds a full date, not a month start, is the case to leave alone: formatting Month End Date as `MMMM yyyy` throws away the day, which is the part a reader needs. House style is the other case, where a model already writes its month labels as `MMM yyyy` and changing them would leave two conventions in the same report. A hidden month column shows nothing to anyone, so its format string is not worth a change on its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTHCOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTHCOLUMN_FORMATSTRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any DateTime column with month in its name is checked, including Month End Date and a Bimonthly Cutoff column.\n- Only the exact string `MMMM yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.\n- Only DateTime columns are read. A month held as text or as a whole number is not reported here.\n- Hidden columns, and columns in hidden tables, are in scope.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring"
|
|
10942
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n summarizeBy: none\n sourceColumn: Date\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n summarizeBy: none\n sourceColumn: Month Start\n```\n\n### Why it matters\n\nA DateTime column named Month usually holds the first day of each month, and with a default date format it reads as March 1, 2026 rather than March 2026. The `MMMM yyyy` format shows the month and year, and setting it on the column fixes every visual at once.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Format under Column tools. In the TMDL file, add `formatString: MMMM yyyy` under the column. The column keeps its DateTime type and its value, so it still sorts chronologically and still works in a relationship to the date table; only what a visual prints changes.\n\n### When to ignore it\n\nA DateTime column with month in its name that holds a full date, not a month start, is the case to leave alone: formatting Month End Date as `MMMM yyyy` throws away the day, which is the part a reader needs. House style is the other case, where a model already writes its month labels as `MMM yyyy` and changing them would leave two conventions in the same report. A hidden month column shows nothing to anyone, so its format string is not worth a change on its own.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MONTHCOLUMN_FORMATSTRING` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MONTHCOLUMN_FORMATSTRING\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The name test is a substring matched without regard to letter case, so any DateTime column with month in its name is checked, including Month End Date and a Bimonthly Cutoff column.\n- Only the exact string `MMMM yyyy` passes, and the comparison is case-sensitive, so a format string that differs from it only in letter case is still reported.\n- Only DateTime columns are read. A month held as text or as a whole number is not reported here.\n- Hidden columns, and columns in hidden tables, are in scope.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/monthcolumn-formatstring"
|
|
10943
|
+
},
|
|
10944
|
+
NAME_WITHOUT_TRANSLATION: {
|
|
10945
|
+
text: `Example
|
|
10946
|
+
|
|
10947
|
+
Fires the rule
|
|
10948
|
+
|
|
10949
|
+
model Model
|
|
10950
|
+
culture: en-US
|
|
10951
|
+
|
|
10952
|
+
table Sales
|
|
10953
|
+
measure Revenue = 1
|
|
10954
|
+
formatString: #,0
|
|
10955
|
+
|
|
10956
|
+
cultureInfo fr-FR
|
|
10957
|
+
translations
|
|
10958
|
+
model Model
|
|
10959
|
+
table Sales
|
|
10960
|
+
caption: Ventes
|
|
10961
|
+
|
|
10962
|
+
After the fix
|
|
10963
|
+
|
|
10964
|
+
model Model
|
|
10965
|
+
culture: en-US
|
|
10966
|
+
|
|
10967
|
+
table Sales
|
|
10968
|
+
measure Revenue = 1
|
|
10969
|
+
formatString: #,0
|
|
10970
|
+
|
|
10971
|
+
cultureInfo fr-FR
|
|
10972
|
+
translations
|
|
10973
|
+
model Model
|
|
10974
|
+
table Sales
|
|
10975
|
+
caption: Ventes
|
|
10976
|
+
measure Revenue
|
|
10977
|
+
caption: Chiffre d'affaires
|
|
10978
|
+
|
|
10979
|
+
Why it matters
|
|
10980
|
+
|
|
10981
|
+
A caption is the name a reader sees for an object when a report is shown in that culture's language: Microsoft describes a translated caption as an alternative name that appears "when the report is rendered in a different language" (Power BI support for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). A model that carries translations for a language is meant to be read in it, so every visible name its culture leaves out is one its readers do not see in their language, beside the ones they do.
|
|
10982
|
+
|
|
10983
|
+
How to fix it
|
|
10984
|
+
|
|
10985
|
+
Give the object a caption in the culture's translations block. Power BI Desktop has no editor for translations, and Microsoft gives its TMDL view as the route for metadata that lacks a graphical interface, "such as translations" (Common use cases for TMDL view (https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). In TMDL view, type createOrReplace on the first line of an empty tab, paste the whole cultureInfo block from definition/cultures/<culture>.tmdl beneath it, and indent the pasted lines one level, so the block sits under the command. Then give each object with no caption an entry in its place, with a caption: line one level beneath the entry, as the fixed example adds measure Revenue under table Sales: a table's entry goes under the model entry, a column's, measure's, or hierarchy's under its table's entry, and a level's under its hierarchy's entry. Where the block already has the object's entry, as it has a table's once any of the table's objects is captioned, add only the caption: line. Select Apply. Script the whole block: createOrReplace "Creates or replaces the specified semantic model objects and all the descendants" (CreateOrReplace command (https://learn.microsoft.com/analysis-services/tmdl/tmdl-scripts#createorreplace-command)), so a script of part of it drops the captions it leaves out. Or edit the culture's file while Desktop is closed. For many objects at once, Microsoft's Translations Builder, an optional tool, can fill in captions, machine translations included (Create multiple-language reports with Translations Builder (https://learn.microsoft.com/power-bi/guidance/translation-builder)).
|
|
10986
|
+
|
|
10987
|
+
When to ignore it
|
|
10988
|
+
|
|
10989
|
+
When the culture's names are not meant for readers yet, such as a translation still in progress, or a culture kept for another purpose that happens to carry a translations block. Otherwise a visible object with no caption is one a reader in that language sees untranslated. A key or helper column no reader needs is better hidden than translated, which clears its finding too.
|
|
10990
|
+
|
|
10991
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = NAME_WITHOUT_TRANSLATION under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "NAME_WITHOUT_TRANSLATION": "off" under rules in pbiplint.config.json.
|
|
10992
|
+
|
|
10993
|
+
Quirks
|
|
10994
|
+
|
|
10995
|
+
- A calculation group table is read like any other table, so a visible one with no caption is reported. The source rules do not read calculation group tables.
|
|
10996
|
+
- An object counts as visible only when it and its table are not hidden, as pbiplint reads visibility elsewhere, so a hidden table, and a measure in one, are not reported. The source rules report both.
|
|
10997
|
+
- A culture with no translations block is not read, so the culture file Power BI Desktop writes for the model's own language, which holds linguistic metadata only, never produces a finding. Tabular Editor's rules read every culture other than the model's own.
|
|
10998
|
+
- The model's own culture is left out, as Microsoft advises: "You don't need to supply metadata translations for the default language of the semantic model" (Organize project for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-locale#organize-project-for-metadata-translation)). The community rules this rule follows leave it out too; Tabular Editor 3's built-in translation rules read it, so they report nearly every visible object in a model Desktop saved. With no culture: line in model.tmdl, every culture with a translations block is read.
|
|
10999
|
+
- A caption equal to the object's name counts, as it does in Tabular Editor's rules, so a name that reads the same in both languages still needs its caption line. A caption of only spaces does not count.
|
|
11000
|
+
- Descriptions and display folders are not read: Microsoft says they need translating only for report authors who work in the Power BI service (Power BI support for metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). The model's name and perspective names are not read either; they are not among the object types Microsoft lists as translatable (Metadata translation (https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#metadata-translation)).
|
|
11001
|
+
- 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 caption, or the line that hides an object, 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.
|
|
11002
|
+
|
|
11003
|
+
Read more: https://pbiplint.com/rules/name-without-translation`,
|
|
11004
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\nmodel Model\n culture: en-US\n\ntable Sales\n measure Revenue = 1\n formatString: #,0\n\ncultureInfo fr-FR\n translations\n model Model\n table Sales\n caption: Ventes\n```\n\n**After the fix**\n\n```tmdl\nmodel Model\n culture: en-US\n\ntable Sales\n measure Revenue = 1\n formatString: #,0\n\ncultureInfo fr-FR\n translations\n model Model\n table Sales\n caption: Ventes\n measure Revenue\n caption: Chiffre d'affaires\n```\n\n### Why it matters\n\nA caption is the name a reader sees for an object when a report is shown in that culture's language: Microsoft describes a translated caption as an alternative name that appears \"when the report is rendered in a different language\" ([Power BI support for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). A model that carries translations for a language is meant to be read in it, so every visible name its culture leaves out is one its readers do not see in their language, beside the ones they do.\n\n### How to fix it\n\nGive the object a `caption` in the culture's `translations` block. Power BI Desktop has no editor for translations, and Microsoft gives its TMDL view as the route for metadata that lacks a graphical interface, \"such as translations\" ([Common use cases for TMDL view](https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). In TMDL view, type `createOrReplace` on the first line of an empty tab, paste the whole `cultureInfo` block from `definition/cultures/<culture>.tmdl` beneath it, and indent the pasted lines one level, so the block sits under the command. Then give each object with no caption an entry in its place, with a `caption:` line one level beneath the entry, as the fixed example adds `measure Revenue` under `table Sales`: a table's entry goes under the `model` entry, a column's, measure's, or hierarchy's under its table's entry, and a level's under its hierarchy's entry. Where the block already has the object's entry, as it has a table's once any of the table's objects is captioned, add only the `caption:` line. Select Apply. Script the whole block: `createOrReplace` \"Creates or replaces the specified semantic model objects and all the descendants\" ([CreateOrReplace command](https://learn.microsoft.com/analysis-services/tmdl/tmdl-scripts#createorreplace-command)), so a script of part of it drops the captions it leaves out. Or edit the culture's file while Desktop is closed. For many objects at once, Microsoft's Translations Builder, an optional tool, can fill in captions, machine translations included ([Create multiple-language reports with Translations Builder](https://learn.microsoft.com/power-bi/guidance/translation-builder)).\n\n### When to ignore it\n\nWhen the culture's names are not meant for readers yet, such as a translation still in progress, or a culture kept for another purpose that happens to carry a `translations` block. Otherwise a visible object with no caption is one a reader in that language sees untranslated. A key or helper column no reader needs is better hidden than translated, which clears its finding too.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NAME_WITHOUT_TRANSLATION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"NAME_WITHOUT_TRANSLATION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A calculation group table is read like any other table, so a visible one with no caption is reported. The source rules do not read calculation group tables.\n- An object counts as visible only when it and its table are not hidden, as pbiplint reads visibility elsewhere, so a hidden table, and a measure in one, are not reported. The source rules report both.\n- A culture with no `translations` block is not read, so the culture file Power BI Desktop writes for the model's own language, which holds linguistic metadata only, never produces a finding. Tabular Editor's rules read every culture other than the model's own.\n- The model's own culture is left out, as Microsoft advises: \"You don't need to supply metadata translations for the default language of the semantic model\" ([Organize project for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-locale#organize-project-for-metadata-translation)). The community rules this rule follows leave it out too; Tabular Editor 3's built-in translation rules read it, so they report nearly every visible object in a model Desktop saved. With no `culture:` line in `model.tmdl`, every culture with a `translations` block is read.\n- A caption equal to the object's name counts, as it does in Tabular Editor's rules, so a name that reads the same in both languages still needs its caption line. A caption of only spaces does not count.\n- Descriptions and display folders are not read: Microsoft says they need translating only for report authors who work in the Power BI service ([Power BI support for metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#power-bi-support-for-metadata-translation)). The model's name and perspective names are not read either; they are not among the object types Microsoft lists as translatable ([Metadata translation](https://learn.microsoft.com/power-bi/guidance/multiple-language-translation#metadata-translation)).\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 caption, or the line that hides an object, 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/name-without-translation"
|
|
10355
11005
|
},
|
|
10356
11006
|
NOT_REACHED_FROM_REPORT: {
|
|
10357
11007
|
text: `Example
|
|
@@ -10432,18 +11082,19 @@ To ignore this rule on one object, add annotation pbiplint.ignore = NOT_REACHED_
|
|
|
10432
11082
|
Quirks
|
|
10433
11083
|
|
|
10434
11084
|
- 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.
|
|
10435
|
-
- 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.
|
|
11085
|
+
- 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, the columns a calendar names, 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 calendar likewise needs the columns it names for time intelligence. 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
11086
|
- 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
11087
|
- 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
|
|
11088
|
+
- 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.
|
|
11089
|
+
- 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
11090
|
- 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
|
|
11091
|
+
- 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
11092
|
- The rule compares the report with its model, so it runs only when both are in the input.
|
|
10442
11093
|
- 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
11094
|
- 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
11095
|
|
|
10445
11096
|
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'
|
|
11097
|
+
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, the columns a calendar names, 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 calendar likewise needs the columns it names for time intelligence. 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
11098
|
},
|
|
10448
11099
|
NUMERIC_COLUMN_SUMMARIZE_BY: {
|
|
10449
11100
|
text: `Example
|
|
@@ -10483,10 +11134,11 @@ Quirks
|
|
|
10483
11134
|
- The property value is compared without regard to letter case, so summarizeBy: None passes as well as summarizeBy: none.
|
|
10484
11135
|
- Hidden columns, and columns in hidden tables, are skipped.
|
|
10485
11136
|
- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.
|
|
11137
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
10486
11138
|
- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
|
|
10487
11139
|
|
|
10488
11140
|
Read more: https://pbiplint.com/rules/numeric-column-summarize-by`,
|
|
10489
|
-
markdown:
|
|
11141
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nWith a default summarization, dragging the column onto a visual produces an implicit sum, and it is easy to sum something that should never be summed: a year, a unit price, a percentage, a key. The implicit measure also bypasses the format string and the logic of the real measures, so two visuals of the same thing disagree. With summarization off, the column lands on a visual as a category and the author reaches for a measure.\n\n### How to fix it\n\nIn Power BI Desktop, select the column in the Data pane and set Summarization to Don't summarize under Column tools. In the TMDL file the property is `summarizeBy: none` under the column. Where the column really is a number reports need totals of, add an explicit measure for it, because the point of the change is that the aggregation becomes something the model defines rather than something a visual guesses.\n\n### When to ignore it\n\nAn additive column with no measure behind it is the case to weigh. On a small planning or budget table that a handful of people build their own matrices from, the implicit sum is the feature, and taking it away without writing the measures first makes the model harder to use, not safer. Check which reports drag the column in before you change it. A year, a key, a price, or a rate is never that case: summing any of them produces a number with no meaning, and those are the findings to act on first.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NUMERIC_COLUMN_SUMMARIZE_BY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"NUMERIC_COLUMN_SUMMARIZE_BY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `summarizeBy` property is treated as Default, which is not None, so it is reported. That is where most findings come from.\n- The property value is compared without regard to letter case, so `summarizeBy: None` passes as well as `summarizeBy: none`.\n- Hidden columns, and columns in hidden tables, are skipped.\n- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by"
|
|
10490
11142
|
},
|
|
10491
11143
|
OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
|
|
10492
11144
|
text: `Example
|
|
@@ -10899,7 +11551,7 @@ An empty perspective still shows up in clients that offer perspectives, such as
|
|
|
10899
11551
|
|
|
10900
11552
|
How to fix it
|
|
10901
11553
|
|
|
10902
|
-
Power BI Desktop has no
|
|
11554
|
+
Power BI Desktop has no graphical editor for perspectives, and its TMDL view is the route Microsoft gives (Common use cases for TMDL view (https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). A perspective is a perspective block of its own, and the objects it shows are perspectiveTable entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. In TMDL view, write a createOrReplace script of the whole perspective with the tables it should show, and select Apply. To remove the perspective instead, close Desktop, delete its file from the perspectives folder, and drop the matching ref perspective line from model.tmdl. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.
|
|
10903
11555
|
|
|
10904
11556
|
When to ignore it
|
|
10905
11557
|
|
|
@@ -10910,10 +11562,9 @@ To ignore this rule on one object, add annotation pbiplint.ignore = PERSPECTIVES
|
|
|
10910
11562
|
Quirks
|
|
10911
11563
|
|
|
10912
11564
|
- The rule counts the perspectiveTable entries the perspective carries, not the objects they resolve to. A perspective that lists a table which was deleted is not empty and is not reported.
|
|
10913
|
-
- Power BI Desktop never writes perspectives, so this rule fires only on models built or edited somewhere else.
|
|
10914
11565
|
|
|
10915
11566
|
Read more: https://pbiplint.com/rules/perspectives-with-no-objects`,
|
|
10916
|
-
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\nperspective 'Sales View'\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\nperspective 'Sales View'\n\n perspectiveTable Sales\n\n perspectiveMeasure 'Total Sales'\n```\n\n### Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\n### How to fix it\n\nPower BI Desktop has no
|
|
11567
|
+
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\nperspective 'Sales View'\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\nperspective 'Sales View'\n\n perspectiveTable Sales\n\n perspectiveMeasure 'Total Sales'\n```\n\n### Why it matters\n\nAn empty perspective still shows up in clients that offer perspectives, such as Excel, as a named view of the model that contains nothing. It is either an abandoned start or the remains of objects that were removed, and it leaves the next person asking what it was for.\n\n### How to fix it\n\nPower BI Desktop has no graphical editor for perspectives, and its TMDL view is the route Microsoft gives ([Common use cases for TMDL view](https://learn.microsoft.com/power-bi/transform-model/desktop-tmdl-view#common-use-cases-for-tmdl-view)). A perspective is a `perspective` block of its own, and the objects it shows are `perspectiveTable` entries under it, with the columns, measures, and hierarchies it shows listed beneath each one. In TMDL view, write a `createOrReplace` script of the whole perspective with the tables it should show, and select Apply. To remove the perspective instead, close Desktop, delete its file from the `perspectives` folder, and drop the matching `ref perspective` line from `model.tmdl`. Tabular Editor edits perspectives in a UI if you would rather tick boxes than edit the file.\n\n### When to ignore it\n\nThere is no case for it. A perspective you have created and not yet filled is the one you want reported, because nothing else will tell you it is still empty, and an empty perspective in a published model offers a report author a view of the model with no fields in it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PERSPECTIVES_WITH_NO_OBJECTS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PERSPECTIVES_WITH_NO_OBJECTS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule counts the `perspectiveTable` entries the perspective carries, not the objects they resolve to. A perspective that lists a table which was deleted is not empty and is not reported.\n\nRead more: https://pbiplint.com/rules/perspectives-with-no-objects"
|
|
10917
11568
|
},
|
|
10918
11569
|
PROVIDE_FORMAT_STRING_FOR_MEASURES: {
|
|
10919
11570
|
text: `Example
|
|
@@ -10943,7 +11594,7 @@ table Sales
|
|
|
10943
11594
|
|
|
10944
11595
|
Why it matters
|
|
10945
11596
|
|
|
10946
|
-
A measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.
|
|
11597
|
+
A measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone, and so is one whose DAX plainly returns text, which has nothing to format.
|
|
10947
11598
|
|
|
10948
11599
|
How to fix it
|
|
10949
11600
|
|
|
@@ -10951,19 +11602,21 @@ In Power BI Desktop, select the measure in the Data pane and set Format under Me
|
|
|
10951
11602
|
|
|
10952
11603
|
When to ignore it
|
|
10953
11604
|
|
|
10954
|
-
A measure that returns text has nothing to format.
|
|
11605
|
+
A measure that returns text has nothing to format. pbiplint leaves out the ones whose DAX shows it plainly (see Quirks), but a label that comes from a column, such as MAXX over a text column, and a measure that picks a hex color for conditional formatting with SWITCH or IF, are still reported, and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.
|
|
10955
11606
|
|
|
10956
11607
|
To ignore this rule on one object, add annotation pbiplint.ignore = PROVIDE_FORMAT_STRING_FOR_MEASURES under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "PROVIDE_FORMAT_STRING_FOR_MEASURES": "off" under rules in pbiplint.config.json.
|
|
10957
11608
|
|
|
10958
11609
|
Quirks
|
|
10959
11610
|
|
|
11611
|
+
- A measure that plainly returns text is not reported: one whose result, after its last top-level RETURN, is a lone string, joins values with &, or starts with a function that returns text, such as FORMAT or CONCATENATEX. The source rule does not read what a measure returns, so Tabular Editor reports such a measure. Power BI has no format string for text: "You can't set a custom format string for fields that are of type string or Boolean" (Use custom format strings in Power BI Desktop (https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#considerations-and-limitations)).
|
|
11612
|
+
- Text returned any other way is still reported: MAXX over a text column, a variable holding text returned by its name, or an IF or SWITCH whose every branch is a string. pbiplint does not work out what type a DAX expression returns, and reads only what the tokens show, so a string or & inside a comment, inside a call or parentheses, or before the last RETURN, does not count. The functions that count, when the result starts with a call to one, are FORMAT, CONCATENATE, CONCATENATEX, UNICHAR, COMBINEVALUES, LEFT, RIGHT, MID, UPPER, LOWER, SUBSTITUTE, REPT, TRIM, FIXED, REPLACE, USERPRINCIPALNAME, USERNAME, USEROBJECTID, USERCULTURE, CUSTOMDATA, SELECTEDMEASURENAME, NAMEOF, TOJSON, and TOCSV.
|
|
10960
11613
|
- A format string of nothing but spaces counts as no format string, so formatString: " " is reported.
|
|
10961
11614
|
- A measure with only a dynamic format string passes here but fires INTEGER_FORMATTING, which reads the static format string alone.
|
|
10962
11615
|
- Hidden measures, and measures on hidden tables, are skipped. INTEGER_FORMATTING skips neither.
|
|
10963
11616
|
- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a table line, such as a misspelt table, the rule reports nothing, because a part of the table in what pbiplint missed could hide the measure's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, a model file could not be fully read, and a notice names what pbiplint could not open, or the file's own PARSE_ISSUE finding names the line.
|
|
10964
11617
|
|
|
10965
11618
|
Read more: https://pbiplint.com/rules/provide-format-string-for-measures`,
|
|
10966
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.\n\n### How to fix it\n\nIn Power BI Desktop, select the measure in the Data pane and set Format under Measure tools. In the TMDL file, add `formatString` under the measure: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages. Where the format depends on what the measure returns, a dynamic format string satisfies the rule as well: pick Dynamic in the Format list under Measure tools and write the expression in the formula bar, which the file records as a `formatStringDefinition` block under the measure.\n\n### When to ignore it\n\nA measure that returns text has nothing to format.
|
|
11619
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone, and so is one whose DAX plainly returns text, which has nothing to format.\n\n### How to fix it\n\nIn Power BI Desktop, select the measure in the Data pane and set Format under Measure tools. In the TMDL file, add `formatString` under the measure: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages. Where the format depends on what the measure returns, a dynamic format string satisfies the rule as well: pick Dynamic in the Format list under Measure tools and write the expression in the formula bar, which the file records as a `formatStringDefinition` block under the measure.\n\n### When to ignore it\n\nA measure that returns text has nothing to format. pbiplint leaves out the ones whose DAX shows it plainly (see Quirks), but a label that comes from a column, such as `MAXX` over a text column, and a measure that picks a hex color for conditional formatting with `SWITCH` or `IF`, are still reported, and neither has a number behind it. Everything else the rule reports is a visible number a reader will see, so the finding is usually worth the ten seconds it takes to clear.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PROVIDE_FORMAT_STRING_FOR_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"PROVIDE_FORMAT_STRING_FOR_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A measure that plainly returns text is not reported: one whose result, after its last top-level `RETURN`, is a lone string, joins values with `&`, or starts with a function that returns text, such as `FORMAT` or `CONCATENATEX`. The source rule does not read what a measure returns, so Tabular Editor reports such a measure. Power BI has no format string for text: \"You can't set a custom format string for fields that are of type string or Boolean\" ([Use custom format strings in Power BI Desktop](https://learn.microsoft.com/power-bi/create-reports/desktop-custom-format-strings#considerations-and-limitations)).\n- Text returned any other way is still reported: `MAXX` over a text column, a variable holding text returned by its name, or an `IF` or `SWITCH` whose every branch is a string. pbiplint does not work out what type a DAX expression returns, and reads only what the tokens show, so a string or `&` inside a comment, inside a call or parentheses, or before the last `RETURN`, does not count. The functions that count, when the result starts with a call to one, are `FORMAT`, `CONCATENATE`, `CONCATENATEX`, `UNICHAR`, `COMBINEVALUES`, `LEFT`, `RIGHT`, `MID`, `UPPER`, `LOWER`, `SUBSTITUTE`, `REPT`, `TRIM`, `FIXED`, `REPLACE`, `USERPRINCIPALNAME`, `USERNAME`, `USEROBJECTID`, `USERCULTURE`, `CUSTOMDATA`, `SELECTEDMEASURENAME`, `NAMEOF`, `TOJSON`, and `TOCSV`.\n- A format string of nothing but spaces counts as no format string, so `formatString: \" \"` is reported.\n- A measure with only a dynamic format string passes here but fires `INTEGER_FORMATTING`, which reads the static format string alone.\n- Hidden measures, and measures on hidden tables, are skipped. `INTEGER_FORMATTING` skips neither.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the measure's table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures"
|
|
10967
11620
|
},
|
|
10968
11621
|
REDUCE_ADVANCED_FILTERS: {
|
|
10969
11622
|
text: `Example
|
|
@@ -12004,18 +12657,19 @@ Set the two columns to the same type, and prefer a whole number for a key. In Po
|
|
|
12004
12657
|
|
|
12005
12658
|
When to ignore it
|
|
12006
12659
|
|
|
12007
|
-
|
|
12660
|
+
There is nothing to ignore: where both columns name a type and the types differ, set them to the same type. The rule compares only the types the TMDL names, so a relationship with a calculated column saved with no dataType line is not reported at all (see Quirks).
|
|
12008
12661
|
|
|
12009
12662
|
To ignore this rule on one object, add annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SAME_DATA_TYPE under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "RELATIONSHIP_COLUMNS_SAME_DATA_TYPE": "off" under rules in pbiplint.config.json.
|
|
12010
12663
|
|
|
12011
12664
|
Quirks
|
|
12012
12665
|
|
|
12013
12666
|
- Both sides have to resolve to a column the model declares. A relationship naming a table or a column that does not exist is skipped, so a typo in fromColumn or toColumn hides the relationship from this rule.
|
|
12014
|
-
- The comparison is on the declared dataType, ignoring letter case.
|
|
12667
|
+
- The comparison is on the declared dataType, ignoring letter case.
|
|
12668
|
+
- A relationship with a column that has no dataType line on either side, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know that column's type. Tabular Editor reads the type from the column's DAX, so it still reports a real mismatch on such a relationship, which pbiplint misses.
|
|
12015
12669
|
- Only the declared type is compared, never the values. Two text keys that will never match, one padded and one not, pass the rule.
|
|
12016
12670
|
|
|
12017
12671
|
Read more: https://pbiplint.com/rules/relationship-columns-same-data-type`,
|
|
12018
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: string\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\n### How to fix it\n\nSet the two columns to the same type, and prefer a whole number for a key. In Power BI Desktop there are two places to do it: Column tools, Data type on the selected column, which changes the type on the model, or Transform data, where a Changed Type step in Power Query gets the type right before the data is loaded and keeps it right on every refresh. Power Query is the better of the two when the source is the reason the types differ. In the TMDL file the property is `dataType` on each column. Converting a text key to a whole number fails at refresh on any value that is not a number, so look for padded keys such as `007` first and strip the padding in the same Power Query step.\n\n### When to ignore it\n\
|
|
12672
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: string\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe engine relates columns by value, and when the types differ it converts one side for every query. A text key on one side and a whole number on the other works until a value like 007 meets 7, at which point rows quietly fall into the blank member. Matching types remove both the conversion cost and the surprise.\n\n### How to fix it\n\nSet the two columns to the same type, and prefer a whole number for a key. In Power BI Desktop there are two places to do it: Column tools, Data type on the selected column, which changes the type on the model, or Transform data, where a Changed Type step in Power Query gets the type right before the data is loaded and keeps it right on every refresh. Power Query is the better of the two when the source is the reason the types differ. In the TMDL file the property is `dataType` on each column. Converting a text key to a whole number fails at refresh on any value that is not a number, so look for padded keys such as `007` first and strip the padding in the same Power Query step.\n\n### When to ignore it\n\nThere is nothing to ignore: where both columns name a type and the types differ, set them to the same type. The rule compares only the types the TMDL names, so a relationship with a calculated column saved with no `dataType` line is not reported at all (see Quirks).\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SAME_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SAME_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both sides have to resolve to a column the model declares. A relationship naming a table or a column that does not exist is skipped, so a typo in `fromColumn` or `toColumn` hides the relationship from this rule.\n- The comparison is on the declared `dataType`, ignoring letter case.\n- A relationship with a column that has no `dataType` line on either side, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know that column's type. Tabular Editor reads the type from the column's DAX, so it still reports a real mismatch on such a relationship, which pbiplint misses.\n- Only the declared type is compared, never the values. Two text keys that will never match, one padded and one not, pass the rule.\n\nRead more: https://pbiplint.com/rules/relationship-columns-same-data-type"
|
|
12019
12673
|
},
|
|
12020
12674
|
RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE: {
|
|
12021
12675
|
text: `Example
|
|
@@ -12080,9 +12734,10 @@ Quirks
|
|
|
12080
12734
|
- Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.
|
|
12081
12735
|
- Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.
|
|
12082
12736
|
- Inactive relationships count, and so do relationships whose cross-filter direction is both.
|
|
12737
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
12083
12738
|
|
|
12084
12739
|
Read more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type`,
|
|
12085
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Customer Code'\n dataType: string\n isHidden\n summarizeBy: none\n sourceColumn: Customer Code\n\ntable Customer\n column 'Customer Code'\n dataType: string\n isKey\n summarizeBy: none\n sourceColumn: Customer Code\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Code'\n toColumn: Customer.'Customer Code'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Customer Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Customer Key\n\ntable Customer\n column 'Customer Key'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer Key\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Key'\n toColumn: Customer.'Customer Key'\n```\n\n### Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\n### How to fix it\n\nThe type has to change on both ends, so the fix belongs upstream of the model. Where the source already has an integer surrogate key, load it instead of the natural key: in Power BI Desktop, choose Transform data, add the key column to the query, and drop the text one. Where there is no integer key, add one in the source view or build it with a merge in Power Query against the dimension. The Data type box under Column tools changes a column's type in place on an import model, which is worth using only when the values are already digits held as text. Change both ends in the same edit, because a relationship whose two columns end up with different types is a finding of its own.\n\n### When to ignore it\n\nA date relationship on a DateTime column is the common one to leave: it is what Power BI Desktop builds when you connect a fact table to a date table, it works, and moving the model to an integer date key such as 20260904 is a project rather than a fix. A small dimension is the second: a few thousand rows keyed on a short text code cost almost nothing, and a surrogate key adds a column to build and maintain for a saving nobody will measure. The finding earns its keep on the fact tables, where the key column is one of the widest things in the model.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Every date relationship on a DateTime column is reported. That is what the source rule does, and it is why a model whose date table is keyed on a DateTime column collects a finding for each end of every date relationship.\n- Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.\n- Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.\n- Inactive relationships count, and so do relationships whose cross-filter direction is both.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type"
|
|
12740
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Customer Code'\n dataType: string\n isHidden\n summarizeBy: none\n sourceColumn: Customer Code\n\ntable Customer\n column 'Customer Code'\n dataType: string\n isKey\n summarizeBy: none\n sourceColumn: Customer Code\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Code'\n toColumn: Customer.'Customer Code'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Customer Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Customer Key\n\ntable Customer\n column 'Customer Key'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Customer Key\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer Key'\n toColumn: Customer.'Customer Key'\n```\n\n### Why it matters\n\nA relationship is evaluated by matching values, and whole numbers match fastest and compress smallest. Text keys carry their dictionary into every join, and DateTime keys work but store more than an integer date key would. On the largest fact tables the key columns are often the biggest, so the choice shows up in memory as much as in query time.\n\n### How to fix it\n\nThe type has to change on both ends, so the fix belongs upstream of the model. Where the source already has an integer surrogate key, load it instead of the natural key: in Power BI Desktop, choose Transform data, add the key column to the query, and drop the text one. Where there is no integer key, add one in the source view or build it with a merge in Power Query against the dimension. The Data type box under Column tools changes a column's type in place on an import model, which is worth using only when the values are already digits held as text. Change both ends in the same edit, because a relationship whose two columns end up with different types is a finding of its own.\n\n### When to ignore it\n\nA date relationship on a DateTime column is the common one to leave: it is what Power BI Desktop builds when you connect a fact table to a date table, it works, and moving the model to an integer date key such as 20260904 is a project rather than a fix. A small dimension is the second: a few thousand rows keyed on a short text code cost almost nothing, and a surrogate key adds a column to build and maintain for a saving nobody will measure. The finding earns its keep on the fact tables, where the key column is one of the widest things in the model.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"RELATIONSHIP_COLUMNS_SHOULD_BE_OF_INTEGER_DATA_TYPE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Every date relationship on a DateTime column is reported. That is what the source rule does, and it is why a model whose date table is keyed on a DateTime column collects a finding for each end of every date relationship.\n- Decimal and double columns are reported too. The test is for the whole number type alone, not for numeric types in general.\n- Both ends of a relationship are read, so converting one end and leaving the other clears half the findings.\n- Inactive relationships count, and so do relationships whose cross-filter direction is both.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, is not reported, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n\nRead more: https://pbiplint.com/rules/relationship-columns-should-be-of-integer-data-type"
|
|
12086
12741
|
},
|
|
12087
12742
|
"REMOVE_AUTO-DATE_TABLE": {
|
|
12088
12743
|
text: `Example
|
|
@@ -12556,11 +13211,11 @@ table Date
|
|
|
12556
13211
|
|
|
12557
13212
|
Why it matters
|
|
12558
13213
|
|
|
12559
|
-
A column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.
|
|
13214
|
+
A column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory. A column a calendar names is read by time intelligence through the calendar, and a primary column also sorts the calendar's periods: "the primary columns are used for sorting" (Primary versus associated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)).
|
|
12560
13215
|
|
|
12561
13216
|
How to fix it
|
|
12562
13217
|
|
|
12563
|
-
Power BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the isAvailableInMdx: false line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.
|
|
13218
|
+
Power BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the isAvailableInMdx: false line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. For a column a calendar names, take it out of the calendar. In the TMDL file, delete an associated or time-related column's line under its calendarColumnGroup. A primary column's line cannot go on its own, because "The primary column is required for each category" (Primary versus associated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)): remove its whole calendarColumnGroup, or name another column as the group's primaryColumn. Calendar options in Table tools, which Desktop shows only once the Enhanced DAX Time Intelligence preview is turned on (Enable the enhanced DAX Time Intelligence preview (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)), makes the same changes for primary and associated columns, but not for a time-related column: Microsoft says tagging one "isn't currently possible in the calendar options, but can instead only be done using external tools or TMDL" (Available column categories (https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#available-column-categories)). Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.
|
|
12564
13219
|
|
|
12565
13220
|
When to ignore it
|
|
12566
13221
|
|
|
@@ -12573,9 +13228,10 @@ Quirks
|
|
|
12573
13228
|
- A column with no isAvailableInMdx line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is false, so this rule fires only where something set the property deliberately.
|
|
12574
13229
|
- Variations are matched on the default column alone. pbiplint reads a variation's defaultColumn, so a column a variation reaches only through its default hierarchy is not protected here.
|
|
12575
13230
|
- Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in sortByColumn, whenever the property is false on either.
|
|
13231
|
+
- A column a calendar names, as a primary, associated, or time-related column, is reported when IsAvailableInMdx is false, so the two rules stay mirrors. Tabular Editor 3's built-in version of the rule also reports a primary column a calendar names when its IsAvailableInMdx is false. The source rule does not read calendars.
|
|
12576
13232
|
|
|
12577
13233
|
Read more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns`,
|
|
12578
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n sourceColumn: MonthNumber\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n sourceColumn: MonthNumber\n```\n\n### Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory.\n\n### How to fix it\n\nPower BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the `isAvailableInMdx: false` line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.\n\n### When to ignore it\n\nThere is no case for leaving it. A column reported here is one the engine needs an attribute hierarchy for, and the memory the property saves on it is small next to a model that fails to process. What is worth working out is why the property is there at all: it is almost always one bulk edit made across every hidden column, and the columns that needed the attribute hierarchy are the ones this rule lists. Clear those and leave the rest alone, so the saving stays where it does no harm.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is `false`, so this rule fires only where something set the property deliberately.\n- Variations are matched on the default column alone. pbiplint reads a variation's `defaultColumn`, so a column a variation reaches only through its default hierarchy is not protected here.\n- Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in `sortByColumn`, whenever the property is false on either.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns"
|
|
13234
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n sourceColumn: MonthNumber\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\n column 'Month Name'\n dataType: string\n sortByColumn: 'Month Number'\n sourceColumn: MonthName\n\n column 'Month Number'\n dataType: int64\n isHidden\n sourceColumn: MonthNumber\n```\n\n### Why it matters\n\nA column that sorts another column, or sits in a hierarchy, is used through its attribute hierarchy, and that is exactly what setting IsAvailableInMdx to false removes. The result is a processing error, or a hierarchy that fails in Excel and other MDX clients, usually after someone set the property to false in bulk to save memory. A column a calendar names is read by time intelligence through the calendar, and a primary column also sorts the calendar's periods: \"the primary columns are used for sorting\" ([Primary versus associated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)).\n\n### How to fix it\n\nPower BI Desktop has no setting for this property and never writes it, so the repair is in the TMDL file: delete the `isAvailableInMdx: false` line from under the column and the property goes back to its default of true. The other way to clear the same finding is to remove the need for the attribute hierarchy: take the column out of the hierarchy in Desktop's model view, or clear Sort by column under Column tools on the column that names it, and the rule stops reporting it. For a column a calendar names, take it out of the calendar. In the TMDL file, delete an associated or time-related column's line under its `calendarColumnGroup`. A primary column's line cannot go on its own, because \"The primary column is required for each category\" ([Primary versus associated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#primary-versus-associated-columns)): remove its whole `calendarColumnGroup`, or name another column as the group's `primaryColumn`. Calendar options in Table tools, which Desktop shows only once the Enhanced DAX Time Intelligence preview is turned on ([Enable the enhanced DAX Time Intelligence preview](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#enable-the-enhanced-dax-time-intelligence-preview)), makes the same changes for primary and associated columns, but not for a time-related column: Microsoft says tagging one \"isn't currently possible in the calendar options, but can instead only be done using external tools or TMDL\" ([Available column categories](https://learn.microsoft.com/power-bi/transform-model/desktop-time-intelligence#available-column-categories)). Tabular Editor shows the property in its property grid, which is a quicker way to clear it across the batch of columns a single bulk edit set.\n\n### When to ignore it\n\nThere is no case for leaving it. A column reported here is one the engine needs an attribute hierarchy for, and the memory the property saves on it is small next to a model that fails to process. What is worth working out is why the property is there at all: it is almost always one bulk edit made across every hidden column, and the columns that needed the attribute hierarchy are the ones this rule lists. Clear those and leave the rest alone, so the saving stays where it does no harm.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line is true, because pbiplint applies that default wherever the property is absent. The line appears only where a tool wrote it, and the only value ever written is `false`, so this rule fires only where something set the property deliberately.\n- Variations are matched on the default column alone. pbiplint reads a variation's `defaultColumn`, so a column a variation reaches only through its default hierarchy is not protected here.\n- Both ends of a sort-by pair are covered. The column that does the sorting is reported, and so is the column that names it in `sortByColumn`, whenever the property is false on either.\n- A column a calendar names, as a primary, associated, or time-related column, is reported when IsAvailableInMdx is false, so the two rules stay mirrors. Tabular Editor 3's built-in version of the rule also reports a primary column a calendar names when its IsAvailableInMdx is false. The source rule does not read calendars.\n\nRead more: https://pbiplint.com/rules/set-isavailableinmdx-to-true-on-necessary-columns"
|
|
12579
13235
|
},
|
|
12580
13236
|
SLICER_SEARCH_SAVED: {
|
|
12581
13237
|
text: `Example
|
|
@@ -12831,7 +13487,7 @@ To ignore this rule on one visual, add { "name": "pbiplint.ignore", "value": "SL
|
|
|
12831
13487
|
|
|
12832
13488
|
Quirks
|
|
12833
13489
|
|
|
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
|
|
13490
|
+
- 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
13491
|
- 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
13492
|
- 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
13493
|
- 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 +13496,7 @@ Quirks
|
|
|
12840
13496
|
- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.
|
|
12841
13497
|
|
|
12842
13498
|
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
|
|
13499
|
+
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
13500
|
},
|
|
12845
13501
|
SNOWFLAKE_SCHEMA_ARCHITECTURE: {
|
|
12846
13502
|
text: `Example
|
|
@@ -13233,6 +13889,181 @@ Quirks
|
|
|
13233
13889
|
Read more: https://pbiplint.com/rules/trim-object-names`,
|
|
13234
13890
|
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
13891
|
},
|
|
13892
|
+
UDF_NOT_CALLED: {
|
|
13893
|
+
text: `Example
|
|
13894
|
+
|
|
13895
|
+
Fires the rule
|
|
13896
|
+
|
|
13897
|
+
table Sales
|
|
13898
|
+
column Amount
|
|
13899
|
+
dataType: decimal
|
|
13900
|
+
sourceColumn: Amount
|
|
13901
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13902
|
+
formatString: #,0
|
|
13903
|
+
|
|
13904
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13905
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13906
|
+
|
|
13907
|
+
/// Adds 20 percent VAT to an amount.
|
|
13908
|
+
function 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2
|
|
13909
|
+
|
|
13910
|
+
After the fix
|
|
13911
|
+
|
|
13912
|
+
table Sales
|
|
13913
|
+
column Amount
|
|
13914
|
+
dataType: decimal
|
|
13915
|
+
sourceColumn: Amount
|
|
13916
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13917
|
+
formatString: #,0
|
|
13918
|
+
|
|
13919
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13920
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13921
|
+
|
|
13922
|
+
Why it matters
|
|
13923
|
+
|
|
13924
|
+
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.
|
|
13925
|
+
|
|
13926
|
+
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.
|
|
13927
|
+
|
|
13928
|
+
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.
|
|
13929
|
+
|
|
13930
|
+
How to fix it
|
|
13931
|
+
|
|
13932
|
+
Delete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.
|
|
13933
|
+
|
|
13934
|
+
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.
|
|
13935
|
+
|
|
13936
|
+
When to ignore it
|
|
13937
|
+
|
|
13938
|
+
When something outside the model's own DAX calls the function:
|
|
13939
|
+
|
|
13940
|
+
- A DAX query, such as a test harness that runs a model's test functions from outside it.
|
|
13941
|
+
- 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)).
|
|
13942
|
+
- A visual calculation, which pbiplint does not read.
|
|
13943
|
+
|
|
13944
|
+
Or when the function is kept on purpose, such as one written ahead of the measures that will call it.
|
|
13945
|
+
|
|
13946
|
+
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.
|
|
13947
|
+
|
|
13948
|
+
Quirks
|
|
13949
|
+
|
|
13950
|
+
- 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.
|
|
13951
|
+
- 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.
|
|
13952
|
+
- 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.
|
|
13953
|
+
- 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.
|
|
13954
|
+
|
|
13955
|
+
Read more: https://pbiplint.com/rules/udf-not-called`,
|
|
13956
|
+
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"
|
|
13957
|
+
},
|
|
13958
|
+
UDF_USE_COMPOUND_NAMES: {
|
|
13959
|
+
text: `Example
|
|
13960
|
+
|
|
13961
|
+
Fires the rule
|
|
13962
|
+
|
|
13963
|
+
table Sales
|
|
13964
|
+
column Amount
|
|
13965
|
+
dataType: decimal
|
|
13966
|
+
sourceColumn: Amount
|
|
13967
|
+
measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))
|
|
13968
|
+
formatString: #,0
|
|
13969
|
+
|
|
13970
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13971
|
+
function AddTax = (amount: NUMERIC) => amount * 1.1
|
|
13972
|
+
|
|
13973
|
+
After the fix
|
|
13974
|
+
|
|
13975
|
+
table Sales
|
|
13976
|
+
column Amount
|
|
13977
|
+
dataType: decimal
|
|
13978
|
+
sourceColumn: Amount
|
|
13979
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13980
|
+
formatString: #,0
|
|
13981
|
+
|
|
13982
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13983
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13984
|
+
|
|
13985
|
+
Why it matters
|
|
13986
|
+
|
|
13987
|
+
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.
|
|
13988
|
+
|
|
13989
|
+
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.
|
|
13990
|
+
|
|
13991
|
+
How to fix it
|
|
13992
|
+
|
|
13993
|
+
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.
|
|
13994
|
+
|
|
13995
|
+
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.
|
|
13996
|
+
|
|
13997
|
+
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.
|
|
13998
|
+
|
|
13999
|
+
When to ignore it
|
|
14000
|
+
|
|
14001
|
+
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.
|
|
14002
|
+
|
|
14003
|
+
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.
|
|
14004
|
+
|
|
14005
|
+
Quirks
|
|
14006
|
+
|
|
14007
|
+
- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so _toggleButton passes, and so does add_tax.
|
|
14008
|
+
- 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).
|
|
14009
|
+
- A function from a DAX Lib package is checked as any other, since its name is what callers write.
|
|
14010
|
+
|
|
14011
|
+
Read more: https://pbiplint.com/rules/udf-use-compound-names`,
|
|
14012
|
+
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"
|
|
14013
|
+
},
|
|
14014
|
+
UDF_WITHOUT_DESCRIPTION: {
|
|
14015
|
+
text: `Example
|
|
14016
|
+
|
|
14017
|
+
Fires the rule
|
|
14018
|
+
|
|
14019
|
+
table Sales
|
|
14020
|
+
column Amount
|
|
14021
|
+
dataType: decimal
|
|
14022
|
+
sourceColumn: Amount
|
|
14023
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
14024
|
+
formatString: #,0
|
|
14025
|
+
|
|
14026
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
14027
|
+
|
|
14028
|
+
After the fix
|
|
14029
|
+
|
|
14030
|
+
table Sales
|
|
14031
|
+
column Amount
|
|
14032
|
+
dataType: decimal
|
|
14033
|
+
sourceColumn: Amount
|
|
14034
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
14035
|
+
formatString: #,0
|
|
14036
|
+
|
|
14037
|
+
/// Adds 10 percent sales tax to an amount.
|
|
14038
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
14039
|
+
|
|
14040
|
+
Why it matters
|
|
14041
|
+
|
|
14042
|
+
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.
|
|
14043
|
+
|
|
14044
|
+
How to fix it
|
|
14045
|
+
|
|
14046
|
+
Write a sentence or two on what the function returns and what each parameter expects.
|
|
14047
|
+
|
|
14048
|
+
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.
|
|
14049
|
+
|
|
14050
|
+
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.
|
|
14051
|
+
|
|
14052
|
+
When to ignore it
|
|
14053
|
+
|
|
14054
|
+
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.
|
|
14055
|
+
|
|
14056
|
+
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.
|
|
14057
|
+
|
|
14058
|
+
Quirks
|
|
14059
|
+
|
|
14060
|
+
- 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.
|
|
14061
|
+
- A description of only spaces or tabs counts as none.
|
|
14062
|
+
- 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.
|
|
14063
|
+
|
|
14064
|
+
Read more: https://pbiplint.com/rules/udf-without-description`,
|
|
14065
|
+
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"
|
|
14066
|
+
},
|
|
13236
14067
|
UNNECESSARY_COLUMNS: {
|
|
13237
14068
|
text: `Example
|
|
13238
14069
|
|
|
@@ -13285,17 +14116,19 @@ To ignore this rule on one object, add annotation pbiplint.ignore = UNNECESSARY_
|
|
|
13285
14116
|
|
|
13286
14117
|
Quirks
|
|
13287
14118
|
|
|
13288
|
-
- DAX
|
|
14119
|
+
- 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.
|
|
14120
|
+
- 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
14121
|
- 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
14122
|
- 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
14123
|
- 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.
|
|
14124
|
+
- A column a calendar names, as a primary, associated, or time-related column, counts as used: the calendar needs it for time intelligence. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden and nothing else uses it.
|
|
13292
14125
|
- 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
14126
|
- 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
|
|
14127
|
+
- 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
14128
|
- 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
14129
|
|
|
13297
14130
|
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
|
|
14131
|
+
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- A column a calendar names, as a primary, associated, or time-related column, counts as used: the calendar needs it for time intelligence. The source rule does not read calendars, so Tabular Editor reports such a column when it is hidden and nothing else uses it.\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
14132
|
},
|
|
13300
14133
|
UNNECESSARY_MEASURES: {
|
|
13301
14134
|
text: `Example
|
|
@@ -13330,7 +14163,7 @@ A hidden measure that no other measure uses can only be reached by a report that
|
|
|
13330
14163
|
|
|
13331
14164
|
How to fix it
|
|
13332
14165
|
|
|
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
|
|
14166
|
+
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
14167
|
|
|
13335
14168
|
When to ignore it
|
|
13336
14169
|
|
|
@@ -13344,11 +14177,11 @@ Quirks
|
|
|
13344
14177
|
- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.
|
|
13345
14178
|
- A row-level security filter counts as a DAX expression, so a measure named in one is used.
|
|
13346
14179
|
- 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.
|
|
14180
|
+
- 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
14181
|
- 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
14182
|
|
|
13350
14183
|
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
|
|
14184
|
+
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
14185
|
},
|
|
13353
14186
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": {
|
|
13354
14187
|
text: `Example
|
|
@@ -13440,10 +14273,11 @@ Quirks
|
|
|
13440
14273
|
- Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.
|
|
13441
14274
|
- The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.
|
|
13442
14275
|
- The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.
|
|
14276
|
+
- A column with no dataType line, as Power BI Desktop saves most calculated columns, does not count as a numeric month column, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.
|
|
13443
14277
|
- Tables and calculated tables are in scope; calculation groups are not.
|
|
13444
14278
|
|
|
13445
14279
|
Read more: https://pbiplint.com/rules/unpivot-pivoted-month-data`,
|
|
13446
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column Jan\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jan\n\n column Feb\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Feb\n\n column Mar\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Mar\n\n column Apr\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Apr\n\n column May\n dataType: decimal\n summarizeBy: sum\n sourceColumn: May\n\n column Jun\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jun\n```\n\n**After the fix**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: MonthName\n sortByColumn: 'Month Number'\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: MonthNumber\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n sourceColumn: MonthStart\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, with the month's start date beside them, the same data relates to the date table through that date and every measure and time intelligence function works over it.\n\n### How to fix it\n\nReshape the table where it is loaded. In Power BI Desktop choose Transform data, select the query, select the month columns, and use Unpivot Columns on the Transform tab, or select the columns that are not months and use Unpivot Other Columns so next year's column is picked up without an edit; then rename the Attribute and Value columns to something a report author will recognize, such as Month Name and Amount. Where the source is a warehouse, the same reshape belongs in a view there, and the refresh gets the finished shape for nothing. Back in the model, give the month column a Month Number column to sort by, which is Sort by column on the Column tools tab and `sortByColumn` in the TMDL file. A month name is text and cannot carry a relationship to a day-grain date table, so load the month's start date alongside it, as a Month Start column of the first of each month, and relate that column to the date table; time intelligence then works over the reshaped table. The rule reads the model's columns, so the finding clears as soon as the reshaped query is applied.\n\n### When to ignore it\n\nA coincidence is the case to check for first, though it takes six of them at once. The month names are matched as substrings of the column names, so Janitorial Cost satisfies Jan, Margin satisfies Mar, Apron Sales satisfies Apr, and Junior Rate satisfies Jun; a table of numeric metrics carrying one such name for each of the six months is reported with no month in it anywhere. Read the column names before reshaping anything. A genuinely pivoted table can also be deliberate: a small budget entry table that a person maintains by hand in a spreadsheet is easier to fill in wide, and where it is a handful of rows, unpivoting it on the way in costs nothing and unpivoting it at the source costs an argument. What is not a legitimate exception is a wide fact table of months feeding visuals through twelve near-identical measures, which is the pattern the rule exists to catch.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNPIVOT_PIVOTED_(MONTH)_DATA` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNPIVOT_PIVOTED_(MONTH)_DATA\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.\n- The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.\n- The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.\n- Tables and calculated tables are in scope; calculation groups are not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data"
|
|
14280
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column Jan\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jan\n\n column Feb\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Feb\n\n column Mar\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Mar\n\n column Apr\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Apr\n\n column May\n dataType: decimal\n summarizeBy: sum\n sourceColumn: May\n\n column Jun\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Jun\n```\n\n**After the fix**\n\n```tmdl\ntable Budget\n column Department\n dataType: string\n summarizeBy: none\n sourceColumn: Department\n\n column 'Month Name'\n dataType: string\n summarizeBy: none\n sourceColumn: MonthName\n sortByColumn: 'Month Number'\n\n column 'Month Number'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: MonthNumber\n\n column 'Month Start'\n dataType: dateTime\n formatString: MMMM yyyy\n sourceColumn: MonthStart\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA column per month is a spreadsheet layout. In a model it means a measure per month, no way to filter by date, no relationship to the date table, and a schema change every year. Unpivoted into one Month column and one Value column, with the month's start date beside them, the same data relates to the date table through that date and every measure and time intelligence function works over it.\n\n### How to fix it\n\nReshape the table where it is loaded. In Power BI Desktop choose Transform data, select the query, select the month columns, and use Unpivot Columns on the Transform tab, or select the columns that are not months and use Unpivot Other Columns so next year's column is picked up without an edit; then rename the Attribute and Value columns to something a report author will recognize, such as Month Name and Amount. Where the source is a warehouse, the same reshape belongs in a view there, and the refresh gets the finished shape for nothing. Back in the model, give the month column a Month Number column to sort by, which is Sort by column on the Column tools tab and `sortByColumn` in the TMDL file. A month name is text and cannot carry a relationship to a day-grain date table, so load the month's start date alongside it, as a Month Start column of the first of each month, and relate that column to the date table; time intelligence then works over the reshaped table. The rule reads the model's columns, so the finding clears as soon as the reshaped query is applied.\n\n### When to ignore it\n\nA coincidence is the case to check for first, though it takes six of them at once. The month names are matched as substrings of the column names, so Janitorial Cost satisfies Jan, Margin satisfies Mar, Apron Sales satisfies Apr, and Junior Rate satisfies Jun; a table of numeric metrics carrying one such name for each of the six months is reported with no month in it anywhere. Read the column names before reshaping anything. A genuinely pivoted table can also be deliberate: a small budget entry table that a person maintains by hand in a spreadsheet is easier to fill in wide, and where it is a handful of rows, unpivoting it on the way in costs nothing and unpivoting it at the source costs an argument. What is not a legitimate exception is a wide fact table of months feeding visuals through twelve near-identical measures, which is the pattern the rule exists to catch.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNPIVOT_PIVOTED_(MONTH)_DATA` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNPIVOT_PIVOTED_(MONTH)_DATA\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only the first six months are tested, so a table with July through December and nothing else is never reported, and one with January through June is reported whether or not the rest of the year is there.\n- The names are matched as substrings of the upper-cased column name, so full names count and so do unrelated words: Margin matches MAR, January Budget matches JAN, and a table needs one match for each of the six months before it is reported.\n- The column that matches has to be numeric, meaning int64, decimal, or double. A month column loaded as text does not count, so a table of twelve text columns passes.\n- A column with no `dataType` line, as Power BI Desktop saves most calculated columns, does not count as a numeric month column, since pbiplint does not know its type. Tabular Editor reads the type from the column's DAX.\n- Tables and calculated tables are in scope; calculation groups are not.\n\nRead more: https://pbiplint.com/rules/unpivot-pivoted-month-data"
|
|
13447
14281
|
},
|
|
13448
14282
|
USE_THE_DIVIDE_FUNCTION_FOR_DIVISION: {
|
|
13449
14283
|
text: `Example
|
|
@@ -13743,7 +14577,7 @@ var statOf = (p) => statSync(p, { throwIfNoEntry: false });
|
|
|
13743
14577
|
var isDir = (p) => statOf(p)?.isDirectory() ?? false;
|
|
13744
14578
|
var isFile = (p) => statOf(p)?.isFile() ?? false;
|
|
13745
14579
|
var byName = (a, b) => a.localeCompare(b, "en");
|
|
13746
|
-
var
|
|
14580
|
+
var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13747
14581
|
function folderAt(p) {
|
|
13748
14582
|
try {
|
|
13749
14583
|
const stat = statOf(p);
|
|
@@ -13930,9 +14764,9 @@ function walked(input, base, read) {
|
|
|
13930
14764
|
}
|
|
13931
14765
|
function reportsNamed(name, text2) {
|
|
13932
14766
|
const json = readJson(name, text2).json;
|
|
13933
|
-
if (!
|
|
14767
|
+
if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
|
|
13934
14768
|
return json.artifacts.flatMap(
|
|
13935
|
-
(a) =>
|
|
14769
|
+
(a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
|
|
13936
14770
|
);
|
|
13937
14771
|
}
|
|
13938
14772
|
function resolvePbip(input, path) {
|
|
@@ -14106,7 +14940,7 @@ function readFolder(w, input, path, preferred) {
|
|
|
14106
14940
|
}
|
|
14107
14941
|
|
|
14108
14942
|
// src/main.ts
|
|
14109
|
-
var VERSION2 = true ? "0.2.
|
|
14943
|
+
var VERSION2 = true ? "0.2.3" : "0.0.0-dev";
|
|
14110
14944
|
function listRules() {
|
|
14111
14945
|
const width = Math.max(...defaultRules.map((r) => r.id.length));
|
|
14112
14946
|
return defaultRules.map(
|