pbiplint 0.2.0 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/pbiplint.mjs
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// ../core/src/version.ts
|
|
4
|
-
var VERSION = "0.2.
|
|
4
|
+
var VERSION = "0.2.2";
|
|
5
5
|
|
|
6
6
|
// ../core/src/engine/config.ts
|
|
7
7
|
var ConfigError = class extends Error {
|
|
8
8
|
};
|
|
9
9
|
function bindConfig(config, rules) {
|
|
10
|
-
const
|
|
11
|
-
const
|
|
10
|
+
const ruleByUpper = new Map(rules.map((r) => [r.id.toUpperCase(), r]));
|
|
11
|
+
const bound2 = {
|
|
12
12
|
disabled: /* @__PURE__ */ new Set(),
|
|
13
13
|
severity: /* @__PURE__ */ new Map(),
|
|
14
14
|
options: /* @__PURE__ */ new Map(),
|
|
@@ -16,22 +16,23 @@ function bindConfig(config, rules) {
|
|
|
16
16
|
};
|
|
17
17
|
const unknownRules = [];
|
|
18
18
|
for (const id of config.disabled) {
|
|
19
|
-
const real =
|
|
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
|
-
const real =
|
|
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
|
-
const
|
|
30
|
-
if (
|
|
29
|
+
const rule = ruleByUpper.get(id.toUpperCase());
|
|
30
|
+
if (rule === void 0) {
|
|
31
31
|
unknownRules.push(id);
|
|
32
32
|
continue;
|
|
33
33
|
}
|
|
34
|
-
const
|
|
34
|
+
const real = rule.id;
|
|
35
|
+
if (Object.keys(options).length === 0) continue;
|
|
35
36
|
const declared = rule.options ?? [];
|
|
36
37
|
if (declared.length === 0)
|
|
37
38
|
throw new ConfigError(`pbiplint.config.json: rules["${real}"] takes no options`);
|
|
@@ -50,9 +51,9 @@ function bindConfig(config, rules) {
|
|
|
50
51
|
`pbiplint.config.json: rules["${real}"].${name} must be one of ${decl.values.join(", ")}`
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
bound2.options.set(real, { ...options });
|
|
54
55
|
}
|
|
55
|
-
return { config:
|
|
56
|
+
return { config: bound2, unknownRules: [...new Set(unknownRules)] };
|
|
56
57
|
}
|
|
57
58
|
var SEVERITY_BY_NAME = { info: 1, warning: 2, error: 3 };
|
|
58
59
|
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -90,7 +91,7 @@ function resolveConfig(raw = {}) {
|
|
|
90
91
|
);
|
|
91
92
|
out.severity.set(id, SEVERITY_BY_NAME[severity]);
|
|
92
93
|
}
|
|
93
|
-
|
|
94
|
+
out.options.set(id, options);
|
|
94
95
|
} else
|
|
95
96
|
throw new ConfigError(
|
|
96
97
|
`pbiplint.config.json: rules["${id}"] must be "off", "info", "warning", "error", or an object with a severity and options`
|
|
@@ -204,12 +205,14 @@ function buildColumn(c, table) {
|
|
|
204
205
|
}
|
|
205
206
|
function buildMeasure(x, table) {
|
|
206
207
|
const p = x.props;
|
|
208
|
+
const kpi = x.children.find((c) => c.type === "kpi")?.props;
|
|
207
209
|
return {
|
|
208
210
|
...named(x),
|
|
209
211
|
table,
|
|
210
212
|
expression: x.value ?? "",
|
|
211
213
|
formatString: str(p.formatstring),
|
|
212
214
|
formatStringDefinition: str(p.formatstringdefinition),
|
|
215
|
+
kpiExpressions: kpi && [kpi.targetexpression, kpi.statusexpression, kpi.trendexpression].map(str).filter((e) => e !== void 0),
|
|
213
216
|
isHidden: flag(p.ishidden),
|
|
214
217
|
displayFolder: str(p.displayfolder)
|
|
215
218
|
};
|
|
@@ -284,7 +287,16 @@ function buildTable(r, model) {
|
|
|
284
287
|
else if (c.kind === "object" && c.type === "partition") t.partitions.push(buildPartition(c, t));
|
|
285
288
|
else if (c.kind === "object" && c.type === "hierarchy")
|
|
286
289
|
t.hierarchies.push(buildHierarchy(c, t));
|
|
287
|
-
else if (c.type === "calculationgroup")
|
|
290
|
+
else if (c.type === "calculationgroup") {
|
|
291
|
+
const group = buildCalculationGroup(c, t);
|
|
292
|
+
const first = t.calculationGroup;
|
|
293
|
+
if (first) {
|
|
294
|
+
first.precedence ??= group.precedence;
|
|
295
|
+
first.description ??= group.description;
|
|
296
|
+
Object.assign(first.annotations, group.annotations);
|
|
297
|
+
first.items.push(...group.items);
|
|
298
|
+
} else t.calculationGroup = group;
|
|
299
|
+
}
|
|
288
300
|
}
|
|
289
301
|
}
|
|
290
302
|
function buildRelationship(r) {
|
|
@@ -335,7 +347,85 @@ function finalizeKinds(model) {
|
|
|
335
347
|
c.kind = c.expression !== void 0 ? "calculated" : t.kind === "calculated" ? "calculatedTable" : "data";
|
|
336
348
|
}
|
|
337
349
|
}
|
|
338
|
-
function
|
|
350
|
+
function walkOrder(a, b) {
|
|
351
|
+
const as = a.split("/");
|
|
352
|
+
const bs = b.split("/");
|
|
353
|
+
for (let i = 0; i < Math.min(as.length, bs.length); i++) {
|
|
354
|
+
const [x, y] = [as[i], bs[i]];
|
|
355
|
+
if (x !== y) return x.localeCompare(y, "en") || (x < y ? -1 : 1);
|
|
356
|
+
}
|
|
357
|
+
return as.length - bs.length;
|
|
358
|
+
}
|
|
359
|
+
function modelDeclarations(f) {
|
|
360
|
+
const declared = (n2) => n2.kind === "object" || n2.kind === "flag";
|
|
361
|
+
const isModel = (n2) => n2.type === "model" && declared(n2);
|
|
362
|
+
const out = [];
|
|
363
|
+
for (const r of f.roots) {
|
|
364
|
+
out.push(r);
|
|
365
|
+
const models = isModel(r) ? [r] : r.type === "database" && declared(r) ? r.children.filter(isModel) : [];
|
|
366
|
+
for (const m of models) {
|
|
367
|
+
if (m !== r) out.push(m);
|
|
368
|
+
out.push(...m.children);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
function readDeclaration(r, model) {
|
|
374
|
+
if (r.kind === "ref" || r.kind === "prop" || r.kind === "expr") return;
|
|
375
|
+
switch (r.type) {
|
|
376
|
+
case "model":
|
|
377
|
+
readModel(r, model);
|
|
378
|
+
break;
|
|
379
|
+
case "annotation":
|
|
380
|
+
if (r.name && r.children.length === 0) model.annotations[r.name] = r.value ?? "";
|
|
381
|
+
break;
|
|
382
|
+
case "table":
|
|
383
|
+
buildTable(r, model);
|
|
384
|
+
break;
|
|
385
|
+
case "relationship":
|
|
386
|
+
model.relationships.push(buildRelationship(r));
|
|
387
|
+
break;
|
|
388
|
+
case "role":
|
|
389
|
+
model.roles.push(buildRole(r));
|
|
390
|
+
break;
|
|
391
|
+
case "perspective": {
|
|
392
|
+
const p = {
|
|
393
|
+
...named(r),
|
|
394
|
+
tables: objects(r, "perspectivetable").map((t) => t.name)
|
|
395
|
+
};
|
|
396
|
+
model.perspectives.push(p);
|
|
397
|
+
break;
|
|
398
|
+
}
|
|
399
|
+
case "cultureinfo":
|
|
400
|
+
model.cultures.push(named(r));
|
|
401
|
+
break;
|
|
402
|
+
case "expression":
|
|
403
|
+
model.expressions.push({ ...named(r), expression: r.value ?? "" });
|
|
404
|
+
break;
|
|
405
|
+
case "function":
|
|
406
|
+
model.functions.push({ ...named(r), expression: r.value ?? "" });
|
|
407
|
+
break;
|
|
408
|
+
case "datasource": {
|
|
409
|
+
const ds = {
|
|
410
|
+
...named(r),
|
|
411
|
+
kind: (r.value ?? "").trim().toLowerCase() === "provider" ? "provider" : "structured"
|
|
412
|
+
};
|
|
413
|
+
model.dataSources.push(ds);
|
|
414
|
+
break;
|
|
415
|
+
}
|
|
416
|
+
default:
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function readModel(r, model) {
|
|
421
|
+
Object.assign(model, named(r, r.name ?? "Model"), {
|
|
422
|
+
description: r.description ?? model.description,
|
|
423
|
+
annotations: model.annotations,
|
|
424
|
+
props: { ...model.props, ...r.props }
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
function buildModel(given, unreadPaths = []) {
|
|
428
|
+
const files = [...given].sort((a, b) => walkOrder(a.file, b.file));
|
|
339
429
|
const model = {
|
|
340
430
|
name: "Model",
|
|
341
431
|
annotations: {},
|
|
@@ -352,64 +442,15 @@ function buildModel(files, unreadPaths = []) {
|
|
|
352
442
|
files,
|
|
353
443
|
unreadPaths: unreadPaths.filter((p) => p.endsWith(".tmdl") || p.endsWith("/"))
|
|
354
444
|
};
|
|
355
|
-
for (const f of files)
|
|
356
|
-
for (const r of f.roots) {
|
|
357
|
-
if (r.kind === "ref" || r.kind === "prop" || r.kind === "expr") continue;
|
|
358
|
-
switch (r.type) {
|
|
359
|
-
case "model":
|
|
360
|
-
Object.assign(model, named(r, r.name ?? "Model"), {
|
|
361
|
-
annotations: { ...model.annotations, ...annotationsOf(r) },
|
|
362
|
-
props: r.props
|
|
363
|
-
});
|
|
364
|
-
break;
|
|
365
|
-
case "annotation":
|
|
366
|
-
if (r.name) model.annotations[r.name] = r.value ?? "";
|
|
367
|
-
break;
|
|
368
|
-
case "table":
|
|
369
|
-
buildTable(r, model);
|
|
370
|
-
break;
|
|
371
|
-
case "relationship":
|
|
372
|
-
model.relationships.push(buildRelationship(r));
|
|
373
|
-
break;
|
|
374
|
-
case "role":
|
|
375
|
-
model.roles.push(buildRole(r));
|
|
376
|
-
break;
|
|
377
|
-
case "perspective": {
|
|
378
|
-
const p = {
|
|
379
|
-
...named(r),
|
|
380
|
-
tables: objects(r, "perspectivetable").map((t) => t.name)
|
|
381
|
-
};
|
|
382
|
-
model.perspectives.push(p);
|
|
383
|
-
break;
|
|
384
|
-
}
|
|
385
|
-
case "cultureinfo":
|
|
386
|
-
model.cultures.push(named(r));
|
|
387
|
-
break;
|
|
388
|
-
case "expression":
|
|
389
|
-
model.expressions.push({ ...named(r), expression: r.value ?? "" });
|
|
390
|
-
break;
|
|
391
|
-
case "function":
|
|
392
|
-
model.functions.push({ ...named(r), expression: r.value ?? "" });
|
|
393
|
-
break;
|
|
394
|
-
case "datasource": {
|
|
395
|
-
const ds = {
|
|
396
|
-
...named(r),
|
|
397
|
-
kind: (r.value ?? "").trim().toLowerCase() === "provider" ? "provider" : "structured"
|
|
398
|
-
};
|
|
399
|
-
model.dataSources.push(ds);
|
|
400
|
-
break;
|
|
401
|
-
}
|
|
402
|
-
default:
|
|
403
|
-
break;
|
|
404
|
-
}
|
|
405
|
-
}
|
|
406
|
-
}
|
|
445
|
+
for (const f of files) for (const r of modelDeclarations(f)) readDeclaration(r, model);
|
|
407
446
|
finalizeKinds(model);
|
|
408
447
|
return model;
|
|
409
448
|
}
|
|
410
449
|
|
|
411
450
|
// ../core/src/model/names.ts
|
|
412
451
|
var isAutoDateTable = (t) => t.kind === "calculated" && (t.name.startsWith("DateTableTemplate_") || t.name.startsWith("LocalDateTable_"));
|
|
452
|
+
var isAutoDateTableCopy = (t) => t.name.startsWith("LocalDateTable_") && t.partitions.some((p) => p.sourceType === "entity" && p.mode === "directquery");
|
|
453
|
+
var isHiddenAutoDateTable = (t) => isAutoDateTable(t) || isAutoDateTableCopy(t);
|
|
413
454
|
var tableRef = (name) => `'${name.replace(/'/g, "''")}'`;
|
|
414
455
|
var bracket = (name) => `[${name.replace(/\]/g, "]]")}]`;
|
|
415
456
|
var columnRef = (table, column) => `${tableRef(table)}${bracket(column)}`;
|
|
@@ -427,10 +468,12 @@ var ruleUrl = (id) => RULE_URL_BASE + slug(id);
|
|
|
427
468
|
|
|
428
469
|
// ../core/src/index/reachability.ts
|
|
429
470
|
var isTable = (n2) => "columns" in n2;
|
|
430
|
-
var isMeasure = (n2) => "
|
|
431
|
-
var nameOf = (n2) => isTable(n2) ? tableRef(n2.name) : isMeasure(n2) ? measureRef(n2.name) : columnRef(n2.table.name, n2.name);
|
|
471
|
+
var isMeasure = (n2) => "table" in n2 && !("kind" in n2);
|
|
432
472
|
var listOf = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
433
473
|
function buildReachabilityIndex(model, references, reportRefs) {
|
|
474
|
+
const functions = new Set(model.functions);
|
|
475
|
+
const isFunction = (n2) => functions.has(n2);
|
|
476
|
+
const nameOf = (n2) => isTable(n2) ? tableRef(n2.name) : isFunction(n2) ? n2.name : isMeasure(n2) ? measureRef(n2.name) : columnRef(n2.table.name, n2.name);
|
|
434
477
|
const tables = new Map(model.tables.map((t) => [t.name.toLowerCase(), t]));
|
|
435
478
|
const columnOf = (table, name) => tables.get(table.toLowerCase())?.columns.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
436
479
|
const measureOf = (table, name) => tables.get(table.toLowerCase())?.measures.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
|
@@ -446,6 +489,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
446
489
|
if (r.kind === "column") reach(columnOf(r.table, r.name), from);
|
|
447
490
|
else if (r.kind === "measure") reach(measureOf(r.table, r.name), from);
|
|
448
491
|
}
|
|
492
|
+
for (const f of references.callsOf(owner)) reach(f, from);
|
|
449
493
|
};
|
|
450
494
|
for (const r of reportRefs.refs) {
|
|
451
495
|
const res = r.resolution;
|
|
@@ -458,9 +502,10 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
458
502
|
}
|
|
459
503
|
if ("variationOf" in res) reach(res.variationOf, null);
|
|
460
504
|
}
|
|
505
|
+
for (const c of reportRefs.functionCalls) for (const f of c.calls) reach(f, null);
|
|
461
506
|
const autoDate = (table) => {
|
|
462
507
|
const t = tables.get(table.toLowerCase());
|
|
463
|
-
return t !== void 0 &&
|
|
508
|
+
return t !== void 0 && isHiddenAutoDateTable(t);
|
|
464
509
|
};
|
|
465
510
|
for (const rel of model.relationships) {
|
|
466
511
|
if (autoDate(rel.fromTable) || autoDate(rel.toTable)) continue;
|
|
@@ -469,8 +514,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
469
514
|
}
|
|
470
515
|
for (const role of model.roles)
|
|
471
516
|
for (const tp of role.tablePermissions) {
|
|
472
|
-
|
|
473
|
-
if (r.kind === "column") reach(columnOf(r.table, r.name), null);
|
|
517
|
+
reachDax(tp, null);
|
|
474
518
|
for (const cp of tp.columnPermissions) reach(columnOf(tp.table, cp.column), null);
|
|
475
519
|
}
|
|
476
520
|
for (const t of model.tables)
|
|
@@ -480,6 +524,10 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
480
524
|
for (const t of model.tables) for (const c of t.columns) if (c.alternateOf) reach(c, null);
|
|
481
525
|
while (queue.length) {
|
|
482
526
|
const n2 = queue.shift();
|
|
527
|
+
if (isFunction(n2)) {
|
|
528
|
+
reachDax(n2, n2);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
483
531
|
if (isTable(n2)) {
|
|
484
532
|
if (n2.kind === "calculated") reachDax(n2, n2);
|
|
485
533
|
for (const item of n2.calculationGroup?.items ?? []) reachDax(item, n2);
|
|
@@ -498,8 +546,8 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
498
546
|
if (base?.baseColumn) reach(columnOf(base.baseTable ?? "", base.baseColumn), n2);
|
|
499
547
|
else if (base?.baseTable) reach(tables.get(base.baseTable.toLowerCase()), n2);
|
|
500
548
|
}
|
|
501
|
-
const
|
|
502
|
-
const daxReferrers = (owners) => owners.filter(
|
|
549
|
+
const nameable = (o) => o.kind === "measure" || o.kind === "calculatedColumn" || o.kind === "function";
|
|
550
|
+
const daxReferrers = (owners) => owners.filter(nameable).map((o) => o.object);
|
|
503
551
|
const referrersOf = (n2) => {
|
|
504
552
|
if (isMeasure(n2)) return daxReferrers(references.measureReferencedBy(n2));
|
|
505
553
|
const dax = daxReferrers(references.columnReferencedBy(n2));
|
|
@@ -521,7 +569,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
521
569
|
return path;
|
|
522
570
|
},
|
|
523
571
|
unreached: () => {
|
|
524
|
-
const listed = model.tables.filter((t) => !
|
|
572
|
+
const listed = model.tables.filter((t) => !isHiddenAutoDateTable(t));
|
|
525
573
|
const columns2 = listed.flatMap((t) => t.columns.filter((c) => !parent.has(c)));
|
|
526
574
|
const measures = listed.flatMap((t) => t.measures.filter((m) => !parent.has(m)));
|
|
527
575
|
const tables2 = listed.filter(
|
|
@@ -540,25 +588,227 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
540
588
|
};
|
|
541
589
|
}
|
|
542
590
|
|
|
543
|
-
// ../core/src/
|
|
544
|
-
var
|
|
545
|
-
var
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
591
|
+
// ../core/src/dax/tokenize.ts
|
|
592
|
+
var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
|
|
593
|
+
var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
594
|
+
var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
|
|
595
|
+
var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
596
|
+
var DIGIT = /[0-9]/;
|
|
597
|
+
var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
|
|
598
|
+
var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
|
|
599
|
+
function quoted(text2, from, close) {
|
|
600
|
+
let value = "";
|
|
601
|
+
let j = from + 1;
|
|
602
|
+
while (j < text2.length) {
|
|
603
|
+
const c = text2[j];
|
|
604
|
+
if (c === close) {
|
|
605
|
+
if (text2[j + 1] !== close) return { value, end: j + 1 };
|
|
606
|
+
j++;
|
|
607
|
+
}
|
|
608
|
+
value += c;
|
|
609
|
+
j++;
|
|
553
610
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
611
|
+
return { value, end: text2.length };
|
|
612
|
+
}
|
|
613
|
+
function tokenizeDax(expression) {
|
|
614
|
+
const s = expression;
|
|
615
|
+
const tokens = [];
|
|
616
|
+
const push2 = (kind, text2, start, end) => {
|
|
617
|
+
tokens.push({ kind, text: text2, start, end, depth: 0 });
|
|
618
|
+
};
|
|
619
|
+
let i = 0;
|
|
620
|
+
while (i < s.length) {
|
|
621
|
+
const c = s[i];
|
|
622
|
+
if (/\s/.test(c)) {
|
|
623
|
+
i++;
|
|
624
|
+
} else if (s.startsWith("//", i) || s.startsWith("--", i)) {
|
|
625
|
+
const nl = s.indexOf("\n", i);
|
|
626
|
+
i = nl === -1 ? s.length : nl;
|
|
627
|
+
} else if (s.startsWith("/*", i)) {
|
|
628
|
+
const close = s.indexOf("*/", i + 2);
|
|
629
|
+
i = close === -1 ? s.length : close + 2;
|
|
630
|
+
} else if (c === '"') {
|
|
631
|
+
const q2 = quoted(s, i, '"');
|
|
632
|
+
push2("string", q2.value, i, q2.end);
|
|
633
|
+
i = q2.end;
|
|
634
|
+
} else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
|
|
635
|
+
const q2 = quoted(s, i + 2, '"');
|
|
636
|
+
push2("date", q2.value, i, q2.end);
|
|
637
|
+
i = q2.end;
|
|
638
|
+
} else if (c === "'" || c === "[") {
|
|
639
|
+
const q2 = quoted(s, i, c === "'" ? "'" : "]");
|
|
640
|
+
push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
|
|
641
|
+
i = q2.end;
|
|
642
|
+
} else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
|
|
643
|
+
NUMBER.lastIndex = i;
|
|
644
|
+
const text2 = NUMBER.exec(s)[0];
|
|
645
|
+
push2("number", text2, i, i + text2.length);
|
|
646
|
+
i += text2.length;
|
|
647
|
+
} else {
|
|
648
|
+
IDENTIFIER.lastIndex = i;
|
|
649
|
+
const id = IDENTIFIER.exec(s)?.[0];
|
|
650
|
+
const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
|
|
651
|
+
const text2 = id ?? op ?? c;
|
|
652
|
+
push2(
|
|
653
|
+
id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
|
|
654
|
+
text2,
|
|
655
|
+
i,
|
|
656
|
+
i + text2.length
|
|
657
|
+
);
|
|
658
|
+
i += text2.length;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
annotate(tokens);
|
|
662
|
+
return tokens;
|
|
663
|
+
}
|
|
664
|
+
function annotate(tokens) {
|
|
665
|
+
const open = [];
|
|
666
|
+
const args = [];
|
|
667
|
+
const place = (t) => {
|
|
668
|
+
t.depth = open.length;
|
|
669
|
+
if (open.length === 0) return;
|
|
670
|
+
t.parent = open[open.length - 1];
|
|
671
|
+
t.arg = args[args.length - 1];
|
|
672
|
+
};
|
|
673
|
+
tokens.forEach((t, k) => {
|
|
674
|
+
if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
|
|
675
|
+
place(t);
|
|
676
|
+
const before = tokens[k - 1];
|
|
677
|
+
if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
|
|
678
|
+
open.push(k);
|
|
679
|
+
args.push(0);
|
|
680
|
+
} else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
|
|
681
|
+
const o = open.pop();
|
|
682
|
+
args.pop();
|
|
683
|
+
if (o !== void 0) {
|
|
684
|
+
tokens[o].close = k;
|
|
685
|
+
t.open = o;
|
|
686
|
+
}
|
|
687
|
+
place(t);
|
|
688
|
+
} else {
|
|
689
|
+
place(t);
|
|
690
|
+
if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
|
|
691
|
+
}
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
function opensBlock(tokens, k) {
|
|
695
|
+
if (isWord(tokens[k - 1], "RETURN")) return true;
|
|
696
|
+
const eq = tokens[k - 1];
|
|
697
|
+
return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
|
|
698
|
+
}
|
|
699
|
+
function daxVariables(tokens) {
|
|
700
|
+
const out = [];
|
|
701
|
+
tokens.forEach((t, k) => {
|
|
702
|
+
const name = tokens[k + 1];
|
|
703
|
+
const eq = tokens[k + 2];
|
|
704
|
+
if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
|
|
705
|
+
if (eq.text !== "=") return;
|
|
706
|
+
const from = k + 3;
|
|
707
|
+
let to = from;
|
|
708
|
+
let nested = 0;
|
|
709
|
+
for (; to < tokens.length; to++) {
|
|
710
|
+
const x = tokens[to];
|
|
711
|
+
if (x.depth < t.depth) break;
|
|
712
|
+
if (x.depth !== t.depth) continue;
|
|
713
|
+
if (isWord(x, "VAR")) {
|
|
714
|
+
if (opensBlock(tokens, to)) nested++;
|
|
715
|
+
else if (nested === 0) break;
|
|
716
|
+
} else if (isWord(x, "RETURN")) {
|
|
717
|
+
if (nested === 0) break;
|
|
718
|
+
nested--;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
let blockEnd2 = to;
|
|
722
|
+
for (; blockEnd2 < tokens.length; blockEnd2++) {
|
|
723
|
+
const x = tokens[blockEnd2];
|
|
724
|
+
if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
|
|
725
|
+
}
|
|
726
|
+
out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
|
|
727
|
+
});
|
|
728
|
+
for (const v of out)
|
|
729
|
+
for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
|
|
730
|
+
return out;
|
|
731
|
+
}
|
|
732
|
+
function variableAt(vars, name, use) {
|
|
733
|
+
const key4 = name.toUpperCase();
|
|
734
|
+
let found;
|
|
735
|
+
for (const v of vars)
|
|
736
|
+
if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
|
|
737
|
+
found = v;
|
|
738
|
+
return found;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// ../core/src/index/references.ts
|
|
742
|
+
var CREATES_COLUMNS = /* @__PURE__ */ new Set([
|
|
743
|
+
"ADDCOLUMNS",
|
|
744
|
+
"SELECTCOLUMNS",
|
|
745
|
+
"SUMMARIZE",
|
|
746
|
+
"SUMMARIZECOLUMNS",
|
|
747
|
+
"ROW",
|
|
748
|
+
"DATATABLE"
|
|
749
|
+
]);
|
|
750
|
+
function createdColumns(tokens) {
|
|
751
|
+
const out = /* @__PURE__ */ new Map();
|
|
752
|
+
for (const t of tokens) {
|
|
753
|
+
if (t.kind !== "string" || t.parent === void 0) continue;
|
|
754
|
+
const open = tokens[t.parent];
|
|
755
|
+
if (open.call === void 0 || !CREATES_COLUMNS.has(open.call)) continue;
|
|
756
|
+
const name = t.text.toLowerCase();
|
|
757
|
+
out.set(name, [...out.get(name) ?? [], [t.parent, open.close ?? tokens.length]]);
|
|
557
758
|
}
|
|
558
759
|
return out;
|
|
559
760
|
}
|
|
761
|
+
function refsInTokens(tokens) {
|
|
762
|
+
const created = createdColumns(tokens);
|
|
763
|
+
const out = [];
|
|
764
|
+
tokens.forEach((t, k) => {
|
|
765
|
+
if (t.kind !== "column") return;
|
|
766
|
+
const before = tokens[k - 1];
|
|
767
|
+
if (isPunctuation(before, ".") && tokens[k - 2]?.kind === "column") return;
|
|
768
|
+
if (before?.kind === "table" || before?.kind === "identifier" && before.end === t.start) {
|
|
769
|
+
out.push({ table: before.text, name: t.text, qualified: true });
|
|
770
|
+
return;
|
|
771
|
+
}
|
|
772
|
+
const calls = created.get(t.text.toLowerCase());
|
|
773
|
+
if (calls?.every(([open, close]) => k < open || k > close))
|
|
774
|
+
out.push({ name: t.text, qualified: false, created: true });
|
|
775
|
+
else out.push({ name: t.text, qualified: false });
|
|
776
|
+
});
|
|
777
|
+
return out;
|
|
778
|
+
}
|
|
560
779
|
var lower2 = (s) => s.toLowerCase();
|
|
561
780
|
var key = (table, name) => `${lower2(table)} ${lower2(name)}`;
|
|
781
|
+
function functionCallReader(functions) {
|
|
782
|
+
if (functions.length === 0) return () => [];
|
|
783
|
+
const byName2 = /* @__PURE__ */ new Map();
|
|
784
|
+
for (const f of functions) if (!byName2.has(lower2(f.name))) byName2.set(lower2(f.name), f);
|
|
785
|
+
const order3 = new Map(functions.map((f, i) => [f, i]));
|
|
786
|
+
return (tokens) => {
|
|
787
|
+
const found = /* @__PURE__ */ new Set();
|
|
788
|
+
tokens.forEach((t, k) => {
|
|
789
|
+
const f = t.call === void 0 ? void 0 : byName2.get(lower2(tokens[k - 1].text));
|
|
790
|
+
if (f) found.add(f);
|
|
791
|
+
});
|
|
792
|
+
return [...found].sort((a, b) => order3.get(a) - order3.get(b));
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function resolveBareName(ref, owner, lookup) {
|
|
796
|
+
const { name } = ref;
|
|
797
|
+
const measure = lookup.measureNamed(name);
|
|
798
|
+
if (measure !== void 0) return { kind: "measure", measure };
|
|
799
|
+
if (ref.created || owner.kind === "calculationItem") return { kind: "none" };
|
|
800
|
+
if (owner.kind === "function") {
|
|
801
|
+
const columns2 = lookup.tables.flatMap((t) => lookup.columnOf(t, name) ?? []);
|
|
802
|
+
return columns2.length > 0 ? { kind: "columns", columns: columns2 } : { kind: "none" };
|
|
803
|
+
}
|
|
804
|
+
const own = owner.table && lookup.columnOf(owner.table, name);
|
|
805
|
+
if (own) return { kind: "columns", columns: [own] };
|
|
806
|
+
for (const t of lookup.tables) {
|
|
807
|
+
const column = lookup.columnOf(t, name);
|
|
808
|
+
if (column) return { kind: "columns", columns: [column] };
|
|
809
|
+
}
|
|
810
|
+
return { kind: "none" };
|
|
811
|
+
}
|
|
562
812
|
function buildReferenceIndex(model) {
|
|
563
813
|
const tables = new Map(model.tables.map((t) => [lower2(t.name), t]));
|
|
564
814
|
const columns2 = /* @__PURE__ */ new Map();
|
|
@@ -568,66 +818,90 @@ function buildReferenceIndex(model) {
|
|
|
568
818
|
for (const m of t.measures) measures.set(lower2(m.name), m);
|
|
569
819
|
}
|
|
570
820
|
const columnOf = (t, name) => columns2.get(key(t.name, name));
|
|
821
|
+
const lookup = {
|
|
822
|
+
tables: model.tables,
|
|
823
|
+
columnOf,
|
|
824
|
+
measureNamed: (name) => measures.get(lower2(name))
|
|
825
|
+
};
|
|
571
826
|
const resolve4 = (raw, ownerTable, ownerKind) => {
|
|
572
827
|
if (raw.qualified) {
|
|
573
828
|
const t = tables.get(lower2(raw.table));
|
|
574
829
|
if (!t) return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
575
830
|
const col = columnOf(t, raw.name);
|
|
576
831
|
if (col) return { kind: "column", table: t.name, name: col.name, qualified: true };
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
832
|
+
const meas = t.measures.find((m) => lower2(m.name) === lower2(raw.name));
|
|
833
|
+
if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
|
|
579
834
|
return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
580
835
|
}
|
|
581
|
-
const
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
836
|
+
const bare = resolveBareName(raw, { kind: ownerKind, table: ownerTable }, lookup);
|
|
837
|
+
if (bare.kind === "measure")
|
|
838
|
+
return {
|
|
839
|
+
kind: "measure",
|
|
840
|
+
table: bare.measure.table.name,
|
|
841
|
+
name: bare.measure.name,
|
|
842
|
+
qualified: false
|
|
843
|
+
};
|
|
844
|
+
if (bare.kind === "columns")
|
|
845
|
+
return bare.columns.map((c) => ({
|
|
846
|
+
kind: "column",
|
|
847
|
+
table: c.table.name,
|
|
848
|
+
name: c.name,
|
|
849
|
+
qualified: false
|
|
850
|
+
}));
|
|
593
851
|
return { kind: "unresolved", name: raw.name, qualified: false };
|
|
594
852
|
};
|
|
853
|
+
const callsIn = functionCallReader(model.functions);
|
|
595
854
|
const owners = [];
|
|
596
855
|
const byObject = /* @__PURE__ */ new Map();
|
|
597
|
-
const add = (
|
|
856
|
+
const add = (of, ownerTable, ...expressions) => {
|
|
598
857
|
const expression = expressions.filter((e) => e !== void 0).join("\n");
|
|
858
|
+
const tokens = tokenizeDax(expression);
|
|
599
859
|
const owner = {
|
|
600
|
-
|
|
601
|
-
object,
|
|
860
|
+
...of,
|
|
602
861
|
ownerTable,
|
|
603
862
|
expression,
|
|
604
|
-
refs:
|
|
863
|
+
refs: refsInTokens(tokens).flatMap((r) => resolve4(r, ownerTable, of.kind)),
|
|
864
|
+
calls: callsIn(tokens)
|
|
605
865
|
};
|
|
606
866
|
owners.push(owner);
|
|
607
|
-
byObject.set(object, owner);
|
|
867
|
+
byObject.set(of.object, owner);
|
|
608
868
|
};
|
|
609
869
|
for (const t of model.tables) {
|
|
610
|
-
for (const m of t.measures)
|
|
870
|
+
for (const m of t.measures)
|
|
871
|
+
add(
|
|
872
|
+
{ kind: "measure", object: m },
|
|
873
|
+
t,
|
|
874
|
+
m.expression,
|
|
875
|
+
m.formatStringDefinition,
|
|
876
|
+
...m.kpiExpressions ?? []
|
|
877
|
+
);
|
|
611
878
|
for (const c of t.columns)
|
|
612
|
-
if (c.kind === "calculated") add("calculatedColumn", c, t, c.expression);
|
|
879
|
+
if (c.kind === "calculated") add({ kind: "calculatedColumn", object: c }, t, c.expression);
|
|
613
880
|
if (t.kind === "calculated")
|
|
614
881
|
add(
|
|
615
|
-
"calculatedTable",
|
|
616
|
-
t,
|
|
882
|
+
{ kind: "calculatedTable", object: t },
|
|
617
883
|
t,
|
|
618
884
|
...t.partitions.filter((p) => p.sourceType === "calculated").map((p) => p.source)
|
|
619
885
|
);
|
|
620
886
|
for (const item of t.calculationGroup?.items ?? [])
|
|
621
|
-
add(
|
|
887
|
+
add(
|
|
888
|
+
{ kind: "calculationItem", object: item },
|
|
889
|
+
t,
|
|
890
|
+
item.expression,
|
|
891
|
+
item.formatStringDefinition
|
|
892
|
+
);
|
|
622
893
|
}
|
|
623
894
|
for (const role of model.roles) {
|
|
624
895
|
for (const tp of role.tablePermissions)
|
|
625
896
|
if (tp.filter !== void 0)
|
|
626
|
-
add("tablePermission", tp, tables.get(lower2(tp.table)), tp.filter);
|
|
897
|
+
add({ kind: "tablePermission", object: tp }, tables.get(lower2(tp.table)), tp.filter);
|
|
627
898
|
}
|
|
899
|
+
for (const f of model.functions) add({ kind: "function", object: f }, void 0, f.expression);
|
|
628
900
|
const columnRefs = /* @__PURE__ */ new Map();
|
|
629
901
|
const measureRefs = /* @__PURE__ */ new Map();
|
|
902
|
+
const callers = /* @__PURE__ */ new Map();
|
|
630
903
|
for (const o of owners) {
|
|
904
|
+
for (const f of o.calls) callers.set(f, [...callers.get(f) ?? [], o]);
|
|
631
905
|
for (const r of o.refs) {
|
|
632
906
|
if (r.kind === "column") {
|
|
633
907
|
const k = key(r.table, r.name);
|
|
@@ -646,7 +920,9 @@ function buildReferenceIndex(model) {
|
|
|
646
920
|
owners,
|
|
647
921
|
refsOf: (object) => byObject.get(object)?.refs ?? [],
|
|
648
922
|
columnReferencedBy: (c) => columnRefs.get(key(c.table.name, c.name)) ?? [],
|
|
649
|
-
measureReferencedBy: (m) => measureRefs.get(lower2(m.name)) ?? []
|
|
923
|
+
measureReferencedBy: (m) => measureRefs.get(lower2(m.name)) ?? [],
|
|
924
|
+
callsOf: (object) => byObject.get(object)?.calls ?? [],
|
|
925
|
+
functionCalledBy: (f) => callers.get(f) ?? []
|
|
650
926
|
};
|
|
651
927
|
}
|
|
652
928
|
|
|
@@ -706,7 +982,9 @@ function buildReportReferenceIndex(report, model) {
|
|
|
706
982
|
const missingOn = (t, reason) => {
|
|
707
983
|
const files = model?.files ?? [];
|
|
708
984
|
const declaring = files.find(
|
|
709
|
-
(f) => partlyRead.has(f.file) && f.
|
|
985
|
+
(f) => partlyRead.has(f.file) && modelDeclarations(f).some(
|
|
986
|
+
(r) => r.kind === "object" && r.type === "table" && r.name === t.name
|
|
987
|
+
)
|
|
710
988
|
);
|
|
711
989
|
if (declaring) return unread2(reason, declaring.file);
|
|
712
990
|
if (neverRead !== void 0) return notRead(reason, neverRead);
|
|
@@ -822,52 +1100,62 @@ function buildReportReferenceIndex(report, model) {
|
|
|
822
1100
|
}
|
|
823
1101
|
}
|
|
824
1102
|
for (const b of report.bookmarks) add({ kind: "bookmark", object: b }, b.file, b.refs);
|
|
1103
|
+
const bareLookup = {
|
|
1104
|
+
tables: model?.tables ?? [],
|
|
1105
|
+
columnOf,
|
|
1106
|
+
measureNamed: (name) => {
|
|
1107
|
+
const inModel = measuresByName.get(lower3(name));
|
|
1108
|
+
return inModel ? { table: inModel.table.name, name: inModel.name } : reportMeasuresByName.get(lower3(name));
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
const callsIn = functionCallReader(model?.functions ?? []);
|
|
1112
|
+
const functionCalls = [];
|
|
825
1113
|
for (const m of report.measures) {
|
|
826
1114
|
const owner = { kind: "reportMeasure", object: m };
|
|
827
|
-
|
|
1115
|
+
const tokens = tokenizeDax(m.expression);
|
|
1116
|
+
const calls = callsIn(tokens);
|
|
1117
|
+
if (calls.length > 0) functionCalls.push({ measure: m, calls });
|
|
1118
|
+
for (const raw of refsInTokens(tokens)) {
|
|
828
1119
|
if (raw.qualified) {
|
|
829
1120
|
const t = tables.get(lower3(raw.table));
|
|
830
1121
|
const kind = t && measureOf(t, raw.name) || reportMeasures.has(`${lower3(raw.table)}\0${lower3(raw.name)}`) ? "measure" : "column";
|
|
831
1122
|
add(owner, m.file, [{ kind, table: raw.table, name: raw.name, pointer: "" }]);
|
|
832
1123
|
continue;
|
|
833
1124
|
}
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
else if (extension)
|
|
1125
|
+
const bare = resolveBareName(
|
|
1126
|
+
raw,
|
|
1127
|
+
{ kind: "reportMeasure", table: tables.get(lower3(m.table)) },
|
|
1128
|
+
bareLookup
|
|
1129
|
+
);
|
|
1130
|
+
if (bare.kind === "measure")
|
|
841
1131
|
add(owner, m.file, [
|
|
842
|
-
{ kind: "measure", table:
|
|
1132
|
+
{ kind: "measure", table: bare.measure.table, name: bare.measure.name, pointer: "" }
|
|
843
1133
|
]);
|
|
844
|
-
else
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
1134
|
+
else if (bare.kind === "columns")
|
|
1135
|
+
add(
|
|
1136
|
+
owner,
|
|
1137
|
+
m.file,
|
|
1138
|
+
bare.columns.map((c) => ({
|
|
1139
|
+
kind: "column",
|
|
1140
|
+
table: c.table.name,
|
|
1141
|
+
name: c.name,
|
|
1142
|
+
pointer: ""
|
|
1143
|
+
}))
|
|
1144
|
+
);
|
|
1145
|
+
else
|
|
1146
|
+
refs.push({
|
|
1147
|
+
ref: { kind: "measure", table: m.table, name: raw.name, pointer: "" },
|
|
1148
|
+
owner,
|
|
1149
|
+
file: m.file,
|
|
1150
|
+
resolution: missing(`no measure or column named ${q(raw.name)}`)
|
|
1151
|
+
});
|
|
856
1152
|
}
|
|
857
1153
|
}
|
|
858
|
-
const byTarget = /* @__PURE__ */ new Map();
|
|
859
|
-
for (const r of refs) {
|
|
860
|
-
const target = r.resolution.kind === "column" ? r.resolution.column : r.resolution.kind === "measure" ? r.resolution.measure : void 0;
|
|
861
|
-
if (!target) continue;
|
|
862
|
-
const arr = byTarget.get(target) ?? [];
|
|
863
|
-
arr.push(r);
|
|
864
|
-
byTarget.set(target, arr);
|
|
865
|
-
}
|
|
866
1154
|
return {
|
|
867
1155
|
refs,
|
|
868
|
-
referencedBy: (target) => byTarget.get(target) ?? [],
|
|
869
1156
|
unresolved: () => refs.filter((r) => r.resolution.kind === "unresolved"),
|
|
870
|
-
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v)
|
|
1157
|
+
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v),
|
|
1158
|
+
functionCalls
|
|
871
1159
|
};
|
|
872
1160
|
}
|
|
873
1161
|
|
|
@@ -877,9 +1165,11 @@ function buildUsageIndex(model) {
|
|
|
877
1165
|
const sortTargets = /* @__PURE__ */ new Set();
|
|
878
1166
|
const levelColumns = /* @__PURE__ */ new Set();
|
|
879
1167
|
const variationDefaults = /* @__PURE__ */ new Set();
|
|
1168
|
+
const groupByTargets = /* @__PURE__ */ new Set();
|
|
880
1169
|
for (const t of model.tables) {
|
|
881
1170
|
for (const c of t.columns) {
|
|
882
1171
|
if (c.sortByColumn !== void 0) sortTargets.add(key3(t.name, c.sortByColumn));
|
|
1172
|
+
for (const g of c.groupByColumns) groupByTargets.add(key3(t.name, g));
|
|
883
1173
|
for (const v of c.variations)
|
|
884
1174
|
if (v.defaultColumn)
|
|
885
1175
|
variationDefaults.add(key3(v.defaultColumn.table, v.defaultColumn.column));
|
|
@@ -890,7 +1180,8 @@ function buildUsageIndex(model) {
|
|
|
890
1180
|
return {
|
|
891
1181
|
usedInSortBy: (c) => sortTargets.has(key3(c.table.name, c.name)),
|
|
892
1182
|
usedInHierarchies: (c) => levelColumns.has(key3(c.table.name, c.name)),
|
|
893
|
-
usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name))
|
|
1183
|
+
usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name)),
|
|
1184
|
+
usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name))
|
|
894
1185
|
};
|
|
895
1186
|
}
|
|
896
1187
|
|
|
@@ -916,20 +1207,42 @@ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
916
1207
|
var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : "a boolean";
|
|
917
1208
|
var CONFLICT_MARKER = /^(?:<{7}|={7}|>{7})(?:\s|$)/;
|
|
918
1209
|
var LINE_BREAK = /\r\n?|\n/;
|
|
1210
|
+
function quoted2(line) {
|
|
1211
|
+
if (line === void 0 || line.length <= 120) return line ?? "";
|
|
1212
|
+
const text2 = line.trimStart();
|
|
1213
|
+
if (text2.length <= 120) return text2;
|
|
1214
|
+
const high = text2.charCodeAt(118);
|
|
1215
|
+
const end = high >= 55296 && high <= 56319 ? 118 : 119;
|
|
1216
|
+
return `${text2.slice(0, end)}\u2026`;
|
|
1217
|
+
}
|
|
1218
|
+
var lineAt = (body, offset) => body.slice(0, offset).split(LINE_BREAK).length;
|
|
1219
|
+
var MAX_DEPTH = 256;
|
|
1220
|
+
function tooDeepAt(body) {
|
|
1221
|
+
let depth = 0;
|
|
1222
|
+
for (let i = 0; i < body.length; i++) {
|
|
1223
|
+
const ch = body[i];
|
|
1224
|
+
if (ch === '"') {
|
|
1225
|
+
for (i++; body[i] !== '"'; i++) if (body[i] === "\\") i++;
|
|
1226
|
+
} else if (ch === "{" || ch === "[") {
|
|
1227
|
+
if (++depth > MAX_DEPTH) return i;
|
|
1228
|
+
} else if (ch === "}" || ch === "]") depth--;
|
|
1229
|
+
}
|
|
1230
|
+
return -1;
|
|
1231
|
+
}
|
|
919
1232
|
var AT_POSITION = /\bJSON at position (\d+)/;
|
|
920
1233
|
var AT_LINE = /\bline (\d+) column \d+/;
|
|
921
1234
|
var QUOTED_RUN = /^Unexpected token '(.)', (?:\.\.\.)?"([\s\S]*)"(?:\.\.\.)? is not valid JSON$/;
|
|
922
1235
|
function lineOfParseError(body, message) {
|
|
923
1236
|
const lineCount = body.split(LINE_BREAK).length;
|
|
924
|
-
const lineOf = (offset2) => body
|
|
1237
|
+
const lineOf = (offset2) => lineAt(body, offset2);
|
|
925
1238
|
const at2 = AT_POSITION.exec(message);
|
|
926
1239
|
if (at2 && Number(at2[1]) <= body.length) return lineOf(Number(at2[1]));
|
|
927
1240
|
const named2 = AT_LINE.exec(message);
|
|
928
1241
|
if (named2 && Number(named2[1]) >= 1 && Number(named2[1]) <= lineCount) return Number(named2[1]);
|
|
929
|
-
const
|
|
930
|
-
if (!
|
|
931
|
-
const char =
|
|
932
|
-
const run =
|
|
1242
|
+
const around = QUOTED_RUN.exec(message);
|
|
1243
|
+
if (!around) return 1;
|
|
1244
|
+
const char = around[1];
|
|
1245
|
+
const run = around[2];
|
|
933
1246
|
const start = body.indexOf(run);
|
|
934
1247
|
if (start < 0) return 1;
|
|
935
1248
|
const middle = run.length / 2;
|
|
@@ -943,7 +1256,7 @@ function readJson(file, text2, options = {}) {
|
|
|
943
1256
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
944
1257
|
const lines = body.split(LINE_BREAK);
|
|
945
1258
|
const issues = lines.flatMap(
|
|
946
|
-
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: line, reason: "merge conflict marker" }] : []
|
|
1259
|
+
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted2(line), reason: "merge conflict marker" }] : []
|
|
947
1260
|
);
|
|
948
1261
|
if (issues.length > 0) return { json: void 0, issues };
|
|
949
1262
|
let json;
|
|
@@ -955,7 +1268,22 @@ function readJson(file, text2, options = {}) {
|
|
|
955
1268
|
const flat = message.replace(/\s+/g, " ");
|
|
956
1269
|
return {
|
|
957
1270
|
json: void 0,
|
|
958
|
-
issues: [{ file, line, text: lines[line - 1]
|
|
1271
|
+
issues: [{ file, line, text: quoted2(lines[line - 1]), reason: `not valid JSON (${flat})` }]
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const deep = tooDeepAt(body);
|
|
1275
|
+
if (deep >= 0) {
|
|
1276
|
+
const line = lineAt(body, deep);
|
|
1277
|
+
return {
|
|
1278
|
+
json: void 0,
|
|
1279
|
+
issues: [
|
|
1280
|
+
{
|
|
1281
|
+
file,
|
|
1282
|
+
line,
|
|
1283
|
+
text: quoted2(lines[line - 1]),
|
|
1284
|
+
reason: `nested more than ${MAX_DEPTH} levels deep`
|
|
1285
|
+
}
|
|
1286
|
+
]
|
|
959
1287
|
};
|
|
960
1288
|
}
|
|
961
1289
|
if (!isRecord2(json)) {
|
|
@@ -967,7 +1295,7 @@ function readJson(file, text2, options = {}) {
|
|
|
967
1295
|
{
|
|
968
1296
|
file,
|
|
969
1297
|
line: start + 1,
|
|
970
|
-
text: lines[start]
|
|
1298
|
+
text: quoted2(lines[start]),
|
|
971
1299
|
reason: `not a JSON object (the file holds ${kindOf(json)})`
|
|
972
1300
|
}
|
|
973
1301
|
]
|
|
@@ -990,9 +1318,12 @@ function newerThan(a, b) {
|
|
|
990
1318
|
return false;
|
|
991
1319
|
}
|
|
992
1320
|
var newerMajor = (a, b) => Number(a.split(".")[0]) > Number(b.split(".")[0]);
|
|
1321
|
+
var escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1322
|
+
var unescapePointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1323
|
+
var endsLine = (text2, i) => text2[i] === "\n" || text2[i] === "\r" && text2[i + 1] !== "\n";
|
|
993
1324
|
function lineOfPointer(text2, pointer) {
|
|
994
1325
|
if (pointer === "") return 1;
|
|
995
|
-
const want = pointer.split("/").slice(1).map(
|
|
1326
|
+
const want = pointer.split("/").slice(1).map(unescapePointer);
|
|
996
1327
|
const path = [];
|
|
997
1328
|
const kinds = [];
|
|
998
1329
|
const parent = () => kinds[kinds.length - 1];
|
|
@@ -1001,7 +1332,7 @@ function lineOfPointer(text2, pointer) {
|
|
|
1001
1332
|
let expectKey = false;
|
|
1002
1333
|
for (let i = text2.charCodeAt(0) === 65279 ? 1 : 0; i < text2.length; i++) {
|
|
1003
1334
|
const ch = text2[i];
|
|
1004
|
-
if (
|
|
1335
|
+
if (endsLine(text2, i)) {
|
|
1005
1336
|
line++;
|
|
1006
1337
|
continue;
|
|
1007
1338
|
}
|
|
@@ -1012,13 +1343,18 @@ function lineOfPointer(text2, pointer) {
|
|
|
1012
1343
|
if (text2[j2] === "\\") j2++;
|
|
1013
1344
|
j2++;
|
|
1014
1345
|
}
|
|
1015
|
-
|
|
1346
|
+
let value;
|
|
1347
|
+
try {
|
|
1348
|
+
value = JSON.parse(text2.slice(i, j2 + 1));
|
|
1349
|
+
} catch {
|
|
1350
|
+
value = text2.slice(i + 1, j2);
|
|
1351
|
+
}
|
|
1016
1352
|
if (parent() === "object" && expectKey) {
|
|
1017
1353
|
path[path.length - 1] = value;
|
|
1018
1354
|
expectKey = false;
|
|
1019
1355
|
if (matches()) return line;
|
|
1020
1356
|
} else if (parent() === "array" && matches()) return line;
|
|
1021
|
-
for (let k = i; k <= j2; k++) if (text2
|
|
1357
|
+
for (let k = i; k <= j2; k++) if (endsLine(text2, k)) line++;
|
|
1022
1358
|
i = j2;
|
|
1023
1359
|
continue;
|
|
1024
1360
|
}
|
|
@@ -1050,7 +1386,6 @@ function lineOfPointer(text2, pointer) {
|
|
|
1050
1386
|
|
|
1051
1387
|
// ../core/src/pbir/refs.ts
|
|
1052
1388
|
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1053
|
-
var escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1054
1389
|
var schemaOf = (node) => typeof node.Schema === "string" && node.Schema !== "" ? { schema: node.Schema } : {};
|
|
1055
1390
|
function sourceOf(expression, aliases) {
|
|
1056
1391
|
if (!isRecord3(expression)) return { table: "", noTable: "noSource" };
|
|
@@ -1192,13 +1527,16 @@ function filtersOf(filterConfig, file, pointer) {
|
|
|
1192
1527
|
if (!isRecord4(f)) return [];
|
|
1193
1528
|
const p = `${pointer}/filters/${i}`;
|
|
1194
1529
|
const field = collectFieldRefs(f.field, `${p}/field`)[0];
|
|
1530
|
+
const where = isRecord4(f.filter) && Array.isArray(f.filter.Where) ? f.filter.Where : void 0;
|
|
1195
1531
|
return [
|
|
1196
1532
|
{
|
|
1197
1533
|
name: str2(f.name) ?? String(i),
|
|
1198
1534
|
type: str2(f.type),
|
|
1535
|
+
howCreated: str2(f.howCreated),
|
|
1199
1536
|
...field ? { field } : {},
|
|
1200
1537
|
refs: collectFieldRefs(f, p),
|
|
1201
1538
|
applied: isRecord4(f.filter),
|
|
1539
|
+
...where ? { where } : {},
|
|
1202
1540
|
file,
|
|
1203
1541
|
pointer: p
|
|
1204
1542
|
}
|
|
@@ -1368,6 +1706,8 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1368
1706
|
visual,
|
|
1369
1707
|
pointer: `/explorationState/sections/${escapePointer(page)}/visualContainers/${escapePointer(visual)}`
|
|
1370
1708
|
});
|
|
1709
|
+
const options = isRecord4(json.options) ? json.options : {};
|
|
1710
|
+
const names = Array.isArray(options.targetVisualNames) ? options.targetVisualNames : [];
|
|
1371
1711
|
return {
|
|
1372
1712
|
id: str2(json.name) ?? id,
|
|
1373
1713
|
displayName: str2(json.displayName) ?? id,
|
|
@@ -1376,6 +1716,11 @@ function buildBookmark(id, file, text2, json) {
|
|
|
1376
1716
|
...str2(state.activeSection) !== void 0 ? { activePage: str2(state.activeSection) } : {},
|
|
1377
1717
|
pages: Object.keys(sections),
|
|
1378
1718
|
visuals,
|
|
1719
|
+
...options.applyOnlyToTargetVisuals === true ? {
|
|
1720
|
+
targetVisuals: names.flatMap(
|
|
1721
|
+
(visual, i) => typeof visual === "string" ? [{ visual, pointer: `/options/targetVisualNames/${i}` }] : []
|
|
1722
|
+
)
|
|
1723
|
+
} : {},
|
|
1379
1724
|
refs: collectFieldRefs(state, "/explorationState")
|
|
1380
1725
|
};
|
|
1381
1726
|
}
|
|
@@ -1434,7 +1779,7 @@ var folderHoldsFieldReferences = (folder) => ["definition", "page", "visual", "b
|
|
|
1434
1779
|
);
|
|
1435
1780
|
var PAGE_FOLDER = /^definition\/pages\/([^/]+)\/$/;
|
|
1436
1781
|
var VISUAL_FOLDER = /^definition\/pages\/([^/]+)\/visuals\/([^/]+)\/$/;
|
|
1437
|
-
var definedByPbir = (path) => path
|
|
1782
|
+
var definedByPbir = (path) => path === "definition.pbir" || path === ".platform" || path.endsWith(".pbip") || definitionFile(path);
|
|
1438
1783
|
function buildReport(files, unreadPaths = []) {
|
|
1439
1784
|
const report = {
|
|
1440
1785
|
publicCustomVisuals: [],
|
|
@@ -1508,13 +1853,13 @@ function buildReport(files, unreadPaths = []) {
|
|
|
1508
1853
|
});
|
|
1509
1854
|
}
|
|
1510
1855
|
const json = read.json;
|
|
1511
|
-
if (f.path
|
|
1856
|
+
if (f.path === "definition.pbir") {
|
|
1512
1857
|
report.datasetReference = datasetReferenceOf(json);
|
|
1513
1858
|
continue;
|
|
1514
1859
|
}
|
|
1515
1860
|
if (!isRecord4(json)) continue;
|
|
1516
1861
|
let m;
|
|
1517
|
-
if (f.path
|
|
1862
|
+
if (f.path === ".platform") {
|
|
1518
1863
|
if (isRecord4(json.metadata) && json.metadata.type === "Report")
|
|
1519
1864
|
report.displayName = str2(json.metadata.displayName);
|
|
1520
1865
|
} else if (f.path === "definition/report.json") {
|
|
@@ -1662,7 +2007,7 @@ function factsLines(result) {
|
|
|
1662
2007
|
rule: f.ruleId ?? ""
|
|
1663
2008
|
}));
|
|
1664
2009
|
const labelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
1665
|
-
const valueWidth = Math.max(...rows.map((r) => r.value.length));
|
|
2010
|
+
const valueWidth = Math.max(0, ...rows.filter((r) => r.rule).map((r) => r.value.length));
|
|
1666
2011
|
return [
|
|
1667
2012
|
"Report at a glance",
|
|
1668
2013
|
...rows.map(
|
|
@@ -1731,6 +2076,7 @@ var isBlank = (s) => s === void 0 || s.trim() === "";
|
|
|
1731
2076
|
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1732
2077
|
var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
|
|
1733
2078
|
var modelPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropObjects));
|
|
2079
|
+
var tablesPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropTableLine));
|
|
1734
2080
|
var tablesInScope = (m) => m.tables.filter((t) => t.kind !== "calculationGroup");
|
|
1735
2081
|
var tableObjectType = (t) => t.kind === "calculated" ? "CalculatedTable" : t.kind === "calculationGroup" ? "CalculationGroupTable" : "Table";
|
|
1736
2082
|
var columnObjectType = (c) => c.kind === "calculated" ? "CalculatedColumn" : c.kind === "calculatedTable" ? "CalculatedTableColumn" : "Column";
|
|
@@ -1820,6 +2166,13 @@ var finding = {
|
|
|
1820
2166
|
location: e.location,
|
|
1821
2167
|
object: e
|
|
1822
2168
|
}),
|
|
2169
|
+
/** A user-defined function, named bare (`Local.AddTax`) as Tabular Editor names it. */
|
|
2170
|
+
function: (f) => ({
|
|
2171
|
+
objectType: "Function",
|
|
2172
|
+
objectName: f.name,
|
|
2173
|
+
location: f.location,
|
|
2174
|
+
object: f
|
|
2175
|
+
}),
|
|
1823
2176
|
dataSource: (d) => ({
|
|
1824
2177
|
objectType: "DataSource",
|
|
1825
2178
|
objectName: d.name,
|
|
@@ -1893,6 +2246,11 @@ var reportMeasureLabel = (m) => `[${m.name}] (report)`;
|
|
|
1893
2246
|
var pageFilterLabel = (p) => `Page filter on "${p.displayName}"`;
|
|
1894
2247
|
var REPORT_LABEL = "Report";
|
|
1895
2248
|
var REPORT_FILTER_LABEL = "Report filter";
|
|
2249
|
+
var fieldLabel = (ref) => {
|
|
2250
|
+
const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
|
|
2251
|
+
const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
|
|
2252
|
+
return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
|
|
2253
|
+
};
|
|
1896
2254
|
|
|
1897
2255
|
// ../core/src/rules/report-helpers.ts
|
|
1898
2256
|
var isRecord5 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -2056,13 +2414,13 @@ function generalFilter(v, key4) {
|
|
|
2056
2414
|
function slicerSearch(v) {
|
|
2057
2415
|
const found = generalFilter(v, "selfFilter");
|
|
2058
2416
|
if (found === void 0) return void 0;
|
|
2059
|
-
const [
|
|
2060
|
-
const condition = isRecord5(
|
|
2417
|
+
const [only2, ...more] = found.where;
|
|
2418
|
+
const condition = isRecord5(only2) && isRecord5(only2.Condition) ? only2.Condition : {};
|
|
2061
2419
|
const contains = isRecord5(condition.Contains) ? condition.Contains : {};
|
|
2062
2420
|
const right = isRecord5(contains.Right) ? contains.Right : {};
|
|
2063
2421
|
const value = isRecord5(right.Literal) ? right.Literal.Value : void 0;
|
|
2064
|
-
const
|
|
2065
|
-
return
|
|
2422
|
+
const quoted3 = typeof value === "string" ? /^'(.+)'$/s.exec(value) : null;
|
|
2423
|
+
return quoted3 === null || more.length > 0 ? { pointer: found.pointer } : { pointer: found.pointer, term: quoted3[1].replaceAll("''", "'") };
|
|
2066
2424
|
}
|
|
2067
2425
|
var reportMeasuresToMove = (project) => project.model && project.report ? project.report.measures : [];
|
|
2068
2426
|
function openingPage(r) {
|
|
@@ -2123,22 +2481,31 @@ function reportFacts(project, report, known, ruleOptions) {
|
|
|
2123
2481
|
)
|
|
2124
2482
|
);
|
|
2125
2483
|
const pane = filtersPaneState(report);
|
|
2126
|
-
|
|
2127
|
-
|
|
2484
|
+
if (pane === void 0) {
|
|
2485
|
+
facts.push({
|
|
2128
2486
|
layer: "report",
|
|
2129
2487
|
label: "Filters pane",
|
|
2130
2488
|
value: "unknown",
|
|
2131
2489
|
detail: "report.json was not read"
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2490
|
+
});
|
|
2491
|
+
} else {
|
|
2492
|
+
const policySet = ruleOptions.get("FILTERS_PANE_STATE")?.expect !== void 0;
|
|
2493
|
+
const detail = [
|
|
2494
|
+
pane.recordedAt === void 0 && "read as open; report.json does not record it",
|
|
2495
|
+
!policySet && known.has("FILTERS_PANE_STATE") && "not checked; set an expect policy for FILTERS_PANE_STATE in pbiplint.config.json to check it"
|
|
2496
|
+
].filter(Boolean);
|
|
2497
|
+
facts.push(
|
|
2498
|
+
withRule(
|
|
2499
|
+
{
|
|
2500
|
+
layer: "report",
|
|
2501
|
+
label: "Filters pane",
|
|
2502
|
+
value: pane.state,
|
|
2503
|
+
...detail.length ? { detail: detail.join("; ") } : {}
|
|
2504
|
+
},
|
|
2505
|
+
policySet ? "FILTERS_PANE_STATE" : void 0
|
|
2506
|
+
)
|
|
2507
|
+
);
|
|
2508
|
+
}
|
|
2142
2509
|
const visualUnread2 = visualFileUnread(report);
|
|
2143
2510
|
const unreadVisual = "a visual.json could not be read";
|
|
2144
2511
|
const hidden = pages.filter(isHiddenPage).length;
|
|
@@ -2254,16 +2621,17 @@ function buildFacts(project, indexes, knownRules, ruleOptions = /* @__PURE__ */
|
|
|
2254
2621
|
const facts = reportFacts(project, project.report, knownRules, ruleOptions);
|
|
2255
2622
|
const model = project.model;
|
|
2256
2623
|
if (model) {
|
|
2257
|
-
const
|
|
2258
|
-
const columns2 =
|
|
2259
|
-
const measures =
|
|
2624
|
+
const shown2 = model.tables.filter((t) => !isHiddenAutoDateTable(t));
|
|
2625
|
+
const columns2 = shown2.reduce((s, t) => s + t.columns.length, 0);
|
|
2626
|
+
const measures = shown2.reduce((s, t) => s + t.measures.length, 0);
|
|
2260
2627
|
const partly = modelPartlyRead(model);
|
|
2261
2628
|
const count = (k, noun) => k === 0 && partly ? `${noun}s: unknown` : n(k, noun);
|
|
2262
|
-
const countUnknown = partly && [
|
|
2629
|
+
const countUnknown = partly && [shown2.length, columns2, measures].includes(0);
|
|
2630
|
+
const functions = model.functions.length > 0 ? `, ${n(model.functions.length, "function")}` : "";
|
|
2263
2631
|
const fact = {
|
|
2264
2632
|
layer: "model",
|
|
2265
2633
|
label: "Model",
|
|
2266
|
-
value: `${count(
|
|
2634
|
+
value: `${count(shown2.length, "table")}, ${count(columns2, "column")}, ${count(measures, "measure")}${functions}`
|
|
2267
2635
|
};
|
|
2268
2636
|
const reach = indexes.reachability;
|
|
2269
2637
|
const unreadModel = "a model file could not be fully read";
|
|
@@ -2284,18 +2652,35 @@ function buildFacts(project, indexes, knownRules, ruleOptions = /* @__PURE__ */
|
|
|
2284
2652
|
|
|
2285
2653
|
// ../core/src/project/route.ts
|
|
2286
2654
|
var isModelFile = (path) => path.endsWith(".tmdl");
|
|
2287
|
-
var isReportFile = (path) =>
|
|
2655
|
+
var isReportFile = (path) => /(^|\/)(definition\.pbir|\.platform)$/.test(path) || path.endsWith(".pbip") || /(^|\/)definition\/.*\.json$/.test(path);
|
|
2288
2656
|
var isPbix = (path) => /\.pbix$/i.test(path);
|
|
2289
|
-
var
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2657
|
+
var SAVE_AS_PROJECT_URL = "https://learn.microsoft.com/power-bi/developer/projects/projects-overview#save-as-a-project";
|
|
2658
|
+
var CONVERT_TO_PBIR_URL = "https://learn.microsoft.com/power-bi/developer/projects/projects-report#convert-existing-report-to-pbir";
|
|
2659
|
+
var LEARN_HELP_URLS = Object.freeze([
|
|
2660
|
+
SAVE_AS_PROJECT_URL,
|
|
2661
|
+
CONVERT_TO_PBIR_URL
|
|
2293
2662
|
]);
|
|
2294
|
-
var SAVE_AS_PROJECT = `pbiplint reads a report saved as a Power BI project (PBIP). In Power BI Desktop, choose File > Save as and pick Power BI project files (*.pbip) as the file type.
|
|
2663
|
+
var SAVE_AS_PROJECT = `pbiplint reads a report saved as a Power BI project (PBIP). In Power BI Desktop, choose File > Save as and pick Power BI project files (*.pbip) as the file type. See Microsoft Learn: ${SAVE_AS_PROJECT_URL}`;
|
|
2295
2664
|
function pbixRefusal(path, others = 0) {
|
|
2296
2665
|
const what = others === 0 ? `${path} is a Power BI Desktop file (.pbix)` : `${path} and ${others} other .pbix ${others === 1 ? "file" : "files"} are Power BI Desktop files`;
|
|
2297
2666
|
return `${what}, which pbiplint cannot read. ${SAVE_AS_PROJECT}`;
|
|
2298
2667
|
}
|
|
2668
|
+
function legacyReportNotice(name) {
|
|
2669
|
+
return {
|
|
2670
|
+
kind: "legacy-report-format",
|
|
2671
|
+
path: name,
|
|
2672
|
+
message: `${name} is stored as a single report.json (PBIR-Legacy), which pbiplint cannot read. Power BI Desktop converts it to PBIR when you edit and save it, in releases from September 2026 on. See Microsoft Learn: ${CONVERT_TO_PBIR_URL}`
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
function legacyModelNotice(name) {
|
|
2676
|
+
return {
|
|
2677
|
+
kind: "legacy-model-format",
|
|
2678
|
+
path: name,
|
|
2679
|
+
message: `${name} is stored as model.bim, which pbiplint cannot read; save it in the TMDL format from Power BI Desktop`
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
var LEGACY_REPORT_REASON = "the report is saved in the legacy report.json format";
|
|
2683
|
+
var LEGACY_MODEL_REASON = "the model is saved in the legacy model.bim format";
|
|
2299
2684
|
var listOf2 = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
2300
2685
|
var TMDL_ONLY = "Only a model stored as TMDL can be linted; if it is in the older model.bim format, save it in the TMDL format from Power BI Desktop first.";
|
|
2301
2686
|
var holdNoTmdl = (folders) => `${listOf2(folders)} hold${folders.length === 1 ? "s" : ""} no .tmdl files`;
|
|
@@ -2314,7 +2699,8 @@ function datasetReference(pbirText) {
|
|
|
2314
2699
|
function pairingDecision(ref, siblingModelFolder, reportFolder) {
|
|
2315
2700
|
if (ref.kind === "byConnection")
|
|
2316
2701
|
return { useModel: false, reason: "this report reads a published model" };
|
|
2317
|
-
if (siblingModelFolder === void 0)
|
|
2702
|
+
if (siblingModelFolder === void 0)
|
|
2703
|
+
return ref.kind === "byPath" && ref.path.trim() !== "" ? { useModel: false, reason: `this report reads ${ref.path}, which this run did not include` } : { useModel: false };
|
|
2318
2704
|
if (ref.kind === "none") return { useModel: true };
|
|
2319
2705
|
const named2 = ref.path.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? "";
|
|
2320
2706
|
if (named2.toLowerCase() === siblingModelFolder.toLowerCase()) return { useModel: true };
|
|
@@ -3110,7 +3496,7 @@ var RULE_SUMMARIES = {
|
|
|
3110
3496
|
"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY": "Regular tables that carry a row-level security filter in any role and take part in a many-to-many relationship.",
|
|
3111
3497
|
AVOID_USING_THE_IFERROR_FUNCTION: "Measures and calculated columns that call IFERROR.",
|
|
3112
3498
|
BROKEN_ACTION_TARGET: "Buttons, shapes, and images whose page navigation, drillthrough, or bookmark action names a page or a bookmark the report does not have, matched against the `name` in each page.json and bookmark file.",
|
|
3113
|
-
BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report,
|
|
3499
|
+
BROKEN_BOOKMARK_REFERENCE: "Bookmarks whose active page, or another page they capture, is not in the report, which capture a visual that is not on its page, or which apply only to selected visuals and name one that is not on their active page, matched against the `name` in each page.json and visual.json.",
|
|
3114
3500
|
BROKEN_FIELD_REFERENCE: "References in the report to a table, column, measure, hierarchy, or hierarchy level that the model does not have, wherever the report names a field: a visual's wells, formatting, and sort, a filter on a visual, a page, or the whole report, a drillthrough or tooltip page's fields, and a bookmark.",
|
|
3115
3501
|
CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS: "Calculation groups that contain no calculation items.",
|
|
3116
3502
|
"CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": "Every relationship that is bi-directional, many-to-many, or both. This is a review list at info severity, not a defect.",
|
|
@@ -3133,11 +3519,13 @@ var RULE_SUMMARIES = {
|
|
|
3133
3519
|
FIRST_LETTER_OF_OBJECTS_MUST_BE_CAPITALIZED: "Tables, measures, hierarchies, calculated columns, calculated tables, and calculation groups whose first character has an upper case form and is not upper case.",
|
|
3134
3520
|
FIX_REFERENTIAL_INTEGRITY_VIOLATIONS: "Relationships where the many side holds key values that do not exist on the one side. The count of offending rows is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
|
|
3135
3521
|
"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS": "Visible columns whose name starts with Is and whose type is whole number, and visible columns whose name ends with the word Flag after a space and whose type is not text.",
|
|
3522
|
+
HARDCODED_PERIOD_IN_DAX: "Measures, calculated columns, and calculation items whose DAX fixes a year or a date, and date tables whose CALENDAR ends on a fixed date.",
|
|
3523
|
+
HARDCODED_YEAR_IN_FILTER: "Filters in the Filters pane, on a visual, a page, or all pages, that hold a year column to fixed years: years picked in Basic filtering, a year set with Advanced filtering's is, years kept up to one with is less than or is less than or equal to, and a range between two years.",
|
|
3136
3524
|
HIDDEN_VISUAL_WITH_FIELDS: "Visuals hidden in the Selection pane, with their own eye icon or with that of a group they sit in, that still have fields in their wells.",
|
|
3137
3525
|
HIDE_FACT_TABLE_COLUMNS: "Visible numeric columns that a measure aggregates directly with a fully qualified reference, such as `SUM('Sales'[Amount])`. COUNT, COUNTBLANK, SUM, AVERAGE, MIN, MAX, DISTINCTCOUNT, VALUES, DISTINCT, and the A-suffixed COUNTA, AVERAGEA, MAXA, and MINA count as aggregations.",
|
|
3138
3526
|
HIDE_FOREIGN_KEYS: "Visible columns whose name matches the from column of a relationship whose from side is many. Only the from cardinality is tested, so a many-to-many relationship counts here too, not just many-to-one.",
|
|
3139
3527
|
HIDE_TOOLTIP_DRILLTROUGH_PAGES: "Tooltip pages and drillthrough pages that are not hidden.",
|
|
3140
|
-
INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure
|
|
3528
|
+
INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure, calculation item, or user-defined function activates with USERELATIONSHIP.",
|
|
3141
3529
|
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.",
|
|
3142
3530
|
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 variation, and do not themselves sort by another column.",
|
|
3143
3531
|
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.",
|
|
@@ -3152,12 +3540,12 @@ var RULE_SUMMARIES = {
|
|
|
3152
3540
|
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.",
|
|
3153
3541
|
"MONTH_(AS_A_STRING)_MUST_BE_SORTED": "Text columns with month in the name, but not months, that have no sort-by column.",
|
|
3154
3542
|
MONTHCOLUMN_FORMATSTRING: "DateTime columns with month in the name whose format string is not exactly `MMMM yyyy`.",
|
|
3155
|
-
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 that row-level
|
|
3543
|
+
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 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.",
|
|
3156
3544
|
NUMERIC_COLUMN_SUMMARIZE_BY: "Visible whole number, decimal, or double columns whose default summarization is anything other than None.",
|
|
3157
3545
|
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.",
|
|
3158
3546
|
OBJECTS_WITH_NO_DESCRIPTION: "Visible tables, columns, measures, and calculation groups with no description. Visibility is the object's own flag.",
|
|
3159
3547
|
OPENING_PAGE_INVALID: "A pages.json whose landing page names a page the report does not have, or, when no landing page is set, whose active page names a page the report does not have or a page hidden from readers.",
|
|
3160
|
-
PARSE_ISSUE: "Lines the TMDL parser could not use: space indentation, an unterminated code fence, a line at an impossible indentation, a line in no form the parser recognizes, a line at the root of a file that TMDL does not allow there (a misspelt `table`, a `column` or a property that lost its tabs, or an annotation with lines under it, for example), and a `///` description with a blank line between it and its declaration; a report JSON file that is not valid JSON
|
|
3548
|
+
PARSE_ISSUE: "Lines the TMDL parser could not use: space indentation, an unterminated code fence, a line at an impossible indentation, a line in no form the parser recognizes, a line at the root of a file that TMDL does not allow there (a misspelt `table`, a `column` or a property that lost its tabs, or an annotation with lines under it, for example), much the same directly under a model, where TMDL lets the model's tables, relationships, and other objects sit indented (a property that lost its tabs is reported there only when lines sit under it, and a flag such as `isHidden` only when a declaration does, since the model has properties and flags of its own), a declaration under a database other than its model, a `table` line under anything but a model, which a stray tab puts under the declaration above it, a declaration such as `table` or `role` written with no name, a name not enclosed in single quotes as TMDL requires, such as `table 'Date`, and a `///` description with a blank line between it and its declaration; a report JSON file that is not valid JSON, carries a merge conflict marker, or nests more than 256 levels deep; and a report file Microsoft publishes a schema for, such as report.json or a page.json, whose content is not a JSON object.",
|
|
3161
3549
|
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.",
|
|
3162
3550
|
PERCENTAGE_FORMATTING: "Measures with a percent format string other than `#,0.0%;-#,0.0%;#,0.0%`.",
|
|
3163
3551
|
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.",
|
|
@@ -3187,7 +3575,10 @@ var RULE_SUMMARIES = {
|
|
|
3187
3575
|
SPLIT_DATE_AND_TIME: "DateTime columns holding values that are not at midnight. Whether any row carries a time is a statistic of the loaded data, not of the model files, so pbiplint lists this rule but does not run it: it needs statistics that only a live model carries.",
|
|
3188
3576
|
TAB_ORDER_FOLLOWS_LAYOUT: "Pages whose tab order, the order keyboard users move through the visuals in, disagrees with the order the layout reads in: rows from top to bottom, and left to right within a row. The rule checks this only when the project's policy asks for tab order to follow the layout, and reports nothing without it.",
|
|
3189
3577
|
TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
|
|
3190
|
-
|
|
3578
|
+
UDF_NOT_CALLED: "User-defined functions that no measure, calculated column, calculated table, calculation item, row-level security filter, format string expression, KPI, or other function in the model calls. The functions of a DAX Lib package, which share one `DAXLIB_PackageId` annotation, count as one: the package is reported once, on its first function, when nothing outside it calls any of them.",
|
|
3579
|
+
UDF_USE_COMPOUND_NAMES: "User-defined functions whose name holds neither a dot nor an underscore, such as `AddTax`. Tabular Editor 3 has a built-in rule with the same test.",
|
|
3580
|
+
UDF_WITHOUT_DESCRIPTION: "User-defined functions with no description, or one of only spaces, other than functions installed from a DAX Lib package. Tabular Editor 3 has a built-in rule with the same test, which also reports package functions.",
|
|
3581
|
+
UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, group-by column, row-level security filter, or object-level security rule.",
|
|
3191
3582
|
UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
|
|
3192
3583
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": "Tables that have a numeric column for each of Jan, Feb, Mar, Apr, May, and Jun, matched as substrings of the column names.",
|
|
3193
3584
|
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.",
|
|
@@ -3218,6 +3609,7 @@ var SCOPE_MAP = {
|
|
|
3218
3609
|
NamedExpression: "NamedExpression",
|
|
3219
3610
|
ProviderDataSource: "DataSource",
|
|
3220
3611
|
StructuredDataSource: "DataSource",
|
|
3612
|
+
UserDefinedFunction: "Function",
|
|
3221
3613
|
KPI: null
|
|
3222
3614
|
};
|
|
3223
3615
|
function mapScope(scope) {
|
|
@@ -3238,7 +3630,8 @@ var stripCategory = (name) => name.replace(/^\[[^\]]*\]\s*/, "");
|
|
|
3238
3630
|
var extractUrls = (text2) => [
|
|
3239
3631
|
...new Set((text2.match(/https?:\/\/[^\s)"]+/g) ?? []).map((u) => u.replace(/[.,]$/, "")))
|
|
3240
3632
|
];
|
|
3241
|
-
function bpaRule(id,
|
|
3633
|
+
function bpaRule(id, ...args) {
|
|
3634
|
+
const [{ skipWhenModelUnread }, check] = args.length === 1 ? [{}, args[0]] : args;
|
|
3242
3635
|
const meta = metaOf(id);
|
|
3243
3636
|
return {
|
|
3244
3637
|
id,
|
|
@@ -3248,6 +3641,7 @@ function bpaRule(id, check) {
|
|
|
3248
3641
|
scope: mapScope(meta.scope),
|
|
3249
3642
|
layer: "model",
|
|
3250
3643
|
needs: ["model"],
|
|
3644
|
+
...skipWhenModelUnread ? { skipWhenModelUnread } : {},
|
|
3251
3645
|
description: RULE_SUMMARIES[id] ?? stripCategory(meta.name),
|
|
3252
3646
|
fixExpression: meta.fixExpression,
|
|
3253
3647
|
references: extractUrls(meta.description),
|
|
@@ -3297,6 +3691,7 @@ var MONTH_AS_A_STRING_MUST_BE_SORTED = bpaRule(
|
|
|
3297
3691
|
);
|
|
3298
3692
|
var NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule(
|
|
3299
3693
|
"NUMERIC_COLUMN_SUMMARIZE_BY",
|
|
3694
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3300
3695
|
(m) => columns(
|
|
3301
3696
|
m,
|
|
3302
3697
|
(c) => isNumericType(c) && (c.summarizeBy ?? "default").toLowerCase() !== "none" && !hiddenOrTableHidden(c)
|
|
@@ -3304,6 +3699,7 @@ var NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule(
|
|
|
3304
3699
|
);
|
|
3305
3700
|
var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
|
|
3306
3701
|
"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
|
|
3702
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3307
3703
|
(m) => columns(
|
|
3308
3704
|
m,
|
|
3309
3705
|
(c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && dataType(c) !== "string")
|
|
@@ -3311,10 +3707,12 @@ var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
|
|
|
3311
3707
|
);
|
|
3312
3708
|
var DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
|
|
3313
3709
|
"DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
|
|
3710
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3314
3711
|
(m) => columns(m, (c) => c.kind === "data" && isBlank(c.sourceColumn))
|
|
3315
3712
|
);
|
|
3316
3713
|
var ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
|
|
3317
3714
|
"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
|
|
3715
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3318
3716
|
(m, { indexes: { usage } }) => columns(
|
|
3319
3717
|
m,
|
|
3320
3718
|
(c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && c.sortByColumn === void 0
|
|
@@ -3327,33 +3725,38 @@ var SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
|
|
|
3327
3725
|
(c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || c.sortByColumn !== void 0)
|
|
3328
3726
|
)
|
|
3329
3727
|
);
|
|
3330
|
-
var UNNECESSARY_COLUMNS = bpaRule(
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3340
|
-
|
|
3341
|
-
|
|
3342
|
-
|
|
3343
|
-
|
|
3344
|
-
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3728
|
+
var UNNECESSARY_COLUMNS = bpaRule(
|
|
3729
|
+
"UNNECESSARY_COLUMNS",
|
|
3730
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3731
|
+
(m, { indexes }) => {
|
|
3732
|
+
const permissions = allTablePermissions(m);
|
|
3733
|
+
return columns(m, (c) => {
|
|
3734
|
+
if (!hiddenOrTableHidden(c)) return false;
|
|
3735
|
+
if (indexes.references.columnReferencedBy(c).length > 0) return false;
|
|
3736
|
+
if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
|
|
3737
|
+
if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
|
|
3738
|
+
if (indexes.usage.usedInGroupBy(c)) return false;
|
|
3739
|
+
const bare = `[${c.name}]`.toLowerCase();
|
|
3740
|
+
const qualified = [
|
|
3741
|
+
`${c.table.name}[${c.name}]`.toLowerCase(),
|
|
3742
|
+
`'${c.table.name}'[${c.name}]`.toLowerCase()
|
|
3743
|
+
];
|
|
3744
|
+
for (const tp of permissions) {
|
|
3745
|
+
const f = tp.filter?.toLowerCase();
|
|
3746
|
+
if (f === void 0) continue;
|
|
3747
|
+
if (tp.table === c.table.name && f.includes(bare)) return false;
|
|
3748
|
+
if (qualified.some((q2) => f.includes(q2))) return false;
|
|
3749
|
+
}
|
|
3750
|
+
for (const tp of permissions) {
|
|
3751
|
+
if (tp.table !== c.table.name) continue;
|
|
3752
|
+
if (tp.metadataPermission === "none") return false;
|
|
3753
|
+
if (tp.columnPermissions.some((cp) => cp.column === c.name && cp.permission === "none"))
|
|
3754
|
+
return false;
|
|
3755
|
+
}
|
|
3756
|
+
return true;
|
|
3757
|
+
});
|
|
3758
|
+
}
|
|
3759
|
+
);
|
|
3357
3760
|
var AGGREGATIONS = [
|
|
3358
3761
|
"COUNT",
|
|
3359
3762
|
"COUNTBLANK",
|
|
@@ -3396,6 +3799,7 @@ var columnRules = [
|
|
|
3396
3799
|
];
|
|
3397
3800
|
|
|
3398
3801
|
// ../core/src/rules/microsoft-bpa/dependencies.ts
|
|
3802
|
+
var isRuleOwner = (o) => o.kind !== "function";
|
|
3399
3803
|
function ownerFinding(o) {
|
|
3400
3804
|
switch (o.kind) {
|
|
3401
3805
|
case "measure":
|
|
@@ -3412,13 +3816,14 @@ function ownerFinding(o) {
|
|
|
3412
3816
|
}
|
|
3413
3817
|
var DAX_COLUMNS_FULLY_QUALIFIED = bpaRule(
|
|
3414
3818
|
"DAX_COLUMNS_FULLY_QUALIFIED",
|
|
3415
|
-
|
|
3819
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3820
|
+
(_m, { indexes: { references } }) => references.owners.filter(isRuleOwner).filter(
|
|
3416
3821
|
(o) => (o.kind === "measure" || o.kind === "tablePermission" || o.kind === "calculationItem") && o.refs.some((r) => r.kind === "column" && !r.qualified)
|
|
3417
3822
|
).map(ownerFinding)
|
|
3418
3823
|
);
|
|
3419
3824
|
var DAX_MEASURES_UNQUALIFIED = bpaRule(
|
|
3420
3825
|
"DAX_MEASURES_UNQUALIFIED",
|
|
3421
|
-
(_m, { indexes: { references } }) => references.owners.filter(
|
|
3826
|
+
(_m, { indexes: { references } }) => references.owners.filter(isRuleOwner).filter(
|
|
3422
3827
|
(o) => o.kind !== "tablePermission" && o.refs.some((r) => r.kind === "measure" && r.qualified)
|
|
3423
3828
|
).map(ownerFinding)
|
|
3424
3829
|
);
|
|
@@ -3442,6 +3847,7 @@ var MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES = bpaRule(
|
|
|
3442
3847
|
);
|
|
3443
3848
|
var UNNECESSARY_MEASURES = bpaRule(
|
|
3444
3849
|
"UNNECESSARY_MEASURES",
|
|
3850
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3445
3851
|
(m, { indexes: { references } }) => allMeasures(m).filter(
|
|
3446
3852
|
(x) => (x.table.isHidden || x.isHidden) && references.measureReferencedBy(x).length === 0
|
|
3447
3853
|
).map(finding.measure)
|
|
@@ -3473,6 +3879,7 @@ var patternRule = (id, kinds, patterns) => bpaRule(
|
|
|
3473
3879
|
);
|
|
3474
3880
|
var PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
|
|
3475
3881
|
"PROVIDE_FORMAT_STRING_FOR_MEASURES",
|
|
3882
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3476
3883
|
(m) => allMeasures(m).filter(
|
|
3477
3884
|
(x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition)
|
|
3478
3885
|
).map(finding.measure)
|
|
@@ -3564,15 +3971,17 @@ var measureRules = [
|
|
|
3564
3971
|
];
|
|
3565
3972
|
|
|
3566
3973
|
// ../core/src/rules/microsoft-bpa/naming.ts
|
|
3567
|
-
var namedObjectRule = (id, test) => bpaRule(
|
|
3974
|
+
var namedObjectRule = (id, test, spec = {}) => bpaRule(
|
|
3568
3975
|
id,
|
|
3976
|
+
spec,
|
|
3569
3977
|
(m) => namedObjects(m, mapScope(metaOf(id).scope)).filter((o) => test(o.name, o.description)).map((o) => o.finding)
|
|
3570
3978
|
);
|
|
3571
3979
|
var startsOrEndsWithSpace = (name) => name.startsWith(" ") || name.endsWith(" ");
|
|
3572
3980
|
var TRIM_OBJECT_NAMES = namedObjectRule("TRIM_OBJECT_NAMES", startsOrEndsWithSpace);
|
|
3573
3981
|
var OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE = namedObjectRule(
|
|
3574
3982
|
"OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
|
|
3575
|
-
startsOrEndsWithSpace
|
|
3983
|
+
startsOrEndsWithSpace,
|
|
3984
|
+
{ skipWhenModelUnread: tablesPartlyRead }
|
|
3576
3985
|
);
|
|
3577
3986
|
var SPECIAL_CHARS_IN_OBJECT_NAMES = namedObjectRule(
|
|
3578
3987
|
"SPECIAL_CHARS_IN_OBJECT_NAMES",
|
|
@@ -3597,6 +4006,7 @@ var PERSPECTIVES_WITH_NO_OBJECTS = bpaRule(
|
|
|
3597
4006
|
);
|
|
3598
4007
|
var CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS = bpaRule(
|
|
3599
4008
|
"CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
|
|
4009
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3600
4010
|
(m) => m.tables.filter((t) => t.calculationGroup !== void 0 && t.calculationGroup.items.length === 0).map(finding.table)
|
|
3601
4011
|
);
|
|
3602
4012
|
var REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule(
|
|
@@ -3605,6 +4015,7 @@ var REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule(
|
|
|
3605
4015
|
);
|
|
3606
4016
|
var REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS = bpaRule(
|
|
3607
4017
|
"REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
|
|
4018
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3608
4019
|
(m) => {
|
|
3609
4020
|
const partitions = allPartitions(m);
|
|
3610
4021
|
return m.dataSources.filter(
|
|
@@ -3649,6 +4060,7 @@ var HIDE_FOREIGN_KEYS = bpaRule(
|
|
|
3649
4060
|
);
|
|
3650
4061
|
var MARK_PRIMARY_KEYS = bpaRule(
|
|
3651
4062
|
"MARK_PRIMARY_KEYS",
|
|
4063
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3652
4064
|
(m, { indexes: { relationships } }) => allColumns(m).filter(
|
|
3653
4065
|
(c) => !c.isKey && c.table.dataCategory !== "Time" && relationships.forColumn(c.table.name, c.name).some(
|
|
3654
4066
|
(r) => r.toTable === c.table.name && r.toColumn === c.name && r.toCardinality === "one"
|
|
@@ -3657,6 +4069,7 @@ var MARK_PRIMARY_KEYS = bpaRule(
|
|
|
3657
4069
|
);
|
|
3658
4070
|
var REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES = bpaRule(
|
|
3659
4071
|
"REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES",
|
|
4072
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3660
4073
|
(m, { indexes: { relationships } }) => {
|
|
3661
4074
|
const all = allColumns(m);
|
|
3662
4075
|
return all.filter(
|
|
@@ -3675,6 +4088,7 @@ var SNOWFLAKE_SCHEMA_ARCHITECTURE = bpaRule(
|
|
|
3675
4088
|
);
|
|
3676
4089
|
var ENSURE_TABLES_HAVE_RELATIONSHIPS = bpaRule(
|
|
3677
4090
|
"ENSURE_TABLES_HAVE_RELATIONSHIPS",
|
|
4091
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3678
4092
|
(m, { indexes: { relationships } }) => tablesInScope(m).filter((t) => relationships.forTable(t.name).length === 0).map(finding.table)
|
|
3679
4093
|
);
|
|
3680
4094
|
var MANY_TO_MANY_RELATIONSHIPS_SHOULD_BE_SINGLE_DIRECTION = bpaRule(
|
|
@@ -3698,10 +4112,12 @@ var RELATIONSHIP_COLUMNS_SAME_DATA_TYPE = bpaRule(
|
|
|
3698
4112
|
);
|
|
3699
4113
|
var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
3700
4114
|
"INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED",
|
|
4115
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3701
4116
|
(m) => {
|
|
3702
4117
|
const expressions = [
|
|
3703
4118
|
...allMeasures(m).map((x) => x.expression),
|
|
3704
|
-
...allCalculationItems(m).map((i) => i.expression)
|
|
4119
|
+
...allCalculationItems(m).map((i) => i.expression),
|
|
4120
|
+
...m.functions.map((f) => f.expression)
|
|
3705
4121
|
];
|
|
3706
4122
|
return m.relationships.filter((r) => {
|
|
3707
4123
|
if (r.isActive) return false;
|
|
@@ -3715,6 +4131,7 @@ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
|
3715
4131
|
);
|
|
3716
4132
|
var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
|
|
3717
4133
|
"AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS",
|
|
4134
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3718
4135
|
(m) => {
|
|
3719
4136
|
const rels = m.relationships;
|
|
3720
4137
|
const count = rels.filter(isBidirectional).length + rels.filter(isManyToMany).length;
|
|
@@ -3723,6 +4140,7 @@ var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
|
|
|
3723
4140
|
);
|
|
3724
4141
|
var AVOID_USING_MANY_TO_MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY = bpaRule(
|
|
3725
4142
|
"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY",
|
|
4143
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3726
4144
|
(m, { indexes: { relationships } }) => {
|
|
3727
4145
|
const permissions = allTablePermissions(m);
|
|
3728
4146
|
return m.tables.filter(
|
|
@@ -3749,10 +4167,12 @@ var relationshipRules = [
|
|
|
3749
4167
|
var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && dataType(c) === "datetime");
|
|
3750
4168
|
var MODEL_SHOULD_HAVE_A_DATE_TABLE = bpaRule(
|
|
3751
4169
|
"MODEL_SHOULD_HAVE_A_DATE_TABLE",
|
|
4170
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3752
4171
|
(m) => m.tables.some((t) => t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
|
|
3753
4172
|
);
|
|
3754
4173
|
var DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE = bpaRule(
|
|
3755
4174
|
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
|
|
4175
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3756
4176
|
(m) => tablesInScope(m).filter((t) => {
|
|
3757
4177
|
const u = t.name.toUpperCase();
|
|
3758
4178
|
return (u.includes("DATE") || u.includes("CALENDAR")) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
|
|
@@ -3781,6 +4201,7 @@ var UNPIVOT_PIVOTED_MONTH_DATA = bpaRule(
|
|
|
3781
4201
|
);
|
|
3782
4202
|
var PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES = bpaRule(
|
|
3783
4203
|
"PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES",
|
|
4204
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3784
4205
|
(m) => m.tables.filter(
|
|
3785
4206
|
(t) => t.kind === "table" && t.partitions.length === 1 && t.partitions[0].name !== t.name
|
|
3786
4207
|
).map(finding.table)
|
|
@@ -3809,6 +4230,7 @@ var MINIMIZE_POWER_QUERY_TRANSFORMATIONS = bpaRule(
|
|
|
3809
4230
|
);
|
|
3810
4231
|
var MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS = bpaRule(
|
|
3811
4232
|
"MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS",
|
|
4233
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3812
4234
|
(m) => m.tables.some(isDirectQueryTable) && !allColumns(m).some((c) => c.hasAlternateOf) && String(m.props.defaultpowerbidatasourceversion ?? "").toLowerCase() === "powerbi_v3" ? [finding.model(m)] : []
|
|
3813
4235
|
);
|
|
3814
4236
|
var TIME_INTELLIGENCE_FUNCTIONS = [
|
|
@@ -3853,6 +4275,7 @@ var TIME_INTELLIGENCE_FUNCTIONS = [
|
|
|
3853
4275
|
var TIME_INTELLIGENCE = new RegExp(`(?:${TIME_INTELLIGENCE_FUNCTIONS.join("|")})\\s*\\(`);
|
|
3854
4276
|
var MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY = bpaRule(
|
|
3855
4277
|
"MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY",
|
|
4278
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3856
4279
|
(m) => m.tables.some(isDirectQueryTable) ? expressionObjects(m, ["measure", "calculationItem"]).filter((o) => TIME_INTELLIGENCE.test(o.expression)).map((o) => o.finding) : []
|
|
3857
4280
|
);
|
|
3858
4281
|
var RLS_FUNCTIONS = [/RIGHT\s*\(/i, /LEFT\s*\(/i, /UPPER\s*\(/i, /LOWER\s*\(/i, /FIND\s*\(/i];
|
|
@@ -3890,6 +4313,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
|
|
|
3890
4313
|
);
|
|
3891
4314
|
var OBJECTS_WITH_NO_DESCRIPTION = bpaRule(
|
|
3892
4315
|
"OBJECTS_WITH_NO_DESCRIPTION",
|
|
4316
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3893
4317
|
(m) => m.tables.flatMap((t) => [
|
|
3894
4318
|
...isBlank(t.description) && !t.isHidden ? [finding.table(t)] : [],
|
|
3895
4319
|
...t.columns.filter((c) => isBlank(c.description) && !c.isHidden).map(finding.column),
|
|
@@ -4262,6 +4686,397 @@ function pbiplintRule(spec) {
|
|
|
4262
4686
|
};
|
|
4263
4687
|
}
|
|
4264
4688
|
|
|
4689
|
+
// ../core/src/rules/pbiplint/period-words.ts
|
|
4690
|
+
var YEAR_WORDS = /* @__PURE__ */ new Set([
|
|
4691
|
+
"year",
|
|
4692
|
+
"years",
|
|
4693
|
+
"yr",
|
|
4694
|
+
"yrs",
|
|
4695
|
+
"a\xF1o",
|
|
4696
|
+
"a\xF1os",
|
|
4697
|
+
"ano",
|
|
4698
|
+
"anos",
|
|
4699
|
+
"anio",
|
|
4700
|
+
"jahr",
|
|
4701
|
+
"ann\xE9e",
|
|
4702
|
+
"annee",
|
|
4703
|
+
"anno",
|
|
4704
|
+
"jaar",
|
|
4705
|
+
"\xE5r",
|
|
4706
|
+
"rok",
|
|
4707
|
+
"vuosi",
|
|
4708
|
+
"ejercicio",
|
|
4709
|
+
"exercice",
|
|
4710
|
+
"fy",
|
|
4711
|
+
"ay",
|
|
4712
|
+
"cy",
|
|
4713
|
+
"ly",
|
|
4714
|
+
"py",
|
|
4715
|
+
"yyyy",
|
|
4716
|
+
"y"
|
|
4717
|
+
]);
|
|
4718
|
+
var MONTH_WORDS = /* @__PURE__ */ new Set([
|
|
4719
|
+
"month",
|
|
4720
|
+
"months",
|
|
4721
|
+
"mes",
|
|
4722
|
+
"m\xEAs",
|
|
4723
|
+
"meses",
|
|
4724
|
+
"monat",
|
|
4725
|
+
"mois",
|
|
4726
|
+
"mese",
|
|
4727
|
+
"maand",
|
|
4728
|
+
"mm",
|
|
4729
|
+
"mon",
|
|
4730
|
+
"mth",
|
|
4731
|
+
"period",
|
|
4732
|
+
"periodo",
|
|
4733
|
+
"per\xEDodo"
|
|
4734
|
+
]);
|
|
4735
|
+
var QUARTER_WORDS = /* @__PURE__ */ new Set(["quarter", "qtr", "q", "trimestre", "quartal", "kwartaal"]);
|
|
4736
|
+
var COUNT_WORDS = /* @__PURE__ */ new Set(["of", "service", "experience", "at", "in", "since", "tenure", "old"]);
|
|
4737
|
+
function nameWords(name) {
|
|
4738
|
+
return name.normalize("NFC").replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").replace(new RegExp("(\\p{L})(\\p{N})", "gu"), "$1 $2").split(/[^\p{L}\p{M}\p{N}]+/u).filter((w) => w !== "").map((w) => w.toLowerCase());
|
|
4739
|
+
}
|
|
4740
|
+
function nameClass(name) {
|
|
4741
|
+
const words = nameWords(name);
|
|
4742
|
+
const low = name.toLowerCase();
|
|
4743
|
+
const squeezed = low.replace(/ /g, "");
|
|
4744
|
+
const year = words.some(
|
|
4745
|
+
(w) => YEAR_WORDS.has(w) || w.startsWith("year") || w.endsWith("year") && w.length > 4
|
|
4746
|
+
);
|
|
4747
|
+
const month = words.some((w) => MONTH_WORDS.has(w) || w.startsWith("month"));
|
|
4748
|
+
const quarter = words.some((w) => QUARTER_WORDS.has(w) || w.startsWith("quarter"));
|
|
4749
|
+
if (year && (month || quarter || squeezed.includes("yearmonth") || squeezed.includes("yearqtr")))
|
|
4750
|
+
return "yearKey";
|
|
4751
|
+
if (year && words.includes("years") && words.some((w) => COUNT_WORDS.has(w))) return "yearCount";
|
|
4752
|
+
if (year) return "year";
|
|
4753
|
+
if (low.includes("yyyymm") || squeezed.includes("yearmonth") || low.includes("periodkey") || low.includes("monthkey"))
|
|
4754
|
+
return "yearKey";
|
|
4755
|
+
if (month) return "month";
|
|
4756
|
+
if (quarter) return "quarter";
|
|
4757
|
+
return void 0;
|
|
4758
|
+
}
|
|
4759
|
+
|
|
4760
|
+
// ../core/src/rules/pbiplint/period-forms.ts
|
|
4761
|
+
var FIRST_YEAR = 1950;
|
|
4762
|
+
var LAST_YEAR = 2049;
|
|
4763
|
+
var inYearRange = (year) => year >= FIRST_YEAR && year <= LAST_YEAR;
|
|
4764
|
+
var COMPARISONS = /* @__PURE__ */ new Set(["=", "==", "<>"]);
|
|
4765
|
+
var WRAPPERS = /* @__PURE__ */ new Set([
|
|
4766
|
+
"SELECTEDVALUE",
|
|
4767
|
+
"MAX",
|
|
4768
|
+
"MIN",
|
|
4769
|
+
"VALUES",
|
|
4770
|
+
"DISTINCT",
|
|
4771
|
+
"FIRSTNONBLANK",
|
|
4772
|
+
"LASTNONBLANK",
|
|
4773
|
+
"MAXX",
|
|
4774
|
+
"MINX",
|
|
4775
|
+
"LOOKUPVALUE",
|
|
4776
|
+
"RELATED",
|
|
4777
|
+
"CALCULATE",
|
|
4778
|
+
"HASONEVALUE",
|
|
4779
|
+
"SUM",
|
|
4780
|
+
"AVERAGE",
|
|
4781
|
+
"CONVERT",
|
|
4782
|
+
"INT",
|
|
4783
|
+
"VALUE",
|
|
4784
|
+
"FORMAT"
|
|
4785
|
+
]);
|
|
4786
|
+
var BOUNDS = /* @__PURE__ */ new Set(["CALENDAR", "GENERATESERIES"]);
|
|
4787
|
+
var YEAR_FORMATS = /* @__PURE__ */ new Set(["general date", "long date", "medium date", "short date"]);
|
|
4788
|
+
var UNIX_EPOCH = { year: 1970, month: 1, day: 1 };
|
|
4789
|
+
var STRING_DATES = /* @__PURE__ */ new Set(["DATEVALUE", "DATETIMEVALUE", "VALUE"]);
|
|
4790
|
+
var YEAR_FIRST = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(?:[ T]\d{1,2}:\d{2}.*)?$/;
|
|
4791
|
+
var YEAR_LAST = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})(?:[ T]\d{1,2}:\d{2}.*)?$/;
|
|
4792
|
+
var isWhole = (t) => t?.kind === "number" && /^\d+$/.test(t.text);
|
|
4793
|
+
function yearIn(t, strings) {
|
|
4794
|
+
const text2 = t?.kind === "number" ? t.text : strings && t?.kind === "string" ? t.text.trim() : void 0;
|
|
4795
|
+
if (text2 === void 0 || !/^\d{4}$/.test(text2)) return void 0;
|
|
4796
|
+
const year = Number(text2);
|
|
4797
|
+
return inYearRange(year) ? year : void 0;
|
|
4798
|
+
}
|
|
4799
|
+
function argumentsOf(tokens, open) {
|
|
4800
|
+
const end = tokens[open]?.close ?? tokens.length;
|
|
4801
|
+
const spans = [];
|
|
4802
|
+
let from = open + 1;
|
|
4803
|
+
for (let k = open + 1; k < end; k++)
|
|
4804
|
+
if (tokens[k].parent === open && isPunctuation(tokens[k], ",")) {
|
|
4805
|
+
spans.push({ from, to: k });
|
|
4806
|
+
from = k + 1;
|
|
4807
|
+
}
|
|
4808
|
+
spans.push({ from, to: end });
|
|
4809
|
+
return spans;
|
|
4810
|
+
}
|
|
4811
|
+
var only = (tokens, span) => span && span.to - span.from === 1 ? tokens[span.from] : void 0;
|
|
4812
|
+
function callGivesYear(tokens, open) {
|
|
4813
|
+
const o = tokens[open];
|
|
4814
|
+
if (o.call === "YEAR") return true;
|
|
4815
|
+
if (o.call === void 0 || !WRAPPERS.has(o.call) || o.close === void 0) return false;
|
|
4816
|
+
const inside = tokens.slice(open + 1, o.close);
|
|
4817
|
+
if (inside.some((x) => x.call === "YEAR")) return true;
|
|
4818
|
+
for (const x of inside) {
|
|
4819
|
+
const named2 = x.kind === "column" ? nameClass(x.text) : void 0;
|
|
4820
|
+
if (named2 !== void 0) return named2 === "year";
|
|
4821
|
+
}
|
|
4822
|
+
return o.call === "FORMAT" && inside.some((x) => x.kind === "string" && /^(?:yy|yyyy)$/i.test(x.text));
|
|
4823
|
+
}
|
|
4824
|
+
function yearBefore(tokens, k) {
|
|
4825
|
+
const t = tokens[k];
|
|
4826
|
+
if (t === void 0) return false;
|
|
4827
|
+
if (t.kind === "column" || t.kind === "identifier") return nameClass(t.text) === "year";
|
|
4828
|
+
return isPunctuation(t, ")") && t.open !== void 0 && callGivesYear(tokens, t.open);
|
|
4829
|
+
}
|
|
4830
|
+
function yearAfter(tokens, k) {
|
|
4831
|
+
const t = tokens[k];
|
|
4832
|
+
const next = tokens[k + 1];
|
|
4833
|
+
if ((t?.kind === "table" || t?.kind === "identifier") && next?.kind === "column")
|
|
4834
|
+
return nameClass(next.text) === "year";
|
|
4835
|
+
if (t?.kind === "column") return nameClass(t.text) === "year";
|
|
4836
|
+
if (t?.kind === "identifier" && isPunctuation(next, "(")) return callGivesYear(tokens, k + 1);
|
|
4837
|
+
return t?.kind === "identifier" && nameClass(t.text) === "year";
|
|
4838
|
+
}
|
|
4839
|
+
function isBound(tokens, open) {
|
|
4840
|
+
for (let p = tokens[open].parent; p !== void 0; p = tokens[p].parent)
|
|
4841
|
+
if (BOUNDS.has(tokens[p].call ?? "")) return true;
|
|
4842
|
+
return false;
|
|
4843
|
+
}
|
|
4844
|
+
function isYearFreeFormat(tokens, open) {
|
|
4845
|
+
const date = tokens[open - 1];
|
|
4846
|
+
const p = date.parent;
|
|
4847
|
+
if (p === void 0 || tokens[p].call !== "FORMAT" || date.arg !== 0) return false;
|
|
4848
|
+
const format = only(tokens, argumentsOf(tokens, p)[1]);
|
|
4849
|
+
return format?.kind === "string" && !/y/i.test(format.text) && !YEAR_FORMATS.has(format.text.toLowerCase());
|
|
4850
|
+
}
|
|
4851
|
+
var isRealDay = (year, month, day) => month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
4852
|
+
function daxDate(year, month, day) {
|
|
4853
|
+
if (year > 9999) return void 0;
|
|
4854
|
+
const full = year < 50 ? year + 2e3 : year < 100 ? year + 1900 : year;
|
|
4855
|
+
const d = new Date(Date.UTC(full, month - 1, day));
|
|
4856
|
+
if (Number.isNaN(d.getTime())) return void 0;
|
|
4857
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
|
4858
|
+
}
|
|
4859
|
+
function dateString(t) {
|
|
4860
|
+
const text2 = t.text.trim();
|
|
4861
|
+
const first = YEAR_FIRST.exec(text2);
|
|
4862
|
+
if (first) {
|
|
4863
|
+
const [year2, month, day] = [Number(first[1]), Number(first[3]), Number(first[4])];
|
|
4864
|
+
return isRealDay(year2, month, day) ? { at: t.start, year: year2, date: { year: year2, month, day } } : void 0;
|
|
4865
|
+
}
|
|
4866
|
+
const last = YEAR_LAST.exec(text2);
|
|
4867
|
+
if (!last) return void 0;
|
|
4868
|
+
const [a, b, year] = [Number(last[1]), Number(last[2]), Number(last[3])];
|
|
4869
|
+
const monthFirst = isRealDay(year, a, b);
|
|
4870
|
+
const dayFirst = isRealDay(year, b, a);
|
|
4871
|
+
if (monthFirst && dayFirst && a !== b) return { at: t.start, year, ambiguous: text2 };
|
|
4872
|
+
if (monthFirst) return { at: t.start, year, date: { year, month: a, day: b } };
|
|
4873
|
+
if (dayFirst) return { at: t.start, year, date: { year, month: b, day: a } };
|
|
4874
|
+
return void 0;
|
|
4875
|
+
}
|
|
4876
|
+
function expressionPeriods(expression) {
|
|
4877
|
+
const tokens = tokenizeDax(expression);
|
|
4878
|
+
const found = [];
|
|
4879
|
+
const year = (t, y) => {
|
|
4880
|
+
found.push({ at: t.start, year: y });
|
|
4881
|
+
};
|
|
4882
|
+
tokens.forEach((t, k) => {
|
|
4883
|
+
if (t.kind === "operator" && COMPARISONS.has(t.text) && !isWord(tokens[k - 2], "VAR")) {
|
|
4884
|
+
const right = yearIn(tokens[k + 1], true);
|
|
4885
|
+
if (right !== void 0 && yearBefore(tokens, k - 1)) year(tokens[k + 1], right);
|
|
4886
|
+
const left = yearIn(tokens[k - 1], true);
|
|
4887
|
+
if (left !== void 0 && yearAfter(tokens, k + 1)) year(tokens[k - 1], left);
|
|
4888
|
+
}
|
|
4889
|
+
const list = tokens[k + 1];
|
|
4890
|
+
if (isWord(t, "IN") && isPunctuation(list, "{") && yearBefore(tokens, k - 1))
|
|
4891
|
+
for (const x of tokens.slice(k + 2, list.close ?? tokens.length)) {
|
|
4892
|
+
const y = yearIn(x, true);
|
|
4893
|
+
if (y !== void 0) year(x, y);
|
|
4894
|
+
}
|
|
4895
|
+
if (t.call === "DATE" && !isBound(tokens, k) && !isYearFreeFormat(tokens, k)) {
|
|
4896
|
+
const [y, m, d] = argumentsOf(tokens, k).map((span) => only(tokens, span));
|
|
4897
|
+
const fixed2 = yearIn(y, false);
|
|
4898
|
+
if (fixed2 !== void 0) {
|
|
4899
|
+
if (isWhole(m) && isWhole(d)) {
|
|
4900
|
+
const [month, day] = [Number(m.text), Number(d.text)];
|
|
4901
|
+
const epoch = fixed2 === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
|
|
4902
|
+
const date = daxDate(fixed2, month, day);
|
|
4903
|
+
if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed2, date });
|
|
4904
|
+
} else year(y, fixed2);
|
|
4905
|
+
}
|
|
4906
|
+
}
|
|
4907
|
+
});
|
|
4908
|
+
for (const v of daxVariables(tokens)) {
|
|
4909
|
+
const y = yearIn(only(tokens, v), true);
|
|
4910
|
+
if (y !== void 0 && nameClass(v.name) === "year") year(tokens[v.from], y);
|
|
4911
|
+
}
|
|
4912
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4913
|
+
const periods = [];
|
|
4914
|
+
for (const p of found.sort((a, b) => a.at - b.at))
|
|
4915
|
+
if (!seen.has(p.at)) {
|
|
4916
|
+
seen.add(p.at);
|
|
4917
|
+
periods.push(p);
|
|
4918
|
+
}
|
|
4919
|
+
return periods;
|
|
4920
|
+
}
|
|
4921
|
+
function wholeNumber(tokens, vars, span) {
|
|
4922
|
+
let t = only(tokens, span);
|
|
4923
|
+
if (t?.kind === "identifier") {
|
|
4924
|
+
const v = variableAt(vars, t.text, span.from);
|
|
4925
|
+
t = v ? only(tokens, v) : void 0;
|
|
4926
|
+
}
|
|
4927
|
+
return isWhole(t) ? Number(t.text) : void 0;
|
|
4928
|
+
}
|
|
4929
|
+
function fixedDay(tokens, vars, span, seen) {
|
|
4930
|
+
const first = tokens[span.from];
|
|
4931
|
+
if (first === void 0 || span.to <= span.from) return void 0;
|
|
4932
|
+
if (span.to - span.from === 1) {
|
|
4933
|
+
if (first.kind === "string") return dateString(first);
|
|
4934
|
+
if (first.kind === "date") {
|
|
4935
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(first.text.trim());
|
|
4936
|
+
if (!m) return void 0;
|
|
4937
|
+
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
4938
|
+
return isRealDay(year, month, day) ? { at: first.start, year, date: { year, month, day } } : void 0;
|
|
4939
|
+
}
|
|
4940
|
+
if (first.kind !== "identifier") return void 0;
|
|
4941
|
+
const v = variableAt(vars, first.text, span.from);
|
|
4942
|
+
if (v === void 0 || seen.has(v)) return void 0;
|
|
4943
|
+
seen.add(v);
|
|
4944
|
+
return fixedDay(tokens, vars, v, seen);
|
|
4945
|
+
}
|
|
4946
|
+
const open = span.from + 1;
|
|
4947
|
+
if (first.kind !== "identifier" || tokens[open]?.close !== span.to - 1) return void 0;
|
|
4948
|
+
const call = first.text.toUpperCase();
|
|
4949
|
+
const args = argumentsOf(tokens, open);
|
|
4950
|
+
if (call === "DATE" && args.length === 3) {
|
|
4951
|
+
const [y, m, d] = args.map((a) => wholeNumber(tokens, vars, a));
|
|
4952
|
+
if (y === void 0 || m === void 0 || d === void 0) return void 0;
|
|
4953
|
+
const date = daxDate(y, m, d);
|
|
4954
|
+
return date && { at: first.start, year: y, date };
|
|
4955
|
+
}
|
|
4956
|
+
const text2 = args.length === 1 ? only(tokens, args[0]) : void 0;
|
|
4957
|
+
if (STRING_DATES.has(call) && text2?.kind === "string") return dateString(text2);
|
|
4958
|
+
return void 0;
|
|
4959
|
+
}
|
|
4960
|
+
function calendarEnds(expression) {
|
|
4961
|
+
const tokens = tokenizeDax(expression);
|
|
4962
|
+
const vars = daxVariables(tokens);
|
|
4963
|
+
const ends = [];
|
|
4964
|
+
tokens.forEach((t, k) => {
|
|
4965
|
+
if (t.call !== "CALENDAR") return;
|
|
4966
|
+
const end = argumentsOf(tokens, k)[1];
|
|
4967
|
+
const day = end && fixedDay(tokens, vars, end, /* @__PURE__ */ new Set());
|
|
4968
|
+
if (day) ends.push(day);
|
|
4969
|
+
});
|
|
4970
|
+
return ends;
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
// ../core/src/rules/pbiplint/periods.ts
|
|
4974
|
+
var MONTHS2 = [
|
|
4975
|
+
"January",
|
|
4976
|
+
"February",
|
|
4977
|
+
"March",
|
|
4978
|
+
"April",
|
|
4979
|
+
"May",
|
|
4980
|
+
"June",
|
|
4981
|
+
"July",
|
|
4982
|
+
"August",
|
|
4983
|
+
"September",
|
|
4984
|
+
"October",
|
|
4985
|
+
"November",
|
|
4986
|
+
"December"
|
|
4987
|
+
];
|
|
4988
|
+
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);
|
|
4989
|
+
function englishList(items) {
|
|
4990
|
+
const named2 = items.length > 3 ? [...items.slice(0, 3), `${items.length - 3} more`] : [...items];
|
|
4991
|
+
if (named2.length <= 2) return named2.join(" and ");
|
|
4992
|
+
const sep = named2.some((s) => s.includes(",")) ? "; " : ", ";
|
|
4993
|
+
return `${named2.slice(0, -1).join(sep)}${sep}and ${named2.at(-1)}`;
|
|
4994
|
+
}
|
|
4995
|
+
function namesYear(name, year) {
|
|
4996
|
+
const digits = String(year);
|
|
4997
|
+
if (digits.length !== 4) return false;
|
|
4998
|
+
return name.includes(digits) || new RegExp(`(?:^|\\D)${digits.slice(2)}(?:\\D|$)`).test(name);
|
|
4999
|
+
}
|
|
5000
|
+
function lineAt2(node, offset) {
|
|
5001
|
+
if (node?.valueLine === void 0 || node.value === void 0) return void 0;
|
|
5002
|
+
let line = node.valueLine;
|
|
5003
|
+
for (let k = 0; k < offset && k < node.value.length; k++) if (node.value[k] === "\n") line++;
|
|
5004
|
+
return { file: node.file, line };
|
|
5005
|
+
}
|
|
5006
|
+
function fixesDetail(periods) {
|
|
5007
|
+
const names = [...new Set(periods.map(shown))];
|
|
5008
|
+
const days = periods.filter((p) => p.date !== void 0 || p.ambiguous !== void 0).length;
|
|
5009
|
+
const noun = days === 0 ? "year" : days === periods.length ? "date" : "period";
|
|
5010
|
+
return `fixed ${noun}${names.length > 1 ? "s" : ""} ${englishList(names)}`;
|
|
5011
|
+
}
|
|
5012
|
+
function endsDetail(periods) {
|
|
5013
|
+
const names = [...new Set(periods.map(shown))];
|
|
5014
|
+
return names.length === 1 ? `ends on a fixed date, ${names[0]}` : `ends on fixed dates ${englishList(names)}`;
|
|
5015
|
+
}
|
|
5016
|
+
function fixedFinding(base, name, found, detail) {
|
|
5017
|
+
const periods = found.map((f) => f.period);
|
|
5018
|
+
if (periods.length === 0 || periods.some((p) => namesYear(name, p.year))) return [];
|
|
5019
|
+
const where = lineAt2(found[0].node, found[0].period.at);
|
|
5020
|
+
const own = detail(periods);
|
|
5021
|
+
return [
|
|
5022
|
+
{
|
|
5023
|
+
...base,
|
|
5024
|
+
...where ? { location: where } : {},
|
|
5025
|
+
detail: base.detail === void 0 ? own : `${own} in ${base.detail}`
|
|
5026
|
+
}
|
|
5027
|
+
];
|
|
5028
|
+
}
|
|
5029
|
+
var inNode = (node, expression) => expressionPeriods(expression).map((period) => ({ period, node }));
|
|
5030
|
+
function dateTableEnds(t) {
|
|
5031
|
+
if (t.kind !== "calculated" || isAutoDateTable(t)) return [];
|
|
5032
|
+
return t.partitions.filter((p) => p.sourceType === "calculated").flatMap((p) => {
|
|
5033
|
+
const node = p.node?.children.find((c) => c.kind === "expr" && c.type === "source");
|
|
5034
|
+
return calendarEnds(node?.value ?? p.source ?? "").map((period) => ({ period, node }));
|
|
5035
|
+
});
|
|
5036
|
+
}
|
|
5037
|
+
function periodFindings(model) {
|
|
5038
|
+
const out = [];
|
|
5039
|
+
for (const t of model.tables) {
|
|
5040
|
+
out.push(...fixedFinding(finding.table(t), t.name, dateTableEnds(t), endsDetail));
|
|
5041
|
+
for (const x of t.measures)
|
|
5042
|
+
out.push(
|
|
5043
|
+
...fixedFinding(finding.measure(x), x.name, inNode(x.node, x.expression), fixesDetail)
|
|
5044
|
+
);
|
|
5045
|
+
for (const c of t.columns)
|
|
5046
|
+
if (c.kind === "calculated")
|
|
5047
|
+
out.push(
|
|
5048
|
+
...fixedFinding(
|
|
5049
|
+
finding.column(c),
|
|
5050
|
+
c.name,
|
|
5051
|
+
inNode(c.node, c.expression ?? ""),
|
|
5052
|
+
fixesDetail
|
|
5053
|
+
)
|
|
5054
|
+
);
|
|
5055
|
+
for (const i of t.calculationGroup?.items ?? [])
|
|
5056
|
+
out.push(
|
|
5057
|
+
...fixedFinding(
|
|
5058
|
+
finding.calculationItem(i),
|
|
5059
|
+
i.name,
|
|
5060
|
+
inNode(i.node, i.expression),
|
|
5061
|
+
fixesDetail
|
|
5062
|
+
)
|
|
5063
|
+
);
|
|
5064
|
+
}
|
|
5065
|
+
return out;
|
|
5066
|
+
}
|
|
5067
|
+
var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
|
|
5068
|
+
id: "HARDCODED_PERIOD_IN_DAX",
|
|
5069
|
+
name: "Hardcoded period in DAX",
|
|
5070
|
+
category: "DAX Expressions",
|
|
5071
|
+
severity: 1,
|
|
5072
|
+
scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
|
|
5073
|
+
layer: "model",
|
|
5074
|
+
// No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
|
|
5075
|
+
// parser could not read can hide an object from the rule, never put a period in one.
|
|
5076
|
+
check: ({ model }) => model ? periodFindings(model) : []
|
|
5077
|
+
});
|
|
5078
|
+
var periodRules = [HARDCODED_PERIOD_IN_DAX];
|
|
5079
|
+
|
|
4265
5080
|
// ../core/src/rules/pbiplint/actions.ts
|
|
4266
5081
|
var CHECKED = /* @__PURE__ */ new Map([
|
|
4267
5082
|
["pagenavigation", { action: "Page navigation", object: "page" }],
|
|
@@ -4326,14 +5141,14 @@ var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
|
4326
5141
|
severity: 2,
|
|
4327
5142
|
scope: ["Bookmark"],
|
|
4328
5143
|
layer: "report",
|
|
4329
|
-
// Groups
|
|
4330
|
-
//
|
|
4331
|
-
//
|
|
4332
|
-
// could hold.
|
|
5144
|
+
// Groups, captured apart from visuals under `visualContainerGroups`, are not read. A page or a
|
|
5145
|
+
// visual whose own file could not be read is there, under the folder name Desktop gives it, so
|
|
5146
|
+
// it is never reported missing, and neither is one a folder that could not be read could hold.
|
|
4333
5147
|
check: ({ report }) => {
|
|
4334
5148
|
if (!report) return [];
|
|
4335
5149
|
const pages = new Map(report.pages.map((p) => [p.id, p]));
|
|
4336
5150
|
const missing = (id) => !pages.has(id) && !pageUnread(report, id);
|
|
5151
|
+
const notOn = (p, id) => !p.visuals.some((v) => v.id === id) && !visualUnread(report, p, id);
|
|
4337
5152
|
return report.bookmarks.flatMap((b) => {
|
|
4338
5153
|
const out = [];
|
|
4339
5154
|
if (b.activePage !== void 0 && missing(b.activePage))
|
|
@@ -4355,7 +5170,7 @@ var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
|
4355
5170
|
);
|
|
4356
5171
|
for (const { page, visual, pointer } of b.visuals) {
|
|
4357
5172
|
const p = pages.get(page);
|
|
4358
|
-
if (p &&
|
|
5173
|
+
if (p && notOn(p, visual))
|
|
4359
5174
|
out.push(
|
|
4360
5175
|
reportFinding.bookmark(
|
|
4361
5176
|
b,
|
|
@@ -4364,6 +5179,19 @@ var BROKEN_BOOKMARK_REFERENCE = pbiplintRule({
|
|
|
4364
5179
|
)
|
|
4365
5180
|
);
|
|
4366
5181
|
}
|
|
5182
|
+
const active = b.activePage === void 0 ? void 0 : pages.get(b.activePage);
|
|
5183
|
+
const stale = active ? (b.targetVisuals ?? []).filter((t) => notOn(active, t.visual)) : [];
|
|
5184
|
+
if (active && stale.length > 0) {
|
|
5185
|
+
const many = stale.length > 1;
|
|
5186
|
+
const names = englishList(stale.map((t) => `"${t.visual}"`));
|
|
5187
|
+
out.push(
|
|
5188
|
+
reportFinding.bookmark(
|
|
5189
|
+
b,
|
|
5190
|
+
`target visual${many ? "s" : ""} ${names} ${many ? "are" : "is"} not on page "${active.displayName}"`,
|
|
5191
|
+
stale[0].pointer
|
|
5192
|
+
)
|
|
5193
|
+
);
|
|
5194
|
+
}
|
|
4367
5195
|
return out;
|
|
4368
5196
|
});
|
|
4369
5197
|
}
|
|
@@ -4374,6 +5202,168 @@ var actionRules = [
|
|
|
4374
5202
|
BROKEN_BOOKMARK_REFERENCE
|
|
4375
5203
|
];
|
|
4376
5204
|
|
|
5205
|
+
// ../core/src/rules/pbiplint/filters.ts
|
|
5206
|
+
var isRecord7 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
5207
|
+
function yearOf(e) {
|
|
5208
|
+
const value = isRecord7(e) && isRecord7(e.Literal) ? e.Literal.Value : void 0;
|
|
5209
|
+
const m = typeof value === "string" ? /^(?:(\d{4})L|'(\d{4})')$/.exec(value) : null;
|
|
5210
|
+
const year = m ? Number(m[1] ?? m[2]) : void 0;
|
|
5211
|
+
return year !== void 0 && inYearRange(year) ? year : void 0;
|
|
5212
|
+
}
|
|
5213
|
+
function isYearColumn(e) {
|
|
5214
|
+
if (!isRecord7(e)) return false;
|
|
5215
|
+
const name = isRecord7(e.Column) ? e.Column.Property : isRecord7(e.HierarchyLevel) ? e.HierarchyLevel.Level : void 0;
|
|
5216
|
+
return typeof name === "string" && nameClass(name) === "year";
|
|
5217
|
+
}
|
|
5218
|
+
var fixed = (years) => `fixed year${years.length > 1 ? "s" : ""} ${englishList(years.map(String))}`;
|
|
5219
|
+
function keptYears(condition, at2) {
|
|
5220
|
+
if (!isRecord7(condition)) return void 0;
|
|
5221
|
+
const { In: inList, Comparison: compared2 } = condition;
|
|
5222
|
+
if (isRecord7(inList) && Array.isArray(inList.Expressions) && inList.Expressions.length === 1 && isYearColumn(inList.Expressions[0]) && Array.isArray(inList.Values)) {
|
|
5223
|
+
const found = inList.Values.flatMap((row, i) => {
|
|
5224
|
+
const year = Array.isArray(row) && row.length === 1 ? yearOf(row[0]) : void 0;
|
|
5225
|
+
return year === void 0 ? [] : [{ year, at: `${at2}/In/Values/${i}/0/Literal/Value` }];
|
|
5226
|
+
});
|
|
5227
|
+
if (found.length === 0) return void 0;
|
|
5228
|
+
const years = [...new Set(found.map((f) => f.year))];
|
|
5229
|
+
return { years, detail: fixed(years), at: found[0].at };
|
|
5230
|
+
}
|
|
5231
|
+
if (isRecord7(compared2) && compared2.ComparisonKind === 0 && isYearColumn(compared2.Left)) {
|
|
5232
|
+
const year = yearOf(compared2.Right);
|
|
5233
|
+
if (year === void 0) return void 0;
|
|
5234
|
+
return { years: [year], detail: fixed([year]), at: `${at2}/Comparison/Right/Literal/Value` };
|
|
5235
|
+
}
|
|
5236
|
+
return void 0;
|
|
5237
|
+
}
|
|
5238
|
+
function bound(condition, at2) {
|
|
5239
|
+
const compared2 = isRecord7(condition) ? condition.Comparison : void 0;
|
|
5240
|
+
if (!isRecord7(compared2) || !isYearColumn(compared2.Left)) return void 0;
|
|
5241
|
+
const year = yearOf(compared2.Right);
|
|
5242
|
+
if (year === void 0) return void 0;
|
|
5243
|
+
const literal2 = `${at2}/Comparison/Right/Literal/Value`;
|
|
5244
|
+
switch (compared2.ComparisonKind) {
|
|
5245
|
+
case 1:
|
|
5246
|
+
return { side: "lower", year: year + 1, at: literal2 };
|
|
5247
|
+
case 2:
|
|
5248
|
+
return { side: "lower", year, at: literal2 };
|
|
5249
|
+
case 3:
|
|
5250
|
+
return { side: "upper", year: year - 1, at: literal2 };
|
|
5251
|
+
case 4:
|
|
5252
|
+
return { side: "upper", year, at: literal2 };
|
|
5253
|
+
default:
|
|
5254
|
+
return void 0;
|
|
5255
|
+
}
|
|
5256
|
+
}
|
|
5257
|
+
function yearsUpTo(condition, at2) {
|
|
5258
|
+
const alone = bound(condition, at2);
|
|
5259
|
+
if (alone?.side === "upper")
|
|
5260
|
+
return { years: [alone.year], detail: `years up to ${alone.year}`, at: alone.at };
|
|
5261
|
+
const both = isRecord7(condition) ? condition.And : void 0;
|
|
5262
|
+
if (!isRecord7(both)) return void 0;
|
|
5263
|
+
const sides = [bound(both.Left, `${at2}/And/Left`), bound(both.Right, `${at2}/And/Right`)];
|
|
5264
|
+
const lower4 = sides.find((b) => b?.side === "lower");
|
|
5265
|
+
const upper = sides.find((b) => b?.side === "upper");
|
|
5266
|
+
if (!lower4 || !upper) return void 0;
|
|
5267
|
+
return {
|
|
5268
|
+
years: [lower4.year, upper.year],
|
|
5269
|
+
detail: `years ${lower4.year} to ${upper.year}`,
|
|
5270
|
+
at: upper.at
|
|
5271
|
+
};
|
|
5272
|
+
}
|
|
5273
|
+
function yearFinding(f, names, make) {
|
|
5274
|
+
if (f.howCreated === "Drillthrough" || f.howCreated === "Drill") return [];
|
|
5275
|
+
const kept = (f.where ?? []).map((w, i) => {
|
|
5276
|
+
if (!isRecord7(w)) return void 0;
|
|
5277
|
+
const at2 = `${f.pointer}/filter/Where/${i}/Condition`;
|
|
5278
|
+
return keptYears(w.Condition, at2) ?? yearsUpTo(w.Condition, at2);
|
|
5279
|
+
}).find((k) => k !== void 0);
|
|
5280
|
+
if (!kept || kept.years.some((y) => names.some((n2) => n2 !== void 0 && namesYear(n2, y))))
|
|
5281
|
+
return [];
|
|
5282
|
+
return [make(kept.at, f.field ? `${kept.detail} on ${fieldLabel(f.field)}` : kept.detail)];
|
|
5283
|
+
}
|
|
5284
|
+
function yearFindings(report) {
|
|
5285
|
+
const out = report.filters.flatMap(
|
|
5286
|
+
(f) => yearFinding(f, [], (at2, detail) => reportFinding.reportFilter(report, at2, detail))
|
|
5287
|
+
);
|
|
5288
|
+
for (const p of report.pages) {
|
|
5289
|
+
for (const f of p.filters)
|
|
5290
|
+
out.push(...yearFinding(f, [p.displayName], (at2, d) => reportFinding.pageFilter(p, at2, d)));
|
|
5291
|
+
for (const v of p.visuals)
|
|
5292
|
+
for (const f of v.filters)
|
|
5293
|
+
out.push(
|
|
5294
|
+
...yearFinding(f, [p.displayName, v.title], (at2, d) => reportFinding.visual(v, at2, d))
|
|
5295
|
+
);
|
|
5296
|
+
}
|
|
5297
|
+
return out;
|
|
5298
|
+
}
|
|
5299
|
+
var HARDCODED_YEAR_IN_FILTER = pbiplintRule({
|
|
5300
|
+
id: "HARDCODED_YEAR_IN_FILTER",
|
|
5301
|
+
name: "Hardcoded year in a filter",
|
|
5302
|
+
category: "Report Design",
|
|
5303
|
+
severity: 1,
|
|
5304
|
+
scope: ["Visual", "Page", "Report"],
|
|
5305
|
+
layer: "report",
|
|
5306
|
+
// No skipWhenUnread: a report file that could not be read hides its filters from the rule,
|
|
5307
|
+
// never adds one.
|
|
5308
|
+
check: ({ report }) => report ? yearFindings(report) : []
|
|
5309
|
+
});
|
|
5310
|
+
var filterRules = [HARDCODED_YEAR_IN_FILTER];
|
|
5311
|
+
|
|
5312
|
+
// ../core/src/rules/pbiplint/functions.ts
|
|
5313
|
+
var packageOf = (f) => f.annotations.DAXLIB_PackageId;
|
|
5314
|
+
function notCalled(model, references) {
|
|
5315
|
+
const calledBy = (f) => references.functionCalledBy(f);
|
|
5316
|
+
const out = [];
|
|
5317
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5318
|
+
for (const f of model.functions) {
|
|
5319
|
+
const pkg = packageOf(f);
|
|
5320
|
+
if (pkg === void 0) {
|
|
5321
|
+
if (calledBy(f).length === 0) out.push(finding.function(f));
|
|
5322
|
+
continue;
|
|
5323
|
+
}
|
|
5324
|
+
if (seen.has(pkg)) continue;
|
|
5325
|
+
seen.add(pkg);
|
|
5326
|
+
const members = model.functions.filter((g) => packageOf(g) === pkg);
|
|
5327
|
+
const fromOutside = members.some(
|
|
5328
|
+
(g) => calledBy(g).some((o) => !(o.kind === "function" && members.includes(o.object)))
|
|
5329
|
+
);
|
|
5330
|
+
if (fromOutside) continue;
|
|
5331
|
+
const detail = members.length === 1 ? `package ${pkg}: its one function is not called` : `package ${pkg}: none of its ${members.length} functions is called`;
|
|
5332
|
+
out.push({ ...finding.function(f), detail });
|
|
5333
|
+
}
|
|
5334
|
+
return out;
|
|
5335
|
+
}
|
|
5336
|
+
var UDF_NOT_CALLED = pbiplintRule({
|
|
5337
|
+
id: "UDF_NOT_CALLED",
|
|
5338
|
+
name: "User-defined function nothing calls",
|
|
5339
|
+
category: "Maintenance",
|
|
5340
|
+
severity: 1,
|
|
5341
|
+
scope: ["Function"],
|
|
5342
|
+
layer: "model",
|
|
5343
|
+
// A model file pbiplint could not fully read may hold the call.
|
|
5344
|
+
skipWhenModelUnread: modelPartlyRead,
|
|
5345
|
+
check: ({ model }, { indexes: { references } }) => model ? notCalled(model, references) : []
|
|
5346
|
+
});
|
|
5347
|
+
var UDF_USE_COMPOUND_NAMES = pbiplintRule({
|
|
5348
|
+
id: "UDF_USE_COMPOUND_NAMES",
|
|
5349
|
+
name: "User-defined function with a one-word name",
|
|
5350
|
+
category: "Error Prevention",
|
|
5351
|
+
severity: 1,
|
|
5352
|
+
scope: ["Function"],
|
|
5353
|
+
layer: "model",
|
|
5354
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => !f.name.includes(".") && !f.name.includes("_")).map(finding.function)
|
|
5355
|
+
});
|
|
5356
|
+
var UDF_WITHOUT_DESCRIPTION = pbiplintRule({
|
|
5357
|
+
id: "UDF_WITHOUT_DESCRIPTION",
|
|
5358
|
+
name: "User-defined function with no description",
|
|
5359
|
+
category: "Maintenance",
|
|
5360
|
+
severity: 1,
|
|
5361
|
+
scope: ["Function"],
|
|
5362
|
+
layer: "model",
|
|
5363
|
+
check: ({ model }) => (model?.functions ?? []).filter((f) => packageOf(f) === void 0 && isBlank(f.description)).map(finding.function)
|
|
5364
|
+
});
|
|
5365
|
+
var functionRules = [UDF_NOT_CALLED, UDF_USE_COMPOUND_NAMES, UDF_WITHOUT_DESCRIPTION];
|
|
5366
|
+
|
|
4377
5367
|
// ../core/src/rules/pbiplint/measures.ts
|
|
4378
5368
|
var REPORT_LEVEL_MEASURES = pbiplintRule({
|
|
4379
5369
|
id: "REPORT_LEVEL_MEASURES",
|
|
@@ -4483,11 +5473,6 @@ var DEFAULT_PAGE_NAME = pbiplintRule({
|
|
|
4483
5473
|
var pageRules2 = [DEFAULT_PAGE_NAME];
|
|
4484
5474
|
|
|
4485
5475
|
// ../core/src/rules/pbiplint/references.ts
|
|
4486
|
-
var fieldLabel = (ref) => {
|
|
4487
|
-
const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
|
|
4488
|
-
const field = ref.variation ? `${inTable(ref.variation.column)}.${measureRef(ref.name)}` : ref.kind === "measure" ? measureRef(ref.name) : inTable(ref.name);
|
|
4489
|
-
return ref.kind === "hierarchyLevel" && ref.level ? `${field}.${measureRef(ref.level)}` : field;
|
|
4490
|
-
};
|
|
4491
5476
|
var firstPerObjectAndField = (findings) => {
|
|
4492
5477
|
const seen = /* @__PURE__ */ new Set();
|
|
4493
5478
|
return findings.filter((f) => {
|
|
@@ -4757,6 +5742,9 @@ var pbiplintRules = [
|
|
|
4757
5742
|
...visualRules2,
|
|
4758
5743
|
...pageRules2,
|
|
4759
5744
|
...measureRules2,
|
|
5745
|
+
...periodRules,
|
|
5746
|
+
...filterRules,
|
|
5747
|
+
...functionRules,
|
|
4760
5748
|
...actionRules,
|
|
4761
5749
|
...tabOrderRules
|
|
4762
5750
|
];
|
|
@@ -4792,6 +5780,14 @@ var NOT_MODELED = [
|
|
|
4792
5780
|
];
|
|
4793
5781
|
var KNOWN = /* @__PURE__ */ new Set([...MODELED, ...NOT_MODELED]);
|
|
4794
5782
|
var isRootType = (type) => KNOWN.has(type.toLowerCase());
|
|
5783
|
+
var isModelChildType = (type) => {
|
|
5784
|
+
const t = type.toLowerCase();
|
|
5785
|
+
return KNOWN.has(t) && t !== "model" && t !== "database" && t !== "createorreplace";
|
|
5786
|
+
};
|
|
5787
|
+
var isNamedRootType = (type) => {
|
|
5788
|
+
const t = type.toLowerCase();
|
|
5789
|
+
return t !== "model" && MODELED.includes(t);
|
|
5790
|
+
};
|
|
4795
5791
|
|
|
4796
5792
|
// ../core/src/tmdl/parse.ts
|
|
4797
5793
|
var HEADER = /^([A-Za-z_]\w*)(?:\s+(.+))?$/;
|
|
@@ -4804,6 +5800,9 @@ var tabIndent = (line) => {
|
|
|
4804
5800
|
};
|
|
4805
5801
|
var leadingWs = (line) => line.length - line.trimStart().length;
|
|
4806
5802
|
var mayBeRootLine = (line) => line.trim() !== "" && tabIndent(line) === 0 && (!/^\s/.test(line) || splitHeader(line)?.type.toLowerCase() === "table");
|
|
5803
|
+
var namesTable = (line) => /^table(?:[\s:=]|$)/i.test(line.trim());
|
|
5804
|
+
var HOLDS_TABLE = /* @__PURE__ */ new Set(["model", "createorreplace"]);
|
|
5805
|
+
var nameReadable = (name) => !name.includes("'") || /^'(?:[^']|'')*'$/.test(name);
|
|
4807
5806
|
function splitHeader(content) {
|
|
4808
5807
|
let inQuote = false;
|
|
4809
5808
|
let eqAt = -1;
|
|
@@ -4822,16 +5821,41 @@ function splitHeader(content) {
|
|
|
4822
5821
|
return {
|
|
4823
5822
|
type: m[1],
|
|
4824
5823
|
name: m[2] === void 0 ? void 0 : unquoteName(m[2]),
|
|
5824
|
+
nameReadable: m[2] === void 0 || nameReadable(m[2].trim()),
|
|
4825
5825
|
hasEq: eqAt >= 0,
|
|
4826
5826
|
inline
|
|
4827
5827
|
};
|
|
4828
5828
|
}
|
|
5829
|
+
var opensFence = (line) => {
|
|
5830
|
+
const content = line.slice(tabIndent(line));
|
|
5831
|
+
if (/^\s/.test(content) || REF.test(content) || PROP.test(content)) return false;
|
|
5832
|
+
const h = splitHeader(content);
|
|
5833
|
+
return h !== null && h.hasEq && h.inline === "```";
|
|
5834
|
+
};
|
|
5835
|
+
var isDescription = (line) => line.slice(tabIndent(line)).startsWith("///");
|
|
5836
|
+
var blockEnd = (lines, from, limit, depth, head) => {
|
|
5837
|
+
const lead = lines[from]?.slice(0, leadingWs(lines[from])) ?? "";
|
|
5838
|
+
const inBlock = (line) => line.trim() === "" || leadingWs(line) >= depth && (line.startsWith(lead) || tabIndent(line) > head + 1 || !namesTable(line));
|
|
5839
|
+
let k = from;
|
|
5840
|
+
while (k < limit && inBlock(lines[k])) k++;
|
|
5841
|
+
return k;
|
|
5842
|
+
};
|
|
5843
|
+
var blockText = (lines, from, end, depth) => {
|
|
5844
|
+
const out = lines.slice(from, end).map((l) => l.trim() === "" ? "" : l.slice(depth));
|
|
5845
|
+
while (out.at(-1) === "") out.pop();
|
|
5846
|
+
return out.join("\n");
|
|
5847
|
+
};
|
|
4829
5848
|
function parseTmdl(file, text2) {
|
|
4830
5849
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
4831
5850
|
const lines = body.replace(/\r\n?/g, "\n").split("\n");
|
|
4832
5851
|
const roots = [];
|
|
4833
5852
|
const issues = [];
|
|
4834
5853
|
const stack = [];
|
|
5854
|
+
const holders = /* @__PURE__ */ new Map();
|
|
5855
|
+
const mayBeModelLevelLine = (line, depth) => {
|
|
5856
|
+
const tabs = tabIndent(line);
|
|
5857
|
+
return mayBeRootLine(line) || tabs > 0 && tabs <= depth && line.trim() !== "" && holders.has(stack[tabs - 1]) && (!/^\s/.test(line.slice(tabs)) || namesTable(line));
|
|
5858
|
+
};
|
|
4835
5859
|
let pendingDescription = null;
|
|
4836
5860
|
const orphanDescription = (pending) => {
|
|
4837
5861
|
issues.push({
|
|
@@ -4869,51 +5893,52 @@ function parseTmdl(file, text2) {
|
|
|
4869
5893
|
text: raw,
|
|
4870
5894
|
reason: "space indentation (TMDL requires tabs)",
|
|
4871
5895
|
canDropObjects: true,
|
|
4872
|
-
canDropTableLine: mayBeRootLine(raw)
|
|
5896
|
+
canDropTableLine: mayBeRootLine(raw) || namesTable(raw)
|
|
4873
5897
|
});
|
|
4874
5898
|
i++;
|
|
4875
5899
|
continue;
|
|
4876
5900
|
}
|
|
5901
|
+
let valueLine = lineNo;
|
|
4877
5902
|
const collectBlock = () => {
|
|
4878
5903
|
let j = i + 1;
|
|
4879
5904
|
while (j < lines.length && lines[j].trim() === "") j++;
|
|
4880
5905
|
if (j >= lines.length) return "";
|
|
4881
5906
|
const blockIndent = leadingWs(lines[j]);
|
|
4882
5907
|
if (blockIndent <= indent) return "";
|
|
4883
|
-
|
|
4884
|
-
|
|
4885
|
-
|
|
4886
|
-
|
|
4887
|
-
if (l.trim() === "") {
|
|
4888
|
-
out.push("");
|
|
4889
|
-
continue;
|
|
4890
|
-
}
|
|
4891
|
-
if (leadingWs(l) < blockIndent) break;
|
|
4892
|
-
out.push(l.slice(blockIndent));
|
|
4893
|
-
lastNonBlank = out.length - 1;
|
|
4894
|
-
}
|
|
4895
|
-
i = j - 1;
|
|
4896
|
-
return out.slice(0, lastNonBlank + 1).join("\n");
|
|
5908
|
+
valueLine = j + 1;
|
|
5909
|
+
const end = blockEnd(lines, j, lines.length, blockIndent, indent);
|
|
5910
|
+
i = end - 1;
|
|
5911
|
+
return blockText(lines, j, end, blockIndent);
|
|
4897
5912
|
};
|
|
4898
5913
|
const collectFenced = () => {
|
|
4899
|
-
|
|
5914
|
+
valueLine = lineNo + 1;
|
|
4900
5915
|
let j = i + 1;
|
|
4901
|
-
while (j < lines.length && lines[j].trim() !== "```")
|
|
4902
|
-
|
|
4903
|
-
j
|
|
5916
|
+
while (j < lines.length && lines[j].trim() !== "```" && !opensFence(lines[j])) j++;
|
|
5917
|
+
if (j < lines.length && lines[j].trim() === "```") {
|
|
5918
|
+
const boundary = leadingWs(lines[j]);
|
|
5919
|
+
const out = lines.slice(i + 1, j).map((l) => l.slice(Math.min(boundary, leadingWs(l))));
|
|
5920
|
+
i = j;
|
|
5921
|
+
return out.join("\n");
|
|
4904
5922
|
}
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
5923
|
+
let first = i + 1;
|
|
5924
|
+
while (first < j && lines[first].trim() === "") first++;
|
|
5925
|
+
const blockIndent = first < j ? leadingWs(lines[first]) : 0;
|
|
5926
|
+
const depth = blockIndent > indent ? blockIndent : 0;
|
|
5927
|
+
let end = depth > 0 ? blockEnd(lines, first, j, depth, indent) : j;
|
|
5928
|
+
if (end === j && j < lines.length)
|
|
5929
|
+
while (end > i + 1 && isDescription(lines[end - 1])) end--;
|
|
5930
|
+
const read = lines.slice(i + 1, end);
|
|
5931
|
+
issues.push({
|
|
5932
|
+
file,
|
|
5933
|
+
line: lineNo,
|
|
5934
|
+
text: raw,
|
|
5935
|
+
reason: "unterminated code fence",
|
|
5936
|
+
canDropObjects: true,
|
|
5937
|
+
canDropTableLine: read.some((l) => mayBeModelLevelLine(l, indent))
|
|
5938
|
+
});
|
|
5939
|
+
const value = blockText(lines, i + 1, end, depth);
|
|
5940
|
+
i = end - 1;
|
|
5941
|
+
return value;
|
|
4917
5942
|
};
|
|
4918
5943
|
const base = {
|
|
4919
5944
|
props: {},
|
|
@@ -4925,6 +5950,7 @@ function parseTmdl(file, text2) {
|
|
|
4925
5950
|
let node;
|
|
4926
5951
|
let m;
|
|
4927
5952
|
let word;
|
|
5953
|
+
let readableName = true;
|
|
4928
5954
|
if (m = REF.exec(content)) {
|
|
4929
5955
|
node = { ...base, kind: "ref", type: m[1].toLowerCase(), name: unquoteName(m[2]) };
|
|
4930
5956
|
} else if (m = PROP.exec(content)) {
|
|
@@ -4939,23 +5965,33 @@ function parseTmdl(file, text2) {
|
|
|
4939
5965
|
text: raw,
|
|
4940
5966
|
reason: "unrecognized line",
|
|
4941
5967
|
canDropObjects: true,
|
|
4942
|
-
canDropTableLine:
|
|
5968
|
+
canDropTableLine: mayBeModelLevelLine(raw, indent)
|
|
4943
5969
|
});
|
|
4944
5970
|
i++;
|
|
4945
5971
|
continue;
|
|
4946
5972
|
}
|
|
4947
5973
|
word = h.type;
|
|
5974
|
+
readableName = h.nameReadable;
|
|
4948
5975
|
if (h.hasEq) {
|
|
4949
5976
|
const value = h.inline === "```" ? collectFenced() : h.inline === "" ? collectBlock() : h.inline;
|
|
4950
|
-
node = h.name === void 0 ? { ...base, kind: "expr", type: h.type.toLowerCase(), value } : {
|
|
5977
|
+
node = h.name === void 0 ? { ...base, kind: "expr", type: h.type.toLowerCase(), value, valueLine } : {
|
|
5978
|
+
...base,
|
|
5979
|
+
kind: "object",
|
|
5980
|
+
type: h.type.toLowerCase(),
|
|
5981
|
+
name: h.name,
|
|
5982
|
+
value,
|
|
5983
|
+
valueLine
|
|
5984
|
+
};
|
|
4951
5985
|
} else if (h.name !== void 0) {
|
|
4952
5986
|
node = { ...base, kind: "object", type: h.type.toLowerCase(), name: h.name };
|
|
4953
5987
|
} else {
|
|
4954
5988
|
node = { ...base, kind: "flag", type: h.type.toLowerCase() };
|
|
4955
5989
|
}
|
|
4956
5990
|
}
|
|
5991
|
+
let rootIssue;
|
|
4957
5992
|
if (indent === 0 && word !== void 0) {
|
|
4958
5993
|
const reason = node.kind === "prop" || node.kind === "expr" ? `"${word}" is a property, which TMDL allows only under an object` : isRootType(word) ? void 0 : `"${word}" is not a type TMDL declares at the root of a file`;
|
|
5994
|
+
rootIssue = reason;
|
|
4959
5995
|
if (reason !== void 0)
|
|
4960
5996
|
issues.push({
|
|
4961
5997
|
file,
|
|
@@ -4963,7 +5999,7 @@ function parseTmdl(file, text2) {
|
|
|
4963
5999
|
text: raw,
|
|
4964
6000
|
reason,
|
|
4965
6001
|
canDropObjects: true,
|
|
4966
|
-
canDropTableLine: node.kind === "object" || node.kind === "flag"
|
|
6002
|
+
canDropTableLine: node.kind === "object" || node.kind === "flag" || namesTable(raw)
|
|
4967
6003
|
});
|
|
4968
6004
|
}
|
|
4969
6005
|
if (pendingDescription) {
|
|
@@ -4979,8 +6015,41 @@ function parseTmdl(file, text2) {
|
|
|
4979
6015
|
text: raw,
|
|
4980
6016
|
reason: "orphan indentation",
|
|
4981
6017
|
canDropObjects: true,
|
|
4982
|
-
canDropTableLine:
|
|
6018
|
+
canDropTableLine: namesTable(raw) || roots.length === 0 && !issues.some((x) => x.canDropObjects)
|
|
6019
|
+
});
|
|
6020
|
+
i++;
|
|
6021
|
+
continue;
|
|
6022
|
+
}
|
|
6023
|
+
const level = parent && holders.get(parent);
|
|
6024
|
+
const declaration = node.kind === "object" || node.kind === "flag";
|
|
6025
|
+
const keyword = word?.toLowerCase();
|
|
6026
|
+
let malformed;
|
|
6027
|
+
let mayBeTable = namesTable(raw);
|
|
6028
|
+
if (rootIssue === void 0 && keyword !== void 0) {
|
|
6029
|
+
if (!declaration) {
|
|
6030
|
+
if (level === "model" && isModelChildType(keyword))
|
|
6031
|
+
malformed = `"${word}" is not a property TMDL allows under a model`;
|
|
6032
|
+
} else if (indent > 0 && keyword === "table" && !HOLDS_TABLE.has(parent?.type ?? "")) {
|
|
6033
|
+
malformed = `"${word}" is a type TMDL declares only at the root of a file or under a model`;
|
|
6034
|
+
} else if (level !== void 0 && !(level === "model" ? isModelChildType(keyword) : keyword === "model") && (node.kind === "object" || isRootType(keyword))) {
|
|
6035
|
+
malformed = `"${word}" is not a type TMDL declares under a ${level}`;
|
|
6036
|
+
mayBeTable = true;
|
|
6037
|
+
} else if ((indent === 0 || level === "model") && (node.kind === "flag" || node.name === "") && isNamedRootType(keyword)) {
|
|
6038
|
+
malformed = `"${word}" is declared with no name`;
|
|
6039
|
+
} else if (!readableName) {
|
|
6040
|
+
malformed = "the name is not enclosed in single quotes as TMDL requires";
|
|
6041
|
+
}
|
|
6042
|
+
}
|
|
6043
|
+
if (malformed !== void 0) {
|
|
6044
|
+
issues.push({
|
|
6045
|
+
file,
|
|
6046
|
+
line: lineNo,
|
|
6047
|
+
text: raw,
|
|
6048
|
+
reason: malformed,
|
|
6049
|
+
canDropObjects: true,
|
|
6050
|
+
canDropTableLine: mayBeTable
|
|
4983
6051
|
});
|
|
6052
|
+
stack[indent] = node;
|
|
4984
6053
|
i++;
|
|
4985
6054
|
continue;
|
|
4986
6055
|
}
|
|
@@ -4991,19 +6060,31 @@ function parseTmdl(file, text2) {
|
|
|
4991
6060
|
} else {
|
|
4992
6061
|
roots.push(node);
|
|
4993
6062
|
}
|
|
6063
|
+
if (declaration && (!parent && (node.type === "model" || node.type === "database") || level === "database" && node.type === "model"))
|
|
6064
|
+
holders.set(node, node.type);
|
|
4994
6065
|
stack[indent] = node;
|
|
4995
6066
|
i++;
|
|
4996
6067
|
}
|
|
4997
6068
|
if (pendingDescription) orphanDescription(pendingDescription);
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
6069
|
+
const modelLevel = [
|
|
6070
|
+
...roots.map((node) => ({ node, root: true })),
|
|
6071
|
+
...[...holders].filter(([, holds]) => holds === "model").flatMap(([m]) => m.children.map((node) => ({ node, root: false })))
|
|
6072
|
+
];
|
|
6073
|
+
for (const { node: r, root } of modelLevel) {
|
|
6074
|
+
if (r.children.length === 0) continue;
|
|
6075
|
+
if (r.kind === "ref") continue;
|
|
6076
|
+
const valued = r.kind === "prop" || r.kind === "expr";
|
|
6077
|
+
const noted = r.type === "annotation" || r.type === "extendedproperty";
|
|
6078
|
+
const holdsDeclaration = !root && r.kind === "flag" && !noted && r.children.some((c) => c.kind === "object");
|
|
6079
|
+
if (!(valued ? !root : noted || holdsDeclaration)) continue;
|
|
5001
6080
|
const text3 = lines[r.line - 1];
|
|
6081
|
+
const where = root ? "at the root of a file" : "under a model";
|
|
6082
|
+
const under = holdsDeclaration ? "a declaration" : "lines";
|
|
5002
6083
|
const issue = {
|
|
5003
6084
|
file,
|
|
5004
6085
|
line: r.line,
|
|
5005
6086
|
text: text3,
|
|
5006
|
-
reason: `"${/^\w+/.exec(text3)[0]}"
|
|
6087
|
+
reason: `"${/^\w+/.exec(text3.trimStart())[0]}" ${where} has ${under} under it, which TMDL does not allow`,
|
|
5007
6088
|
canDropObjects: true,
|
|
5008
6089
|
canDropTableLine: false
|
|
5009
6090
|
};
|
|
@@ -5208,14 +6289,14 @@ var oneLine = (s) => s.replace(/\r\n?|\n/g, " ");
|
|
|
5208
6289
|
var text = (s) => showControls(s.replace(/\\/g, "\\\\")).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[`[\]*_~|@$]/g, "\\$&").replace(/:(?=\/\/)/g, "\\:").replace(/(www)\./gi, "$1\\.");
|
|
5209
6290
|
var cell = (s) => text(oneLine(s));
|
|
5210
6291
|
function code(name) {
|
|
5211
|
-
const
|
|
6292
|
+
const shown2 = showControls(oneLine(name)).replace(
|
|
5212
6293
|
/(\\*)\|/g,
|
|
5213
6294
|
(_, run) => `${run}${run.length % 2 ? "\\" : ""}\\|`
|
|
5214
6295
|
);
|
|
5215
|
-
const fence = "`".repeat(Math.max(0, ...(
|
|
5216
|
-
const spaced =
|
|
5217
|
-
const pad =
|
|
5218
|
-
return `${fence}${pad}${
|
|
6296
|
+
const fence = "`".repeat(Math.max(0, ...(shown2.match(/`+/g) ?? []).map((r) => r.length)) + 1);
|
|
6297
|
+
const spaced = shown2.startsWith(" ") && shown2.endsWith(" ") && /[^ ]/.test(shown2);
|
|
6298
|
+
const pad = shown2.startsWith("`") || shown2.endsWith("`") || spaced ? " " : "";
|
|
6299
|
+
return `${fence}${pad}${shown2}${pad}${fence}`;
|
|
5219
6300
|
}
|
|
5220
6301
|
function formatMarkdown(result, _options = {}) {
|
|
5221
6302
|
const out = [
|
|
@@ -5830,9 +6911,10 @@ Quirks
|
|
|
5830
6911
|
- A relationship that is both bi-directional and many-to-many counts twice, once in each tally, so the ratio can exceed 1. A model of nothing but such relationships scores 2.0.
|
|
5831
6912
|
- The denominator is every relationship in the model, and a model with no relationships at all is never reported.
|
|
5832
6913
|
- Inactive relationships are counted on both sides of the ratio. A bi-directional relationship that no measure ever activates still pushes the model over the threshold.
|
|
6914
|
+
- 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 share is of every relationship in the model, and the relationships pbiplint missed could change it; 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.
|
|
5833
6915
|
|
|
5834
6916
|
Read more: https://pbiplint.com/rules/avoid-excessive-bi-directional-or-many-to-many-relationships`,
|
|
5835
|
-
markdown: "### Example\n\nOne bi-directional relationship out of two is half the model, well past the threshold.\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column 'Customer ID'\n dataType: int64\n sourceColumn: CustomerID\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n\nrelationship Sales_Customer\n crossFilteringBehavior: bothDirections\n fromColumn: Sales.'Customer ID'\n toColumn: Customer.'Customer 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 'Customer ID'\n dataType: int64\n sourceColumn: CustomerID\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer ID'\n toColumn: Customer.'Customer ID'\n```\n\n### Why it matters\n\nEach bi-directional or many-to-many relationship adds a filter path the engine has to consider on every query. A few in the right places are fine. When they are a third of the model, most queries pay for filter propagation they do not need, and the model starts to show ambiguity: two routes between the same tables, the engine picking one, and totals that stop adding up.\n\n### How to fix it\n\nTake the direction off the relationships that do not need it. In Power BI Desktop, open the model view, double-click a relationship, and set Cross filter direction to Single in the Edit relationship dialog; in the TMDL file the same change is the `crossFilteringBehavior: bothDirections` line, deleted, since single direction is the default. Where one report genuinely needs the reverse filter, leave the relationship single and ask for the filter inside the measure that needs it, as in `Products Sold = CALCULATE(DISTINCTCOUNT('Product'[Product ID]), CROSSFILTER('Sales'[Product ID], 'Product'[Product ID], BOTH))`, so the cost falls on one visual instead of every query. For a many-to-many relationship, load a bridge table holding the distinct key values and relate both tables to it, which makes each hop many-to-one: in the dialog that is the Cardinality dropdown, and in the file it is `fromCardinality` and `toCardinality` on each relationship. The dialog and the properties read the same on an import model and a DirectQuery one.\n\n### When to ignore it\n\nA small model is the usual false alarm. Three relationships with one deliberate bi-directional relationship among them is 33 percent and fires, and there is nothing excessive about one. Count the relationships before you read the finding as a verdict: the rule is a ratio, and a ratio over four relationships says almost nothing. A model built on a bridge table by design is the other case, for example one that maps accounts to account groups or budgets to regions, where the many-to-many relationships are the architecture rather than an accident. What is not a legitimate exception is a large model whose bi-directional relationships nobody can account for one by one; that is the finding doing its job.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A relationship that is both bi-directional and many-to-many counts twice, once in each tally, so the ratio can exceed 1. A model of nothing but such relationships scores 2.0.\n- The denominator is every relationship in the model, and a model with no relationships at all is never reported.\n- Inactive relationships are counted on both sides of the ratio. A bi-directional relationship that no measure ever activates still pushes the model over the threshold.\n\nRead more: https://pbiplint.com/rules/avoid-excessive-bi-directional-or-many-to-many-relationships"
|
|
6917
|
+
markdown: "### Example\n\nOne bi-directional relationship out of two is half the model, well past the threshold.\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\n\n column 'Customer ID'\n dataType: int64\n sourceColumn: CustomerID\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n\nrelationship Sales_Customer\n crossFilteringBehavior: bothDirections\n fromColumn: Sales.'Customer ID'\n toColumn: Customer.'Customer 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 'Customer ID'\n dataType: int64\n sourceColumn: CustomerID\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n sourceColumn: ProductID\n\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n\nrelationship Sales_Customer\n fromColumn: Sales.'Customer ID'\n toColumn: Customer.'Customer ID'\n```\n\n### Why it matters\n\nEach bi-directional or many-to-many relationship adds a filter path the engine has to consider on every query. A few in the right places are fine. When they are a third of the model, most queries pay for filter propagation they do not need, and the model starts to show ambiguity: two routes between the same tables, the engine picking one, and totals that stop adding up.\n\n### How to fix it\n\nTake the direction off the relationships that do not need it. In Power BI Desktop, open the model view, double-click a relationship, and set Cross filter direction to Single in the Edit relationship dialog; in the TMDL file the same change is the `crossFilteringBehavior: bothDirections` line, deleted, since single direction is the default. Where one report genuinely needs the reverse filter, leave the relationship single and ask for the filter inside the measure that needs it, as in `Products Sold = CALCULATE(DISTINCTCOUNT('Product'[Product ID]), CROSSFILTER('Sales'[Product ID], 'Product'[Product ID], BOTH))`, so the cost falls on one visual instead of every query. For a many-to-many relationship, load a bridge table holding the distinct key values and relate both tables to it, which makes each hop many-to-one: in the dialog that is the Cardinality dropdown, and in the file it is `fromCardinality` and `toCardinality` on each relationship. The dialog and the properties read the same on an import model and a DirectQuery one.\n\n### When to ignore it\n\nA small model is the usual false alarm. Three relationships with one deliberate bi-directional relationship among them is 33 percent and fires, and there is nothing excessive about one. Count the relationships before you read the finding as a verdict: the rule is a ratio, and a ratio over four relationships says almost nothing. A model built on a bridge table by design is the other case, for example one that maps accounts to account groups or budgets to regions, where the many-to-many relationships are the architecture rather than an accident. What is not a legitimate exception is a large model whose bi-directional relationships nobody can account for one by one; that is the finding doing its job.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A relationship that is both bi-directional and many-to-many counts twice, once in each tally, so the ratio can exceed 1. A model of nothing but such relationships scores 2.0.\n- The denominator is every relationship in the model, and a model with no relationships at all is never reported.\n- Inactive relationships are counted on both sides of the ratio. A bi-directional relationship that no measure ever activates still pushes the model over the threshold.\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 share is of every relationship in the model, and the relationships pbiplint missed could change it; 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/avoid-excessive-bi-directional-or-many-to-many-relationships"
|
|
5836
6918
|
},
|
|
5837
6919
|
AVOID_FLOATING_POINT_DATA_TYPES: {
|
|
5838
6920
|
text: `Example
|
|
@@ -6423,9 +7505,10 @@ Quirks
|
|
|
6423
7505
|
- Calculated tables are out of scope, and so are calculation groups. Only a plain table is reported, even where a calculated table carries the filter.
|
|
6424
7506
|
- The relationship only has to touch the table. Direction is not tested, so a many-to-many relationship the security filter never travels through is reported the same as one it does.
|
|
6425
7507
|
- A table permission with no filter expression does not count. An entry that only sets metadataPermission or a column permission, which is object-level security rather than row-level, leaves the table out of this rule.
|
|
7508
|
+
- 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 hold a calculated partition, which makes it a calculated table, out of the rule's scope, 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.
|
|
6426
7509
|
|
|
6427
7510
|
Read more: https://pbiplint.com/rules/avoid-using-many-to-many-relationships-on-tables-used-for-dynamic-row-level-security`,
|
|
6428
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\n column Region\n dataType: string\n sourceColumn: Region\n\ntable 'User Region'\n column 'User Email'\n dataType: string\n sourceColumn: UserEmail\n\n column Region\n dataType: string\n sourceColumn: Region\n\nrelationship UserRegion_Customer\n fromCardinality: many\n toCardinality: many\n crossFilteringBehavior: bothDirections\n fromColumn: 'User Region'.Region\n toColumn: Customer.Region\n\nrole 'Regional Users'\n modelPermission: read\n\n tablePermission 'User Region' = 'User Region'[User Email] = USERPRINCIPALNAME()\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\n column Region\n dataType: string\n sourceColumn: Region\n\ntable Region\n column Region\n dataType: string\n isKey\n sourceColumn: Region\n\ntable 'User Region'\n column 'User Email'\n dataType: string\n sourceColumn: UserEmail\n\n column Region\n dataType: string\n sourceColumn: Region\n\nrelationship UserRegion_Region\n crossFilteringBehavior: bothDirections\n fromColumn: 'User Region'.Region\n toColumn: Region.Region\n\nrelationship Customer_Region\n fromColumn: Customer.Region\n toColumn: Region.Region\n\nrole 'Regional Users'\n modelPermission: read\n\n tablePermission 'User Region' = 'User Region'[User Email] = USERPRINCIPALNAME()\n```\n\n### Why it matters\n\nA security filter is pushed through every relationship leading away from the secured table, on every query, for every user in the role. Through a many-to-many relationship that push is an expansion over the distinct values on both sides rather than a lookup, and it runs before the query proper. The slowdown grows with every such hop, and the model owner never sees it, because Desktop tests without roles.\n\n### How to fix it\n\nGive the two tables a dimension to meet on, so every hop is many-to-one. Load a small table of the distinct key values, one row per region in the example above, and in Power BI Desktop's model view drag both the security table's key and the fact or dimension key onto it; each new relationship is many-to-one because the new table's key is unique. In the TMDL file that is `fromCardinality: many` with `toCardinality: one`, which is what a relationship block with neither line already means, and marking the new table's key column with `isKey`. The security filter itself does not move: it stays where Modeling, Manage roles wrote it, as a `tablePermission` line under the role. Leave one bi-directional hop, from the security table up to the shared dimension, so the filter still reaches the facts, and make it that one hop rather than a many-to-many expansion. The patterns under Links set out the variants in full.\n\n### When to ignore it\n\nThe filter is the thing to look at first. This rule counts any row-level security filter, so a table filtered by a static expression such as `[Region] = \"East\"` is reported exactly like one filtered by a lookup on the signed-in user. A static filter resolves to the same rows for everyone in the role, so the expansion is cached and shared instead of repeated per user. The direction of the relationship is the second check: the finding only asks whether a many-to-many relationship touches the table, not whether the security filter travels through it, so a many-to-many relationship pointing away from the secured path costs nothing here. Size is the third: a security table of a few hundred rows expands cheaply, and the trade the rule assumes is one worth measuring in the service with the role applied before you rebuild anything. A large secured table reached through more than one many-to-many hop is the case the rule was written for, and there ignoring it is not defensible.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Any row-level security filter counts, not only dynamic filters that call USERNAME or USERPRINCIPALNAME.\n- Calculated tables are out of scope, and so are calculation groups. Only a plain table is reported, even where a calculated table carries the filter.\n- The relationship only has to touch the table. Direction is not tested, so a many-to-many relationship the security filter never travels through is reported the same as one it does.\n- A table permission with no filter expression does not count. An entry that only sets `metadataPermission` or a column permission, which is object-level security rather than row-level, leaves the table out of this rule.\n\nRead more: https://pbiplint.com/rules/avoid-using-many-to-many-relationships-on-tables-used-for-dynamic-row-level-security"
|
|
7511
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\n column Region\n dataType: string\n sourceColumn: Region\n\ntable 'User Region'\n column 'User Email'\n dataType: string\n sourceColumn: UserEmail\n\n column Region\n dataType: string\n sourceColumn: Region\n\nrelationship UserRegion_Customer\n fromCardinality: many\n toCardinality: many\n crossFilteringBehavior: bothDirections\n fromColumn: 'User Region'.Region\n toColumn: Customer.Region\n\nrole 'Regional Users'\n modelPermission: read\n\n tablePermission 'User Region' = 'User Region'[User Email] = USERPRINCIPALNAME()\n```\n\n**After the fix**\n\n```tmdl\ntable Customer\n column 'Customer ID'\n dataType: int64\n isKey\n sourceColumn: CustomerID\n\n column Region\n dataType: string\n sourceColumn: Region\n\ntable Region\n column Region\n dataType: string\n isKey\n sourceColumn: Region\n\ntable 'User Region'\n column 'User Email'\n dataType: string\n sourceColumn: UserEmail\n\n column Region\n dataType: string\n sourceColumn: Region\n\nrelationship UserRegion_Region\n crossFilteringBehavior: bothDirections\n fromColumn: 'User Region'.Region\n toColumn: Region.Region\n\nrelationship Customer_Region\n fromColumn: Customer.Region\n toColumn: Region.Region\n\nrole 'Regional Users'\n modelPermission: read\n\n tablePermission 'User Region' = 'User Region'[User Email] = USERPRINCIPALNAME()\n```\n\n### Why it matters\n\nA security filter is pushed through every relationship leading away from the secured table, on every query, for every user in the role. Through a many-to-many relationship that push is an expansion over the distinct values on both sides rather than a lookup, and it runs before the query proper. The slowdown grows with every such hop, and the model owner never sees it, because Desktop tests without roles.\n\n### How to fix it\n\nGive the two tables a dimension to meet on, so every hop is many-to-one. Load a small table of the distinct key values, one row per region in the example above, and in Power BI Desktop's model view drag both the security table's key and the fact or dimension key onto it; each new relationship is many-to-one because the new table's key is unique. In the TMDL file that is `fromCardinality: many` with `toCardinality: one`, which is what a relationship block with neither line already means, and marking the new table's key column with `isKey`. The security filter itself does not move: it stays where Modeling, Manage roles wrote it, as a `tablePermission` line under the role. Leave one bi-directional hop, from the security table up to the shared dimension, so the filter still reaches the facts, and make it that one hop rather than a many-to-many expansion. The patterns under Links set out the variants in full.\n\n### When to ignore it\n\nThe filter is the thing to look at first. This rule counts any row-level security filter, so a table filtered by a static expression such as `[Region] = \"East\"` is reported exactly like one filtered by a lookup on the signed-in user. A static filter resolves to the same rows for everyone in the role, so the expansion is cached and shared instead of repeated per user. The direction of the relationship is the second check: the finding only asks whether a many-to-many relationship touches the table, not whether the security filter travels through it, so a many-to-many relationship pointing away from the secured path costs nothing here. Size is the third: a security table of a few hundred rows expands cheaply, and the trade the rule assumes is one worth measuring in the service with the role applied before you rebuild anything. A large secured table reached through more than one many-to-many hop is the case the rule was written for, and there ignoring it is not defensible.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Any row-level security filter counts, not only dynamic filters that call USERNAME or USERPRINCIPALNAME.\n- Calculated tables are out of scope, and so are calculation groups. Only a plain table is reported, even where a calculated table carries the filter.\n- The relationship only has to touch the table. Direction is not tested, so a many-to-many relationship the security filter never travels through is reported the same as one it does.\n- A table permission with no filter expression does not count. An entry that only sets `metadataPermission` or a column permission, which is object-level security rather than row-level, leaves the table out of this rule.\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 hold a calculated partition, which makes it a calculated table, out of the rule's scope, 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/avoid-using-many-to-many-relationships-on-tables-used-for-dynamic-row-level-security"
|
|
6429
7512
|
},
|
|
6430
7513
|
AVOID_USING_THE_IFERROR_FUNCTION: {
|
|
6431
7514
|
text: `Example
|
|
@@ -6649,17 +7732,19 @@ Why it matters
|
|
|
6649
7732
|
|
|
6650
7733
|
A bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.
|
|
6651
7734
|
|
|
6652
|
-
|
|
7735
|
+
With Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by name. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a name of its own, which the list does not hold, so the bookmark leaves it alone.
|
|
7736
|
+
|
|
7737
|
+
Microsoft's PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark's configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop's saved files do keep bookmarks whose active page is gone. Power BI Desktop's saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark's captured visuals.
|
|
6653
7738
|
|
|
6654
7739
|
How to fix it
|
|
6655
7740
|
|
|
6656
|
-
In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.
|
|
7741
|
+
In Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark's name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new name, so point any button that used the old one at the new one; BROKEN_ACTION_TARGET reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark's More options (...) menu.
|
|
6657
7742
|
|
|
6658
|
-
In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
|
|
7743
|
+
In the bookmark file, a captured visual is a key under explorationState.sections.<page>.visualContainers, named by the visual's name: remove the key that names nothing, as the example does. A target visual is a string in options.targetVisualNames: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is activeSection and the matching key of sections. Pointing both at the name of a page that exists leaves the bookmark holding state for the old page's visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.
|
|
6659
7744
|
|
|
6660
7745
|
When to ignore it
|
|
6661
7746
|
|
|
6662
|
-
There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
|
|
7747
|
+
There is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.
|
|
6663
7748
|
|
|
6664
7749
|
This rule reports on bookmarks, and a bookmark's file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set "BROKEN_BOOKMARK_REFERENCE": "off" under rules in pbiplint.config.json.
|
|
6665
7750
|
|
|
@@ -6667,14 +7752,16 @@ Quirks
|
|
|
6667
7752
|
|
|
6668
7753
|
- A bookmark captures one page. Power BI Desktop's saved files write one key in sections, the active page's, so a missing page is reported once, at activeSection. A sections key that names a different missing page is reported on its own line.
|
|
6669
7754
|
- The rule reports a missing active page whether or not the bookmark's Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.
|
|
6670
|
-
- A captured visual is checked against the page it is captured under, and only when that page exists
|
|
6671
|
-
-
|
|
7755
|
+
- A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.
|
|
7756
|
+
- The list of target visuals is checked only when Selected visuals is on, which the file records as applyOnlyToTargetVisuals set to true beside the list. Power BI Desktop's saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page (Create report bookmarks (https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.
|
|
7757
|
+
- A bookmark's stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.
|
|
7758
|
+
- A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under visualContainerGroups, and the visuals listed as each group's children, are not checked.
|
|
6672
7759
|
- Pages and visuals are matched by name, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a name is supported, and that Power BI Desktop keeps the original folder names when it saves.
|
|
6673
7760
|
- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft's PBIR documentation says is a page's or a visual's name by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file's own PARSE_ISSUE finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.
|
|
6674
7761
|
- bookmarks.json, which holds the bookmarks' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.
|
|
6675
7762
|
|
|
6676
7763
|
Read more: https://pbiplint.com/rules/broken-bookmark-reference`,
|
|
6677
|
-
markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and only when that page exists
|
|
7764
|
+
markdown: '### Example\n\n**Fires the rule in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {\n "8b2e41c07d95a3f6e210": {\n "singleVisual": {\n "visualType": "tableEx",\n "objects": {}\n }\n }\n }\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\n**After the fix in Reset.bookmark.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/bookmark/1.0.0/schema.json",\n "name": "Reset",\n "displayName": "Reset",\n "explorationState": {\n "version": "1.3",\n "activeSection": "p1",\n "sections": {\n "p1": {\n "visualContainers": {}\n }\n }\n },\n "options": {\n "targetVisualNames": []\n }\n}\n```\n\nThe Reset bookmark captures the Overview page with the state of a table, `8b2e41c07d95a3f6e210`, that is not on the page, as a bookmark file copied in from another report or merged by hand can, so the finding reads `Bookmark "Reset"` with `captured visual "8b2e41c07d95a3f6e210" is not on page "Overview"`. The fix removes the table\'s entry from `visualContainers`.\n\n### Why it matters\n\nA bookmark captures the state of a report page: Microsoft lists the current page among what a bookmark saves, with its filters and slicers, sort order, and which objects the Selection pane shows or hides. With its Current page option on, which Microsoft describes as navigating to the page that was active when the bookmark was created, a bookmark whose active page is not in the report has no page to take readers to, whether they select it in the Bookmarks pane or through a button or bookmark navigator that applies it. A captured visual that is not on its page is state kept for a visual the page does not have: whatever the bookmark was made to do to it, show it, hide it, or sort it, has nothing to act on.\n\nWith Selected visuals on, Microsoft says the bookmark "Applies the bookmark settings only to the visuals you select before creating or updating the bookmark" ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), and the file keeps those visuals by `name`. A name in that list that no visual on the page has is a visual the bookmark was made to act on and can no longer reach. A visual put in its place since has a `name` of its own, which the list does not hold, so the bookmark leaves it alone.\n\nMicrosoft\'s PBIR documentation, in its answer about a bookmark file copied from another report, says that Power BI Desktop removes invalid visuals from a bookmark\'s configuration when it saves. So a captured visual that names nothing usually means a bookmark file edited, copied, or merged outside Desktop and not saved in Desktop since. The documentation says nothing of the same for a missing page, and Power BI Desktop\'s saved files do keep bookmarks whose active page is gone. Power BI Desktop\'s saved files also keep names in the list of visuals a bookmark applies to after their visual is gone, even from a save that removed the same visual from the bookmark\'s captured visuals.\n\n### How to fix it\n\nIn Power BI Desktop, on the View tab, select Bookmarks to open the Bookmarks pane. Go to the page the bookmark should show, arrange its visuals as the bookmark should leave them, then select More options (...) next to the bookmark\'s name and choose Update. If nobody needs the bookmark any more, choose Delete from the same menu. Deleting it and adding a new one from the right page also works, but the new bookmark gets a new `name`, so point any button that used the old one at the new one; `BROKEN_ACTION_TARGET` reports any that still name it. For a captured visual that is no longer on the page, opening the report in Desktop and saving it is enough, since Desktop removes such visuals from the bookmark when it saves. For a target visual, saving is not enough: select the visuals the bookmark should apply to, on the page or in the Selection pane (hold Ctrl to select more than one), then choose Update from the bookmark\'s More options (...) menu.\n\nIn the bookmark file, a captured visual is a key under `explorationState.sections.<page>.visualContainers`, named by the visual\'s `name`: remove the key that names nothing, as the example does. A target visual is a string in `options.targetVisualNames`: remove each one that names nothing. To have the bookmark act on a visual added since, update it in Desktop as above. A missing page is `activeSection` and the matching key of `sections`. Pointing both at the `name` of a page that exists leaves the bookmark holding state for the old page\'s visuals, which that page does not have, so for a missing page, recapture the bookmark in Desktop instead.\n\n### When to ignore it\n\nThere is no legitimate case. A bookmark that names a page or visual the report lacks keeps state for, or applies to, something that is not there, so recapture it or delete it; a bookmark nobody uses is better deleted than ignored.\n\nThis rule reports on bookmarks, and a bookmark\'s file has no place for an annotation, so there is no object to annotate. To turn the rule off for a whole project, set `"BROKEN_BOOKMARK_REFERENCE": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A bookmark captures one page. Power BI Desktop\'s saved files write one key in `sections`, the active page\'s, so a missing page is reported once, at `activeSection`. A `sections` key that names a different missing page is reported on its own line.\n- The rule reports a missing active page whether or not the bookmark\'s Current page option is on. With it off, Microsoft says the bookmark applies its settings to whichever page is being viewed, but the visuals it captured are still those of the page that is gone.\n- A captured visual is checked against the page it is captured under, and a target visual against the active page, whose visuals and groups the list names; each only when that page exists, since a missing page is reported in place of the visuals under it.\n- The list of target visuals is checked only when Selected visuals is on, which the file records as `applyOnlyToTargetVisuals` set to `true` beside the list. Power BI Desktop\'s saved files write the list in every bookmark whether or not the option is on, and with All visuals, Microsoft says, the bookmark applies to every visual on the page ([Create report bookmarks](https://learn.microsoft.com/power-bi/create-reports/desktop-bookmarks#create-report-bookmarks)), so a name left in the list then is not reported.\n- A bookmark\'s stale target visuals are one finding together, while each stale captured visual is a finding of its own: one Update with the right visuals selected replaces the whole list.\n- A group the list names, which is a container on the page with no visual of its own, is found as a visual is. Groups that a bookmark keeps apart from visuals under `visualContainerGroups`, and the visuals listed as each group\'s `children`, are not checked.\n- Pages and visuals are matched by `name`, never by display name, and never by folder name, except when their own file cannot be read, as the next point says. Microsoft says renaming a `name` is supported, and that Power BI Desktop keeps the original folder names when it saves.\n- A page or a visual whose own file cannot be read, such as a page.json or a visual.json holding merge-conflict markers or one pbiplint could not open at all, is not reported missing, because pbiplint does not guess what a file it could not read says. It is known by its folder name instead, which Microsoft\'s PBIR documentation says is a page\'s or a visual\'s `name` by default, so a bookmark that captures it is not reported. Nor is a page or a visual that a folder under the definition folder, which pbiplint could not list, could hold, such as any visual on a page whose visuals folder could not be listed. The file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file pbiplint could not open or a folder it could not list.\n- bookmarks.json, which holds the bookmarks\' order and groups, is not checked: a name it lists with no bookmark file, or a bookmark file it does not list, is not reported.\n\nRead more: https://pbiplint.com/rules/broken-bookmark-reference'
|
|
6678
7765
|
},
|
|
6679
7766
|
BROKEN_FIELD_REFERENCE: {
|
|
6680
7767
|
text: `Example
|
|
@@ -6848,9 +7935,10 @@ Quirks
|
|
|
6848
7935
|
|
|
6849
7936
|
- The rule counts items, not what they do. A group with one item whose expression is empty leaves this rule's condition, and EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION picks it up instead.
|
|
6850
7937
|
- pbiplint reads a table as a calculation group when the table carries a calculationGroup block, whatever its partition says, and rules scoped to tables or calculated tables then pass over it.
|
|
7938
|
+
- 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 hold its calculation items, 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.
|
|
6851
7939
|
|
|
6852
7940
|
Read more: https://pbiplint.com/rules/calculation-groups-with-no-calculation-items`,
|
|
6853
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable 'Time Intelligence'\n\n calculationGroup\n\n column 'Time Calculation'\n dataType: string\n summarizeBy: none\n sourceColumn: Name\n\n partition 'Time Intelligence' = calculationGroup\n mode: import\n\ntable Date\n column Date\n dataType: dateTime\n sourceColumn: Date\n```\n\n**After the fix**\n\n```tmdl\ntable 'Time Intelligence'\n\n calculationGroup\n\n calculationItem YTD = CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))\n\n column 'Time Calculation'\n dataType: string\n summarizeBy: none\n sourceColumn: Name\n\n partition 'Time Intelligence' = calculationGroup\n mode: import\n\ntable Date\n column Date\n dataType: dateTime\n sourceColumn: Date\n```\n\n### Why it matters\n\nA calculation group with no items still appears in the field list as a table with one column, and dropping that column on a visual does nothing. It is usually a group that was started and abandoned, and it puzzles whoever finds it later.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, find the group under Calculation groups in the Model explorer pane, choose New calculation item, and write its DAX in the formula bar. In the TMDL file the group is a `calculationGroup` block under the table, and each item is a `calculationItem` inside it with its DAX after the `=`. If the group is not wanted, delete the table: in Desktop, right-click it in the Data pane and choose Delete from model; in the project, remove its file from the `tables` folder and its `ref table` line from `model.tmdl`.\n\n### When to ignore it\n\nThe one moment the finding is noise is while you are building the group, between creating it and writing the first item, when it tells you something you already know. There is no reason to ship one: an empty group is a field in the list that does nothing when a report author uses it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule counts items, not what they do. A group with one item whose expression is empty leaves this rule's condition, and `EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION` picks it up instead.\n- pbiplint reads a table as a calculation group when the table carries a `calculationGroup` block, whatever its partition says, and rules scoped to tables or calculated tables then pass over it.\n\nRead more: https://pbiplint.com/rules/calculation-groups-with-no-calculation-items"
|
|
7941
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable 'Time Intelligence'\n\n calculationGroup\n\n column 'Time Calculation'\n dataType: string\n summarizeBy: none\n sourceColumn: Name\n\n partition 'Time Intelligence' = calculationGroup\n mode: import\n\ntable Date\n column Date\n dataType: dateTime\n sourceColumn: Date\n```\n\n**After the fix**\n\n```tmdl\ntable 'Time Intelligence'\n\n calculationGroup\n\n calculationItem YTD = CALCULATE(SELECTEDMEASURE(), DATESYTD('Date'[Date]))\n\n column 'Time Calculation'\n dataType: string\n summarizeBy: none\n sourceColumn: Name\n\n partition 'Time Intelligence' = calculationGroup\n mode: import\n\ntable Date\n column Date\n dataType: dateTime\n sourceColumn: Date\n```\n\n### Why it matters\n\nA calculation group with no items still appears in the field list as a table with one column, and dropping that column on a visual does nothing. It is usually a group that was started and abandoned, and it puzzles whoever finds it later.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, find the group under Calculation groups in the Model explorer pane, choose New calculation item, and write its DAX in the formula bar. In the TMDL file the group is a `calculationGroup` block under the table, and each item is a `calculationItem` inside it with its DAX after the `=`. If the group is not wanted, delete the table: in Desktop, right-click it in the Data pane and choose Delete from model; in the project, remove its file from the `tables` folder and its `ref table` line from `model.tmdl`.\n\n### When to ignore it\n\nThe one moment the finding is noise is while you are building the group, between creating it and writing the first item, when it tells you something you already know. There is no reason to ship one: an empty group is a field in the list that does nothing when a report author uses it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule counts items, not what they do. A group with one item whose expression is empty leaves this rule's condition, and `EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION` picks it up instead.\n- pbiplint reads a table as a calculation group when the table carries a `calculationGroup` block, whatever its partition says, and rules scoped to tables or calculated tables then pass over it.\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 hold its calculation items, 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/calculation-groups-with-no-calculation-items"
|
|
6854
7942
|
},
|
|
6855
7943
|
"CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": {
|
|
6856
7944
|
text: `Example
|
|
@@ -7066,9 +8154,10 @@ Quirks
|
|
|
7066
8154
|
- Only data columns are read. pbiplint treats a column as calculated when its declaration carries an expression, and as a calculated table column when the table's partition is a calculated one, so neither is reported here.
|
|
7067
8155
|
- Only the presence of the property is tested, never what it names. A sourceColumn that names a column the query does not produce passes the rule and fails the refresh.
|
|
7068
8156
|
- A sourceColumn: line with nothing after it counts as missing, the same as no line at all.
|
|
8157
|
+
- 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 hold a calculated partition, which makes the column a calculated table's, with no source column of its own, 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.
|
|
7069
8158
|
|
|
7070
8159
|
Read more: https://pbiplint.com/rules/data-columns-must-have-a-source-column`,
|
|
7071
|
-
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\n partition Sales = m\n mode: import\n source =\n let\n Source = Sql.Database("localhost", "Sales"),\n Sales = Source{[Schema="dbo",Item="Sales"]}[Data]\n in\n Sales\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 partition Sales = m\n mode: import\n source =\n let\n Source = Sql.Database("localhost", "Sales"),\n Sales = Source{[Schema="dbo",Item="Sales"]}[Data]\n in\n Sales\n```\n\n### Why it matters\n\nA data column is filled from a column in the partition query, and the source column name is how the engine finds it. Without it, processing fails for the whole table, with an error that names the column but not the cause.\n\n### How to fix it\n\nPower BI Desktop writes `sourceColumn` whenever it adds a column to a table, so a finding here means the column was written or edited by hand, or arrived in a migration. In the TMDL file, add a `sourceColumn:` line under the column, naming the field the partition\'s query produces and spelling it the way the query spells it. Where the query no longer produces it, the fix runs the other way: add the column back in Power Query, under Transform data, so the next refresh delivers it, or take the column out of the model, in Desktop by right-clicking it in the Data pane and choosing Delete from model, or by removing its `column` block from the table\'s TMDL file.\n\n### When to ignore it\n\nThere is none. A data column with no source column stops the whole table from loading, so the finding is a refresh failure reported before the refresh. The one thing worth checking is whether the column was meant to be a calculated column: if it was, give it a DAX expression instead, and the column leaves this rule for `EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION`, which asks the same question of the expression.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only data columns are read. pbiplint treats a column as calculated when its declaration carries an expression, and as a calculated table column when the table\'s partition is a calculated one, so neither is reported here.\n- Only the presence of the property is tested, never what it names. A `sourceColumn` that names a column the query does not produce passes the rule and fails the refresh.\n- A `sourceColumn:` line with nothing after it counts as missing, the same as no line at all.\n\nRead more: https://pbiplint.com/rules/data-columns-must-have-a-source-column'
|
|
8160
|
+
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\n partition Sales = m\n mode: import\n source =\n let\n Source = Sql.Database("localhost", "Sales"),\n Sales = Source{[Schema="dbo",Item="Sales"]}[Data]\n in\n Sales\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 partition Sales = m\n mode: import\n source =\n let\n Source = Sql.Database("localhost", "Sales"),\n Sales = Source{[Schema="dbo",Item="Sales"]}[Data]\n in\n Sales\n```\n\n### Why it matters\n\nA data column is filled from a column in the partition query, and the source column name is how the engine finds it. Without it, processing fails for the whole table, with an error that names the column but not the cause.\n\n### How to fix it\n\nPower BI Desktop writes `sourceColumn` whenever it adds a column to a table, so a finding here means the column was written or edited by hand, or arrived in a migration. In the TMDL file, add a `sourceColumn:` line under the column, naming the field the partition\'s query produces and spelling it the way the query spells it. Where the query no longer produces it, the fix runs the other way: add the column back in Power Query, under Transform data, so the next refresh delivers it, or take the column out of the model, in Desktop by right-clicking it in the Data pane and choosing Delete from model, or by removing its `column` block from the table\'s TMDL file.\n\n### When to ignore it\n\nThere is none. A data column with no source column stops the whole table from loading, so the finding is a refresh failure reported before the refresh. The one thing worth checking is whether the column was meant to be a calculated column: if it was, give it a DAX expression instead, and the column leaves this rule for `EXPRESSION_RELIANT_OBJECTS_MUST_HAVE_AN_EXPRESSION`, which asks the same question of the expression.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only data columns are read. pbiplint treats a column as calculated when its declaration carries an expression, and as a calculated table column when the table\'s partition is a calculated one, so neither is reported here.\n- Only the presence of the property is tested, never what it names. A `sourceColumn` that names a column the query does not produce passes the rule and fails the refresh.\n- A `sourceColumn:` line with nothing after it counts as missing, the same as no line at all.\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 hold a calculated partition, which makes the column a calculated table\'s, with no source column of its own, 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/data-columns-must-have-a-source-column'
|
|
7072
8161
|
},
|
|
7073
8162
|
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": {
|
|
7074
8163
|
text: `Example
|
|
@@ -7122,9 +8211,10 @@ Quirks
|
|
|
7122
8211
|
- The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked.
|
|
7123
8212
|
- The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.
|
|
7124
8213
|
- Calculated tables are in scope and calculation groups are not, so a calendar built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.
|
|
8214
|
+
- 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, 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.
|
|
7125
8215
|
|
|
7126
8216
|
Read more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table`,
|
|
7127
|
-
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 every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. 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 calendar rather than deriving it from a fact table. Where the table is not a calendar 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 calendar 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 calendar 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 and the finding stays for as long as the table exists. What is not a legitimate exception is the model\'s real day-grain calendar left unmarked because time intelligence appears to work in a quick test; it fails quietly at the edges of the range.\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.\n- The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.\n- Calculated tables are in scope and calculation groups are not, so a calendar built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.\n\nRead more: https://pbiplint.com/rules/date-calendar-tables-should-be-marked-as-a-date-table'
|
|
8217
|
+
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 every time intelligence function relies on it: DATESYTD, SAMEPERIODLASTYEAR, and the rest return wrong or blank results over an unmarked table without raising any error. 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 calendar rather than deriving it from a fact table. Where the table is not a calendar 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 calendar 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 calendar 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 and the finding stays for as long as the table exists. What is not a legitimate exception is the model\'s real day-grain calendar left unmarked because time intelligence appears to work in a quick test; it fails quietly at the edges of the range.\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.\n- The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.\n- Calculated tables are in scope and calculation groups are not, so a calendar built with CALENDAR is checked and a calculation group called Date Intelligence is left alone.\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, 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'
|
|
7128
8218
|
},
|
|
7129
8219
|
DATECOLUMN_FORMATSTRING: {
|
|
7130
8220
|
text: `Example
|
|
@@ -7215,7 +8305,7 @@ Put the table name in front of every column reference:
|
|
|
7215
8305
|
|
|
7216
8306
|
Total Sales = SUM ( 'Sales'[Amount] )
|
|
7217
8307
|
|
|
7218
|
-
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role.
|
|
8308
|
+
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after measure 'Total Sales' =, or the filter after tablePermission Sales = inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
|
|
7219
8309
|
|
|
7220
8310
|
When to ignore it
|
|
7221
8311
|
|
|
@@ -7227,13 +8317,15 @@ Quirks
|
|
|
7227
8317
|
|
|
7228
8318
|
- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.
|
|
7229
8319
|
- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.
|
|
7230
|
-
- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in model
|
|
7231
|
-
-
|
|
7232
|
-
-
|
|
8320
|
+
- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.
|
|
8321
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ). Inside a call that creates the name, such as SELECTCOLUMNS ( 'Sales', "Region", [Region] ), the name is read as any other bare name is, since a call cannot read a column it is creating.
|
|
8322
|
+
- DAX is read token by token, so a bare [Column] inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as 'Date'[Date].[Year].
|
|
8323
|
+
- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
|
|
7233
8324
|
- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.
|
|
8325
|
+
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
7234
8326
|
|
|
7235
8327
|
Read more: https://pbiplint.com/rules/dax-columns-fully-qualified`,
|
|
7236
|
-
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 model
|
|
8328
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nIn DAX a bare `[Name]` is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\n### How to fix it\n\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is that column, not a model column, so it is not reported, even when a model column has the same name: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), \"Share\", [Total] ), [Share] )`. Inside a call that creates the name, such as `SELECTCOLUMNS ( 'Sales', \"Region\", [Region] )`, the name is read as any other bare name is, since a call cannot read a column it is creating.\n- DAX is read token by token, so a bare `[Column]` inside a string literal or a comment is not reported, nor is the name after the dot in extended column syntax such as `'Date'[Date].[Year]`.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so a bare column reference written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a bare name reads as a column only when the model has no measure of that name, and a measure of that name could be in what pbiplint missed; pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/dax-columns-fully-qualified"
|
|
7237
8329
|
},
|
|
7238
8330
|
DAX_MEASURES_UNQUALIFIED: {
|
|
7239
8331
|
text: `Example
|
|
@@ -7276,7 +8368,7 @@ Delete the table name from the reference and leave the brackets:
|
|
|
7276
8368
|
|
|
7277
8369
|
Average Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )
|
|
7278
8370
|
|
|
7279
|
-
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group.
|
|
8371
|
+
In Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after measure 'Average Price' =, after column Name = for a calculated column, after source = in a calculated table's partition, or after calculationItem Name = in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after targetExpression =, statusExpression =, or trendExpression = in the measure's kpi block, an edit Power BI Desktop keeps.
|
|
7280
8372
|
|
|
7281
8373
|
When to ignore it
|
|
7282
8374
|
|
|
@@ -7286,14 +8378,14 @@ To ignore this rule on one object, add annotation pbiplint.ignore = DAX_MEASURES
|
|
|
7286
8378
|
|
|
7287
8379
|
Quirks
|
|
7288
8380
|
|
|
7289
|
-
-
|
|
7290
|
-
- A measure's dynamic format string
|
|
8381
|
+
- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's "'Sales'[Total Sales]", is not reported.
|
|
8382
|
+
- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so 'Sales'[Total Sales] written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like [Total Sales].KPI, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.
|
|
7291
8383
|
- The reference has to resolve. 'Sales'[Total Sales] written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.
|
|
7292
8384
|
- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.
|
|
7293
8385
|
- The table name may be written bare or in single quotes; both forms are matched.
|
|
7294
8386
|
|
|
7295
8387
|
Read more: https://pbiplint.com/rules/dax-measures-unqualified`,
|
|
7296
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n-
|
|
8388
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE('Sales'[Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column Quantity\n dataType: int64\n sourceColumn: Quantity\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Average Price' = DIVIDE([Total Sales], SUM(Sales[Quantity]))\n formatString: #,0.00\n```\n\n### Why it matters\n\nA measure belongs to the model, not to the table it sits in; the table is only its home in the field list. Writing `'Sales'[Total Sales]` makes it look like a column, which changes what the next reader expects it to do, and it breaks the moment someone moves the measure to a measure table, which is a routine tidy-up.\n\n### How to fix it\n\nDelete the table name from the reference and leave the brackets:\n\n```\nAverage Price = DIVIDE ( [Total Sales], SUM ( Sales[Quantity] ) )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar; the same goes for a calculated column, a calculated table, and a calculation item, each selected in the model view or the Data pane. In the TMDL file, edit the expression after `measure 'Average Price' =`, after `column Name =` for a calculated column, after `source =` in a calculated table's partition, or after `calculationItem Name =` in the calculation group. A finding that only the measure's KPI raises is fixed in the TMDL file, after `targetExpression =`, `statusExpression =`, or `trendExpression =` in the measure's `kpi` block, an edit Power BI Desktop keeps.\n\n### When to ignore it\n\nThere is no case for the table prefix on a measure. If a finding surprises you, check whether the table really does hold a measure of that name: pbiplint resolves `'Table'[Name]` to a column first and only calls it a measure when the table has no column of that name, so a finding here means the reference bound to a measure.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_MEASURES_UNQUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_MEASURES_UNQUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a qualified measure reference inside a comment or a string literal, such as a field parameter's `\"'Sales'[Total Sales]\"`, is not reported.\n- A measure's dynamic format string and its KPI's target, status, and trend expressions are read together with its expression, so `'Sales'[Total Sales]` written in any of them reports the measure that carries it, once however many of them hold one. Tabular Editor reports a reference in a KPI's expression on the KPI, named like `[Total Sales].KPI`, so a measure whose own expression and KPI both hold one gets two findings there and one here: pbiplint has no KPI object, so it names the measure.\n- The reference has to resolve. `'Sales'[Total Sales]` written where the model has no table called Sales, or where Sales has no measure of that name, is not reported by this rule at all.\n- Row-level security filters are out of scope, so a qualified measure reference inside a role's table filter is never reported.\n- The table name may be written bare or in single quotes; both forms are matched.\n\nRead more: https://pbiplint.com/rules/dax-measures-unqualified"
|
|
7297
8389
|
},
|
|
7298
8390
|
DEFAULT_PAGE_NAME: {
|
|
7299
8391
|
text: `Example
|
|
@@ -7542,9 +8634,10 @@ Quirks
|
|
|
7542
8634
|
- The rule reads relationships by the table names written in them, matched exactly. A relationship that spells the table differently, in letter case or after a rename, counts for the name it carries and not for the table, so the table is still reported.
|
|
7543
8635
|
- Visibility is not read. A hidden measures table with a single hidden column is reported like any other table.
|
|
7544
8636
|
- A table with no partitions, such as one whose file was only partly written, is a table to this rule and is reported when nothing relates to it.
|
|
8637
|
+
- 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 table's relationships 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.
|
|
7545
8638
|
|
|
7546
8639
|
Read more: https://pbiplint.com/rules/ensure-tables-have-relationships`,
|
|
7547
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\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 column Category\n dataType: string\n sourceColumn: Category\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\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 column Category\n dataType: string\n sourceColumn: Category\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nA table that relates to nothing filters nothing and is filtered by nothing, so a visual that mixes its columns with another table's shows the same value repeated on every row. Sometimes that is the point: a parameter table, a measure table, or a security lookup that is read from DAX. More often it is a table that was loaded and never wired up, or a relationship that was deleted by accident.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view and drag the key column of the fact table onto the matching column of the dimension, or use Manage relationships, New, and pick the two columns. Check the cardinality and the cross filter direction that Desktop proposes before you accept them. In the TMDL file a relationship is a `relationship` block of its own, with `fromColumn` on the many side and `toColumn` on the one side, each written as `Table.Column`. If the table really does stand alone, leave it as it is.\n\n### When to ignore it\n\nA table that is meant to stand alone is the case to ignore: a what-if parameter table, a field parameter table, a table of slicer labels that a measure reads with SELECTEDVALUE, a list of thresholds compared against measures, or a lookup table read only by a row-level security filter. Check that the table is one of those before you ignore the finding, because the same line appears when a relationship was dropped in a merge or when a rename left the relationship pointing at a table name that no longer exists.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ENSURE_TABLES_HAVE_RELATIONSHIPS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ENSURE_TABLES_HAVE_RELATIONSHIPS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads relationships by the table names written in them, matched exactly. A relationship that spells the table differently, in letter case or after a rename, counts for the name it carries and not for the table, so the table is still reported.\n- Visibility is not read. A hidden measures table with a single hidden column is reported like any other table.\n- A table with no partitions, such as one whose file was only partly written, is a table to this rule and is reported when nothing relates to it.\n\nRead more: https://pbiplint.com/rules/ensure-tables-have-relationships"
|
|
8640
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\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 column Category\n dataType: string\n sourceColumn: Category\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n sourceColumn: ProductID\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 column Category\n dataType: string\n sourceColumn: Category\n\nrelationship Sales_Product\n fromColumn: Sales.'Product ID'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nA table that relates to nothing filters nothing and is filtered by nothing, so a visual that mixes its columns with another table's shows the same value repeated on every row. Sometimes that is the point: a parameter table, a measure table, or a security lookup that is read from DAX. More often it is a table that was loaded and never wired up, or a relationship that was deleted by accident.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view and drag the key column of the fact table onto the matching column of the dimension, or use Manage relationships, New, and pick the two columns. Check the cardinality and the cross filter direction that Desktop proposes before you accept them. In the TMDL file a relationship is a `relationship` block of its own, with `fromColumn` on the many side and `toColumn` on the one side, each written as `Table.Column`. If the table really does stand alone, leave it as it is.\n\n### When to ignore it\n\nA table that is meant to stand alone is the case to ignore: a what-if parameter table, a field parameter table, a table of slicer labels that a measure reads with SELECTEDVALUE, a list of thresholds compared against measures, or a lookup table read only by a row-level security filter. Check that the table is one of those before you ignore the finding, because the same line appears when a relationship was dropped in a merge or when a rename left the relationship pointing at a table name that no longer exists.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ENSURE_TABLES_HAVE_RELATIONSHIPS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ENSURE_TABLES_HAVE_RELATIONSHIPS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads relationships by the table names written in them, matched exactly. A relationship that spells the table differently, in letter case or after a rename, counts for the name it carries and not for the table, so the table is still reported.\n- Visibility is not read. A hidden measures table with a single hidden column is reported like any other table.\n- A table with no partitions, such as one whose file was only partly written, is a table to this rule and is reported when nothing relates to it.\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 table's relationships 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/ensure-tables-have-relationships"
|
|
7548
8641
|
},
|
|
7549
8642
|
ENSURE_THEME_COLOURS: {
|
|
7550
8643
|
text: `Example
|
|
@@ -8124,9 +9217,216 @@ Quirks
|
|
|
8124
9217
|
- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.
|
|
8125
9218
|
- Hidden columns, and columns in hidden tables, are skipped by both halves.
|
|
8126
9219
|
- 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.
|
|
9220
|
+
- 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.
|
|
8127
9221
|
|
|
8128
9222
|
Read more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings`,
|
|
8129
|
-
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\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
|
|
9223
|
+
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"
|
|
9224
|
+
},
|
|
9225
|
+
HARDCODED_PERIOD_IN_DAX: {
|
|
9226
|
+
text: `Example
|
|
9227
|
+
|
|
9228
|
+
Fires the rule
|
|
9229
|
+
|
|
9230
|
+
table Sales
|
|
9231
|
+
column Amount
|
|
9232
|
+
dataType: decimal
|
|
9233
|
+
sourceColumn: Amount
|
|
9234
|
+
column 'Order Date'
|
|
9235
|
+
dataType: dateTime
|
|
9236
|
+
sourceColumn: Order Date
|
|
9237
|
+
measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = 2025)
|
|
9238
|
+
formatString: #,0
|
|
9239
|
+
|
|
9240
|
+
After the fix
|
|
9241
|
+
|
|
9242
|
+
table Sales
|
|
9243
|
+
column Amount
|
|
9244
|
+
dataType: decimal
|
|
9245
|
+
sourceColumn: Amount
|
|
9246
|
+
column 'Order Date'
|
|
9247
|
+
dataType: dateTime
|
|
9248
|
+
sourceColumn: Order Date
|
|
9249
|
+
measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = YEAR(TODAY()))
|
|
9250
|
+
formatString: #,0
|
|
9251
|
+
|
|
9252
|
+
Why it matters
|
|
9253
|
+
|
|
9254
|
+
A 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.
|
|
9255
|
+
|
|
9256
|
+
A 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.
|
|
9257
|
+
|
|
9258
|
+
How to fix it
|
|
9259
|
+
|
|
9260
|
+
Take the period from something that moves with time.
|
|
9261
|
+
|
|
9262
|
+
- For the current period, use TODAY(): YEAR(TODAY()) for this year, as the fixed example does, or TODAY() itself for an as-of date.
|
|
9263
|
+
- 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.
|
|
9264
|
+
- 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.
|
|
9265
|
+
|
|
9266
|
+
A 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:
|
|
9267
|
+
|
|
9268
|
+
Latest Year Sales =
|
|
9269
|
+
VAR LatestYear = YEAR ( CALCULATE ( MAX ( Sales[Order Date] ), REMOVEFILTERS () ) )
|
|
9270
|
+
RETURN
|
|
9271
|
+
CALCULATE ( SUM ( Sales[Amount] ), YEAR ( Sales[Order Date] ) = LatestYear )
|
|
9272
|
+
|
|
9273
|
+
For a date table, end CALENDAR on the data rather than on a day:
|
|
9274
|
+
|
|
9275
|
+
Date = CALENDAR ( DATE ( 2020, 1, 1 ), DATE ( YEAR ( MAX ( Sales[Order Date] ) ), 12, 31 ) )
|
|
9276
|
+
|
|
9277
|
+
This 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.
|
|
9278
|
+
|
|
9279
|
+
In 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.
|
|
9280
|
+
|
|
9281
|
+
When to ignore it
|
|
9282
|
+
|
|
9283
|
+
A 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.
|
|
9284
|
+
|
|
9285
|
+
Often 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.
|
|
9286
|
+
|
|
9287
|
+
To 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.
|
|
9288
|
+
|
|
9289
|
+
Quirks
|
|
9290
|
+
|
|
9291
|
+
- 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.
|
|
9292
|
+
- 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.
|
|
9293
|
+
- 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.
|
|
9294
|
+
- 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.
|
|
9295
|
+
- 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.
|
|
9296
|
+
- 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").
|
|
9297
|
+
- Year names are read in several languages (year, a\xF1o, anio, jahr, ann\xE9e, anno, jaar, \xE5r, and more), whatever the model's culture.
|
|
9298
|
+
- Desktop's own auto date/time tables are left alone, since Desktop builds them itself.
|
|
9299
|
+
|
|
9300
|
+
Read more: https://pbiplint.com/rules/hardcoded-period-in-dax`,
|
|
9301
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = 2025)\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n column 'Order Date'\n dataType: dateTime\n sourceColumn: Order Date\n measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = YEAR(TODAY()))\n formatString: #,0\n```\n\n### Why it matters\n\nA year typed into DAX is right for the year it was written in and quietly wrong after it. A measure named Current Year Sales that filters on 2025 still shows 2025's sales all through 2026, under the same name, with no error, and a reader has no way to tell.\n\nA date table built with `CALENDAR(DATE(2020, 1, 1), DATE(2026, 12, 31))` has no rows after December 31, 2026. From January 1, 2027, new rows in a table related to it find no date there. A visual that groups by the date table's columns shows them under a blank value: the [blank virtual row](https://learn.microsoft.com/power-bi/transform-model/desktop-relationships-understand#regular-relationships) Power BI adds when a value on a relationship's many side has no match on its one side. A filter or slicer on the date table leaves them out, and time intelligence stops at the table's last day. [Microsoft's guidance on date tables](https://learn.microsoft.com/power-bi/guidance/model-date-tables#generate-with-dax) says CALENDAR's start and end can come from other DAX functions, like `MAX(Sales[OrderDate])`. An end taken from the data moves with it.\n\n### How to fix it\n\nTake the period from something that moves with time.\n\n- For the current period, use `TODAY()`: `YEAR(TODAY())` for this year, as the fixed example does, or `TODAY()` itself for an as-of date.\n- For the latest period in the data, which stays right when a refresh runs late, take it from the fact table: `YEAR(MAX(Sales[Order Date]))`. Inside a measure, MAX reads only the dates the visual's filters leave, so write `CALCULATE(MAX(Sales[Order Date]), REMOVEFILTERS())` when the measure needs the latest date in all the data.\n- When a report reader should choose the period, add a parameter: on the Modeling tab, select New parameter, then Numeric range, and set its Minimum and Maximum to the first and last years it should offer. Power BI Desktop creates the parameter and, with it, a measure that gives the parameter's current value ([what-if parameters](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#create-a-parameter)), and your measure compares with that measure instead of a number. Parameters are [designed for measures](https://learn.microsoft.com/power-bi/transform-model/desktop-what-if#considerations-and-limitations), so this route suits a measure, not a calculated column.\n\nA filter argument of CALCULATE written as a comparison, as in the example, [can't reference a measure or use a nested CALCULATE](https://learn.microsoft.com/dax/calculate-function-dax#boolean-filter-expressions), so put the latest year or the parameter's value in a variable first:\n\n```\nLatest Year Sales =\nVAR LatestYear = YEAR ( CALCULATE ( MAX ( Sales[Order Date] ), REMOVEFILTERS () ) )\nRETURN\n CALCULATE ( SUM ( Sales[Amount] ), YEAR ( Sales[Order Date] ) = LatestYear )\n```\n\nFor a date table, end CALENDAR on the data rather than on a day:\n\n```\nDate = CALENDAR ( DATE ( 2020, 1, 1 ), DATE ( YEAR ( MAX ( Sales[Order Date] ) ), 12, 31 ) )\n```\n\nThis ends on the last day of the latest year in Sales, so the table spans full years, as [Microsoft's guidance](https://learn.microsoft.com/power-bi/guidance/model-date-tables) asks of a date table, and grows when a refresh brings a new year. [CALENDARAUTO](https://learn.microsoft.com/dax/calendarauto-function-dax#remarks) does the same from every date in the model outside calculated columns and tables, so a birth date or a placeholder such as December 31, 9999 stretches it too. To reach the end of the current year whether or not the data gets there yet, end CALENDAR on `DATE ( YEAR ( TODAY () ), 12, 31 )` instead.\n\nIn Power BI Desktop, select a measure, calculated column, or calculated table in the Data pane and edit its DAX in the formula bar. A calculation item is edited in Model view: select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), then select the calculation item under its calculation group, and its DAX opens in the DAX formula bar ([calculation groups](https://learn.microsoft.com/power-bi/transform-model/calculation-groups#add-a-new-calculation-group-in-model-view)). In TMDL, edit the expression after the object's `=`, or, for a date table, the `source` of its `calculated` partition.\n\n### When to ignore it\n\nA fixed period is sometimes the point: a baseline year a measure compares against, a known event such as a change of data source or a day of bad data, a cohort such as customers whose first purchase was in 2023, a rule that changed in a given year, or sample data that never changes. A date table can end on purpose too, such as one that must stop at a contract's last day.\n\nOften the better move is a name that says so. An object whose name carries its year, such as `Sales 2024` or `Growth from 19/20`, reads as deliberate to anyone who opens the model, and the rule leaves it alone.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = HARDCODED_PERIOD_IN_DAX` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"HARDCODED_PERIOD_IN_DAX\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- An object whose name carries one of the years it fixes, as four digits (`Sales 2024`) or as the year's last two digits with no digit beside them (`Jan-24`, `19/20`), is left out, since such an object is almost always meant to fix its year. Two digits can match by chance, as `Top 20` beside a fixed 2020 does, which hides that one object.\n- A date string at a date table's end whose day and month read either way, such as `\"01/02/2026\"`, is quoted as written: DAX reads it by the model's culture, which pbiplint does not settle.\n- A year outside 1950 to 2049 is left alone, such as `DATE(9999, 12, 31)` as an open end or `DATE(1900, 1, 1)` as a default, and so is the Unix epoch, `DATE(1970, 1, 1)`, the base of a conversion such as `DATE(1970, 1, 1) + 'Log'[UnixTime] / 86400`. A date table's end is reported whatever its year or day.\n- A year compared with `<`, `<=`, `>`, or `>=` is left out, since in real models it is usually a cut-off or a cohort, and so are year-month keys such as `202306` and date strings outside a date table's end, which never went stale there. Year arithmetic such as `[Year] - 2025` and fiscal-year labels such as `\"2024/25\"` are left out too, since each came up in too few models to judge. A `DATE()` with a fixed year is read whatever it is compared with, though, so `'Sales'[Order Date] >= DATE(2024, 1, 1)` is reported.\n- From a calculated table, the rule reads only where its CALENDAR calls end, so a year compared inside a date table's ADDCOLUMNS is not reported. The rest of a calculated table's DAX, user-defined functions, and row-level security filters are left out, since in real models they mostly hold inline data, sample generators, or deliberate cut-offs. Format string expressions are not read either, since they choose how a value is shown rather than which rows are counted, and fixed dates in Power Query (M) are left to pbiplint's Power Query rules, which are still to come. A date table end reached through a measure or written with arithmetic, such as `DATE(2026, 12, 31) + 1`, is not taken for a fixed end, and a `DATE()` inside a `CALENDAR` or `GENERATESERIES` call in a measure, a calculated column, or a calculation item is taken for a table's bound and left alone.\n- A month or a quarter compared with a number, a year-end date such as `\"6/30\"` given to DATESYTD, and a `DATE()` given straight to FORMAT with a format that shows no year, as in `FORMAT(DATE(2000, [Month], 1), \"mmmm\")` for a month's name, never fire: none of them goes stale. The rule counts a format as showing the year when it has a `y` in it or is one of the named formats General Date, Long Date, Medium Date, and Short Date, so `FORMAT(DATE(2024, 12, 31), \"Long Date\")` is reported. A `DATE()` inside another call within FORMAT is reported whatever the format, as in `FORMAT(EOMONTH(DATE(2000, [Month], 1), 0), \"mmmm\")`.\n- Year names are read in several languages (year, a\xF1o, anio, jahr, ann\xE9e, anno, jaar, \xE5r, and more), whatever the model's culture.\n- Desktop's own auto date/time tables are left alone, since Desktop builds them itself.\n\nRead more: https://pbiplint.com/rules/hardcoded-period-in-dax"
|
|
9302
|
+
},
|
|
9303
|
+
HARDCODED_YEAR_IN_FILTER: {
|
|
9304
|
+
text: `Example
|
|
9305
|
+
|
|
9306
|
+
Fires the rule in page.json
|
|
9307
|
+
|
|
9308
|
+
{
|
|
9309
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9310
|
+
"name": "3cea48e58036b1654474",
|
|
9311
|
+
"displayName": "Overview",
|
|
9312
|
+
"displayOption": "FitToPage",
|
|
9313
|
+
"height": 720,
|
|
9314
|
+
"width": 1280,
|
|
9315
|
+
"filterConfig": {
|
|
9316
|
+
"filters": [
|
|
9317
|
+
{
|
|
9318
|
+
"name": "21d1dc168996da6409ea",
|
|
9319
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },
|
|
9320
|
+
"type": "Categorical",
|
|
9321
|
+
"filter": {
|
|
9322
|
+
"Version": 2,
|
|
9323
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9324
|
+
"Where": [
|
|
9325
|
+
{
|
|
9326
|
+
"Condition": {
|
|
9327
|
+
"In": {
|
|
9328
|
+
"Expressions": [
|
|
9329
|
+
{ "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }
|
|
9330
|
+
],
|
|
9331
|
+
"Values": [[{ "Literal": { "Value": "2025L" } }]]
|
|
9332
|
+
}
|
|
9333
|
+
}
|
|
9334
|
+
}
|
|
9335
|
+
]
|
|
9336
|
+
},
|
|
9337
|
+
"howCreated": "User"
|
|
9338
|
+
}
|
|
9339
|
+
]
|
|
9340
|
+
}
|
|
9341
|
+
}
|
|
9342
|
+
|
|
9343
|
+
After the fix in page.json
|
|
9344
|
+
|
|
9345
|
+
{
|
|
9346
|
+
"$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",
|
|
9347
|
+
"name": "3cea48e58036b1654474",
|
|
9348
|
+
"displayName": "Overview",
|
|
9349
|
+
"displayOption": "FitToPage",
|
|
9350
|
+
"height": 720,
|
|
9351
|
+
"width": 1280,
|
|
9352
|
+
"filterConfig": {
|
|
9353
|
+
"filters": [
|
|
9354
|
+
{
|
|
9355
|
+
"name": "f7a189206cee6051586c",
|
|
9356
|
+
"field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },
|
|
9357
|
+
"type": "RelativeDate",
|
|
9358
|
+
"filter": {
|
|
9359
|
+
"Version": 2,
|
|
9360
|
+
"From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],
|
|
9361
|
+
"Where": [
|
|
9362
|
+
{
|
|
9363
|
+
"Condition": {
|
|
9364
|
+
"Comparison": {
|
|
9365
|
+
"ComparisonKind": 0,
|
|
9366
|
+
"Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },
|
|
9367
|
+
"Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }
|
|
9368
|
+
}
|
|
9369
|
+
}
|
|
9370
|
+
}
|
|
9371
|
+
]
|
|
9372
|
+
},
|
|
9373
|
+
"howCreated": "User"
|
|
9374
|
+
}
|
|
9375
|
+
]
|
|
9376
|
+
}
|
|
9377
|
+
}
|
|
9378
|
+
|
|
9379
|
+
The Overview page was saved with 2025 picked for 'Date'[Year] under Filters on this page, so the finding reads Page filter on "Overview" with fixed year 2025 on 'Date'[Year]. The fix filters 'Date'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.
|
|
9380
|
+
|
|
9381
|
+
Why it matters
|
|
9382
|
+
|
|
9383
|
+
A filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" (Reset to default values (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.
|
|
9384
|
+
|
|
9385
|
+
A filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year's data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft's words, "If you hide the filter, they can't even see it" (Lock or hide filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).
|
|
9386
|
+
|
|
9387
|
+
How to fix it
|
|
9388
|
+
|
|
9389
|
+
Filter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:
|
|
9390
|
+
|
|
9391
|
+
1. Drag the date column, such as 'Date'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).
|
|
9392
|
+
2. On the date column's filter card, select Relative date from the Filter type drop-down (Create the relative date range filter (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).
|
|
9393
|
+
3. Under Show items when the value, choose is in this, then year.
|
|
9394
|
+
4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual's own fields cannot be deleted, since the visual refers to it, so clear it instead (Types of filters (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).
|
|
9395
|
+
|
|
9396
|
+
A relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column's Year level, filter that date column itself (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).
|
|
9397
|
+
|
|
9398
|
+
When the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter (Add a filter to a visual (https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.
|
|
9399
|
+
|
|
9400
|
+
When the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column's DAX in the formula bar (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):
|
|
9401
|
+
|
|
9402
|
+
Is Latest Year = 'Date'[Year] = YEAR ( MAX ( Sales[Order Date] ) )
|
|
9403
|
+
|
|
9404
|
+
Then drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar's current year, write YEAR ( TODAY () ) in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" (Using calculated columns (https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).
|
|
9405
|
+
|
|
9406
|
+
In the report's files, the filter is an entry in filterConfig in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year's entry with one on the date column, as the example does.
|
|
9407
|
+
|
|
9408
|
+
When to ignore it
|
|
9409
|
+
|
|
9410
|
+
A fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.
|
|
9411
|
+
|
|
9412
|
+
Often the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as Sales 2024 or Review FY24, reads as deliberate to every reader, and the rule leaves that filter alone.
|
|
9413
|
+
|
|
9414
|
+
To ignore this rule on one page or visual, add { "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" } to the annotations array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "HARDCODED_YEAR_IN_FILTER": "off" under rules in pbiplint.config.json.
|
|
9415
|
+
|
|
9416
|
+
Quirks
|
|
9417
|
+
|
|
9418
|
+
- A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.
|
|
9419
|
+
- Years are counted whole, so is less than 2026 reads as years up to 2025, and a range from is greater than 2017 reads as starting in 2018.
|
|
9420
|
+
- An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.
|
|
9421
|
+
- A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.
|
|
9422
|
+
- Filters that drilling sets are left alone: a drillthrough page's field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.
|
|
9423
|
+
- A filter hidden from readers or locked is reported like any other, since it still filters.
|
|
9424
|
+
- Only the Filters pane is read. A year saved as a slicer's selection is SLICER_SELECTION_SAVED's to report, and bookmarks are not read.
|
|
9425
|
+
- A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.
|
|
9426
|
+
- In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" (Considerations and limitations (https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.
|
|
9427
|
+
|
|
9428
|
+
Read more: https://pbiplint.com/rules/hardcoded-year-in-filter`,
|
|
9429
|
+
markdown: '### Example\n\n**Fires the rule in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "21d1dc168996da6409ea",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Year" } },\n "type": "Categorical",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Year" } }\n ],\n "Values": [[{ "Literal": { "Value": "2025L" } }]]\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\n**After the fix in page.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/page/2.1.0/schema.json",\n "name": "3cea48e58036b1654474",\n "displayName": "Overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280,\n "filterConfig": {\n "filters": [\n {\n "name": "f7a189206cee6051586c",\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Date" } }, "Property": "Date" } },\n "type": "RelativeDate",\n "filter": {\n "Version": 2,\n "From": [{ "Name": "d", "Entity": "Date", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "Comparison": {\n "ComparisonKind": 0,\n "Left": { "Column": { "Expression": { "SourceRef": { "Source": "d" } }, "Property": "Date" } },\n "Right": { "DateSpan": { "Expression": { "Now": {} }, "TimeUnit": 3 } }\n }\n }\n }\n ]\n },\n "howCreated": "User"\n }\n ]\n }\n}\n```\n\nThe Overview page was saved with 2025 picked for \'Date\'[Year] under Filters on this page, so the finding reads `Page filter on "Overview"` with `fixed year 2025 on \'Date\'[Year]`. The fix filters \'Date\'[Date] instead, with a relative date filter that keeps the dates in this year. Nothing in it names a year, so on January 1 the page moves to the new one by itself.\n\n### Why it matters\n\nA filter set in the Filters pane is saved with the report, and Microsoft says such filters "become the default filter state for all your report readers" ([Reset to default values](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#reset-to-default-values)). A year picked there is right for the year it was saved in and quietly wrong after it. A page filtered to 2026 still shows 2026 all through 2027, under the same titles, and nothing on it says the year has passed.\n\nA filter that keeps years up to one, or years picked one by one, goes wrong more quietly still. When a new year\'s data arrives the filter leaves it out, and the visuals look complete without it. A hidden filter is the hardest to catch: in Microsoft\'s words, "If you hide the filter, they can\'t even see it" ([Lock or hide filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter#lock-or-hide-filters)).\n\n### How to fix it\n\nFilter the date, not the year, with a filter that moves with the calendar. In Power BI Desktop:\n\n1. Drag the date column, such as \'Date\'[Date], from the Data pane into the section of the Filters pane that holds the year filter: Filters on this visual, Filters on this page, or Filters on all pages ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)).\n2. On the date column\'s filter card, select Relative date from the Filter type drop-down ([Create the relative date range filter](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#create-the-relative-date-range-filter)).\n3. Under Show items when the value, choose is in this, then year.\n4. Remove the year filter. A filter you added can be deleted from the pane; a year that is one of the visual\'s own fields cannot be deleted, since the visual refers to it, so clear it instead ([Types of filters](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-filter-types#compare-filter-types)).\n\nA relative date filter needs a column whose data type is a date, and it cannot use the auto date/time hierarchy, so for a filter on a date column\'s Year level, filter that date column itself ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)).\n\nWhen the filter keeps every year up to the latest one, as a range, an upper bound, or years picked one by one, it usually means every year from the first on. On its card, choose Advanced filtering, set the first condition to is greater than or equal to the first year, leave the second empty, and select Apply filter ([Add a filter to a visual](https://learn.microsoft.com/power-bi/create-reports/power-bi-report-add-filter#add-a-filter-to-a-visual)), so each new year is kept as it arrives.\n\nWhen the page should follow the latest year in the data rather than the calendar, as when data for a year lands weeks after it starts, or when the model has no date column, mark the year in the model instead. Right-click the date table in the Data pane, select New column, and enter the column\'s DAX in the formula bar ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns#lets-look-at-an-example)):\n\n```\nIs Latest Year = \'Date\'[Year] = YEAR ( MAX ( Sales[Order Date] ) )\n```\n\nThen drag Is Latest Year into the Filters pane in place of the year filter and keep True. For the calendar\'s current year, write `YEAR ( TODAY () )` in place of the MAX. Either way the flag moves at the first refresh of the new year, since, as Microsoft puts it for calculated columns, "Column values are recalculated as necessary, like when the underlying data is refreshed and values have changed" ([Using calculated columns](https://learn.microsoft.com/power-bi/transform-model/desktop-calculated-columns)).\n\nIn the report\'s files, the filter is an entry in `filterConfig` in report.json for all pages, the page.json for a page, or the visual.json for a visual: replace the year\'s entry with one on the date column, as the example does.\n\n### When to ignore it\n\nA fixed year is sometimes the point: a page or a visual about one year, such as a review of 2024; a baseline year others are compared with; a cohort; a series that has ended, such as figures that stopped being published; or sample data that never changes.\n\nOften the better move is a name that says so. A page whose name, or a visual whose title, carries one of the years its filter keeps, such as `Sales 2024` or `Review FY24`, reads as deliberate to every reader, and the rule leaves that filter alone.\n\nTo ignore this rule on one page or visual, add `{ "name": "pbiplint.ignore", "value": "HARDCODED_YEAR_IN_FILTER" }` to the `annotations` array of its page.json or visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"HARDCODED_YEAR_IN_FILTER": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter that leaves years out, with is not or with Select all and years cleared, or only starts from a year, with is greater than or is greater than or equal to, is not reported, since each new year still shows.\n- Years are counted whole, so `is less than 2026` reads as `years up to 2025`, and a range from `is greater than 2017` reads as starting in 2018.\n- An upper bound joined by And to anything but a lower bound, such as is not blank, and conditions joined by Or are not read.\n- A filter kept to one day, such as a date column set to December 31, 2025, is not read, and neither is a year written as a label, such as FY2025 or 2024/25, or stored as a decimal number.\n- Filters that drilling sets are left alone: a drillthrough page\'s field keeps the last value passed to it, and a visual saved drilled down keeps the value drilled into, and the author picked neither.\n- A filter hidden from readers or locked is reported like any other, since it still filters.\n- Only the Filters pane is read. A year saved as a slicer\'s selection is `SLICER_SELECTION_SAVED`\'s to report, and bookmarks are not read.\n- A name that pairs a year with a month or a quarter, such as YearMonth, is not a year column, and neither is a count of years, such as Years of Service.\n- In the Power BI service, Microsoft says "slicer and filter relative options are always based on the time in UTC" ([Considerations and limitations](https://learn.microsoft.com/power-bi/visuals/desktop-slicer-filter-date-range#considerations-and-limitations)), so a relative date filter moves to the new year at midnight UTC, not at local midnight.\n\nRead more: https://pbiplint.com/rules/hardcoded-year-in-filter'
|
|
8130
9430
|
},
|
|
8131
9431
|
HIDDEN_VISUAL_WITH_FIELDS: {
|
|
8132
9432
|
text: `Example
|
|
@@ -8486,20 +9786,22 @@ Where nothing needs the relationship, delete it instead: open the model view, ri
|
|
|
8486
9786
|
|
|
8487
9787
|
When to ignore it
|
|
8488
9788
|
|
|
8489
|
-
|
|
9789
|
+
This rule reads the semantic model, not the reports built on it. A report-level measure in a live-connected report can call USERELATIONSHIP, and the rule cannot see it, so check the reports before deleting a relationship that looks unused. The same goes for a relationship activated from a calculated column, a calculated table, or a row-level security filter: none of those is scanned here, and a finding on one of them is noise. A relationship added this week for measures that are still being written is a fair thing to leave alone for a sprint.
|
|
8490
9790
|
|
|
8491
9791
|
To ignore this rule on one object, add annotation pbiplint.ignore = INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED": "off" under rules in pbiplint.config.json.
|
|
8492
9792
|
|
|
8493
9793
|
Quirks
|
|
8494
9794
|
|
|
8495
9795
|
- Only USERELATIONSHIP(from column, to column) counts as activation; the reversed argument order does not, even though DAX accepts it.
|
|
8496
|
-
- Only measures
|
|
9796
|
+
- Only measures, calculation items, and user-defined functions are scanned. A USERELATIONSHIP call in a calculated column, a calculated table, or a row-level security filter does not count as activation.
|
|
9797
|
+
- A USERELATIONSHIP inside a user-defined function counts as activating the relationship, which Tabular Editor does not count, since the source rule reads measures and calculation items only. It counts even when nothing calls the function. When the function receives the two columns as parameters, as in USERELATIONSHIP ( fromColumn, toColumn ), the call names neither column, so the relationship is still reported.
|
|
8497
9798
|
- The call is matched as text, in any letter case, so a USERELATIONSHIP written inside a string literal or a comment counts as activating the relationship.
|
|
8498
9799
|
- Either table name may be written bare or in single quotes, and any spacing around the comma is accepted.
|
|
8499
9800
|
- pbiplint escapes table and column names before building the pattern, which the source rule does not, so names with parentheses cannot break the check.
|
|
9801
|
+
- 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 activates the relationship with USERELATIONSHIP 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.
|
|
8500
9802
|
|
|
8501
9803
|
Read more: https://pbiplint.com/rules/inactive-relationships-that-are-never-activated`,
|
|
8502
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n sourceColumn: OrderDate\n column 'Ship Date'\n dataType: dateTime\n sourceColumn: ShipDate\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\nrelationship Sales_Date_Order\n fromColumn: Sales.'Order Date'\n toColumn: Date.Date\n\nrelationship Sales_Date_Ship\n isActive: false\n fromColumn: Sales.'Ship Date'\n toColumn: Date.Date\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n sourceColumn: OrderDate\n column 'Ship Date'\n dataType: dateTime\n sourceColumn: ShipDate\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Shipped Sales' = CALCULATE([Total Sales], USERELATIONSHIP(Sales[Ship Date], 'Date'[Date]))\n formatString: #,0\n\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\nrelationship Sales_Date_Order\n fromColumn: Sales.'Order Date'\n toColumn: Date.Date\n\nrelationship Sales_Date_Ship\n isActive: false\n fromColumn: Sales.'Ship Date'\n toColumn: Date.Date\n```\n\n### Why it matters\n\nAn inactive relationship does nothing on its own. It exists so a measure can switch it on with USERELATIONSHIP, typically for a second date on a fact table. If no measure does, the relationship is either a leftover from a design that changed or a plan that was never finished, and a report author who sees the dotted line in the model view will assume the filter works.\n\n### How to fix it\n\nWrite the measure the relationship was put there for, so the second date has something that reads it:\n\n```\nShipped Sales = CALCULATE ( [Total Sales], USERELATIONSHIP ( Sales[Ship Date], 'Date'[Date] ) )\n```\n\nIn Power BI Desktop, choose New measure on the Home ribbon and type the expression in the formula bar. In the TMDL file, add the `measure` block to the fact table. Name the columns in the order the relationship declares them, its from column first. DAX takes them either way round, but only that order counts as activation here.\n\nWhere nothing needs the relationship, delete it instead: open the model view, right-click the dotted line, and choose Delete, or remove its `relationship` block from the TMDL file.\n\n### When to ignore it\n\
|
|
9804
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n sourceColumn: OrderDate\n column 'Ship Date'\n dataType: dateTime\n sourceColumn: ShipDate\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\nrelationship Sales_Date_Order\n fromColumn: Sales.'Order Date'\n toColumn: Date.Date\n\nrelationship Sales_Date_Ship\n isActive: false\n fromColumn: Sales.'Ship Date'\n toColumn: Date.Date\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n sourceColumn: OrderDate\n column 'Ship Date'\n dataType: dateTime\n sourceColumn: ShipDate\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n formatString: #,0\n measure 'Shipped Sales' = CALCULATE([Total Sales], USERELATIONSHIP(Sales[Ship Date], 'Date'[Date]))\n formatString: #,0\n\ntable Date\n column Date\n dataType: dateTime\n isKey\n sourceColumn: Date\n\nrelationship Sales_Date_Order\n fromColumn: Sales.'Order Date'\n toColumn: Date.Date\n\nrelationship Sales_Date_Ship\n isActive: false\n fromColumn: Sales.'Ship Date'\n toColumn: Date.Date\n```\n\n### Why it matters\n\nAn inactive relationship does nothing on its own. It exists so a measure can switch it on with USERELATIONSHIP, typically for a second date on a fact table. If no measure does, the relationship is either a leftover from a design that changed or a plan that was never finished, and a report author who sees the dotted line in the model view will assume the filter works.\n\n### How to fix it\n\nWrite the measure the relationship was put there for, so the second date has something that reads it:\n\n```\nShipped Sales = CALCULATE ( [Total Sales], USERELATIONSHIP ( Sales[Ship Date], 'Date'[Date] ) )\n```\n\nIn Power BI Desktop, choose New measure on the Home ribbon and type the expression in the formula bar. In the TMDL file, add the `measure` block to the fact table. Name the columns in the order the relationship declares them, its from column first. DAX takes them either way round, but only that order counts as activation here.\n\nWhere nothing needs the relationship, delete it instead: open the model view, right-click the dotted line, and choose Delete, or remove its `relationship` block from the TMDL file.\n\n### When to ignore it\n\nThis rule reads the semantic model, not the reports built on it. A report-level measure in a live-connected report can call USERELATIONSHIP, and the rule cannot see it, so check the reports before deleting a relationship that looks unused. The same goes for a relationship activated from a calculated column, a calculated table, or a row-level security filter: none of those is scanned here, and a finding on one of them is noise. A relationship added this week for measures that are still being written is a fair thing to leave alone for a sprint.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Only `USERELATIONSHIP(from column, to column)` counts as activation; the reversed argument order does not, even though DAX accepts it.\n- Only measures, calculation items, and user-defined functions are scanned. A USERELATIONSHIP call in a calculated column, a calculated table, or a row-level security filter does not count as activation.\n- A USERELATIONSHIP inside a user-defined function counts as activating the relationship, which Tabular Editor does not count, since the source rule reads measures and calculation items only. It counts even when nothing calls the function. When the function receives the two columns as parameters, as in `USERELATIONSHIP ( fromColumn, toColumn )`, the call names neither column, so the relationship is still reported.\n- The call is matched as text, in any letter case, so a USERELATIONSHIP written inside a string literal or a comment counts as activating the relationship.\n- Either table name may be written bare or in single quotes, and any spacing around the comma is accepted.\n- pbiplint escapes table and column names before building the pattern, which the source rule does not, so names with parentheses cannot break the check.\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 activates the relationship with USERELATIONSHIP 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/inactive-relationships-that-are-never-activated"
|
|
8503
9805
|
},
|
|
8504
9806
|
INTEGER_FORMATTING: {
|
|
8505
9807
|
text: `Example
|
|
@@ -8611,9 +9913,10 @@ Quirks
|
|
|
8611
9913
|
- 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.
|
|
8612
9914
|
- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.
|
|
8613
9915
|
- Relationships are not read. A hidden foreign key, which is exactly what HIDE_FOREIGN_KEYS asks you to create, is reported here.
|
|
9916
|
+
- 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.
|
|
8614
9917
|
|
|
8615
9918
|
Read more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns`,
|
|
8616
|
-
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\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns"
|
|
9919
|
+
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"
|
|
8617
9920
|
},
|
|
8618
9921
|
LANDING_PAGE_NOT_SET: {
|
|
8619
9922
|
text: `Example
|
|
@@ -8921,9 +10224,10 @@ Quirks
|
|
|
8921
10224
|
- A relationship that names no cardinality counts as many-to-one, which is Power BI's default, so an ordinary relationship block with only fromColumn and toColumn puts its to column in scope.
|
|
8922
10225
|
- Inactive relationships count. A column that is only ever the one side of a relationship no measure activates is still reported.
|
|
8923
10226
|
- The column is reported once however many relationships point at it.
|
|
10227
|
+
- 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 as a date 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.
|
|
8924
10228
|
|
|
8925
10229
|
Read more: https://pbiplint.com/rules/mark-primary-keys`,
|
|
8926
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Product Key\n\ntable Product\n column 'Product ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: Product ID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product Key'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Product Key\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Product ID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product Key'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe key flag declares that the column is unique, which lets the engine and client tools treat it as the identifier of the row rather than one more attribute to aggregate, and on an import model the engine enforces the uniqueness at refresh, so a duplicate key fails loudly instead of quietly doubling a total. It also documents intent: the next person reading the model can see at a glance which column defines the grain of the dimension, without tracing every relationship to work it out. Tables marked as date tables are skipped, because marking a table as a date table already sets the key on its date column.\n\n### How to fix it\n\nAdd `isKey` under the column in the TMDL file. Power BI Desktop has no box for the property on an ordinary table, and it keeps the value once it is in the file; the one place Desktop sets a key itself is Mark as date table, which sets it on the date column of the table you mark and takes that table out of this rule at the same time. Check that the column really is unique before you set it, because the engine takes the property at its word. Tabular Editor exposes the property in its property grid, which is a quicker way to set it across the dimensions of a large model than editing each file.\n\n### When to ignore it\n\nA dimension you cannot yet guarantee is unique is the case to leave alone. If the table is loaded from a feed that occasionally repeats a row, the key property turns a silent double-count into a loud failure, which is the right trade once the source is under control and the wrong one while you are still chasing it. Check the distinct count against the row count before you set the property, not after. A calendar table is the other case: mark it as a date table instead, which sets the key on its date column and takes the table out of the rule.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MARK_PRIMARY_KEYS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MARK_PRIMARY_KEYS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A table is skipped only when its data category is exactly `Time`, and the comparison is case-sensitive, so a file that says `dataCategory: time` does not count as a date table here.\n- A relationship that names no cardinality counts as many-to-one, which is Power BI's default, so an ordinary relationship block with only `fromColumn` and `toColumn` puts its to column in scope.\n- Inactive relationships count. A column that is only ever the one side of a relationship no measure activates is still reported.\n- The column is reported once however many relationships point at it.\n\nRead more: https://pbiplint.com/rules/mark-primary-keys"
|
|
10230
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Product Key\n\ntable Product\n column 'Product ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: Product ID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product Key'\n toColumn: Product.'Product ID'\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: Product Key\n\ntable Product\n column 'Product ID'\n dataType: int64\n isKey\n summarizeBy: none\n sourceColumn: Product ID\n\nrelationship Sales_Product\n fromColumn: Sales.'Product Key'\n toColumn: Product.'Product ID'\n```\n\n### Why it matters\n\nThe key flag declares that the column is unique, which lets the engine and client tools treat it as the identifier of the row rather than one more attribute to aggregate, and on an import model the engine enforces the uniqueness at refresh, so a duplicate key fails loudly instead of quietly doubling a total. It also documents intent: the next person reading the model can see at a glance which column defines the grain of the dimension, without tracing every relationship to work it out. Tables marked as date tables are skipped, because marking a table as a date table already sets the key on its date column.\n\n### How to fix it\n\nAdd `isKey` under the column in the TMDL file. Power BI Desktop has no box for the property on an ordinary table, and it keeps the value once it is in the file; the one place Desktop sets a key itself is Mark as date table, which sets it on the date column of the table you mark and takes that table out of this rule at the same time. Check that the column really is unique before you set it, because the engine takes the property at its word. Tabular Editor exposes the property in its property grid, which is a quicker way to set it across the dimensions of a large model than editing each file.\n\n### When to ignore it\n\nA dimension you cannot yet guarantee is unique is the case to leave alone. If the table is loaded from a feed that occasionally repeats a row, the key property turns a silent double-count into a loud failure, which is the right trade once the source is under control and the wrong one while you are still chasing it. Check the distinct count against the row count before you set the property, not after. A calendar table is the other case: mark it as a date table instead, which sets the key on its date column and takes the table out of the rule.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MARK_PRIMARY_KEYS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MARK_PRIMARY_KEYS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A table is skipped only when its data category is exactly `Time`, and the comparison is case-sensitive, so a file that says `dataCategory: time` does not count as a date table here.\n- A relationship that names no cardinality counts as many-to-one, which is Power BI's default, so an ordinary relationship block with only `fromColumn` and `toColumn` puts its to column in scope.\n- Inactive relationships count. A column that is only ever the one side of a relationship no measure activates is still reported.\n- The column is reported once however many relationships point at it.\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 as a date 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/mark-primary-keys"
|
|
8927
10231
|
},
|
|
8928
10232
|
MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: {
|
|
8929
10233
|
text: `Example
|
|
@@ -9060,9 +10364,10 @@ Quirks
|
|
|
9060
10364
|
- The names are matched anywhere in the expression, with no word boundary in front, so a measure or function whose name ends in one of them, such as MYDATEADD(, counts as a match. Whitespace between the name and the parenthesis is allowed.
|
|
9061
10365
|
- The list is the source's thirty-seven functions, and it includes FIRSTNONBLANK, LASTNONBLANK, FIRSTNONBLANKVALUE, and LASTNONBLANKVALUE, which are regularly used over columns that hold no dates, so a measure with no date in it anywhere can be reported.
|
|
9062
10366
|
- A table counts as DirectQuery when its first partition says mode: directQuery, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table here, and a calculated table or a calculation group never is.
|
|
10367
|
+
- 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 table is DirectQuery by its first partition and the partition that comes first 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.
|
|
9063
10368
|
|
|
9064
10369
|
Read more: https://pbiplint.com/rules/measures-using-time-intelligence-and-model-is-using-direct-query`,
|
|
9065
|
-
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 measure 'Sales Last Year' = CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Date'[Date]))\n formatString: #,0\n\n partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database(\"finance\", \"Warehouse\"), Sales = Source{[Item = \"Sales\"]}[Data] in Sales\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\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\n column 'Amount Prior Year'\n dataType: decimal\n summarizeBy: sum\n sourceColumn: AmountPriorYear\n\n measure 'Sales Last Year' = SUM('Sales'[Amount Prior Year])\n formatString: #,0\n\n partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database(\"finance\", \"Warehouse\"), Sales = Source{[Item = \"vwSalesWithPriorYear\"]}[Data] in Sales\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\n### Why it matters\n\nTime intelligence functions build sets of dates and evaluate the measure over each set. In Import mode that is in-memory work. In DirectQuery each set becomes a query, or a long list of dates inside one, sent to the source on every visual refresh, and sources are rarely fast at it. The functions work, but a page of year-to-date and prior-year cards can take many seconds to render.\n\n### How to fix it\n\nEither take the model out of DirectQuery or take the time intelligence out of the measure. For the first, select the table in Power BI Desktop's model view and set Storage mode to Import in the Properties pane, which is the partition's `mode: import` line in the TMDL file; the finding clears only once no table in the model is left in DirectQuery, because the condition is about the model and not about the tables the measure happens to read. For the second, move the comparison into the data: add the prior period's value to each row in the source view or the warehouse, load it as an ordinary column, and write the measure as a SUM over it, which is what the example above does. Where neither is possible, keep the number of time intelligence measures on one page small and cache what you can in the source, because every one of them is another round trip.\n\n### When to ignore it\n\nThe common false alarm is a measure that has nothing to do with the DirectQuery table. The rule reads the model, not the query path, so one small DirectQuery table kept for real-time status will report every time intelligence measure in the model, including the ones that run entirely over imported tables. Check which tables the measure actually touches before you act on it. A source that answers quickly is the second case: a well-indexed warehouse over a few million rows returns a year-to-date query in well under a second, and a page that renders fast is not a problem whatever the rule says. Time the visual before rewriting anything. What is not a legitimate exception is a large DirectQuery fact table over a source you do not control, where the finding is naming the reason the report is slow.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Function names are matched case-sensitively, in upper case only, as in the source rule, so `totalytd(` is not reported.\n- The names are matched anywhere in the expression, with no word boundary in front, so a measure or function whose name ends in one of them, such as `MYDATEADD(`, counts as a match. Whitespace between the name and the parenthesis is allowed.\n- The list is the source's thirty-seven functions, and it includes FIRSTNONBLANK, LASTNONBLANK, FIRSTNONBLANKVALUE, and LASTNONBLANKVALUE, which are regularly used over columns that hold no dates, so a measure with no date in it anywhere can be reported.\n- A table counts as DirectQuery when its first partition says `mode: directQuery`, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table here, and a calculated table or a calculation group never is.\n\nRead more: https://pbiplint.com/rules/measures-using-time-intelligence-and-model-is-using-direct-query"
|
|
10370
|
+
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 measure 'Sales Last Year' = CALCULATE(SUM('Sales'[Amount]), SAMEPERIODLASTYEAR('Date'[Date]))\n formatString: #,0\n\n partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database(\"finance\", \"Warehouse\"), Sales = Source{[Item = \"Sales\"]}[Data] in Sales\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\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\n column 'Amount Prior Year'\n dataType: decimal\n summarizeBy: sum\n sourceColumn: AmountPriorYear\n\n measure 'Sales Last Year' = SUM('Sales'[Amount Prior Year])\n formatString: #,0\n\n partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database(\"finance\", \"Warehouse\"), Sales = Source{[Item = \"vwSalesWithPriorYear\"]}[Data] in Sales\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\n### Why it matters\n\nTime intelligence functions build sets of dates and evaluate the measure over each set. In Import mode that is in-memory work. In DirectQuery each set becomes a query, or a long list of dates inside one, sent to the source on every visual refresh, and sources are rarely fast at it. The functions work, but a page of year-to-date and prior-year cards can take many seconds to render.\n\n### How to fix it\n\nEither take the model out of DirectQuery or take the time intelligence out of the measure. For the first, select the table in Power BI Desktop's model view and set Storage mode to Import in the Properties pane, which is the partition's `mode: import` line in the TMDL file; the finding clears only once no table in the model is left in DirectQuery, because the condition is about the model and not about the tables the measure happens to read. For the second, move the comparison into the data: add the prior period's value to each row in the source view or the warehouse, load it as an ordinary column, and write the measure as a SUM over it, which is what the example above does. Where neither is possible, keep the number of time intelligence measures on one page small and cache what you can in the source, because every one of them is another round trip.\n\n### When to ignore it\n\nThe common false alarm is a measure that has nothing to do with the DirectQuery table. The rule reads the model, not the query path, so one small DirectQuery table kept for real-time status will report every time intelligence measure in the model, including the ones that run entirely over imported tables. Check which tables the measure actually touches before you act on it. A source that answers quickly is the second case: a well-indexed warehouse over a few million rows returns a year-to-date query in well under a second, and a page that renders fast is not a problem whatever the rule says. Time the visual before rewriting anything. What is not a legitimate exception is a large DirectQuery fact table over a source you do not control, where the finding is naming the reason the report is slow.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Function names are matched case-sensitively, in upper case only, as in the source rule, so `totalytd(` is not reported.\n- The names are matched anywhere in the expression, with no word boundary in front, so a measure or function whose name ends in one of them, such as `MYDATEADD(`, counts as a match. Whitespace between the name and the parenthesis is allowed.\n- The list is the source's thirty-seven functions, and it includes FIRSTNONBLANK, LASTNONBLANK, FIRSTNONBLANKVALUE, and LASTNONBLANKVALUE, which are regularly used over columns that hold no dates, so a measure with no date in it anywhere can be reported.\n- A table counts as DirectQuery when its first partition says `mode: directQuery`, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table here, and a calculated table or a calculation group never is.\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 table is DirectQuery by its first partition and the partition that comes first 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/measures-using-time-intelligence-and-model-is-using-direct-query"
|
|
9066
10371
|
},
|
|
9067
10372
|
MINIMIZE_POWER_QUERY_TRANSFORMATIONS: {
|
|
9068
10373
|
text: `Example
|
|
@@ -9188,9 +10493,10 @@ Quirks
|
|
|
9188
10493
|
- The data category comparison is exact and case-sensitive, so dataCategory: time leaves the model reported.
|
|
9189
10494
|
- 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.
|
|
9190
10495
|
- Any table can satisfy it, of any kind. A calculated calendar counts the same as a loaded one.
|
|
10496
|
+
- 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.
|
|
9191
10497
|
|
|
9192
10498
|
Read more: https://pbiplint.com/rules/model-should-have-a-date-table`,
|
|
9193
|
-
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\nEvery time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, 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 calendar with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a calendar 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 calendar 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 calendars stop being built.\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 calendar 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- 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- 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 calendar counts the same as a loaded one.\n\nRead more: https://pbiplint.com/rules/model-should-have-a-date-table"
|
|
10499
|
+
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\nEvery time intelligence function needs a contiguous date column to work over, and the marked date table is where it finds one. Without it, 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 calendar with one row per day covering every date the model holds, then mark it. The first-choice route is the source: load a calendar 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 calendar 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 calendars stop being built.\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 calendar 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- 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- 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 calendar 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"
|
|
9194
10500
|
},
|
|
9195
10501
|
MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: {
|
|
9196
10502
|
text: `Example
|
|
@@ -9280,9 +10586,10 @@ Quirks
|
|
|
9280
10586
|
- The data source version has to be present and read as powerBI_V3, compared without regard to letter case. A model file that does not carry defaultPowerBIDataSourceVersion at all is never reported, whatever its tables say.
|
|
9281
10587
|
- One column with an alternateOf block anywhere in the model clears the finding, whatever it maps to. A single half-finished aggregation table counts as much as a complete set.
|
|
9282
10588
|
- A table counts as DirectQuery when its first partition says mode: directQuery, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table here.
|
|
10589
|
+
- 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 an aggregation 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.
|
|
9283
10590
|
|
|
9284
10591
|
Read more: https://pbiplint.com/rules/model-using-direct-query-and-no-aggregations`,
|
|
9285
|
-
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\nmodel Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n\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 partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database("finance", "Warehouse"), Sales = Source{[Item = "Sales"]}[Data] in Sales\n```\n\n**After the fix**\n\n```tmdl\nmodel Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n\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 partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database("finance", "Warehouse"), Sales = Source{[Item = "Sales"]}[Data] in Sales\n\ntable \'Sales by Day\'\n isHidden\n\n column \'Order Date\'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n alternateOf\n baseColumn: Sales.\'Order Date\'\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\n alternateOf\n summarization: sum\n baseColumn: Sales.Amount\n\n partition \'Sales by Day\' = m\n mode: import\n source = let Source = Sql.Database("finance", "Warehouse"), Agg = Source{[Item = "vwSalesByDay"]}[Data] in Agg\n```\n\n### Why it matters\n\nIn DirectQuery every visual sends a query to the source. Aggregation tables let the engine answer the common high-level questions, totals by month or by region, from a small imported table and send only the detail queries through. Without them, the summary page of a dashboard pays the full round trip to the source on every interaction. This is an info-level prompt to consider the feature, not a defect.\n\n### How to fix it\n\nBuild a summary table at the grain the reports use most, one row per day and region rather than one per transaction, and load it from a view in the source so the two tables stay consistent. Import it, keep the detail table in DirectQuery, and in Power BI Desktop right-click the summary table in the model view, choose Manage aggregations, and map each of its columns to the detail column it summarizes and the function that summarizes it. In the TMDL file that mapping is an `alternateOf` block under each aggregation column, which is the only thing the rule looks for. The block names the detail column in `baseColumn`, qualified by its table as in `Sales.\'Order Date\'`, or, for Count table rows, names the detail table alone in `baseTable`. It sets a `summarization`, such as `sum` or `count`, for every function except group by, which it leaves out. The summary table should be hidden, because report authors write their measures against the detail table and the engine redirects the query when it can. The Manage aggregations dialog hides it for you when you select Apply all, if it is not hidden already; in the TMDL file that is `isHidden` on the table, as in the example. Any one aggregation mapping anywhere in the model clears this finding, so treat the rule as a prompt to start rather than a measure of how far you got.\n\n### When to ignore it\n\nThe question is whether there is a summary worth precomputing. A DirectQuery table kept for a handful of live status tiles has none: the whole point is that the numbers are current to the second, and a cached summary would be wrong. A report that only ever reads detail rows, such as a lookup of one order by number, has none either, because there is no total for an aggregation to answer. A source that already serves a summarized view at the grain the report asks for is the third case, and there the aggregation exists, it is just on the other side of the connector. Where the report opens on a page of totals by month over a large DirectQuery fact table, the finding is worth acting on, and at info severity it will not fail a build while you decide.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The data source version has to be present and read as powerBI_V3, compared without regard to letter case. A model file that does not carry `defaultPowerBIDataSourceVersion` at all is never reported, whatever its tables say.\n- One column with an `alternateOf` block anywhere in the model clears the finding, whatever it maps to. A single half-finished aggregation table counts as much as a complete set.\n- A table counts as DirectQuery when its first partition says `mode: directQuery`, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table here.\n\nRead more: https://pbiplint.com/rules/model-using-direct-query-and-no-aggregations'
|
|
10592
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\nmodel Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n\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 partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database("finance", "Warehouse"), Sales = Source{[Item = "Sales"]}[Data] in Sales\n```\n\n**After the fix**\n\n```tmdl\nmodel Model\n culture: en-US\n defaultPowerBIDataSourceVersion: powerBI_V3\n\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 partition Sales = m\n mode: directQuery\n source = let Source = Sql.Database("finance", "Warehouse"), Sales = Source{[Item = "Sales"]}[Data] in Sales\n\ntable \'Sales by Day\'\n isHidden\n\n column \'Order Date\'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n alternateOf\n baseColumn: Sales.\'Order Date\'\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\n alternateOf\n summarization: sum\n baseColumn: Sales.Amount\n\n partition \'Sales by Day\' = m\n mode: import\n source = let Source = Sql.Database("finance", "Warehouse"), Agg = Source{[Item = "vwSalesByDay"]}[Data] in Agg\n```\n\n### Why it matters\n\nIn DirectQuery every visual sends a query to the source. Aggregation tables let the engine answer the common high-level questions, totals by month or by region, from a small imported table and send only the detail queries through. Without them, the summary page of a dashboard pays the full round trip to the source on every interaction. This is an info-level prompt to consider the feature, not a defect.\n\n### How to fix it\n\nBuild a summary table at the grain the reports use most, one row per day and region rather than one per transaction, and load it from a view in the source so the two tables stay consistent. Import it, keep the detail table in DirectQuery, and in Power BI Desktop right-click the summary table in the model view, choose Manage aggregations, and map each of its columns to the detail column it summarizes and the function that summarizes it. In the TMDL file that mapping is an `alternateOf` block under each aggregation column, which is the only thing the rule looks for. The block names the detail column in `baseColumn`, qualified by its table as in `Sales.\'Order Date\'`, or, for Count table rows, names the detail table alone in `baseTable`. It sets a `summarization`, such as `sum` or `count`, for every function except group by, which it leaves out. The summary table should be hidden, because report authors write their measures against the detail table and the engine redirects the query when it can. The Manage aggregations dialog hides it for you when you select Apply all, if it is not hidden already; in the TMDL file that is `isHidden` on the table, as in the example. Any one aggregation mapping anywhere in the model clears this finding, so treat the rule as a prompt to start rather than a measure of how far you got.\n\n### When to ignore it\n\nThe question is whether there is a summary worth precomputing. A DirectQuery table kept for a handful of live status tiles has none: the whole point is that the numbers are current to the second, and a cached summary would be wrong. A report that only ever reads detail rows, such as a lookup of one order by number, has none either, because there is no total for an aggregation to answer. A source that already serves a summarized view at the grain the report asks for is the third case, and there the aggregation exists, it is just on the other side of the connector. Where the report opens on a page of totals by month over a large DirectQuery fact table, the finding is worth acting on, and at info severity it will not fail a build while you decide.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The data source version has to be present and read as powerBI_V3, compared without regard to letter case. A model file that does not carry `defaultPowerBIDataSourceVersion` at all is never reported, whatever its tables say.\n- One column with an `alternateOf` block anywhere in the model clears the finding, whatever it maps to. A single half-finished aggregation table counts as much as a complete set.\n- A table counts as DirectQuery when its first partition says `mode: directQuery`, letter case aside. No other mode counts, so a table set to Dual storage mode is not a DirectQuery table 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 an aggregation 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-using-direct-query-and-no-aggregations'
|
|
9286
10593
|
},
|
|
9287
10594
|
"MONTH_(AS_A_STRING)_MUST_BE_SORTED": {
|
|
9288
10595
|
text: `Example
|
|
@@ -9467,7 +10774,7 @@ How to fix it
|
|
|
9467
10774
|
|
|
9468
10775
|
Check 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.
|
|
9469
10776
|
|
|
9470
|
-
Then 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
|
|
10777
|
+
Then 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.
|
|
9471
10778
|
|
|
9472
10779
|
When to ignore it
|
|
9473
10780
|
|
|
@@ -9478,17 +10785,19 @@ To ignore this rule on one object, add annotation pbiplint.ignore = NOT_REACHED_
|
|
|
9478
10785
|
Quirks
|
|
9479
10786
|
|
|
9480
10787
|
- 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.
|
|
9481
|
-
- Both columns of a relationship, the columns that row-level
|
|
9482
|
-
- 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. Desktop manages these tables and keeps them
|
|
9483
|
-
- UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS keep the one-hop test of the ruleset they are ported from,
|
|
9484
|
-
- DAX
|
|
10788
|
+
- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
|
|
10789
|
+
- Calculated tables whose names start with LocalDateTable_ or DateTableTemplate_, which pbiplint reads as Power BI Desktop's auto date/time tables the way REMOVE_AUTO-DATE_TABLE does, are left out of the findings, reached or not. So is a composite model's copy of one: a LocalDateTable_ table whose entity partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with showAsVariationsOnly, so it is shown only through a date column's hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and REMOVE_AUTO-DATE_TABLE reports them; the table a copy reads is calculated in the model it extends, and that model's own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.
|
|
10790
|
+
- UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model's own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.
|
|
10791
|
+
- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.
|
|
10792
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: [Share] in MAXX ( ADDCOLUMNS ( VALUES ( 'Sales'[Region] ), "Share", [Total] ), [Share] ) does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.
|
|
9485
10793
|
- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.
|
|
10794
|
+
- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.
|
|
9486
10795
|
- The rule compares the report with its model, so it runs only when both are in the input.
|
|
9487
10796
|
- The rule also needs every file it reads the report's fields from: report.json (the report's filters), reportExtensions.json (the report's own measures), each page.json (a page's filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, a report file could not be read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual's mobile.json, which hold the report's format version, the order of its pages, the order and groups of its bookmarks, and a visual's mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.
|
|
9488
10797
|
- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt table, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, a model file could not be fully read, the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A /// description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.
|
|
9489
10798
|
|
|
9490
10799
|
Read more: https://pbiplint.com/rules/not-reached-from-report`,
|
|
9491
|
-
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 from the top, the measure first and then the fields only it used, so one pass down the list removes all of it.\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 that row-level and object-level security name, 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 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. Desktop manages these tables and keeps them hidden even from modelers, so there is nothing here to delete; turning Auto date/time off removes them, and `REMOVE_AUTO-DATE_TABLE` reports them. The relationship Desktop adds from a date column to its auto date/time table 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, so their results match Tabular Editor: 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- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
|
|
10800
|
+
markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX is read token by token, the way the model rules read it, so a field named only inside a string or a comment of a reached measure is not reached through it.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE reaches no model column: `[Share]` in `MAXX ( ADDCOLUMNS ( VALUES ( \'Sales\'[Region] ), "Share", [Total] ), [Share] )` does not reach a model column called Share. Inside a call that creates the name, the name reaches what any other bare name reaches, since a call cannot read a column it is creating.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is the function\'s whole name, dots included, in any letter case, followed by an opening parenthesis; one written inside a string or a comment is not a call.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
|
|
9492
10801
|
},
|
|
9493
10802
|
NUMERIC_COLUMN_SUMMARIZE_BY: {
|
|
9494
10803
|
text: `Example
|
|
@@ -9528,9 +10837,10 @@ Quirks
|
|
|
9528
10837
|
- The property value is compared without regard to letter case, so summarizeBy: None passes as well as summarizeBy: none.
|
|
9529
10838
|
- Hidden columns, and columns in hidden tables, are skipped.
|
|
9530
10839
|
- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.
|
|
10840
|
+
- 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.
|
|
9531
10841
|
|
|
9532
10842
|
Read more: https://pbiplint.com/rules/numeric-column-summarize-by`,
|
|
9533
|
-
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\nRead more: https://pbiplint.com/rules/numeric-column-summarize-by'
|
|
10843
|
+
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- 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'
|
|
9534
10844
|
},
|
|
9535
10845
|
OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
|
|
9536
10846
|
text: `Example
|
|
@@ -9581,9 +10891,10 @@ Quirks
|
|
|
9581
10891
|
- The test is for the 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.
|
|
9582
10892
|
- 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.
|
|
9583
10893
|
- In the browser, the "Choose a folder" button in Chrome and Edge does not list a file whose name begins or ends with a space, so a table file named that way is never read on that route and its findings are missing. Drag the folder onto the page, or use the command line, to read it.
|
|
10894
|
+
- 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 hold a calculated partition, which puts the table and its columns out of the rule's scope, 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.
|
|
9584
10895
|
|
|
9585
10896
|
Read more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space`,
|
|
9586
|
-
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 Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\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 Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so \"Sales \" and \"Sales\" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. `TRIM_OBJECT_NAMES` makes the same check across more object types at a lower severity, so every finding here appears there as well.\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 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 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 too, 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, and this rule reports at error severity because a name that reads as correct and is not costs more to diagnose than to fix. A leading space is sometimes used to push a measure to the top of the field list; a display folder puts the measure where it belongs without hiding a character in its name.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Narrower scope than `TRIM_OBJECT_NAMES`: levels, roles, named expressions, calculation items, calculation group tables, data sources, calculated tables, and calculated table columns are not checked here.\n- The test is for the 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- In the browser, the \"Choose a folder\" button in Chrome and Edge does not list a file whose name begins or ends with a space, so a table file named that way is never read on that route and its findings are missing. Drag the folder onto the page, or use the command line, to read it.\n\nRead more: https://pbiplint.com/rules/objects-should-not-start-or-end-with-a-space"
|
|
10897
|
+
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 Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\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 Amount\n dataType: decimal\n summarizeBy: none\n sourceColumn: Amount\n```\n\n### Why it matters\n\nA leading or trailing space is invisible on screen but is part of the name, so \"Sales \" and \"Sales\" are two different objects to the engine. That is enough to break a DAX reference, a report visual binding, or a deployment that expects the trimmed name, and the error you get back will name an object that looks perfectly correct. Stray spaces almost always arrive by accident, pasted in or inherited from a source column name, so trimming them is safe and rarely breaks anything downstream. `TRIM_OBJECT_NAMES` makes the same check across more object types at a lower severity, so every finding here appears there as well.\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 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 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 too, 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, and this rule reports at error severity because a name that reads as correct and is not costs more to diagnose than to fix. A leading space is sometimes used to push a measure to the top of the field list; a display folder puts the measure where it belongs without hiding a character in its name.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Narrower scope than `TRIM_OBJECT_NAMES`: levels, roles, named expressions, calculation items, calculation group tables, data sources, calculated tables, and calculated table columns are not checked here.\n- The test is for the 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- In the browser, the \"Choose a folder\" button in Chrome and Edge does not list a file whose name begins or ends with a space, so a table file named that way is never read on that route and its findings are missing. Drag the folder onto the page, or use the command line, to read it.\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 hold a calculated partition, which puts the table and its columns out of the rule's scope, 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/objects-should-not-start-or-end-with-a-space"
|
|
9587
10898
|
},
|
|
9588
10899
|
OBJECTS_WITH_NO_DESCRIPTION: {
|
|
9589
10900
|
text: `Example
|
|
@@ -9632,9 +10943,10 @@ Quirks
|
|
|
9632
10943
|
- A calculation group table is reported once, as a calculation group.
|
|
9633
10944
|
- A description of only spaces or tabs counts as none, so padding a description to quiet the rule does not work.
|
|
9634
10945
|
- Hierarchies, hierarchy levels, calculation items, partitions, roles, perspectives, data sources, and named expressions are outside the scope, so an undescribed hierarchy is never reported here.
|
|
10946
|
+
- 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 describe or 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.
|
|
9635
10947
|
|
|
9636
10948
|
Read more: https://pbiplint.com/rules/objects-with-no-description`,
|
|
9637
|
-
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\n**After the fix**\n\n```tmdl\n/// One row per order line, loaded nightly from the sales warehouse.\ntable Sales\n\n /// Line amount in US dollars, net of discount and before tax.\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n /// Sum of line amount over whatever the visual is filtered to.\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more `///` lines directly above the object's declaration, with no blank line between the last `///` line and the declaration: a blank line ends the description, and the object below it is read as having none. With hundreds of objects, Tabular Editor can paste descriptions into many at once from a list.\n\n### When to ignore it\n\nA name that already says the whole thing needs nothing added: `'Date'[Year]` gains nothing from a sentence reading \"the year\". Spend the descriptions on measures, on any column whose unit, currency, grain, or filter behavior is not in its name, and on the two fields a reader has to choose between. A table or column you are about to hide is better hidden than described, because hiding it also clears this finding.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = OBJECTS_WITH_NO_DESCRIPTION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"OBJECTS_WITH_NO_DESCRIPTION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n- A description of only spaces or tabs counts as none, so padding a description to quiet the rule does not work.\n- Hierarchies, hierarchy levels, calculation items, partitions, roles, perspectives, data sources, and named expressions are outside the scope, so an undescribed hierarchy is never reported here.\n\nRead more: https://pbiplint.com/rules/objects-with-no-description"
|
|
10949
|
+
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\n**After the fix**\n\n```tmdl\n/// One row per order line, loaded nightly from the sales warehouse.\ntable Sales\n\n /// Line amount in US dollars, net of discount and before tax.\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n /// Sum of line amount over whatever the visual is filtered to.\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nThe description is the tooltip a report author sees when hovering a field in the field list, and it is the only place in the model to say what a measure counts, which currency a column is in, or which of two similar fields to use. Without it, every author works that out from the name, and gets it wrong at about the same rate. Descriptions also feed documentation tools, so the same sentence pays off twice.\n\n### How to fix it\n\nIn Power BI Desktop, open Model view, select the object, and type the Description in the Properties pane. In the TMDL file a description is one or more `///` lines directly above the object's declaration, with no blank line between the last `///` line and the declaration: a blank line ends the description, and the object below it is read as having none. With hundreds of objects, Tabular Editor can paste descriptions into many at once from a list.\n\n### When to ignore it\n\nA name that already says the whole thing needs nothing added: `'Date'[Year]` gains nothing from a sentence reading \"the year\". Spend the descriptions on measures, on any column whose unit, currency, grain, or filter behavior is not in its name, and on the two fields a reader has to choose between. A table or column you are about to hide is better hidden than described, because hiding it also clears this finding.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = OBJECTS_WITH_NO_DESCRIPTION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"OBJECTS_WITH_NO_DESCRIPTION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Visibility is the object's own isHidden flag: a visible column inside a hidden table is still reported.\n- A calculation group table is reported once, as a calculation group.\n- A description of only spaces or tabs counts as none, so padding a description to quiet the rule does not work.\n- Hierarchies, hierarchy levels, calculation items, partitions, roles, perspectives, data sources, and named expressions are outside the scope, so an undescribed hierarchy is never reported here.\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 describe or 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/objects-with-no-description"
|
|
9638
10950
|
},
|
|
9639
10951
|
OPENING_PAGE_INVALID: {
|
|
9640
10952
|
text: `Example
|
|
@@ -9773,13 +11085,13 @@ The second pair is a page.json saved in the middle of a merge, with both branche
|
|
|
9773
11085
|
|
|
9774
11086
|
Why it matters
|
|
9775
11087
|
|
|
9776
|
-
pbiplint could not read the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. A line at the root that TMDL does not allow there takes everything under it out of the model: a misspelt table takes the table's columns, measures, and partitions with it, and a column's property or annotation that lost its tabs takes the columns, measures, and partitions after it. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a /// line at all.
|
|
11088
|
+
pbiplint could not read the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. A line at the root, or directly under a model, that TMDL does not allow there takes everything under it out of the model: a misspelt table takes the table's columns, measures, and partitions with it, and a column's property or annotation that lost its tabs takes the columns, measures, and partitions after it. A code fence left open has no end pbiplint can be sure of: pbiplint ends the expression where the indented text under the declaration ends, which keeps what follows in most files, and otherwise runs it to the next line that opens a fence, or to the end of the file, taking the declarations between with it. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor's TMDL reader is stricter and refuses to open a file that puts a blank line after a /// line at all.
|
|
9777
11089
|
|
|
9778
|
-
In a report, a JSON file that is not valid JSON,
|
|
11090
|
+
In a report, a JSON file that is not valid JSON, that carries a conflict marker, or that nests too deep to read safely is not read at all, and neither is a file Microsoft publishes a schema for, such as report.json, when its content is not a JSON object, so nothing in it reaches the rules. A visual whose visual.json fails is left out of every rule. A page whose page.json fails has no display name, size, visibility, or filters as far as the rules know, and nothing to say it is a tooltip or drillthrough page, so the rules that read those say nothing about it; its visuals sit in files of their own and are still checked, and an ignore annotation in the page.json no longer applies. A report.json that fails takes the report's theme, custom visuals, filters, and filter pane settings with it. None of those rules says what it could not read, so here too a result that looks clean may not be.
|
|
9779
11091
|
|
|
9780
11092
|
How to fix it
|
|
9781
11093
|
|
|
9782
|
-
Open the file at the reported line. TMDL is indented with tabs, and a fenced expression opens with three backticks (\`\`\`) after the = and closes with three backticks on a line of its own. Only a few types sit at the root of a TMDL file, such as table, relationship, and role: correct the word the finding names, or, when it names something that belongs inside another object, such as a column, indent it one tab deeper than that object's line. A property or child object sits one tab deeper than the object it belongs to, and a multi-line expression one tab deeper than that object's properties, so check the lines under it by the same rule. A /// description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker. In a report JSON file, the finding names the file and the line where the JSON stops being valid, where a conflict marker sits, or where a document that is not a JSON object starts. Resolve the conflict or restore the JSON at that line. A file whose content is not an object needs its object restored, for example from version control: Microsoft's schema for each file it defines in a report's definition folder puts a JSON object at its root. Then reopen the report in Power BI Desktop to confirm that it loads.
|
|
11094
|
+
Open the file at the reported line. TMDL is indented with tabs, and a fenced expression opens with three backticks (\`\`\`) after the = and closes with three backticks on a line of its own; an unterminated code fence names the declaration whose fence never closes, so add that line where its expression ends. Only a few types sit at the root of a TMDL file, such as table, relationship, and role: correct the word the finding names, or, when it names something that belongs inside another object, such as a column, indent it one tab deeper than that object's line. The same types may sit one tab under the model line instead, and no declaration but the model under the database line: move any other declaration named there to the root of a file, or to the object it belongs to. A table line reported under another object has gained a tab: move it back to the start of the line, with the lines under it one tab shallower too. A declaration with no name needs its name back, and a name with a space, a dot, an equals sign, a colon, or a single quote in it is enclosed in single quotes, with each quote inside doubled, as in 'O''Brien'. A property or child object sits one tab deeper than the object it belongs to, and a multi-line expression one tab deeper than that object's properties, so check the lines under it by the same rule. A /// description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker. In a report JSON file, the finding names the file and the line where the JSON stops being valid, where a conflict marker sits, where the document passes 256 levels of nesting, or where a document that is not a JSON object starts. Resolve the conflict or restore the JSON at that line. Power BI Desktop's own files nest a few dozen levels at most, so a file nested past 256 was generated or edited outside Desktop: restore it from version control, or rebuild what it holds in Desktop and save the report again. A file whose content is not an object needs its object restored, for example from version control: Microsoft's schema for each file it defines in a report's definition folder puts a JSON object at its root. Then reopen the report in Power BI Desktop to confirm that it loads.
|
|
9783
11095
|
|
|
9784
11096
|
When to ignore it
|
|
9785
11097
|
|
|
@@ -9788,7 +11100,7 @@ Not on purpose. A parse issue means the model pbiplint checked is not the model
|
|
|
9788
11100
|
This rule reports on files, so there is no object to annotate. To turn the rule off for a whole project, set "PARSE_ISSUE": "off" under rules in pbiplint.config.json.
|
|
9789
11101
|
|
|
9790
11102
|
Read more: https://pbiplint.com/rules/parse-issue`,
|
|
9791
|
-
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n```\n\nThe first snippet is indented with spaces, so both lines under the table are reported, and the model pbiplint checks has a Sales table with no columns.\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": "d3583278a4fc59eaa0d6",\n<<<<<<< HEAD\n "displayName": "Sales overview",\n=======\n "displayName": "Overview",\n>>>>>>> rename-pages\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280\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": "d3583278a4fc59eaa0d6",\n "displayName": "Sales overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280\n}\n```\n\nThe second pair is a page.json saved in the middle of a merge, with both branches\' names for the page still in it. Each of the three marker lines is reported, and nothing in the file is read until it is valid JSON again.\n\n### Why it matters\n\npbiplint could not read the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. A line at the root that TMDL does not allow there takes everything under it out of the model: a misspelt `table` takes the table\'s columns, measures, and partitions with it, and a column\'s property or annotation that lost its tabs takes the columns, measures, and partitions after it. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor\'s TMDL reader is stricter and refuses to open a file that puts a blank line after a `///` line at all.\n\nIn a report, a JSON file that is not valid JSON,
|
|
11103
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n```\n\nThe first snippet is indented with spaces, so both lines under the table are reported, and the model pbiplint checks has a Sales table with no columns.\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": "d3583278a4fc59eaa0d6",\n<<<<<<< HEAD\n "displayName": "Sales overview",\n=======\n "displayName": "Overview",\n>>>>>>> rename-pages\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280\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": "d3583278a4fc59eaa0d6",\n "displayName": "Sales overview",\n "displayOption": "FitToPage",\n "height": 720,\n "width": 1280\n}\n```\n\nThe second pair is a page.json saved in the middle of a merge, with both branches\' names for the page still in it. Each of the three marker lines is reported, and nothing in the file is read until it is valid JSON again.\n\n### Why it matters\n\npbiplint could not read the line, so whatever it declared, a column, a property, a measure, is missing from the model the rules see. Findings on that object and on anything that references it may be missing or wrong, and a result that looks clean may not be. A line at the root, or directly under a model, that TMDL does not allow there takes everything under it out of the model: a misspelt `table` takes the table\'s columns, measures, and partitions with it, and a column\'s property or annotation that lost its tabs takes the columns, measures, and partitions after it. A code fence left open has no end pbiplint can be sure of: pbiplint ends the expression where the indented text under the declaration ends, which keeps what follows in most files, and otherwise runs it to the next line that opens a fence, or to the end of the file, taking the declarations between with it. The orphaned description is the mild case: no declaration is lost, only the description, which stops at the blank line instead of reaching the object below it, so that object is read as having none. Tabular Editor\'s TMDL reader is stricter and refuses to open a file that puts a blank line after a `///` line at all.\n\nIn a report, a JSON file that is not valid JSON, that carries a conflict marker, or that nests too deep to read safely is not read at all, and neither is a file Microsoft publishes a schema for, such as report.json, when its content is not a JSON object, so nothing in it reaches the rules. A visual whose visual.json fails is left out of every rule. A page whose page.json fails has no display name, size, visibility, or filters as far as the rules know, and nothing to say it is a tooltip or drillthrough page, so the rules that read those say nothing about it; its visuals sit in files of their own and are still checked, and an ignore annotation in the page.json no longer applies. A report.json that fails takes the report\'s theme, custom visuals, filters, and filter pane settings with it. None of those rules says what it could not read, so here too a result that looks clean may not be.\n\n### How to fix it\n\nOpen the file at the reported line. TMDL is indented with tabs, and a fenced expression opens with three backticks (```) after the `=` and closes with three backticks on a line of its own; an unterminated code fence names the declaration whose fence never closes, so add that line where its expression ends. Only a few types sit at the root of a TMDL file, such as `table`, `relationship`, and `role`: correct the word the finding names, or, when it names something that belongs inside another object, such as a `column`, indent it one tab deeper than that object\'s line. The same types may sit one tab under the `model` line instead, and no declaration but the model under the `database` line: move any other declaration named there to the root of a file, or to the object it belongs to. A `table` line reported under another object has gained a tab: move it back to the start of the line, with the lines under it one tab shallower too. A declaration with no name needs its name back, and a name with a space, a dot, an equals sign, a colon, or a single quote in it is enclosed in single quotes, with each quote inside doubled, as in `\'O\'\'Brien\'`. A property or child object sits one tab deeper than the object it belongs to, and a multi-line expression one tab deeper than that object\'s properties, so check the lines under it by the same rule. A `///` description must sit directly above its declaration, with no blank line between them. Power BI Desktop writes valid TMDL, so a parse issue usually means a hand edit or a merge conflict marker. In a report JSON file, the finding names the file and the line where the JSON stops being valid, where a conflict marker sits, where the document passes 256 levels of nesting, or where a document that is not a JSON object starts. Resolve the conflict or restore the JSON at that line. Power BI Desktop\'s own files nest a few dozen levels at most, so a file nested past 256 was generated or edited outside Desktop: restore it from version control, or rebuild what it holds in Desktop and save the report again. A file whose content is not an object needs its object restored, for example from version control: Microsoft\'s schema for each file it defines in a report\'s definition folder puts a JSON object at its root. Then reopen the report in Power BI Desktop to confirm that it loads.\n\n### When to ignore it\n\nNot on purpose. A parse issue means the model pbiplint checked is not the model in the file, or, in a report JSON file, that nothing the file defines was checked, so every other result on that file is in doubt until the line is fixed. The one exception is a line that Power BI Desktop wrote and opens without complaint and pbiplint still reports, in a TMDL file or a report JSON file: that is a gap in pbiplint\'s parser. Report it with the line. Until it is fixed, only the project-wide switch quiets it, and that also hides real parse issues, so weigh the two.\n\nThis rule reports on files, so there is no object to annotate. To turn the rule off for a whole project, set `"PARSE_ISSUE": "off"` under `rules` in `pbiplint.config.json`.\n\nRead more: https://pbiplint.com/rules/parse-issue'
|
|
9792
11104
|
},
|
|
9793
11105
|
PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: {
|
|
9794
11106
|
text: `Example
|
|
@@ -9836,9 +11148,10 @@ To ignore this rule on one object, add annotation pbiplint.ignore = PARTITION_NA
|
|
|
9836
11148
|
Quirks
|
|
9837
11149
|
|
|
9838
11150
|
- The condition tests tables with exactly one partition. A table with none, and a table with several, is never reported, whatever its partitions are called.
|
|
11151
|
+
- 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 hold another partition, or a calculated one, 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.
|
|
9839
11152
|
|
|
9840
11153
|
Read more: https://pbiplint.com/rules/partition-name-should-match-table-name-for-single-partition-tables`,
|
|
9841
|
-
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 partition SalesData = m\n mode: import\n source = Sql.Database("contoso", "Sales")\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 partition Sales = m\n mode: import\n source = Sql.Database("contoso", "Sales")\n```\n\n### Why it matters\n\nA single-partition table has no reason for its partition to carry a different name, and when it does it is usually the table\'s old name from before a rename. Refresh logs, error messages, and the TMDL file all name the partition, so a mismatch sends the reader looking for a table that no longer exists.\n\n### How to fix it\n\nThe partition\'s name sits on its declaration line, so `partition SalesData = m` under `table Sales` becomes `partition Sales = m`. Nothing else in the TMDL file refers to a partition by name, so on a single-partition table the rename changes nothing but the label in the refresh log. Power BI Desktop has no partition list and no field for a partition name: it names the partition when it creates the table and leaves the name alone afterwards, so a mismatch on a Desktop project points at a hand edit, a table renamed outside Desktop, or a model migrated from another tool. If a whole model needs correcting, Tabular Editor\'s partition editor renames partitions without opening each file.\n\n### When to ignore it\n\nA partition that the people who run the refresh already know by another name can keep it, for example one named after the stored procedure that fills it, because that is the name they will look for when the refresh fails. Outside that, a single partition named anything but its table is a leftover, and the rename costs nothing.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The condition tests tables with exactly one partition. A table with none, and a table with several, is never reported, whatever its partitions are called.\n\nRead more: https://pbiplint.com/rules/partition-name-should-match-table-name-for-single-partition-tables'
|
|
11154
|
+
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 partition SalesData = m\n mode: import\n source = Sql.Database("contoso", "Sales")\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 partition Sales = m\n mode: import\n source = Sql.Database("contoso", "Sales")\n```\n\n### Why it matters\n\nA single-partition table has no reason for its partition to carry a different name, and when it does it is usually the table\'s old name from before a rename. Refresh logs, error messages, and the TMDL file all name the partition, so a mismatch sends the reader looking for a table that no longer exists.\n\n### How to fix it\n\nThe partition\'s name sits on its declaration line, so `partition SalesData = m` under `table Sales` becomes `partition Sales = m`. Nothing else in the TMDL file refers to a partition by name, so on a single-partition table the rename changes nothing but the label in the refresh log. Power BI Desktop has no partition list and no field for a partition name: it names the partition when it creates the table and leaves the name alone afterwards, so a mismatch on a Desktop project points at a hand edit, a table renamed outside Desktop, or a model migrated from another tool. If a whole model needs correcting, Tabular Editor\'s partition editor renames partitions without opening each file.\n\n### When to ignore it\n\nA partition that the people who run the refresh already know by another name can keep it, for example one named after the stored procedure that fills it, because that is the name they will look for when the refresh fails. Outside that, a single partition named anything but its table is a leftover, and the rename costs nothing.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The condition tests tables with exactly one partition. A table with none, and a table with several, is never reported, whatever its partitions are called.\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 hold another partition, or a calculated one, 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/partition-name-should-match-table-name-for-single-partition-tables'
|
|
9842
11155
|
},
|
|
9843
11156
|
PERCENTAGE_FORMATTING: {
|
|
9844
11157
|
text: `Example
|
|
@@ -10001,9 +11314,10 @@ Quirks
|
|
|
10001
11314
|
- A format string of nothing but spaces counts as no format string, so formatString: " " is reported.
|
|
10002
11315
|
- A measure with only a dynamic format string passes here but fires INTEGER_FORMATTING, which reads the static format string alone.
|
|
10003
11316
|
- Hidden measures, and measures on hidden tables, are skipped. INTEGER_FORMATTING skips neither.
|
|
11317
|
+
- 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.
|
|
10004
11318
|
|
|
10005
11319
|
Read more: https://pbiplint.com/rules/provide-format-string-for-measures`,
|
|
10006
|
-
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. A label measure that builds a title, and a measure that returns a hex color for conditional formatting, are both reported here 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 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\nRead more: https://pbiplint.com/rules/provide-format-string-for-measures"
|
|
11320
|
+
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. A label measure that builds a title, and a measure that returns a hex color for conditional formatting, are both reported here 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 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"
|
|
10007
11321
|
},
|
|
10008
11322
|
REDUCE_ADVANCED_FILTERS: {
|
|
10009
11323
|
text: `Example
|
|
@@ -11201,12 +12515,13 @@ To ignore this rule on one object, add annotation pbiplint.ignore = REMOVE_AUTO-
|
|
|
11201
12515
|
Quirks
|
|
11202
12516
|
|
|
11203
12517
|
- The table has to be a calculated table as well as carry the name. A loaded table someone called DateTableTemplate_Old is not reported.
|
|
12518
|
+
- A composite model's copy of an auto date/time table is not reported either: 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 the composite model extends. The copy is not a calculated table, so the source rule leaves it out too. The table it reads is calculated in the model it extends, where this rule reports it and where Auto date/time is turned off.
|
|
11204
12519
|
- The name test is case-sensitive and is a prefix, not a substring, so localdatetable_1 is not reported and neither is a table whose name merely contains the prefix further in.
|
|
11205
12520
|
- Only these two names are known. A hidden calendar built by hand, or by another tool under another name, is invisible to this rule.
|
|
11206
12521
|
- DateTableTemplate_ is the template table Desktop keeps; LocalDateTable_ is one table per date column. Both prefixes are reported, so a model with many date columns collects a finding for each of its hidden calendars.
|
|
11207
12522
|
|
|
11208
12523
|
Read more: https://pbiplint.com/rules/remove-auto-date-table`,
|
|
11209
|
-
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\ntable LocalDateTable_8f2c1b4e\n isHidden\n\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 partition LocalDateTable_8f2c1b4e = calculated\n mode: import\n source = Calendar(Date(Year(MIN('Sales'[Order Date])), 1, 1), Date(Year(MAX('Sales'[Order Date])), 12, 31))\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\nWith Auto date/time on, Power BI Desktop creates a hidden calculated date table for every date column in the model, each with its own year, quarter, month, and day hierarchy. A model with a dozen date columns carries a dozen hidden tables, all rebuilt on every refresh, and none of them can be extended with fiscal periods or holidays or shared between fact tables. One real date table does everything they do, once.\n\n### How to fix it\n\nIn Power BI Desktop, open File, Options and settings, Options, then Current file, Data Load, and clear Auto date/time. Desktop deletes the hidden tables and the variations that point at them when the file is saved, so the `table LocalDateTable_...` blocks leave the project's TMDL files on the next save. The option itself is recorded on the model as `annotation __PBI_TimeIntelligenceEnabled`, which Desktop writes and clears for you rather than a property to edit by hand, and deleting the table blocks yourself without clearing the option only invites Desktop to write them again. Then give the model a calendar of its own: add a date table, relate each fact table's date column to it, and mark it with Mark as date table under Table tools, which writes `dataCategory: Time` on the table and `isKey` on its date column. Report authors lose the automatic date hierarchy under each date column when the option goes off, so expect to rebuild the visuals that used one against the new table's Year, Quarter, and Month columns.\n\n### When to ignore it\n\nThere is no lasting exception. These tables are not something a modeler chose, they are what Power BI Desktop falls back to, and every one of them is replaced by the single calendar the model should have anyway. The one honest reason to leave a finding for now is sequencing: in a report with many visuals built on the automatic date hierarchies, clearing the option breaks all of them at once, so the calendar goes in first, the visuals move across, and the option goes off last. That is a plan with a date on it rather than a decision to ignore the rule.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_AUTO-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 `\"REMOVE_AUTO-DATE_TABLE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The table has to be a calculated table as well as carry the name. A loaded table someone called DateTableTemplate_Old is not reported.\n- The name test is case-sensitive and is a prefix, not a substring, so `localdatetable_1` is not reported and neither is a table whose name merely contains the prefix further in.\n- Only these two names are known. A hidden calendar built by hand, or by another tool under another name, is invisible to this rule.\n- DateTableTemplate_ is the template table Desktop keeps; LocalDateTable_ is one table per date column. Both prefixes are reported, so a model with many date columns collects a finding for each of its hidden calendars.\n\nRead more: https://pbiplint.com/rules/remove-auto-date-table"
|
|
12524
|
+
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\ntable LocalDateTable_8f2c1b4e\n isHidden\n\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 partition LocalDateTable_8f2c1b4e = calculated\n mode: import\n source = Calendar(Date(Year(MIN('Sales'[Order Date])), 1, 1), Date(Year(MAX('Sales'[Order Date])), 12, 31))\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\nWith Auto date/time on, Power BI Desktop creates a hidden calculated date table for every date column in the model, each with its own year, quarter, month, and day hierarchy. A model with a dozen date columns carries a dozen hidden tables, all rebuilt on every refresh, and none of them can be extended with fiscal periods or holidays or shared between fact tables. One real date table does everything they do, once.\n\n### How to fix it\n\nIn Power BI Desktop, open File, Options and settings, Options, then Current file, Data Load, and clear Auto date/time. Desktop deletes the hidden tables and the variations that point at them when the file is saved, so the `table LocalDateTable_...` blocks leave the project's TMDL files on the next save. The option itself is recorded on the model as `annotation __PBI_TimeIntelligenceEnabled`, which Desktop writes and clears for you rather than a property to edit by hand, and deleting the table blocks yourself without clearing the option only invites Desktop to write them again. Then give the model a calendar of its own: add a date table, relate each fact table's date column to it, and mark it with Mark as date table under Table tools, which writes `dataCategory: Time` on the table and `isKey` on its date column. Report authors lose the automatic date hierarchy under each date column when the option goes off, so expect to rebuild the visuals that used one against the new table's Year, Quarter, and Month columns.\n\n### When to ignore it\n\nThere is no lasting exception. These tables are not something a modeler chose, they are what Power BI Desktop falls back to, and every one of them is replaced by the single calendar the model should have anyway. The one honest reason to leave a finding for now is sequencing: in a report with many visuals built on the automatic date hierarchies, clearing the option breaks all of them at once, so the calendar goes in first, the visuals move across, and the option goes off last. That is a plan with a date on it rather than a decision to ignore the rule.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_AUTO-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 `\"REMOVE_AUTO-DATE_TABLE\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The table has to be a calculated table as well as carry the name. A loaded table someone called DateTableTemplate_Old is not reported.\n- A composite model's copy of an auto date/time table is not reported either: 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 the composite model extends. The copy is not a calculated table, so the source rule leaves it out too. The table it reads is calculated in the model it extends, where this rule reports it and where Auto date/time is turned off.\n- The name test is case-sensitive and is a prefix, not a substring, so `localdatetable_1` is not reported and neither is a table whose name merely contains the prefix further in.\n- Only these two names are known. A hidden calendar built by hand, or by another tool under another name, is invisible to this rule.\n- DateTableTemplate_ is the template table Desktop keeps; LocalDateTable_ is one table per date column. Both prefixes are reported, so a model with many date columns collects a finding for each of its hidden calendars.\n\nRead more: https://pbiplint.com/rules/remove-auto-date-table"
|
|
11210
12525
|
},
|
|
11211
12526
|
REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS: {
|
|
11212
12527
|
text: `Example
|
|
@@ -11257,9 +12572,10 @@ Quirks
|
|
|
11257
12572
|
- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.
|
|
11258
12573
|
- The text test is a plain substring match on each partition's query or M text, and letter case has to agree. A data source whose name happens to appear in that text, even inside a comment or inside a longer name, counts as referenced, and a name the partition spells with different capitalization does not match at all.
|
|
11259
12574
|
- The source rule searches each table's source expression as well as every partition's query. pbiplint reads the partitions for both, since that is where a TMDL file keeps the text.
|
|
12575
|
+
- 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 partition that uses the data source 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.
|
|
11260
12576
|
|
|
11261
12577
|
Read more: https://pbiplint.com/rules/remove-data-sources-not-referenced-by-any-partitions`,
|
|
11262
|
-
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ndataSource \'Legacy SQL\' = provider\n connectionString: Data Source=contoso;Initial Catalog=Warehouse\n impersonationMode: impersonateServiceAccount\n\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n partition Sales = m\n mode: import\n source = let Source = Sql.Database("contoso", "Sales") in Source\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n partition Sales = m\n mode: import\n source = let Source = Sql.Database("contoso", "Sales") in Source\n```\n\n### Why it matters\n\nAn unused data source is a connection string, and usually a credential, that the model still asks to have configured on every deployment, so the service keeps prompting for credentials to a source nothing reads. It is a leftover from a migration or a source that was replaced.\n\n### How to fix it\n\nDelete the data source. In the project it is a `dataSource` block, usually in a file of its own under the `dataSources` folder: remove the file and the matching `ref` line in `model.tmdl`, or delete the block where it sits. Power BI Desktop does not show data sources, and Transform data, Data source settings lists the sources the queries name rather than these declarations, so the file is the place to do it. If the source is still wanted, point a partition at it instead, with `dataSource:` under the partition\'s `source` block.\n\n### When to ignore it\n\nA model whose partitions are created after it is deployed, by a pipeline or a script that binds them to a source the files do not yet name, keeps the declaration on purpose, and the finding on it is noise. So is a source you have just added and are about to wire up. Outside those, a data source nothing reads is a leftover, and the credential prompt it produces on every deployment is the cost of keeping it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Tabular Editor 3 CLI 0.5.2 did not report this rule when loading from TMDL, although it did from .bim; pbiplint follows the rule text.\n- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.\n- The text test is a plain substring match on each partition\'s query or M text, and letter case has to agree. A data source whose name happens to appear in that text, even inside a comment or inside a longer name, counts as referenced, and a name the partition spells with different capitalization does not match at all.\n- The source rule searches each table\'s source expression as well as every partition\'s query. pbiplint reads the partitions for both, since that is where a TMDL file keeps the text.\n\nRead more: https://pbiplint.com/rules/remove-data-sources-not-referenced-by-any-partitions'
|
|
12578
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ndataSource \'Legacy SQL\' = provider\n connectionString: Data Source=contoso;Initial Catalog=Warehouse\n impersonationMode: impersonateServiceAccount\n\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n partition Sales = m\n mode: import\n source = let Source = Sql.Database("contoso", "Sales") in Source\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n partition Sales = m\n mode: import\n source = let Source = Sql.Database("contoso", "Sales") in Source\n```\n\n### Why it matters\n\nAn unused data source is a connection string, and usually a credential, that the model still asks to have configured on every deployment, so the service keeps prompting for credentials to a source nothing reads. It is a leftover from a migration or a source that was replaced.\n\n### How to fix it\n\nDelete the data source. In the project it is a `dataSource` block, usually in a file of its own under the `dataSources` folder: remove the file and the matching `ref` line in `model.tmdl`, or delete the block where it sits. Power BI Desktop does not show data sources, and Transform data, Data source settings lists the sources the queries name rather than these declarations, so the file is the place to do it. If the source is still wanted, point a partition at it instead, with `dataSource:` under the partition\'s `source` block.\n\n### When to ignore it\n\nA model whose partitions are created after it is deployed, by a pipeline or a script that binds them to a source the files do not yet name, keeps the declaration on purpose, and the finding on it is noise. So is a source you have just added and are about to wire up. Outside those, a data source nothing reads is a leftover, and the credential prompt it produces on every deployment is the cost of keeping it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Tabular Editor 3 CLI 0.5.2 did not report this rule when loading from TMDL, although it did from .bim; pbiplint follows the rule text.\n- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.\n- The text test is a plain substring match on each partition\'s query or M text, and letter case has to agree. A data source whose name happens to appear in that text, even inside a comment or inside a longer name, counts as referenced, and a name the partition spells with different capitalization does not match at all.\n- The source rule searches each table\'s source expression as well as every partition\'s query. pbiplint reads the partitions for both, since that is where a TMDL file keeps the text.\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 partition that uses the data source 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/remove-data-sources-not-referenced-by-any-partitions'
|
|
11263
12579
|
},
|
|
11264
12580
|
REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES: {
|
|
11265
12581
|
text: `Example
|
|
@@ -11344,9 +12660,10 @@ Quirks
|
|
|
11344
12660
|
- Any column on the related table counts as the duplicate, the relationship key and hidden columns included.
|
|
11345
12661
|
- Cardinality is not tested, only the from and to sides of the relationship, so a one-to-one relationship counts the same as a many-to-one.
|
|
11346
12662
|
- All three column kinds are in scope, so a loaded column, a DAX calculated column, and a calculated table's column are read alike.
|
|
12663
|
+
- 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 column's own relationship 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.
|
|
11347
12664
|
|
|
11348
12665
|
Read more: https://pbiplint.com/rules/remove-redundant-columns-in-related-tables`,
|
|
11349
|
-
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n isHidden\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\n column Amount\n dataType: decimal\n summarizeBy: sum\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 isHidden\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n summarizeBy: sum\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\nA fact table row that carries the product name as well as the product key stores the name once per sale instead of once per product, and offers the report author two Product Name fields that behave differently: the fact table's version cannot filter other fact tables and shows only the names that have sales. The dimension's copy is the one that should exist.\n\n### How to fix it\n\nStop loading the column on the fact table. In Power BI Desktop choose Transform data, select the fact table's 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. A duplicate that is a DAX calculated column goes instead by right-clicking it in the Data pane and choosing Delete from model, or by removing its `column Name = expression` block from the table's TMDL file. Then point the visuals that used it at the dimension's column, which filters the way a report author expects, and where a measure needs the value at row grain, reach it with RELATED inside the measure rather than storing it again. Hiding the column is not a fix here: the rule never reads a column's visibility, so a hidden copy is reported exactly as a visible one is, and it still costs the same memory.\n\n### When to ignore it\n\nCoincidence is the common false alarm. The test is the column's name and nothing else, so `'Sales'[Description]` and `'Product'[Description]` are reported as duplicates when one is a line note and the other a catalogue blurb. Read both columns before removing either. A copy kept on purpose is the other legitimate case: a fact table that carries a snapshot of the attribute as it was on the order date is not the dimension's current value and must not be replaced by it, and a column the source system denormalized for a composite key can be cheaper to keep than to rebuild. Check whether the two columns hold the same values for the same key, which a quick visual of both will answer, and ignore the finding where they do not. A plain copy of a slowly changing dimension's current name on a large fact table is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Matching is by column name only, exactly and with letter case respected, so two unrelated columns that happen to share a name fire too, and renaming the copy clears the finding without removing anything.\n- The direction matters. Only a column on the from side's table is reported, so the dimension's own copy is never named, and a column on the to side that duplicates a fact table column is not reported either.\n- A column that takes part in any relationship, on either side, is never reported. A foreign key whose name matches the dimension's key is therefore out of scope.\n- Any column on the related table counts as the duplicate, the relationship key and hidden columns included.\n- Cardinality is not tested, only the from and to sides of the relationship, so a one-to-one relationship counts the same as a many-to-one.\n- All three column kinds are in scope, so a loaded column, a DAX calculated column, and a calculated table's column are read alike.\n\nRead more: https://pbiplint.com/rules/remove-redundant-columns-in-related-tables"
|
|
12666
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Product ID'\n dataType: int64\n isHidden\n sourceColumn: ProductID\n\n column 'Product Name'\n dataType: string\n sourceColumn: ProductName\n\n column Amount\n dataType: decimal\n summarizeBy: sum\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 isHidden\n sourceColumn: ProductID\n\n column Amount\n dataType: decimal\n summarizeBy: sum\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\nA fact table row that carries the product name as well as the product key stores the name once per sale instead of once per product, and offers the report author two Product Name fields that behave differently: the fact table's version cannot filter other fact tables and shows only the names that have sales. The dimension's copy is the one that should exist.\n\n### How to fix it\n\nStop loading the column on the fact table. In Power BI Desktop choose Transform data, select the fact table's 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. A duplicate that is a DAX calculated column goes instead by right-clicking it in the Data pane and choosing Delete from model, or by removing its `column Name = expression` block from the table's TMDL file. Then point the visuals that used it at the dimension's column, which filters the way a report author expects, and where a measure needs the value at row grain, reach it with RELATED inside the measure rather than storing it again. Hiding the column is not a fix here: the rule never reads a column's visibility, so a hidden copy is reported exactly as a visible one is, and it still costs the same memory.\n\n### When to ignore it\n\nCoincidence is the common false alarm. The test is the column's name and nothing else, so `'Sales'[Description]` and `'Product'[Description]` are reported as duplicates when one is a line note and the other a catalogue blurb. Read both columns before removing either. A copy kept on purpose is the other legitimate case: a fact table that carries a snapshot of the attribute as it was on the order date is not the dimension's current value and must not be replaced by it, and a column the source system denormalized for a composite key can be cheaper to keep than to rebuild. Check whether the two columns hold the same values for the same key, which a quick visual of both will answer, and ignore the finding where they do not. A plain copy of a slowly changing dimension's current name on a large fact table is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Matching is by column name only, exactly and with letter case respected, so two unrelated columns that happen to share a name fire too, and renaming the copy clears the finding without removing anything.\n- The direction matters. Only a column on the from side's table is reported, so the dimension's own copy is never named, and a column on the to side that duplicates a fact table column is not reported either.\n- A column that takes part in any relationship, on either side, is never reported. A foreign key whose name matches the dimension's key is therefore out of scope.\n- Any column on the related table counts as the duplicate, the relationship key and hidden columns included.\n- Cardinality is not tested, only the from and to sides of the relationship, so a one-to-one relationship counts the same as a many-to-one.\n- All three column kinds are in scope, so a loaded column, a DAX calculated column, and a calculated table's column are read alike.\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 column's own relationship 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/remove-redundant-columns-in-related-tables"
|
|
11350
12667
|
},
|
|
11351
12668
|
REMOVE_ROLES_WITH_NO_MEMBERS: {
|
|
11352
12669
|
text: `Example
|
|
@@ -11544,12 +12861,12 @@ This rule reports on measures defined in the report, and pbiplint reads no annot
|
|
|
11544
12861
|
|
|
11545
12862
|
Quirks
|
|
11546
12863
|
|
|
11547
|
-
- The rule reports only when the model the report reads is in the input. A report whose definition.pbir connects to a published model is left alone on purpose: Microsoft presents report measures as the way an author who builds on a shared semantic model through a live connection, which cannot change the model itself, adds calculations of their own. Such a report is linted without a model, as is a report given on its own, and the skipped line gives the reason, such as this report reads a published model or
|
|
12864
|
+
- The rule reports only when the model the report reads is in the input. A report whose definition.pbir connects to a published model is left alone on purpose: Microsoft presents report measures as the way an author who builds on a shared semantic model through a live connection, which cannot change the model itself, adds calculations of their own. Such a report is linted without a model, as is a report given on its own, and the skipped line gives the reason, such as this report reads a published model or this report reads ../Sales.SemanticModel, which this run did not include.
|
|
11548
12865
|
- pbiplint reads no annotation on a measure in reportExtensions.json, so the rule is turned off for a whole project rather than for one measure.
|
|
11549
12866
|
- A measure with an empty expression is reported like any other.
|
|
11550
12867
|
|
|
11551
12868
|
Read more: https://pbiplint.com/rules/report-level-measures`,
|
|
11552
|
-
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 reportExtensions.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/reportExtension/1.0.0/schema.json",\n "name": "extension",\n "entities": [\n {\n "name": "Sales",\n "measures": [\n {\n "name": "Sales per Region",\n "dataType": "Double",\n "expression": "DIVIDE([Total Sales], DISTINCTCOUNT(\'Sales\'[Region]))"\n }\n ]\n }\n ]\n}\n```\n\n**After the fix in reportExtensions.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/reportExtension/1.0.0/schema.json",\n "name": "extension",\n "entities": []\n}\n```\n\nThe report defines Sales per Region for itself, on the Sales table, so the finding reads `[Sales per Region] (report)` with `defined in the report on table "Sales"`. The fixed file shows the report\'s side of the move: once the measure is in the model\'s Sales table, as How to fix it describes, the report defines none.\n\n### Why it matters\n\nA report measure is a calculation created directly within a report, without altering the semantic model, so the model never carries it and only the report that defines it can use it. Another report on the same model does not have it. When the model is in your hands, as it is when the report reads it by path from the same project, a calculation kept in one report splits the model\'s logic in two: the next author looking for it in the model does not find it, and a second report that needs it gets a copy of its own, free to drift from the first.\n\npbiplint checks such a measure less well too. Its DAX rules read the model\'s measures only, so none of them looks at a report measure\'s expression, and `BROKEN_FIELD_REFERENCE` does not report a field that a report measure\'s DAX names and the model lacks.\n\n### How to fix it\n\nMove the measure into the model, with the same name, on the same table, with the same DAX, then point the report at it.\n\nTake the measure\'s `name` and `expression` from its entry in reportExtensions.json, with any other property it sets, such as `formatString`, `displayFolder`, `description`, `hidden`, or `dataCategory`. Then delete that entry, and the entity with it when it holds no other measure, as in the example. Do this before the model gets the measure: the schema for reportExtensions.json asks that a report measure\'s name be unique across the model, so the two should not exist side by side.\n\nCreate the measure in the model. A report that reads its model by path opens that model for editing in Power BI Desktop: right-click the table in the Data pane, or hover over it and select More options (...), choose New measure, and type the name and the DAX into the formula bar. A measure created from a table\'s menu is saved in that table. In the model\'s TMDL, the same measure is a `measure` under the table in its file, as `measure \'Sales per Region\' = DIVIDE([Total Sales], DISTINCTCOUNT(\'Sales\'[Region]))`, with its other properties set to match.\n\nThen point the report at the model\'s measure. A reference to a report measure names the report\'s extension: `"Schema": "extension"` sits on the reference\'s `SourceRef`, beside the `Entity`, or, in a filter\'s condition, on the `From` entry its alias names, where a reference to a model measure has no `Schema`. Microsoft\'s schemas describe the key: in a query, `Schema` is "the name of the schema containing the referenced entity" and can be left out, and where a report measure lists the measures its DAX uses, the schema is left empty for a model measure and set to the extension\'s name for one of the extension\'s own. So every well, filter, sort, and bookmark that used the measure still names it in the extension after the move. In Power BI Desktop, add the model\'s measure from the Data pane to each well, filter, and sort that used the report measure, and for a bookmark that captured it, select the bookmark and choose Update from its More options menu. In the report\'s JSON, remove the `"Schema": "extension"` key from every reference to the measure, in each visual.json and in the bookmarks.\n\nLint again when you are done. `BROKEN_FIELD_REFERENCE` reports every reference still naming the extension, and each finding\'s detail depends on whether reportExtensions.json is still there. While it is, as in the fixed example, the detail reads `[Sales per Region]: no measure named "Sales per Region" on "Sales" in the report\'s extension`. A report\'s definition folder does not require the file, so once its `entities` list is empty you can delete it instead, and then the detail reads `[Sales per Region]: no measure named "Sales per Region" on "Sales": the report defines no extension measures`.\n\n### When to ignore it\n\nA report measure is the supported route for an author who cannot change the model, and such a report can still reach pbiplint beside the model. When the model and the report share a workspace, Fabric Git integration always exports the report with a reference by path to the model. Microsoft describes keeping a second .pbir file that connects to the published model, so that the report opens in live connect to work with report-level measures, while definition.pbir, the file pbiplint reads, keeps its reference by path. If that is how the report is built and the model belongs to someone else, ignore the finding. When the model is yours to change, a measure rarely has a reason to stay in one report.\n\nThis rule reports on measures defined in the report, and pbiplint reads no annotation on them, so there is no object to annotate. To turn the rule off for a whole project, set `"REPORT_LEVEL_MEASURES": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reports only when the model the report reads is in the input. A report whose definition.pbir connects to a published model is left alone on purpose: Microsoft presents report measures as the way an author who builds on a shared semantic model through a live connection, which cannot change the model itself, adds calculations of their own. Such a report is linted without a model, as is a report given on its own, and the skipped line gives the reason, such as `this report reads a published model` or `
|
|
12869
|
+
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 reportExtensions.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/reportExtension/1.0.0/schema.json",\n "name": "extension",\n "entities": [\n {\n "name": "Sales",\n "measures": [\n {\n "name": "Sales per Region",\n "dataType": "Double",\n "expression": "DIVIDE([Total Sales], DISTINCTCOUNT(\'Sales\'[Region]))"\n }\n ]\n }\n ]\n}\n```\n\n**After the fix in reportExtensions.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/reportExtension/1.0.0/schema.json",\n "name": "extension",\n "entities": []\n}\n```\n\nThe report defines Sales per Region for itself, on the Sales table, so the finding reads `[Sales per Region] (report)` with `defined in the report on table "Sales"`. The fixed file shows the report\'s side of the move: once the measure is in the model\'s Sales table, as How to fix it describes, the report defines none.\n\n### Why it matters\n\nA report measure is a calculation created directly within a report, without altering the semantic model, so the model never carries it and only the report that defines it can use it. Another report on the same model does not have it. When the model is in your hands, as it is when the report reads it by path from the same project, a calculation kept in one report splits the model\'s logic in two: the next author looking for it in the model does not find it, and a second report that needs it gets a copy of its own, free to drift from the first.\n\npbiplint checks such a measure less well too. Its DAX rules read the model\'s measures only, so none of them looks at a report measure\'s expression, and `BROKEN_FIELD_REFERENCE` does not report a field that a report measure\'s DAX names and the model lacks.\n\n### How to fix it\n\nMove the measure into the model, with the same name, on the same table, with the same DAX, then point the report at it.\n\nTake the measure\'s `name` and `expression` from its entry in reportExtensions.json, with any other property it sets, such as `formatString`, `displayFolder`, `description`, `hidden`, or `dataCategory`. Then delete that entry, and the entity with it when it holds no other measure, as in the example. Do this before the model gets the measure: the schema for reportExtensions.json asks that a report measure\'s name be unique across the model, so the two should not exist side by side.\n\nCreate the measure in the model. A report that reads its model by path opens that model for editing in Power BI Desktop: right-click the table in the Data pane, or hover over it and select More options (...), choose New measure, and type the name and the DAX into the formula bar. A measure created from a table\'s menu is saved in that table. In the model\'s TMDL, the same measure is a `measure` under the table in its file, as `measure \'Sales per Region\' = DIVIDE([Total Sales], DISTINCTCOUNT(\'Sales\'[Region]))`, with its other properties set to match.\n\nThen point the report at the model\'s measure. A reference to a report measure names the report\'s extension: `"Schema": "extension"` sits on the reference\'s `SourceRef`, beside the `Entity`, or, in a filter\'s condition, on the `From` entry its alias names, where a reference to a model measure has no `Schema`. Microsoft\'s schemas describe the key: in a query, `Schema` is "the name of the schema containing the referenced entity" and can be left out, and where a report measure lists the measures its DAX uses, the schema is left empty for a model measure and set to the extension\'s name for one of the extension\'s own. So every well, filter, sort, and bookmark that used the measure still names it in the extension after the move. In Power BI Desktop, add the model\'s measure from the Data pane to each well, filter, and sort that used the report measure, and for a bookmark that captured it, select the bookmark and choose Update from its More options menu. In the report\'s JSON, remove the `"Schema": "extension"` key from every reference to the measure, in each visual.json and in the bookmarks.\n\nLint again when you are done. `BROKEN_FIELD_REFERENCE` reports every reference still naming the extension, and each finding\'s detail depends on whether reportExtensions.json is still there. While it is, as in the fixed example, the detail reads `[Sales per Region]: no measure named "Sales per Region" on "Sales" in the report\'s extension`. A report\'s definition folder does not require the file, so once its `entities` list is empty you can delete it instead, and then the detail reads `[Sales per Region]: no measure named "Sales per Region" on "Sales": the report defines no extension measures`.\n\n### When to ignore it\n\nA report measure is the supported route for an author who cannot change the model, and such a report can still reach pbiplint beside the model. When the model and the report share a workspace, Fabric Git integration always exports the report with a reference by path to the model. Microsoft describes keeping a second .pbir file that connects to the published model, so that the report opens in live connect to work with report-level measures, while definition.pbir, the file pbiplint reads, keeps its reference by path. If that is how the report is built and the model belongs to someone else, ignore the finding. When the model is yours to change, a measure rarely has a reason to stay in one report.\n\nThis rule reports on measures defined in the report, and pbiplint reads no annotation on them, so there is no object to annotate. To turn the rule off for a whole project, set `"REPORT_LEVEL_MEASURES": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reports only when the model the report reads is in the input. A report whose definition.pbir connects to a published model is left alone on purpose: Microsoft presents report measures as the way an author who builds on a shared semantic model through a live connection, which cannot change the model itself, adds calculations of their own. Such a report is linted without a model, as is a report given on its own, and the skipped line gives the reason, such as `this report reads a published model` or `this report reads ../Sales.SemanticModel, which this run did not include`.\n- pbiplint reads no annotation on a measure in reportExtensions.json, so the rule is turned off for a whole project rather than for one measure.\n- A measure with an empty expression is reported like any other.\n\nRead more: https://pbiplint.com/rules/report-level-measures'
|
|
11553
12870
|
},
|
|
11554
12871
|
SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: {
|
|
11555
12872
|
text: `Example
|
|
@@ -11868,7 +13185,7 @@ To ignore this rule on one visual, add { "name": "pbiplint.ignore", "value": "SL
|
|
|
11868
13185
|
|
|
11869
13186
|
Quirks
|
|
11870
13187
|
|
|
11871
|
-
- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and
|
|
13188
|
+
- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer's filterConfig, is a visual-level filter, not the slicer's selection, and this rule does not report it. HARDCODED_YEAR_IN_FILTER reports one that holds a year column to fixed years.
|
|
11872
13189
|
- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.
|
|
11873
13190
|
- In Power BI Desktop's saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.
|
|
11874
13191
|
- Each synced copy of a slicer is reported on its own page, since Power BI Desktop's saved files write the selection into every copy.
|
|
@@ -11877,7 +13194,7 @@ Quirks
|
|
|
11877
13194
|
- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.
|
|
11878
13195
|
|
|
11879
13196
|
Read more: https://pbiplint.com/rules/slicer-selection-saved`,
|
|
11880
|
-
markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and
|
|
13197
|
+
markdown: '### Example\n\nWithout a policy the finding is info. The config below applies to both documents and sets the policy, `expect` set to `none`, which raises the finding to a warning.\n\n**pbiplint.config.json**\n\n```json\n{\n "rules": {\n "SLICER_SELECTION_SAVED": { "expect": "none" }\n }\n}\n```\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } },\n "filter": {\n "filter": {\n "Version": 2,\n "From": [{ "Name": "s", "Entity": "Sales", "Type": 0 }],\n "Where": [\n {\n "Condition": {\n "In": {\n "Expressions": [\n { "Column": { "Expression": { "SourceRef": { "Source": "s" } }, "Property": "Region" } }\n ],\n "Values": [[{ "Literal": { "Value": "\'West\'" } }]]\n }\n }\n }\n ]\n }\n }\n }\n }\n ]\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "096193a525ec376a0147",\n "position": { "x": 432, "y": 222, "z": 10000, "height": 62, "width": 416, "tabOrder": 10000 },\n "visual": {\n "visualType": "slicer",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region",\n "active": true\n }\n ]\n }\n }\n },\n "objects": {\n "data": [\n { "properties": { "mode": { "expr": { "Literal": { "Value": "\'Basic\'" } } } } }\n ],\n "general": [\n {\n "properties": {\n "orientation": { "expr": { "Literal": { "Value": "1D" } } }\n }\n }\n ]\n }\n }\n}\n```\n\nThe Region slicer was saved with West selected, so every reader starts with the page filtered to the West region, and the finding reads `slicer (096193) on "Overview"` with `opens with this selection applied`. The fix clears the selection, which takes the `filter` out of `objects.general` and leaves the slicer\'s other settings as they were.\n\n### Why it matters\n\nA slicer narrows what the other visuals show, and its selection is saved with the report: "The slicer saves the selected values," in the words of Microsoft\'s troubleshooting guidance, which goes on to warn: "Report authors should avoid saving and publishing reports with selected items that might be inappropriate for certain users, particularly in environments that use row-level security (RLS)." It recommends clearing any selection that shouldn\'t apply to everyone before saving and distributing a report, and points out that a saved selection can stop being relevant or appropriate when the data or a user\'s permissions change.\n\nA saved selection is also what readers come back to. Microsoft says readers can always return to the state the author published with the Reset to default button, so a value left selected while the author checked one region becomes every reader\'s starting point, and the state Reset to default restores.\n\n### How to fix it\n\nIn Power BI Desktop, clear the slicer and save the report in that state, as Microsoft recommends before publishing: select the slicer\'s Clear button, an eraser icon, then save. On the original slicer the Clear button sits in the Slicer header and shows when you hover over it; on the Slicer (new) visuals it sits in the Visual container header. Either way, if the header that holds it is turned off, turn that header on first, as Microsoft advises for the Slicer header, since the Clear button is not there without it. Check any bookmark that captures the slicer as well, since a bookmark saves slicer state of its own.\n\nA custom slicer from AppSource that filters through the Visual Filters API sets and clears that filter itself, as Microsoft\'s guidance for building Power BI visuals describes, so the controls for clearing it are the visual\'s own. In Power BI Desktop, clear it on the visual the way it offers, then save the report in that state. The Chiclet Slicer, for one, is cleared with the Clear button in the visual\'s top right corner, by its README in Microsoft\'s repository for the visual. Check any bookmark that captures it as well, since Microsoft\'s guidance for building visuals says that when a reader switches bookmarks, Power BI hands the visual the filter the bookmark holds.\n\nIn visual.json, the selection is the `filter` property under `objects.general[0].properties`, on a custom slicer as on the slicers built into Power BI: remove it, as the example does, and leave the rest of `general` as it is.\n\n### When to ignore it\n\nA default selection readers are meant to start from, which Microsoft endorses for slicers other than range slicers: "you might intentionally save a default selection so that report consumers start with a specific set of filters." Examples are a button slicer or list slicer with Force selection on, or an original slicer with Single select on, which Microsoft\'s visual capability data describes the same way: only one item can be chosen, and the first available is chosen when none is. Others are a slicer saved on the scenario a page opens in, such as Actual rather than Budget, and a field parameter slicer saved on the field the visuals should open with. Microsoft recommends saving range slicers cleared, and says date range slicers typically work best when they start that way, so a saved range deserves a second look. Without a policy the finding is info, a prompt to check each selection; with `expect` set to `none`, ignore it on the slicers whose default is deliberate.\n\nTo ignore this rule on one visual, add `{ "name": "pbiplint.ignore", "value": "SLICER_SELECTION_SAVED" }` to the `annotations` array of its visual.json. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"SLICER_SELECTION_SAVED": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A filter on the slicer in the Filters pane, which visual.json keeps in the slicer\'s `filterConfig`, is a visual-level filter, not the slicer\'s selection, and this rule does not report it. `HARDCODED_YEAR_IN_FILTER` reports one that holds a year column to fixed years.\n- Select all saves no selection. Microsoft says it produces the same filtering result as clearing the slicer, and that Power BI does not store each item as a selection. Clearing items after Select all is a selection, though: Power BI applies an is not filter holding the cleared items, and that is reported.\n- In Power BI Desktop\'s saved files, a range or relative date slicer saves its value the same way as a list of picked items, so it is reported the same way.\n- Each synced copy of a slicer is reported on its own page, since Power BI Desktop\'s saved files write the selection into every copy.\n- A hidden slicer is reported like a visible one. Microsoft notes that slicers continue to filter a report page whether or not they are visible.\n- A selection is read by where it sits, not by the visual\'s type, so custom slicers from AppSource, such as the Chiclet Slicer, the Hierarchy Slicer, and the Text Filter, are reported the same way as the slicers built into Power BI. Microsoft\'s guidance for building Power BI visuals says a visual that filters through the Visual Filters API declares a `filter` in the `general` section of its capabilities and applies its filter there. Separately, Power BI Desktop\'s saved files keep these slicers\' selections in that `filter` under `objects.general`, and in those files every kind of visual that carries that `filter` is one that filters the report.\n- Only the slicer as saved in visual.json is read. A bookmark that captures a different selection is not.\n\nRead more: https://pbiplint.com/rules/slicer-selection-saved'
|
|
11881
13198
|
},
|
|
11882
13199
|
SNOWFLAKE_SCHEMA_ARCHITECTURE: {
|
|
11883
13200
|
text: `Example
|
|
@@ -12270,6 +13587,181 @@ Quirks
|
|
|
12270
13587
|
Read more: https://pbiplint.com/rules/trim-object-names`,
|
|
12271
13588
|
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID '\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM(Sales[Amount])\n```\n\n### Why it matters\n\nA leading or trailing space is invisible in the field list but part of the name, so \"Sales \" and \"Sales\" are two objects to the engine, and a DAX reference, a visual binding, or a deployment script that uses the trimmed name fails against something that looks correct. The space usually arrives with a source column name or a paste. `OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE` reports the same names at error severity for a narrower set of object types.\n\n### How to fix it\n\nIn Power BI Desktop, double-click the field in the Data pane and retype the name without the space, or select the object and clear the space from Name in the Properties pane. In the TMDL file the name is on the declaration line, so `column 'Order ID '` becomes `column 'Order ID'`. A data column carries its own `sourceColumn`, so the model name and the source column name are independent and trimming the model name does not change what the refresh loads. Renaming in Desktop updates the visuals bound to the field; a hand edit in the TMDL file does not, so open the report and check its visuals after one. Where the space arrives with a source column name, trim it in Power Query as well, so the next column you add from that query comes in clean.\n\n### When to ignore it\n\nNo name should start or end with a space. A leading space is sometimes used to push a measure to the top of the field list; a display folder gathers the measure with the ones it belongs beside, and it does that without hiding a character in the name.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = TRIM_OBJECT_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"TRIM_OBJECT_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is for a space character at the start or the end of the name. A name padded with a tab is not reported here; `SPECIAL_CHARS_IN_OBJECT_NAMES` covers that one.\n- The model object is in scope, and a finding on the model is always named `Model` whatever the model is called, so a stray space in the model's own name gives a finding with nothing in the line to show it. The model's name is on the `model` line in the TMDL file.\n\nRead more: https://pbiplint.com/rules/trim-object-names"
|
|
12272
13589
|
},
|
|
13590
|
+
UDF_NOT_CALLED: {
|
|
13591
|
+
text: `Example
|
|
13592
|
+
|
|
13593
|
+
Fires the rule
|
|
13594
|
+
|
|
13595
|
+
table Sales
|
|
13596
|
+
column Amount
|
|
13597
|
+
dataType: decimal
|
|
13598
|
+
sourceColumn: Amount
|
|
13599
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13600
|
+
formatString: #,0
|
|
13601
|
+
|
|
13602
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13603
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13604
|
+
|
|
13605
|
+
/// Adds 20 percent VAT to an amount.
|
|
13606
|
+
function 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2
|
|
13607
|
+
|
|
13608
|
+
After the fix
|
|
13609
|
+
|
|
13610
|
+
table Sales
|
|
13611
|
+
column Amount
|
|
13612
|
+
dataType: decimal
|
|
13613
|
+
sourceColumn: Amount
|
|
13614
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13615
|
+
formatString: #,0
|
|
13616
|
+
|
|
13617
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13618
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13619
|
+
|
|
13620
|
+
Why it matters
|
|
13621
|
+
|
|
13622
|
+
A function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.
|
|
13623
|
+
|
|
13624
|
+
It also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so UNNECESSARY_MEASURES and UNNECESSARY_COLUMNS pass over everything the dead function names. Deleting the function brings those to light.
|
|
13625
|
+
|
|
13626
|
+
A DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.
|
|
13627
|
+
|
|
13628
|
+
How to fix it
|
|
13629
|
+
|
|
13630
|
+
Delete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.
|
|
13631
|
+
|
|
13632
|
+
In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from definition/functions.tmdl: its /// description lines, its function line, and the lines indented under it. For a package, delete each function that carries its DAXLIB_PackageId annotation.
|
|
13633
|
+
|
|
13634
|
+
When to ignore it
|
|
13635
|
+
|
|
13636
|
+
When something outside the model's own DAX calls the function:
|
|
13637
|
+
|
|
13638
|
+
- A DAX query, such as a test harness that runs a model's test functions from outside it.
|
|
13639
|
+
- A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).
|
|
13640
|
+
- A visual calculation, which pbiplint does not read.
|
|
13641
|
+
|
|
13642
|
+
Or when the function is kept on purpose, such as one written ahead of the measures that will call it.
|
|
13643
|
+
|
|
13644
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_NOT_CALLED under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_NOT_CALLED": "off" under rules in pbiplint.config.json.
|
|
13645
|
+
|
|
13646
|
+
Quirks
|
|
13647
|
+
|
|
13648
|
+
- A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so MySales.Tax( is not a call to Sales.Tax. A call written inside a string or a comment is not a call.
|
|
13649
|
+
- The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.
|
|
13650
|
+
- A package is known by the DAXLIB_PackageId annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.
|
|
13651
|
+
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
13652
|
+
|
|
13653
|
+
Read more: https://pbiplint.com/rules/udf-not-called`,
|
|
13654
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n\n/// Adds 20 percent VAT to an amount.\nfunction 'Local.AddVat' = (amount: NUMERIC) => amount * 1.2\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function nothing calls is dead code that looks alive. It sits under Functions in Model explorer beside the ones in use, and anyone writing DAX in the model can find it and call it, with nothing to say whether it still does what its name promises or was left behind by a rewrite.\n\nIt also keeps other dead code alive. A hidden measure or column that a function names counts as used, whether or not anything calls the function, so `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` pass over everything the dead function names. Deleting the function brings those to light.\n\nA DAX Lib package installs all of its functions at once, so a model that calls one of them carries the rest unused as a matter of course. The rule reports a package only when none of it is called: the whole library was installed and never used.\n\n### How to fix it\n\nDelete the function, after checking the callers pbiplint cannot see, listed under When to ignore it.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, and choose Delete from model ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). In TMDL, remove the function's block from `definition/functions.tmdl`: its `///` description lines, its `function` line, and the lines indented under it. For a package, delete each function that carries its `DAXLIB_PackageId` annotation.\n\n### When to ignore it\n\nWhen something outside the model's own DAX calls the function:\n\n- A DAX query, such as a test harness that runs a model's test functions from outside it.\n- A report's own measures, which this rule does not read, including a live-connected report's, which can call the functions of the model it connects to ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)).\n- A visual calculation, which pbiplint does not read.\n\nOr when the function is kept on purpose, such as one written ahead of the measures that will call it.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_NOT_CALLED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_NOT_CALLED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A call is the function's whole name, dots included, in any letter case, followed by an opening parenthesis, so `MySales.Tax(` is not a call to `Sales.Tax`. A call written inside a string or a comment is not a call.\n- The rule looks one step. A function that only an uncalled function calls is not reported until that caller is gone, and then the next run reports it.\n- A package is known by the `DAXLIB_PackageId` annotation that Power BI Desktop keeps when it installs a package from DAX Lib. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are each checked as the model's own.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that calls the function could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/udf-not-called"
|
|
13655
|
+
},
|
|
13656
|
+
UDF_USE_COMPOUND_NAMES: {
|
|
13657
|
+
text: `Example
|
|
13658
|
+
|
|
13659
|
+
Fires the rule
|
|
13660
|
+
|
|
13661
|
+
table Sales
|
|
13662
|
+
column Amount
|
|
13663
|
+
dataType: decimal
|
|
13664
|
+
sourceColumn: Amount
|
|
13665
|
+
measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))
|
|
13666
|
+
formatString: #,0
|
|
13667
|
+
|
|
13668
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13669
|
+
function AddTax = (amount: NUMERIC) => amount * 1.1
|
|
13670
|
+
|
|
13671
|
+
After the fix
|
|
13672
|
+
|
|
13673
|
+
table Sales
|
|
13674
|
+
column Amount
|
|
13675
|
+
dataType: decimal
|
|
13676
|
+
sourceColumn: Amount
|
|
13677
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13678
|
+
formatString: #,0
|
|
13679
|
+
|
|
13680
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13681
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13682
|
+
|
|
13683
|
+
Why it matters
|
|
13684
|
+
|
|
13685
|
+
Most of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name "Must not conflict with built-in DAX functions" (Define and manage user-defined functions (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that "the built-in function takes precedence and your UDF will stop working" (Use compound names for user-defined functions (https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.
|
|
13686
|
+
|
|
13687
|
+
A dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a Local. prefix for a model's own functions "to avoid conflicts with future DAX function names" (Function names (https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.
|
|
13688
|
+
|
|
13689
|
+
How to fix it
|
|
13690
|
+
|
|
13691
|
+
Rename the function to a compound name, such as Local.AddTax for a function of the model's own, or a prefix for your organization or library.
|
|
13692
|
+
|
|
13693
|
+
In Power BI Desktop's Model view, select Model at the top of the Data pane to open Model explorer (https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.
|
|
13694
|
+
|
|
13695
|
+
In TMDL, change the name on the function's line in definition/functions.tmdl, in single quotes when it holds a dot (function 'Local.AddTax' =), and at every call in the files under definition/, where it is written without quotes (Local.AddTax(). Searching those files for the old name followed by an opening parenthesis finds the calls.
|
|
13696
|
+
|
|
13697
|
+
When to ignore it
|
|
13698
|
+
|
|
13699
|
+
When the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.
|
|
13700
|
+
|
|
13701
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_USE_COMPOUND_NAMES": "off" under rules in pbiplint.config.json.
|
|
13702
|
+
|
|
13703
|
+
Quirks
|
|
13704
|
+
|
|
13705
|
+
- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so _toggleButton passes, and so does add_tax.
|
|
13706
|
+
- A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as INFO.USERDEFINEDFUNCTIONS (https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).
|
|
13707
|
+
- A function from a DAX Lib package is checked as any other, since its name is what callers write.
|
|
13708
|
+
|
|
13709
|
+
Read more: https://pbiplint.com/rules/udf-use-compound-names`,
|
|
13710
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction AddTax = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nMost of DAX's built-in functions have one-word names, and new ones arrive with Power BI releases. Microsoft's naming rules say a function's name \"Must not conflict with built-in DAX functions\" ([Define and manage user-defined functions](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#define-and-manage-user-defined-functions)), but a one-word name that is free today can be taken by a built-in tomorrow. Microsoft does not say what happens to the model's function then. Tabular Editor's guidance says that \"the built-in function takes precedence and your UDF will stop working\" ([Use compound names for user-defined functions](https://docs.tabulareditor.com/en/kb/bpa-udf-use-compound-names.html#why-this-matters)), so every call to it would reach the built-in instead.\n\nA dot or an underscore marks the name as the model's own. SQLBI's naming conventions recommend a `Local.` prefix for a model's own functions \"to avoid conflicts with future DAX function names\" ([Function names](https://docs.sqlbi.com/dax-style/dax-naming-conventions#function-names)), and a library's functions start with the library's name.\n\n### How to fix it\n\nRename the function to a compound name, such as `Local.AddTax` for a function of the model's own, or a prefix for your organization or library.\n\nIn Power BI Desktop's Model view, select Model at the top of the Data pane to open [Model explorer](https://learn.microsoft.com/power-bi/transform-model/model-explorer#find-model-explorer), right-click the function under Functions, choose Rename, and enter the new name; Desktop updates the measures and functions that call it.\n\nIn TMDL, change the name on the function's line in `definition/functions.tmdl`, in single quotes when it holds a dot (`function 'Local.AddTax' =`), and at every call in the files under `definition/`, where it is written without quotes (`Local.AddTax(`). Searching those files for the old name followed by an opening parenthesis finds the calls.\n\n### When to ignore it\n\nWhen the name is fixed by something outside the model: a live-connected report's own measures, or a DAX query kept elsewhere, calls the function by that name, and renaming it would break them where nothing in the model shows it. Otherwise the finding is not noise, though the risk it guards against lies in future releases rather than in the model today.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_USE_COMPOUND_NAMES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_USE_COMPOUND_NAMES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The test is Tabular Editor's: a dot or an underscore anywhere in the name passes, so `_toggleButton` passes, and so does `add_tax`.\n- A dot is no guarantee against a clash. Microsoft's own built-ins include dotted names, such as [`INFO.USERDEFINEDFUNCTIONS`](https://learn.microsoft.com/dax/info-userdefinedfunctions-function-dax).\n- A function from a DAX Lib package is checked as any other, since its name is what callers write.\n\nRead more: https://pbiplint.com/rules/udf-use-compound-names"
|
|
13711
|
+
},
|
|
13712
|
+
UDF_WITHOUT_DESCRIPTION: {
|
|
13713
|
+
text: `Example
|
|
13714
|
+
|
|
13715
|
+
Fires the rule
|
|
13716
|
+
|
|
13717
|
+
table Sales
|
|
13718
|
+
column Amount
|
|
13719
|
+
dataType: decimal
|
|
13720
|
+
sourceColumn: Amount
|
|
13721
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13722
|
+
formatString: #,0
|
|
13723
|
+
|
|
13724
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13725
|
+
|
|
13726
|
+
After the fix
|
|
13727
|
+
|
|
13728
|
+
table Sales
|
|
13729
|
+
column Amount
|
|
13730
|
+
dataType: decimal
|
|
13731
|
+
sourceColumn: Amount
|
|
13732
|
+
measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))
|
|
13733
|
+
formatString: #,0
|
|
13734
|
+
|
|
13735
|
+
/// Adds 10 percent sales tax to an amount.
|
|
13736
|
+
function 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1
|
|
13737
|
+
|
|
13738
|
+
Why it matters
|
|
13739
|
+
|
|
13740
|
+
A function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with /// lines, and it notes that single-line (//) or multi-line (/* */) comments "will not appear in IntelliSense function descriptions" (General form (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.
|
|
13741
|
+
|
|
13742
|
+
How to fix it
|
|
13743
|
+
|
|
13744
|
+
Write a sentence or two on what the function returns and what each parameter expects.
|
|
13745
|
+
|
|
13746
|
+
In Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate (Using Model explorer (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write /// lines directly above its FUNCTION line and select Update model with changes (Saving to the model (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the /// syntax serves "both measure and function descriptions" (Add measure descriptions (https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the /// lines directly above the function's function line in definition/functions.tmdl, with no blank line between the last of them and the declaration.
|
|
13747
|
+
|
|
13748
|
+
Microsoft says parameter descriptions are not supported (Considerations and limitations (https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; @param and @returns tags are optional.
|
|
13749
|
+
|
|
13750
|
+
When to ignore it
|
|
13751
|
+
|
|
13752
|
+
A function whose name and parameters already say everything, such as Local.Double(amount), gains little from a sentence repeating them. A function nothing calls is better deleted than described.
|
|
13753
|
+
|
|
13754
|
+
To ignore this rule on one object, add annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set "UDF_WITHOUT_DESCRIPTION": "off" under rules in pbiplint.config.json.
|
|
13755
|
+
|
|
13756
|
+
Quirks
|
|
13757
|
+
|
|
13758
|
+
- Functions with a DAXLIB_PackageId annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version (Submitting a library to DAX Lib (https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own (_functions.py (https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.
|
|
13759
|
+
- A description of only spaces or tabs counts as none.
|
|
13760
|
+
- Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.
|
|
13761
|
+
|
|
13762
|
+
Read more: https://pbiplint.com/rules/udf-without-description`,
|
|
13763
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Sales With Tax' = Local.AddTax(SUM('Sales'[Amount]))\n formatString: #,0\n\n/// Adds 10 percent sales tax to an amount.\nfunction 'Local.AddTax' = (amount: NUMERIC) => amount * 1.1\n```\n\n### Why it matters\n\nA function is written once and called from many places, often by someone other than its author, and its description is what they see of it while they write the call. Microsoft's guidance is to document a function with `///` lines, and it notes that single-line (`//`) or multi-line (`/* */`) comments \"will not appear in IntelliSense function descriptions\" ([General form](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#general-form)). Without a description, a caller learns what the function returns, and what its parameters expect, only by opening its DAX.\n\n### How to fix it\n\nWrite a sentence or two on what the function returns and what each parameter expects.\n\nIn Power BI Desktop, open the function in DAX query view: in Model view, select Model at the top of the Data pane to open Model explorer, right-click the function under Functions, and choose Quick queries, then Define and evaluate ([Using Model explorer](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#using-model-explorer)). Write `///` lines directly above its `FUNCTION` line and select Update model with changes ([Saving to the model](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#saving-to-the-model)); the `///` syntax serves \"both measure and function descriptions\" ([Add measure descriptions](https://learn.microsoft.com/power-bi/transform-model/dax-query-view#add-measure-descriptions)). In TMDL, add the `///` lines directly above the function's `function` line in `definition/functions.tmdl`, with no blank line between the last of them and the declaration.\n\nMicrosoft says parameter descriptions are not supported ([Considerations and limitations](https://learn.microsoft.com/dax/best-practices/dax-user-defined-functions#considerations-and-limitations)), so say what the parameters expect in the description itself; `@param` and `@returns` tags are optional.\n\n### When to ignore it\n\nA function whose name and parameters already say everything, such as `Local.Double(amount)`, gains little from a sentence repeating them. A function nothing calls is better deleted than described.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UDF_WITHOUT_DESCRIPTION` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UDF_WITHOUT_DESCRIPTION\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Functions with a `DAXLIB_PackageId` annotation, which Power BI Desktop keeps when it installs a package from DAX Lib, are skipped, since a published package version cannot be edited, only replaced by a new version ([Submitting a library to DAX Lib](https://docs.daxlib.org/contribute/fork-daxlib#submitting-library-to-dax-lib)). Tabular Editor's rule reports them. Functions installed without that annotation, such as through semantic-link-labs, which writes its own ([`_functions.py`](https://github.com/microsoft/semantic-link-labs/blob/main/src/sempy_labs/daxlib/_functions.py)), are checked as the model's own.\n- A description of only spaces or tabs counts as none.\n- Whether a function is hidden makes no difference, as in Tabular Editor's rule, whose name speaks of visible functions but which reports a hidden one too.\n\nRead more: https://pbiplint.com/rules/udf-without-description"
|
|
13764
|
+
},
|
|
12273
13765
|
UNNECESSARY_COLUMNS: {
|
|
12274
13766
|
text: `Example
|
|
12275
13767
|
|
|
@@ -12316,19 +13808,24 @@ For a data column, stop loading it: in Power BI Desktop, Transform data, select
|
|
|
12316
13808
|
|
|
12317
13809
|
When to ignore it
|
|
12318
13810
|
|
|
12319
|
-
Report 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,
|
|
13811
|
+
Report 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.
|
|
12320
13812
|
|
|
12321
13813
|
To 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.
|
|
12322
13814
|
|
|
12323
13815
|
Quirks
|
|
12324
13816
|
|
|
12325
|
-
- DAX
|
|
13817
|
+
- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, 'Date'[Date].[Year], only 'Date'[Date] is. A bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.
|
|
13818
|
+
- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden 'Archive'[DueDate] that DAX names only as [DueDate] outside SUMMARIZE ( 'Invoices', 'Invoices'[Key], "DueDate", MAX ( 'Invoices'[DueDate] ) ) is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the [Qty] in SUMX ( 'Sales', [Qty] ) is not a use of 'Sales'[Qty] either.
|
|
13819
|
+
- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.
|
|
13820
|
+
- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside SUMX ( 'Sales', [Handling Fee] ) but reported the column a function names in MAX ( [Tax Rate] ), though deleting it would break the function.
|
|
13821
|
+
- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its groupByColumn under relatedColumnDetails, and the parameter stops working without it. The source rule does not test groupByColumn, so Tabular Editor reports that column.
|
|
12326
13822
|
- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.
|
|
12327
13823
|
- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS does read variations.
|
|
12328
|
-
- Row-level security
|
|
13824
|
+
- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: Table[Column] or 'Table'[Column] in any role's filter, or [Column] in a filter on the column's own table, counts as a use even inside a comment or a string there.
|
|
13825
|
+
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
12329
13826
|
|
|
12330
13827
|
Read more: https://pbiplint.com/rules/unnecessary-columns`,
|
|
12331
|
-
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,
|
|
13828
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX is read token by token, so a column named only inside a string or a comment of a DAX expression is not a use (a row-level security filter's text test, below, still counts it), and in extended column syntax, `'Date'[Date].[Year]`, only `'Date'[Date]` is. A bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\n- A bare name for a column the same DAX creates with ADDCOLUMNS, SELECTCOLUMNS, SUMMARIZE, SUMMARIZECOLUMNS, ROW, or DATATABLE is not a use of a model column of that name: a hidden `'Archive'[DueDate]` that DAX names only as `[DueDate]` outside `SUMMARIZE ( 'Invoices', 'Invoices'[Key], \"DueDate\", MAX ( 'Invoices'[DueDate] ) )` is reported, as Tabular Editor reports it. Inside a call that creates the name, the name counts as any other bare name does, since a call cannot read a column it is creating. Outside those calls pbiplint does not work out which table a row context walks, so in DAX that also creates a column Qty, the `[Qty]` in `SUMX ( 'Sales', [Qty] )` is not a use of `'Sales'[Qty]` either.\n- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.\n- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside `SUMX ( 'Sales', [Handling Fee] )` but reported the column a function names in `MAX ( [Tax Rate] )`, though deleting it would break the function.\n- A column that another column in its table groups by counts as used, as a field parameter's hidden Fields column is: the parameter's display column names it as its `groupByColumn` under `relatedColumnDetails`, and the parameter stops working without it. The source rule does not test `groupByColumn`, so Tabular Editor reports that column.\n- Report usage is not visible to this rule. A hidden column used only by a visual, a slicer, or a report-level filter is still flagged.\n- Variations are not tested, here or in the source rule, so a hidden column that a variation names as its default column is reported. `SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS` does read variations.\n- Row-level security filters are also matched as text, ignoring letter case, the way the source rule matches them: `Table[Column]` or `'Table'[Column]` in any role's filter, or `[Column]` in a filter on the column's own table, counts as a use even inside a comment or a string there.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a measure, a relationship, or a security filter that uses the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-columns"
|
|
12332
13829
|
},
|
|
12333
13830
|
UNNECESSARY_MEASURES: {
|
|
12334
13831
|
text: `Example
|
|
@@ -12363,11 +13860,11 @@ A hidden measure that no other measure uses can only be reached by a report that
|
|
|
12363
13860
|
|
|
12364
13861
|
How to fix it
|
|
12365
13862
|
|
|
12366
|
-
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:
|
|
13863
|
+
In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.
|
|
12367
13864
|
|
|
12368
13865
|
When to ignore it
|
|
12369
13866
|
|
|
12370
|
-
Report usage is the case to check first. A hidden measure that a visual or a report-level filter binds to directly is in use,
|
|
13867
|
+
Report 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.
|
|
12371
13868
|
|
|
12372
13869
|
To 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.
|
|
12373
13870
|
|
|
@@ -12376,10 +13873,12 @@ Quirks
|
|
|
12376
13873
|
- References from calculation items and from other hidden measures count as usage.
|
|
12377
13874
|
- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.
|
|
12378
13875
|
- A row-level security filter counts as a DAX expression, so a measure named in one is used.
|
|
12379
|
-
- A
|
|
13876
|
+
- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, NOT_REACHED_FROM_REPORT follows the calls, so it reports a measure that only an uncalled function uses.
|
|
13877
|
+
- A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.
|
|
13878
|
+
- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, a model file could not be fully read, and the file's own PARSE_ISSUE finding names it, or a notice does for a file or folder pbiplint could not open.
|
|
12380
13879
|
|
|
12381
13880
|
Read more: https://pbiplint.com/rules/unnecessary-measures`,
|
|
12382
|
-
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:
|
|
13881
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\n### How to fix it\n\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures with their format strings and KPIs, calculated columns and tables, calculation items, row-level security filters, and user-defined functions), so what a search adds is the report files, which this rule does not read, and anything else in the model that names the measure.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden measure that a visual or a report-level filter binds to directly is in use, but this rule reads the model only, as the source rule does, so it reports that measure all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete a measure. A measure you have written for a calculation item or a measure you have not finished is a fair thing to leave for as long as that lasts. A hidden measure nobody can name a caller for is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_MEASURES` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_MEASURES\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- References from calculation items and from other hidden measures count as usage.\n- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.\n- A row-level security filter counts as a DAX expression, so a measure named in one is used.\n- A measure named in a user-defined function counts as referenced, even when nothing calls the function, as Tabular Editor counts it. When the report is in the input, `NOT_REACHED_FROM_REPORT` follows the calls, so it reports a measure that only an uncalled function uses.\n- A bare `[Measure]` reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. DAX is read token by token, so a measure named only inside a string or a comment is not a use.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because the DAX that references the measure could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/unnecessary-measures"
|
|
12383
13882
|
},
|
|
12384
13883
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": {
|
|
12385
13884
|
text: `Example
|
|
@@ -12752,8 +14251,16 @@ Read more: https://pbiplint.com/rules/visual-without-fields`,
|
|
|
12752
14251
|
};
|
|
12753
14252
|
|
|
12754
14253
|
// src/walk.ts
|
|
12755
|
-
import {
|
|
12756
|
-
|
|
14254
|
+
import {
|
|
14255
|
+
accessSync,
|
|
14256
|
+
constants,
|
|
14257
|
+
lstatSync,
|
|
14258
|
+
readdirSync as readdirSync2,
|
|
14259
|
+
readFileSync as readFileSync2,
|
|
14260
|
+
realpathSync,
|
|
14261
|
+
statSync
|
|
14262
|
+
} from "node:fs";
|
|
14263
|
+
import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
|
|
12757
14264
|
var EXPECTED_INPUT = "a PBIP folder, a .pbip file, a .SemanticModel folder, a .Report folder, a definition folder, or one .tmdl file";
|
|
12758
14265
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".pbi", "node_modules", "StaticResources", "CustomVisuals"]);
|
|
12759
14266
|
var toPosix = (p) => p.split("\\").join("/");
|
|
@@ -12766,7 +14273,7 @@ var statOf = (p) => statSync(p, { throwIfNoEntry: false });
|
|
|
12766
14273
|
var isDir = (p) => statOf(p)?.isDirectory() ?? false;
|
|
12767
14274
|
var isFile = (p) => statOf(p)?.isFile() ?? false;
|
|
12768
14275
|
var byName = (a, b) => a.localeCompare(b, "en");
|
|
12769
|
-
var
|
|
14276
|
+
var isRecord8 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
12770
14277
|
function folderAt(p) {
|
|
12771
14278
|
try {
|
|
12772
14279
|
const stat = statOf(p);
|
|
@@ -12776,23 +14283,55 @@ function folderAt(p) {
|
|
|
12776
14283
|
throw e;
|
|
12777
14284
|
}
|
|
12778
14285
|
}
|
|
12779
|
-
function
|
|
14286
|
+
function refusalOf(e) {
|
|
14287
|
+
const reason = reasonOf(e);
|
|
14288
|
+
return { reason, says: `could not be read (${reason})` };
|
|
14289
|
+
}
|
|
14290
|
+
var LINK = {
|
|
14291
|
+
reason: "it is a symbolic link, which pbiplint does not follow",
|
|
14292
|
+
says: "is a symbolic link, which pbiplint does not follow"
|
|
14293
|
+
};
|
|
14294
|
+
function unread(w, p, why, part, folder = false) {
|
|
12780
14295
|
const path = toPosix(relative(w.base, p));
|
|
12781
|
-
w.refusal ??= { path,
|
|
14296
|
+
w.refusal ??= { path, reason: why.reason };
|
|
14297
|
+
part?.unread.push(toPosix(relative(part.root, p)) + (folder ? "/" : ""));
|
|
12782
14298
|
if (w.project.diagnostics.some((d) => d.kind === "unread-file" && d.path === path)) return;
|
|
12783
14299
|
w.project.diagnostics.push({
|
|
12784
14300
|
kind: "unread-file",
|
|
12785
14301
|
path,
|
|
12786
|
-
message: `${path}
|
|
14302
|
+
message: `${path} ${why.says}, so it was not linted`
|
|
12787
14303
|
});
|
|
12788
14304
|
}
|
|
14305
|
+
function isLink(p) {
|
|
14306
|
+
try {
|
|
14307
|
+
return lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
|
|
14308
|
+
} catch (e) {
|
|
14309
|
+
if (isSystemError(e)) return false;
|
|
14310
|
+
throw e;
|
|
14311
|
+
}
|
|
14312
|
+
}
|
|
14313
|
+
function linked(w, p, part, folder = false) {
|
|
14314
|
+
if (p === w.base || !isLink(p)) return false;
|
|
14315
|
+
unread(w, p, LINK, part, folder);
|
|
14316
|
+
return true;
|
|
14317
|
+
}
|
|
14318
|
+
function linkOnWay(w, to) {
|
|
14319
|
+
const way = relative(w.base, to);
|
|
14320
|
+
if (isAbsolute(way)) return isLink(to) ? to : void 0;
|
|
14321
|
+
let at2 = w.base;
|
|
14322
|
+
for (const segment of way.split(/[\\/]/)) {
|
|
14323
|
+
at2 = join3(at2, segment);
|
|
14324
|
+
if (segment !== "" && segment !== ".." && isLink(at2)) return at2;
|
|
14325
|
+
}
|
|
14326
|
+
return void 0;
|
|
14327
|
+
}
|
|
12789
14328
|
function attempt(w, p, call, part, folder = false) {
|
|
14329
|
+
if (linked(w, p, part, folder)) return void 0;
|
|
12790
14330
|
try {
|
|
12791
14331
|
return call();
|
|
12792
14332
|
} catch (e) {
|
|
12793
14333
|
if (!isSystemError(e)) throw e;
|
|
12794
|
-
unread(w, p, e);
|
|
12795
|
-
part?.unread.push(toPosix(relative(part.root, p)) + (folder ? "/" : ""));
|
|
14334
|
+
unread(w, p, refusalOf(e), part, folder);
|
|
12796
14335
|
return void 0;
|
|
12797
14336
|
}
|
|
12798
14337
|
}
|
|
@@ -12802,7 +14341,7 @@ function readTree(w, part, dir, keep, passed) {
|
|
|
12802
14341
|
(a, b) => byName(a.name, b.name)
|
|
12803
14342
|
)) {
|
|
12804
14343
|
const p = join3(dir, entry.name);
|
|
12805
|
-
if (entry.isDirectory()) {
|
|
14344
|
+
if (entry.isSymbolicLink() ? folderAt(p) === true : entry.isDirectory()) {
|
|
12806
14345
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
12807
14346
|
if (passed && entry.name.endsWith(".SemanticModel"))
|
|
12808
14347
|
passed.models.push(toPosix(relative(w.base, p)));
|
|
@@ -12815,14 +14354,14 @@ function readTree(w, part, dir, keep, passed) {
|
|
|
12815
14354
|
}
|
|
12816
14355
|
function modelPart(w, folder) {
|
|
12817
14356
|
const def = join3(folder, "definition");
|
|
12818
|
-
if (!isDir(def)) return void 0;
|
|
14357
|
+
if (linked(w, def) || !isDir(def)) return void 0;
|
|
12819
14358
|
const part = emptyPart(folder);
|
|
12820
14359
|
readTree(w, part, def, (n2) => n2.endsWith(".tmdl"));
|
|
12821
14360
|
return part.files.length ? part : void 0;
|
|
12822
14361
|
}
|
|
12823
14362
|
function reportPart(w, folder) {
|
|
12824
14363
|
const def = join3(folder, "definition");
|
|
12825
|
-
if (!isDir(def)) return void 0;
|
|
14364
|
+
if (linked(w, def) || !isDir(def)) return void 0;
|
|
12826
14365
|
const part = emptyPart(folder);
|
|
12827
14366
|
for (const name of ["definition.pbir", ".platform"]) {
|
|
12828
14367
|
const p = join3(folder, name);
|
|
@@ -12837,13 +14376,19 @@ var UNREAD_PART = {
|
|
|
12837
14376
|
report: "the report folder could not be read"
|
|
12838
14377
|
};
|
|
12839
14378
|
function readPart(w, layer, folder, read) {
|
|
14379
|
+
const via = linkOnWay(w, folder);
|
|
14380
|
+
if (via !== void 0) {
|
|
14381
|
+
unread(w, via, LINK);
|
|
14382
|
+
if (layer) w.project.absent[layer] = UNREAD_PART[layer];
|
|
14383
|
+
return void 0;
|
|
14384
|
+
}
|
|
12840
14385
|
let part;
|
|
12841
14386
|
try {
|
|
12842
14387
|
accessSync(folder, constants.X_OK);
|
|
12843
14388
|
part = read(w, folder);
|
|
12844
14389
|
} catch (e) {
|
|
12845
14390
|
if (!isSystemError(e)) throw e;
|
|
12846
|
-
unread(w, e.path ?? folder, e);
|
|
14391
|
+
unread(w, e.path ?? folder, refusalOf(e));
|
|
12847
14392
|
}
|
|
12848
14393
|
const at2 = toPosix(relative(w.base, folder));
|
|
12849
14394
|
const refused = w.project.diagnostics.some(
|
|
@@ -12854,7 +14399,9 @@ function readPart(w, layer, folder, read) {
|
|
|
12854
14399
|
}
|
|
12855
14400
|
function pbipIn(input, folder, preferred) {
|
|
12856
14401
|
if (preferred !== void 0) return preferred;
|
|
12857
|
-
const found = readdirSync2(folder, { withFileTypes: true }).filter(
|
|
14402
|
+
const found = readdirSync2(folder, { withFileTypes: true }).filter(
|
|
14403
|
+
(e) => e.name.endsWith(".pbip") && (e.isFile() || e.isSymbolicLink() && folderAt(join3(folder, e.name)) !== true)
|
|
14404
|
+
).map((e) => e.name).sort(byName);
|
|
12858
14405
|
if (found.length > 1)
|
|
12859
14406
|
throw new UsageError(
|
|
12860
14407
|
`${input} contains ${found.length} .pbip files; point at one of them: ${found.join(", ")}`
|
|
@@ -12870,18 +14417,6 @@ function loneReportAbsent(report, folder) {
|
|
|
12870
14417
|
);
|
|
12871
14418
|
return !decision.useModel && decision.reason ? { model: decision.reason } : {};
|
|
12872
14419
|
}
|
|
12873
|
-
var legacyReport = (folder, name) => ({
|
|
12874
|
-
kind: "legacy-report-format",
|
|
12875
|
-
path: name,
|
|
12876
|
-
message: `${name} is stored as a single report.json, which pbiplint cannot read; save it in the PBIR format from Power BI Desktop`
|
|
12877
|
-
});
|
|
12878
|
-
var legacyModel = (folder, name) => ({
|
|
12879
|
-
kind: "legacy-model-format",
|
|
12880
|
-
path: name,
|
|
12881
|
-
message: `${name} is stored as model.bim, which pbiplint cannot read; save it in the TMDL format from Power BI Desktop`
|
|
12882
|
-
});
|
|
12883
|
-
var LEGACY_REPORT_REASON = "the report is saved in the legacy report.json format";
|
|
12884
|
-
var LEGACY_MODEL_REASON = "the model is saved in the legacy model.bim format";
|
|
12885
14420
|
function resolveProject(input) {
|
|
12886
14421
|
const path = resolve2(input);
|
|
12887
14422
|
try {
|
|
@@ -12899,7 +14434,8 @@ function resolveProject(input) {
|
|
|
12899
14434
|
absent: {},
|
|
12900
14435
|
diagnostics: []
|
|
12901
14436
|
};
|
|
12902
|
-
if (path.endsWith(".pbip"))
|
|
14437
|
+
if (path.endsWith(".pbip"))
|
|
14438
|
+
return resolvePbip(input, isLink(path) ? realpathSync(path) : path);
|
|
12903
14439
|
if (isPbix(path)) throw new UsageError(pbixRefusal(input));
|
|
12904
14440
|
throw new UsageError(`${input} is not a .tmdl file, a .pbip file, or a folder`);
|
|
12905
14441
|
}
|
|
@@ -12916,17 +14452,17 @@ function walked(input, base, read) {
|
|
|
12916
14452
|
const w = { base, project: { root: base, absent: {}, diagnostics: [] } };
|
|
12917
14453
|
const project = read(w);
|
|
12918
14454
|
if (!project.model && !project.report && w.refusal) {
|
|
12919
|
-
const { path: below,
|
|
14455
|
+
const { path: below, reason } = w.refusal;
|
|
12920
14456
|
const named2 = below === "" ? input : toPosix(join3(input, below));
|
|
12921
|
-
throw new UsageError(`Could not read ${named2}: ${
|
|
14457
|
+
throw new UsageError(`Could not read ${named2}: ${reason}`);
|
|
12922
14458
|
}
|
|
12923
14459
|
return project;
|
|
12924
14460
|
}
|
|
12925
14461
|
function reportsNamed(name, text2) {
|
|
12926
14462
|
const json = readJson(name, text2).json;
|
|
12927
|
-
if (!
|
|
14463
|
+
if (!isRecord8(json) || !Array.isArray(json.artifacts)) return [];
|
|
12928
14464
|
return json.artifacts.flatMap(
|
|
12929
|
-
(a) =>
|
|
14465
|
+
(a) => isRecord8(a) && isRecord8(a.report) && typeof a.report.path === "string" ? [a.report.path] : []
|
|
12930
14466
|
);
|
|
12931
14467
|
}
|
|
12932
14468
|
function resolvePbip(input, path) {
|
|
@@ -12953,7 +14489,9 @@ function resolvePbip(input, path) {
|
|
|
12953
14489
|
function pbirOf(w, report, folder) {
|
|
12954
14490
|
if (report) return report.files.find((f) => f.path === "definition.pbir")?.text;
|
|
12955
14491
|
const at2 = toPosix(relative(w.base, folder));
|
|
12956
|
-
if (w.project.diagnostics.some(
|
|
14492
|
+
if (w.project.diagnostics.some(
|
|
14493
|
+
(d) => d.kind === "unread-file" && (d.path === at2 || at2.startsWith(`${d.path}/`))
|
|
14494
|
+
))
|
|
12957
14495
|
return void 0;
|
|
12958
14496
|
const p = join3(folder, "definition.pbir");
|
|
12959
14497
|
return attempt(w, p, () => isFile(p) ? readFileSync2(p, "utf8") : void 0);
|
|
@@ -12964,7 +14502,7 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12964
14502
|
const at2 = (p) => toPosix(relative(w.base, p));
|
|
12965
14503
|
const report = readPart(w, "report", reportFolder, reportPart);
|
|
12966
14504
|
if (!report && !out.absent.report && isFile(join3(reportFolder, "report.json"))) {
|
|
12967
|
-
out.diagnostics.push(
|
|
14505
|
+
out.diagnostics.push(legacyReportNotice(at2(reportFolder)));
|
|
12968
14506
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
12969
14507
|
}
|
|
12970
14508
|
if (report) report.files.push({ path: toPosix(relative(report.root, pbip)), text: text2 });
|
|
@@ -12978,9 +14516,13 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12978
14516
|
if (folderAt(modelFolder)) {
|
|
12979
14517
|
model = readPart(w, "model", modelFolder, modelPart);
|
|
12980
14518
|
if (!model && !out.absent.model && isFile(join3(modelFolder, "model.bim"))) {
|
|
12981
|
-
out.diagnostics.push(
|
|
14519
|
+
out.diagnostics.push(legacyModelNotice(at2(modelFolder)));
|
|
12982
14520
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
12983
14521
|
}
|
|
14522
|
+
if (!model && !out.absent.model) {
|
|
14523
|
+
const { reason } = pairingDecision(ref, void 0, basename(reportFolder));
|
|
14524
|
+
if (reason) out.absent.model = reason;
|
|
14525
|
+
}
|
|
12984
14526
|
} else {
|
|
12985
14527
|
out.absent.model = `this report reads a model that is not there (${ref.path})`;
|
|
12986
14528
|
}
|
|
@@ -12998,7 +14540,9 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12998
14540
|
function readFolder(w, input, path, preferred) {
|
|
12999
14541
|
const out = w.project;
|
|
13000
14542
|
const name = basename(path);
|
|
13001
|
-
|
|
14543
|
+
const def = join3(path, "definition");
|
|
14544
|
+
const namedPart = name.endsWith(".Report") || name.endsWith(".SemanticModel");
|
|
14545
|
+
if (isDir(def) || namedPart && isLink(def)) {
|
|
13002
14546
|
const model2 = readPart(
|
|
13003
14547
|
w,
|
|
13004
14548
|
name.endsWith(".SemanticModel") ? "model" : void 0,
|
|
@@ -13023,16 +14567,16 @@ function readFolder(w, input, path, preferred) {
|
|
|
13023
14567
|
};
|
|
13024
14568
|
}
|
|
13025
14569
|
if (name.endsWith(".Report") && isFile(join3(path, "report.json"))) {
|
|
13026
|
-
out.diagnostics.push(
|
|
14570
|
+
out.diagnostics.push(legacyReportNotice(name));
|
|
13027
14571
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
13028
14572
|
return out;
|
|
13029
14573
|
}
|
|
13030
14574
|
if (name.endsWith(".SemanticModel") && isFile(join3(path, "model.bim"))) {
|
|
13031
|
-
out.diagnostics.push(
|
|
14575
|
+
out.diagnostics.push(legacyModelNotice(name));
|
|
13032
14576
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
13033
14577
|
return out;
|
|
13034
14578
|
}
|
|
13035
|
-
const dirs = readdirSync2(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
14579
|
+
const dirs = readdirSync2(path, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink() && folderAt(join3(path, e.name)) === true).map((e) => e.name);
|
|
13036
14580
|
const models = dirs.filter((d) => d.endsWith(".SemanticModel")).sort(byName);
|
|
13037
14581
|
const reports = dirs.filter((d) => d.endsWith(".Report")).sort(byName);
|
|
13038
14582
|
if (models.length > 1)
|
|
@@ -13045,12 +14589,12 @@ function readFolder(w, input, path, preferred) {
|
|
|
13045
14589
|
);
|
|
13046
14590
|
let model = models[0] ? readPart(w, "model", join3(path, models[0]), modelPart) : void 0;
|
|
13047
14591
|
if (models[0] && !model && !out.absent.model && isFile(join3(path, models[0], "model.bim"))) {
|
|
13048
|
-
out.diagnostics.push(
|
|
14592
|
+
out.diagnostics.push(legacyModelNotice(models[0]));
|
|
13049
14593
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
13050
14594
|
}
|
|
13051
14595
|
const report = reports[0] ? readPart(w, "report", join3(path, reports[0]), reportPart) : void 0;
|
|
13052
14596
|
if (reports[0] && !report && !out.absent.report && isFile(join3(path, reports[0], "report.json"))) {
|
|
13053
|
-
out.diagnostics.push(
|
|
14597
|
+
out.diagnostics.push(legacyReportNotice(reports[0]));
|
|
13054
14598
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
13055
14599
|
}
|
|
13056
14600
|
if (report) {
|
|
@@ -13061,14 +14605,12 @@ function readFolder(w, input, path, preferred) {
|
|
|
13061
14605
|
if (text2 !== void 0) report.files.push({ path: toPosix(relative(report.root, p)), text: text2 });
|
|
13062
14606
|
}
|
|
13063
14607
|
const pbir = report.files.find((f) => f.path === "definition.pbir");
|
|
13064
|
-
const
|
|
13065
|
-
|
|
13066
|
-
model ? models[0] : void 0,
|
|
13067
|
-
reports[0]
|
|
13068
|
-
);
|
|
14608
|
+
const ref = pbir ? datasetReference(pbir.text) : { kind: "none" };
|
|
14609
|
+
const decision = pairingDecision(ref, model ? models[0] : void 0, reports[0]);
|
|
13069
14610
|
if (!decision.useModel) {
|
|
13070
14611
|
model = void 0;
|
|
13071
|
-
if (decision.reason
|
|
14612
|
+
if (decision.reason && (ref.kind === "byConnection" || !out.absent.model))
|
|
14613
|
+
out.absent.model = decision.reason;
|
|
13072
14614
|
}
|
|
13073
14615
|
if (decision.diagnostic) out.diagnostics.push(decision.diagnostic);
|
|
13074
14616
|
}
|
|
@@ -13094,7 +14636,7 @@ function readFolder(w, input, path, preferred) {
|
|
|
13094
14636
|
}
|
|
13095
14637
|
|
|
13096
14638
|
// src/main.ts
|
|
13097
|
-
var VERSION2 = true ? "0.2.
|
|
14639
|
+
var VERSION2 = true ? "0.2.2" : "0.0.0-dev";
|
|
13098
14640
|
function listRules() {
|
|
13099
14641
|
const width = Math.max(...defaultRules.map((r) => r.id.length));
|
|
13100
14642
|
return defaultRules.map(
|