pbiplint 0.2.0 → 0.2.1
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/README.md +6 -3
- package/dist/pbiplint.mjs +1346 -334
- package/package.json +1 -1
package/dist/pbiplint.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// ../core/src/version.ts
|
|
4
|
-
var VERSION = "0.2.
|
|
4
|
+
var VERSION = "0.2.1";
|
|
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
|
|
10
|
+
const ruleByUpper = new Map(rules.map((r) => [r.id.toUpperCase(), r]));
|
|
11
11
|
const bound = {
|
|
12
12
|
disabled: /* @__PURE__ */ new Set(),
|
|
13
13
|
severity: /* @__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
21
|
else bound.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
26
|
else bound.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,7 +51,7 @@ function bindConfig(config, rules) {
|
|
|
50
51
|
`pbiplint.config.json: rules["${real}"].${name} must be one of ${decl.values.join(", ")}`
|
|
51
52
|
);
|
|
52
53
|
}
|
|
53
|
-
bound.options.set(real, options);
|
|
54
|
+
bound.options.set(real, { ...options });
|
|
54
55
|
}
|
|
55
56
|
return { config: bound, unknownRules: [...new Set(unknownRules)] };
|
|
56
57
|
}
|
|
@@ -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`
|
|
@@ -284,7 +285,16 @@ function buildTable(r, model) {
|
|
|
284
285
|
else if (c.kind === "object" && c.type === "partition") t.partitions.push(buildPartition(c, t));
|
|
285
286
|
else if (c.kind === "object" && c.type === "hierarchy")
|
|
286
287
|
t.hierarchies.push(buildHierarchy(c, t));
|
|
287
|
-
else if (c.type === "calculationgroup")
|
|
288
|
+
else if (c.type === "calculationgroup") {
|
|
289
|
+
const group = buildCalculationGroup(c, t);
|
|
290
|
+
const first = t.calculationGroup;
|
|
291
|
+
if (first) {
|
|
292
|
+
first.precedence ??= group.precedence;
|
|
293
|
+
first.description ??= group.description;
|
|
294
|
+
Object.assign(first.annotations, group.annotations);
|
|
295
|
+
first.items.push(...group.items);
|
|
296
|
+
} else t.calculationGroup = group;
|
|
297
|
+
}
|
|
288
298
|
}
|
|
289
299
|
}
|
|
290
300
|
function buildRelationship(r) {
|
|
@@ -335,7 +345,85 @@ function finalizeKinds(model) {
|
|
|
335
345
|
c.kind = c.expression !== void 0 ? "calculated" : t.kind === "calculated" ? "calculatedTable" : "data";
|
|
336
346
|
}
|
|
337
347
|
}
|
|
338
|
-
function
|
|
348
|
+
function walkOrder(a, b) {
|
|
349
|
+
const as = a.split("/");
|
|
350
|
+
const bs = b.split("/");
|
|
351
|
+
for (let i = 0; i < Math.min(as.length, bs.length); i++) {
|
|
352
|
+
const [x, y] = [as[i], bs[i]];
|
|
353
|
+
if (x !== y) return x.localeCompare(y, "en") || (x < y ? -1 : 1);
|
|
354
|
+
}
|
|
355
|
+
return as.length - bs.length;
|
|
356
|
+
}
|
|
357
|
+
function modelDeclarations(f) {
|
|
358
|
+
const declared = (n2) => n2.kind === "object" || n2.kind === "flag";
|
|
359
|
+
const isModel = (n2) => n2.type === "model" && declared(n2);
|
|
360
|
+
const out = [];
|
|
361
|
+
for (const r of f.roots) {
|
|
362
|
+
out.push(r);
|
|
363
|
+
const models = isModel(r) ? [r] : r.type === "database" && declared(r) ? r.children.filter(isModel) : [];
|
|
364
|
+
for (const m of models) {
|
|
365
|
+
if (m !== r) out.push(m);
|
|
366
|
+
out.push(...m.children);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
function readDeclaration(r, model) {
|
|
372
|
+
if (r.kind === "ref" || r.kind === "prop" || r.kind === "expr") return;
|
|
373
|
+
switch (r.type) {
|
|
374
|
+
case "model":
|
|
375
|
+
readModel(r, model);
|
|
376
|
+
break;
|
|
377
|
+
case "annotation":
|
|
378
|
+
if (r.name && r.children.length === 0) model.annotations[r.name] = r.value ?? "";
|
|
379
|
+
break;
|
|
380
|
+
case "table":
|
|
381
|
+
buildTable(r, model);
|
|
382
|
+
break;
|
|
383
|
+
case "relationship":
|
|
384
|
+
model.relationships.push(buildRelationship(r));
|
|
385
|
+
break;
|
|
386
|
+
case "role":
|
|
387
|
+
model.roles.push(buildRole(r));
|
|
388
|
+
break;
|
|
389
|
+
case "perspective": {
|
|
390
|
+
const p = {
|
|
391
|
+
...named(r),
|
|
392
|
+
tables: objects(r, "perspectivetable").map((t) => t.name)
|
|
393
|
+
};
|
|
394
|
+
model.perspectives.push(p);
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
case "cultureinfo":
|
|
398
|
+
model.cultures.push(named(r));
|
|
399
|
+
break;
|
|
400
|
+
case "expression":
|
|
401
|
+
model.expressions.push({ ...named(r), expression: r.value ?? "" });
|
|
402
|
+
break;
|
|
403
|
+
case "function":
|
|
404
|
+
model.functions.push({ ...named(r), expression: r.value ?? "" });
|
|
405
|
+
break;
|
|
406
|
+
case "datasource": {
|
|
407
|
+
const ds = {
|
|
408
|
+
...named(r),
|
|
409
|
+
kind: (r.value ?? "").trim().toLowerCase() === "provider" ? "provider" : "structured"
|
|
410
|
+
};
|
|
411
|
+
model.dataSources.push(ds);
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
default:
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function readModel(r, model) {
|
|
419
|
+
Object.assign(model, named(r, r.name ?? "Model"), {
|
|
420
|
+
description: r.description ?? model.description,
|
|
421
|
+
annotations: model.annotations,
|
|
422
|
+
props: { ...model.props, ...r.props }
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
function buildModel(given, unreadPaths = []) {
|
|
426
|
+
const files = [...given].sort((a, b) => walkOrder(a.file, b.file));
|
|
339
427
|
const model = {
|
|
340
428
|
name: "Model",
|
|
341
429
|
annotations: {},
|
|
@@ -352,64 +440,15 @@ function buildModel(files, unreadPaths = []) {
|
|
|
352
440
|
files,
|
|
353
441
|
unreadPaths: unreadPaths.filter((p) => p.endsWith(".tmdl") || p.endsWith("/"))
|
|
354
442
|
};
|
|
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
|
-
}
|
|
443
|
+
for (const f of files) for (const r of modelDeclarations(f)) readDeclaration(r, model);
|
|
407
444
|
finalizeKinds(model);
|
|
408
445
|
return model;
|
|
409
446
|
}
|
|
410
447
|
|
|
411
448
|
// ../core/src/model/names.ts
|
|
412
449
|
var isAutoDateTable = (t) => t.kind === "calculated" && (t.name.startsWith("DateTableTemplate_") || t.name.startsWith("LocalDateTable_"));
|
|
450
|
+
var isAutoDateTableCopy = (t) => t.name.startsWith("LocalDateTable_") && t.partitions.some((p) => p.sourceType === "entity" && p.mode === "directquery");
|
|
451
|
+
var isHiddenAutoDateTable = (t) => isAutoDateTable(t) || isAutoDateTableCopy(t);
|
|
413
452
|
var tableRef = (name) => `'${name.replace(/'/g, "''")}'`;
|
|
414
453
|
var bracket = (name) => `[${name.replace(/\]/g, "]]")}]`;
|
|
415
454
|
var columnRef = (table, column) => `${tableRef(table)}${bracket(column)}`;
|
|
@@ -427,10 +466,12 @@ var ruleUrl = (id) => RULE_URL_BASE + slug(id);
|
|
|
427
466
|
|
|
428
467
|
// ../core/src/index/reachability.ts
|
|
429
468
|
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);
|
|
469
|
+
var isMeasure = (n2) => "table" in n2 && !("kind" in n2);
|
|
432
470
|
var listOf = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
433
471
|
function buildReachabilityIndex(model, references, reportRefs) {
|
|
472
|
+
const functions = new Set(model.functions);
|
|
473
|
+
const isFunction = (n2) => functions.has(n2);
|
|
474
|
+
const nameOf = (n2) => isTable(n2) ? tableRef(n2.name) : isFunction(n2) ? n2.name : isMeasure(n2) ? measureRef(n2.name) : columnRef(n2.table.name, n2.name);
|
|
434
475
|
const tables = new Map(model.tables.map((t) => [t.name.toLowerCase(), t]));
|
|
435
476
|
const columnOf = (table, name) => tables.get(table.toLowerCase())?.columns.find((c) => c.name.toLowerCase() === name.toLowerCase());
|
|
436
477
|
const measureOf = (table, name) => tables.get(table.toLowerCase())?.measures.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
|
@@ -446,6 +487,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
446
487
|
if (r.kind === "column") reach(columnOf(r.table, r.name), from);
|
|
447
488
|
else if (r.kind === "measure") reach(measureOf(r.table, r.name), from);
|
|
448
489
|
}
|
|
490
|
+
for (const f of references.callsOf(owner)) reach(f, from);
|
|
449
491
|
};
|
|
450
492
|
for (const r of reportRefs.refs) {
|
|
451
493
|
const res = r.resolution;
|
|
@@ -458,9 +500,10 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
458
500
|
}
|
|
459
501
|
if ("variationOf" in res) reach(res.variationOf, null);
|
|
460
502
|
}
|
|
503
|
+
for (const c of reportRefs.functionCalls) for (const f of c.calls) reach(f, null);
|
|
461
504
|
const autoDate = (table) => {
|
|
462
505
|
const t = tables.get(table.toLowerCase());
|
|
463
|
-
return t !== void 0 &&
|
|
506
|
+
return t !== void 0 && isHiddenAutoDateTable(t);
|
|
464
507
|
};
|
|
465
508
|
for (const rel of model.relationships) {
|
|
466
509
|
if (autoDate(rel.fromTable) || autoDate(rel.toTable)) continue;
|
|
@@ -469,8 +512,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
469
512
|
}
|
|
470
513
|
for (const role of model.roles)
|
|
471
514
|
for (const tp of role.tablePermissions) {
|
|
472
|
-
|
|
473
|
-
if (r.kind === "column") reach(columnOf(r.table, r.name), null);
|
|
515
|
+
reachDax(tp, null);
|
|
474
516
|
for (const cp of tp.columnPermissions) reach(columnOf(tp.table, cp.column), null);
|
|
475
517
|
}
|
|
476
518
|
for (const t of model.tables)
|
|
@@ -480,6 +522,10 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
480
522
|
for (const t of model.tables) for (const c of t.columns) if (c.alternateOf) reach(c, null);
|
|
481
523
|
while (queue.length) {
|
|
482
524
|
const n2 = queue.shift();
|
|
525
|
+
if (isFunction(n2)) {
|
|
526
|
+
reachDax(n2, n2);
|
|
527
|
+
continue;
|
|
528
|
+
}
|
|
483
529
|
if (isTable(n2)) {
|
|
484
530
|
if (n2.kind === "calculated") reachDax(n2, n2);
|
|
485
531
|
for (const item of n2.calculationGroup?.items ?? []) reachDax(item, n2);
|
|
@@ -498,8 +544,8 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
498
544
|
if (base?.baseColumn) reach(columnOf(base.baseTable ?? "", base.baseColumn), n2);
|
|
499
545
|
else if (base?.baseTable) reach(tables.get(base.baseTable.toLowerCase()), n2);
|
|
500
546
|
}
|
|
501
|
-
const
|
|
502
|
-
const daxReferrers = (owners) => owners.filter(
|
|
547
|
+
const nameable = (o) => o.kind === "measure" || o.kind === "calculatedColumn" || o.kind === "function";
|
|
548
|
+
const daxReferrers = (owners) => owners.filter(nameable).map((o) => o.object);
|
|
503
549
|
const referrersOf = (n2) => {
|
|
504
550
|
if (isMeasure(n2)) return daxReferrers(references.measureReferencedBy(n2));
|
|
505
551
|
const dax = daxReferrers(references.columnReferencedBy(n2));
|
|
@@ -521,7 +567,7 @@ function buildReachabilityIndex(model, references, reportRefs) {
|
|
|
521
567
|
return path;
|
|
522
568
|
},
|
|
523
569
|
unreached: () => {
|
|
524
|
-
const listed = model.tables.filter((t) => !
|
|
570
|
+
const listed = model.tables.filter((t) => !isHiddenAutoDateTable(t));
|
|
525
571
|
const columns2 = listed.flatMap((t) => t.columns.filter((c) => !parent.has(c)));
|
|
526
572
|
const measures = listed.flatMap((t) => t.measures.filter((m) => !parent.has(m)));
|
|
527
573
|
const tables2 = listed.filter(
|
|
@@ -559,6 +605,41 @@ function extractRefs(expression) {
|
|
|
559
605
|
}
|
|
560
606
|
var lower2 = (s) => s.toLowerCase();
|
|
561
607
|
var key = (table, name) => `${lower2(table)} ${lower2(name)}`;
|
|
608
|
+
var escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
609
|
+
function functionCallReader(functions) {
|
|
610
|
+
if (functions.length === 0) return () => [];
|
|
611
|
+
const byName2 = /* @__PURE__ */ new Map();
|
|
612
|
+
for (const f of functions) if (!byName2.has(lower2(f.name))) byName2.set(lower2(f.name), f);
|
|
613
|
+
const call = new RegExp(
|
|
614
|
+
`(^|[^\\p{L}\\p{N}_.])(${[...byName2.keys()].map(escapeRegExp).join("|")})(?=\\s*\\()`,
|
|
615
|
+
"giu"
|
|
616
|
+
);
|
|
617
|
+
const order3 = new Map(functions.map((f, i) => [f, i]));
|
|
618
|
+
return (expression) => {
|
|
619
|
+
const found = /* @__PURE__ */ new Set();
|
|
620
|
+
for (const m of expression.matchAll(call)) {
|
|
621
|
+
const f = byName2.get(lower2(m[2]));
|
|
622
|
+
if (f) found.add(f);
|
|
623
|
+
}
|
|
624
|
+
return [...found].sort((a, b) => order3.get(a) - order3.get(b));
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
function resolveBareName(name, owner, lookup) {
|
|
628
|
+
const measure = lookup.measureNamed(name);
|
|
629
|
+
if (measure !== void 0) return { kind: "measure", measure };
|
|
630
|
+
if (owner.kind === "calculationItem") return { kind: "none" };
|
|
631
|
+
if (owner.kind === "function") {
|
|
632
|
+
const columns2 = lookup.tables.flatMap((t) => lookup.columnOf(t, name) ?? []);
|
|
633
|
+
return columns2.length > 0 ? { kind: "columns", columns: columns2 } : { kind: "none" };
|
|
634
|
+
}
|
|
635
|
+
const own = owner.table && lookup.columnOf(owner.table, name);
|
|
636
|
+
if (own) return { kind: "columns", columns: [own] };
|
|
637
|
+
for (const t of lookup.tables) {
|
|
638
|
+
const column = lookup.columnOf(t, name);
|
|
639
|
+
if (column) return { kind: "columns", columns: [column] };
|
|
640
|
+
}
|
|
641
|
+
return { kind: "none" };
|
|
642
|
+
}
|
|
562
643
|
function buildReferenceIndex(model) {
|
|
563
644
|
const tables = new Map(model.tables.map((t) => [lower2(t.name), t]));
|
|
564
645
|
const columns2 = /* @__PURE__ */ new Map();
|
|
@@ -568,66 +649,83 @@ function buildReferenceIndex(model) {
|
|
|
568
649
|
for (const m of t.measures) measures.set(lower2(m.name), m);
|
|
569
650
|
}
|
|
570
651
|
const columnOf = (t, name) => columns2.get(key(t.name, name));
|
|
652
|
+
const lookup = {
|
|
653
|
+
tables: model.tables,
|
|
654
|
+
columnOf,
|
|
655
|
+
measureNamed: (name) => measures.get(lower2(name))
|
|
656
|
+
};
|
|
571
657
|
const resolve4 = (raw, ownerTable, ownerKind) => {
|
|
572
658
|
if (raw.qualified) {
|
|
573
659
|
const t = tables.get(lower2(raw.table));
|
|
574
660
|
if (!t) return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
575
661
|
const col = columnOf(t, raw.name);
|
|
576
662
|
if (col) return { kind: "column", table: t.name, name: col.name, qualified: true };
|
|
577
|
-
const
|
|
578
|
-
if (
|
|
663
|
+
const meas = t.measures.find((m) => lower2(m.name) === lower2(raw.name));
|
|
664
|
+
if (meas) return { kind: "measure", table: t.name, name: meas.name, qualified: true };
|
|
579
665
|
return { kind: "unresolved", table: raw.table, name: raw.name, qualified: true };
|
|
580
666
|
}
|
|
581
|
-
const
|
|
582
|
-
if (
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
667
|
+
const bare = resolveBareName(raw.name, { kind: ownerKind, table: ownerTable }, lookup);
|
|
668
|
+
if (bare.kind === "measure")
|
|
669
|
+
return {
|
|
670
|
+
kind: "measure",
|
|
671
|
+
table: bare.measure.table.name,
|
|
672
|
+
name: bare.measure.name,
|
|
673
|
+
qualified: false
|
|
674
|
+
};
|
|
675
|
+
if (bare.kind === "columns")
|
|
676
|
+
return bare.columns.map((c) => ({
|
|
677
|
+
kind: "column",
|
|
678
|
+
table: c.table.name,
|
|
679
|
+
name: c.name,
|
|
680
|
+
qualified: false
|
|
681
|
+
}));
|
|
593
682
|
return { kind: "unresolved", name: raw.name, qualified: false };
|
|
594
683
|
};
|
|
684
|
+
const callsIn = functionCallReader(model.functions);
|
|
595
685
|
const owners = [];
|
|
596
686
|
const byObject = /* @__PURE__ */ new Map();
|
|
597
|
-
const add = (
|
|
687
|
+
const add = (of, ownerTable, ...expressions) => {
|
|
598
688
|
const expression = expressions.filter((e) => e !== void 0).join("\n");
|
|
599
689
|
const owner = {
|
|
600
|
-
|
|
601
|
-
object,
|
|
690
|
+
...of,
|
|
602
691
|
ownerTable,
|
|
603
692
|
expression,
|
|
604
|
-
refs: extractRefs(expression).
|
|
693
|
+
refs: extractRefs(expression).flatMap((r) => resolve4(r, ownerTable, of.kind)),
|
|
694
|
+
calls: callsIn(expression)
|
|
605
695
|
};
|
|
606
696
|
owners.push(owner);
|
|
607
|
-
byObject.set(object, owner);
|
|
697
|
+
byObject.set(of.object, owner);
|
|
608
698
|
};
|
|
609
699
|
for (const t of model.tables) {
|
|
610
|
-
for (const m of t.measures)
|
|
700
|
+
for (const m of t.measures)
|
|
701
|
+
add({ kind: "measure", object: m }, t, m.expression, m.formatStringDefinition);
|
|
611
702
|
for (const c of t.columns)
|
|
612
|
-
if (c.kind === "calculated") add("calculatedColumn", c, t, c.expression);
|
|
703
|
+
if (c.kind === "calculated") add({ kind: "calculatedColumn", object: c }, t, c.expression);
|
|
613
704
|
if (t.kind === "calculated")
|
|
614
705
|
add(
|
|
615
|
-
"calculatedTable",
|
|
616
|
-
t,
|
|
706
|
+
{ kind: "calculatedTable", object: t },
|
|
617
707
|
t,
|
|
618
708
|
...t.partitions.filter((p) => p.sourceType === "calculated").map((p) => p.source)
|
|
619
709
|
);
|
|
620
710
|
for (const item of t.calculationGroup?.items ?? [])
|
|
621
|
-
add(
|
|
711
|
+
add(
|
|
712
|
+
{ kind: "calculationItem", object: item },
|
|
713
|
+
t,
|
|
714
|
+
item.expression,
|
|
715
|
+
item.formatStringDefinition
|
|
716
|
+
);
|
|
622
717
|
}
|
|
623
718
|
for (const role of model.roles) {
|
|
624
719
|
for (const tp of role.tablePermissions)
|
|
625
720
|
if (tp.filter !== void 0)
|
|
626
|
-
add("tablePermission", tp, tables.get(lower2(tp.table)), tp.filter);
|
|
721
|
+
add({ kind: "tablePermission", object: tp }, tables.get(lower2(tp.table)), tp.filter);
|
|
627
722
|
}
|
|
723
|
+
for (const f of model.functions) add({ kind: "function", object: f }, void 0, f.expression);
|
|
628
724
|
const columnRefs = /* @__PURE__ */ new Map();
|
|
629
725
|
const measureRefs = /* @__PURE__ */ new Map();
|
|
726
|
+
const callers = /* @__PURE__ */ new Map();
|
|
630
727
|
for (const o of owners) {
|
|
728
|
+
for (const f of o.calls) callers.set(f, [...callers.get(f) ?? [], o]);
|
|
631
729
|
for (const r of o.refs) {
|
|
632
730
|
if (r.kind === "column") {
|
|
633
731
|
const k = key(r.table, r.name);
|
|
@@ -646,7 +744,9 @@ function buildReferenceIndex(model) {
|
|
|
646
744
|
owners,
|
|
647
745
|
refsOf: (object) => byObject.get(object)?.refs ?? [],
|
|
648
746
|
columnReferencedBy: (c) => columnRefs.get(key(c.table.name, c.name)) ?? [],
|
|
649
|
-
measureReferencedBy: (m) => measureRefs.get(lower2(m.name)) ?? []
|
|
747
|
+
measureReferencedBy: (m) => measureRefs.get(lower2(m.name)) ?? [],
|
|
748
|
+
callsOf: (object) => byObject.get(object)?.calls ?? [],
|
|
749
|
+
functionCalledBy: (f) => callers.get(f) ?? []
|
|
650
750
|
};
|
|
651
751
|
}
|
|
652
752
|
|
|
@@ -706,7 +806,9 @@ function buildReportReferenceIndex(report, model) {
|
|
|
706
806
|
const missingOn = (t, reason) => {
|
|
707
807
|
const files = model?.files ?? [];
|
|
708
808
|
const declaring = files.find(
|
|
709
|
-
(f) => partlyRead.has(f.file) && f.
|
|
809
|
+
(f) => partlyRead.has(f.file) && modelDeclarations(f).some(
|
|
810
|
+
(r) => r.kind === "object" && r.type === "table" && r.name === t.name
|
|
811
|
+
)
|
|
710
812
|
);
|
|
711
813
|
if (declaring) return unread2(reason, declaring.file);
|
|
712
814
|
if (neverRead !== void 0) return notRead(reason, neverRead);
|
|
@@ -822,6 +924,14 @@ function buildReportReferenceIndex(report, model) {
|
|
|
822
924
|
}
|
|
823
925
|
}
|
|
824
926
|
for (const b of report.bookmarks) add({ kind: "bookmark", object: b }, b.file, b.refs);
|
|
927
|
+
const bareLookup = {
|
|
928
|
+
tables: model?.tables ?? [],
|
|
929
|
+
columnOf,
|
|
930
|
+
measureNamed: (name) => {
|
|
931
|
+
const inModel = measuresByName.get(lower3(name));
|
|
932
|
+
return inModel ? { table: inModel.table.name, name: inModel.name } : reportMeasuresByName.get(lower3(name));
|
|
933
|
+
}
|
|
934
|
+
};
|
|
825
935
|
for (const m of report.measures) {
|
|
826
936
|
const owner = { kind: "reportMeasure", object: m };
|
|
827
937
|
for (const raw of extractRefs(m.expression)) {
|
|
@@ -831,30 +941,37 @@ function buildReportReferenceIndex(report, model) {
|
|
|
831
941
|
add(owner, m.file, [{ kind, table: raw.table, name: raw.name, pointer: "" }]);
|
|
832
942
|
continue;
|
|
833
943
|
}
|
|
834
|
-
const
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
else if (extension)
|
|
944
|
+
const bare = resolveBareName(
|
|
945
|
+
raw.name,
|
|
946
|
+
{ kind: "reportMeasure", table: tables.get(lower3(m.table)) },
|
|
947
|
+
bareLookup
|
|
948
|
+
);
|
|
949
|
+
if (bare.kind === "measure")
|
|
841
950
|
add(owner, m.file, [
|
|
842
|
-
{ kind: "measure", table:
|
|
951
|
+
{ kind: "measure", table: bare.measure.table, name: bare.measure.name, pointer: "" }
|
|
843
952
|
]);
|
|
844
|
-
else
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
953
|
+
else if (bare.kind === "columns")
|
|
954
|
+
add(
|
|
955
|
+
owner,
|
|
956
|
+
m.file,
|
|
957
|
+
bare.columns.map((c) => ({
|
|
958
|
+
kind: "column",
|
|
959
|
+
table: c.table.name,
|
|
960
|
+
name: c.name,
|
|
961
|
+
pointer: ""
|
|
962
|
+
}))
|
|
963
|
+
);
|
|
964
|
+
else
|
|
965
|
+
refs.push({
|
|
966
|
+
ref: { kind: "measure", table: m.table, name: raw.name, pointer: "" },
|
|
967
|
+
owner,
|
|
968
|
+
file: m.file,
|
|
969
|
+
resolution: missing(`no measure or column named ${q(raw.name)}`)
|
|
970
|
+
});
|
|
856
971
|
}
|
|
857
972
|
}
|
|
973
|
+
const callsIn = functionCallReader(model?.functions ?? []);
|
|
974
|
+
const functionCalls = report.measures.map((measure) => ({ measure, calls: callsIn(measure.expression) })).filter((c) => c.calls.length > 0);
|
|
858
975
|
const byTarget = /* @__PURE__ */ new Map();
|
|
859
976
|
for (const r of refs) {
|
|
860
977
|
const target = r.resolution.kind === "column" ? r.resolution.column : r.resolution.kind === "measure" ? r.resolution.measure : void 0;
|
|
@@ -867,7 +984,8 @@ function buildReportReferenceIndex(report, model) {
|
|
|
867
984
|
refs,
|
|
868
985
|
referencedBy: (target) => byTarget.get(target) ?? [],
|
|
869
986
|
unresolved: () => refs.filter((r) => r.resolution.kind === "unresolved"),
|
|
870
|
-
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v)
|
|
987
|
+
fieldsOf: (v) => refs.filter((r) => r.owner.kind === "visualField" && r.owner.object === v),
|
|
988
|
+
functionCalls
|
|
871
989
|
};
|
|
872
990
|
}
|
|
873
991
|
|
|
@@ -877,9 +995,11 @@ function buildUsageIndex(model) {
|
|
|
877
995
|
const sortTargets = /* @__PURE__ */ new Set();
|
|
878
996
|
const levelColumns = /* @__PURE__ */ new Set();
|
|
879
997
|
const variationDefaults = /* @__PURE__ */ new Set();
|
|
998
|
+
const groupByTargets = /* @__PURE__ */ new Set();
|
|
880
999
|
for (const t of model.tables) {
|
|
881
1000
|
for (const c of t.columns) {
|
|
882
1001
|
if (c.sortByColumn !== void 0) sortTargets.add(key3(t.name, c.sortByColumn));
|
|
1002
|
+
for (const g of c.groupByColumns) groupByTargets.add(key3(t.name, g));
|
|
883
1003
|
for (const v of c.variations)
|
|
884
1004
|
if (v.defaultColumn)
|
|
885
1005
|
variationDefaults.add(key3(v.defaultColumn.table, v.defaultColumn.column));
|
|
@@ -890,7 +1010,8 @@ function buildUsageIndex(model) {
|
|
|
890
1010
|
return {
|
|
891
1011
|
usedInSortBy: (c) => sortTargets.has(key3(c.table.name, c.name)),
|
|
892
1012
|
usedInHierarchies: (c) => levelColumns.has(key3(c.table.name, c.name)),
|
|
893
|
-
usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name))
|
|
1013
|
+
usedInVariations: (c) => variationDefaults.has(key3(c.table.name, c.name)),
|
|
1014
|
+
usedInGroupBy: (c) => groupByTargets.has(key3(c.table.name, c.name))
|
|
894
1015
|
};
|
|
895
1016
|
}
|
|
896
1017
|
|
|
@@ -916,20 +1037,42 @@ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
|
916
1037
|
var kindOf = (v) => v === null ? "null" : Array.isArray(v) ? "an array" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : "a boolean";
|
|
917
1038
|
var CONFLICT_MARKER = /^(?:<{7}|={7}|>{7})(?:\s|$)/;
|
|
918
1039
|
var LINE_BREAK = /\r\n?|\n/;
|
|
1040
|
+
function quoted(line) {
|
|
1041
|
+
if (line === void 0 || line.length <= 120) return line ?? "";
|
|
1042
|
+
const text2 = line.trimStart();
|
|
1043
|
+
if (text2.length <= 120) return text2;
|
|
1044
|
+
const high = text2.charCodeAt(118);
|
|
1045
|
+
const end = high >= 55296 && high <= 56319 ? 118 : 119;
|
|
1046
|
+
return `${text2.slice(0, end)}\u2026`;
|
|
1047
|
+
}
|
|
1048
|
+
var lineAt = (body, offset) => body.slice(0, offset).split(LINE_BREAK).length;
|
|
1049
|
+
var MAX_DEPTH = 256;
|
|
1050
|
+
function tooDeepAt(body) {
|
|
1051
|
+
let depth = 0;
|
|
1052
|
+
for (let i = 0; i < body.length; i++) {
|
|
1053
|
+
const ch = body[i];
|
|
1054
|
+
if (ch === '"') {
|
|
1055
|
+
for (i++; body[i] !== '"'; i++) if (body[i] === "\\") i++;
|
|
1056
|
+
} else if (ch === "{" || ch === "[") {
|
|
1057
|
+
if (++depth > MAX_DEPTH) return i;
|
|
1058
|
+
} else if (ch === "}" || ch === "]") depth--;
|
|
1059
|
+
}
|
|
1060
|
+
return -1;
|
|
1061
|
+
}
|
|
919
1062
|
var AT_POSITION = /\bJSON at position (\d+)/;
|
|
920
1063
|
var AT_LINE = /\bline (\d+) column \d+/;
|
|
921
1064
|
var QUOTED_RUN = /^Unexpected token '(.)', (?:\.\.\.)?"([\s\S]*)"(?:\.\.\.)? is not valid JSON$/;
|
|
922
1065
|
function lineOfParseError(body, message) {
|
|
923
1066
|
const lineCount = body.split(LINE_BREAK).length;
|
|
924
|
-
const lineOf = (offset2) => body
|
|
1067
|
+
const lineOf = (offset2) => lineAt(body, offset2);
|
|
925
1068
|
const at2 = AT_POSITION.exec(message);
|
|
926
1069
|
if (at2 && Number(at2[1]) <= body.length) return lineOf(Number(at2[1]));
|
|
927
1070
|
const named2 = AT_LINE.exec(message);
|
|
928
1071
|
if (named2 && Number(named2[1]) >= 1 && Number(named2[1]) <= lineCount) return Number(named2[1]);
|
|
929
|
-
const
|
|
930
|
-
if (!
|
|
931
|
-
const char =
|
|
932
|
-
const run =
|
|
1072
|
+
const around = QUOTED_RUN.exec(message);
|
|
1073
|
+
if (!around) return 1;
|
|
1074
|
+
const char = around[1];
|
|
1075
|
+
const run = around[2];
|
|
933
1076
|
const start = body.indexOf(run);
|
|
934
1077
|
if (start < 0) return 1;
|
|
935
1078
|
const middle = run.length / 2;
|
|
@@ -943,7 +1086,7 @@ function readJson(file, text2, options = {}) {
|
|
|
943
1086
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
944
1087
|
const lines = body.split(LINE_BREAK);
|
|
945
1088
|
const issues = lines.flatMap(
|
|
946
|
-
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: line, reason: "merge conflict marker" }] : []
|
|
1089
|
+
(line, i) => CONFLICT_MARKER.test(line) ? [{ file, line: i + 1, text: quoted(line), reason: "merge conflict marker" }] : []
|
|
947
1090
|
);
|
|
948
1091
|
if (issues.length > 0) return { json: void 0, issues };
|
|
949
1092
|
let json;
|
|
@@ -955,7 +1098,22 @@ function readJson(file, text2, options = {}) {
|
|
|
955
1098
|
const flat = message.replace(/\s+/g, " ");
|
|
956
1099
|
return {
|
|
957
1100
|
json: void 0,
|
|
958
|
-
issues: [{ file, line, text: lines[line - 1]
|
|
1101
|
+
issues: [{ file, line, text: quoted(lines[line - 1]), reason: `not valid JSON (${flat})` }]
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
1104
|
+
const deep = tooDeepAt(body);
|
|
1105
|
+
if (deep >= 0) {
|
|
1106
|
+
const line = lineAt(body, deep);
|
|
1107
|
+
return {
|
|
1108
|
+
json: void 0,
|
|
1109
|
+
issues: [
|
|
1110
|
+
{
|
|
1111
|
+
file,
|
|
1112
|
+
line,
|
|
1113
|
+
text: quoted(lines[line - 1]),
|
|
1114
|
+
reason: `nested more than ${MAX_DEPTH} levels deep`
|
|
1115
|
+
}
|
|
1116
|
+
]
|
|
959
1117
|
};
|
|
960
1118
|
}
|
|
961
1119
|
if (!isRecord2(json)) {
|
|
@@ -967,7 +1125,7 @@ function readJson(file, text2, options = {}) {
|
|
|
967
1125
|
{
|
|
968
1126
|
file,
|
|
969
1127
|
line: start + 1,
|
|
970
|
-
text: lines[start]
|
|
1128
|
+
text: quoted(lines[start]),
|
|
971
1129
|
reason: `not a JSON object (the file holds ${kindOf(json)})`
|
|
972
1130
|
}
|
|
973
1131
|
]
|
|
@@ -990,9 +1148,12 @@ function newerThan(a, b) {
|
|
|
990
1148
|
return false;
|
|
991
1149
|
}
|
|
992
1150
|
var newerMajor = (a, b) => Number(a.split(".")[0]) > Number(b.split(".")[0]);
|
|
1151
|
+
var escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1152
|
+
var unescapePointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
1153
|
+
var endsLine = (text2, i) => text2[i] === "\n" || text2[i] === "\r" && text2[i + 1] !== "\n";
|
|
993
1154
|
function lineOfPointer(text2, pointer) {
|
|
994
1155
|
if (pointer === "") return 1;
|
|
995
|
-
const want = pointer.split("/").slice(1).map(
|
|
1156
|
+
const want = pointer.split("/").slice(1).map(unescapePointer);
|
|
996
1157
|
const path = [];
|
|
997
1158
|
const kinds = [];
|
|
998
1159
|
const parent = () => kinds[kinds.length - 1];
|
|
@@ -1001,7 +1162,7 @@ function lineOfPointer(text2, pointer) {
|
|
|
1001
1162
|
let expectKey = false;
|
|
1002
1163
|
for (let i = text2.charCodeAt(0) === 65279 ? 1 : 0; i < text2.length; i++) {
|
|
1003
1164
|
const ch = text2[i];
|
|
1004
|
-
if (
|
|
1165
|
+
if (endsLine(text2, i)) {
|
|
1005
1166
|
line++;
|
|
1006
1167
|
continue;
|
|
1007
1168
|
}
|
|
@@ -1012,13 +1173,18 @@ function lineOfPointer(text2, pointer) {
|
|
|
1012
1173
|
if (text2[j2] === "\\") j2++;
|
|
1013
1174
|
j2++;
|
|
1014
1175
|
}
|
|
1015
|
-
|
|
1176
|
+
let value;
|
|
1177
|
+
try {
|
|
1178
|
+
value = JSON.parse(text2.slice(i, j2 + 1));
|
|
1179
|
+
} catch {
|
|
1180
|
+
value = text2.slice(i + 1, j2);
|
|
1181
|
+
}
|
|
1016
1182
|
if (parent() === "object" && expectKey) {
|
|
1017
1183
|
path[path.length - 1] = value;
|
|
1018
1184
|
expectKey = false;
|
|
1019
1185
|
if (matches()) return line;
|
|
1020
1186
|
} else if (parent() === "array" && matches()) return line;
|
|
1021
|
-
for (let k = i; k <= j2; k++) if (text2
|
|
1187
|
+
for (let k = i; k <= j2; k++) if (endsLine(text2, k)) line++;
|
|
1022
1188
|
i = j2;
|
|
1023
1189
|
continue;
|
|
1024
1190
|
}
|
|
@@ -1050,7 +1216,6 @@ function lineOfPointer(text2, pointer) {
|
|
|
1050
1216
|
|
|
1051
1217
|
// ../core/src/pbir/refs.ts
|
|
1052
1218
|
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1053
|
-
var escapePointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
1054
1219
|
var schemaOf = (node) => typeof node.Schema === "string" && node.Schema !== "" ? { schema: node.Schema } : {};
|
|
1055
1220
|
function sourceOf(expression, aliases) {
|
|
1056
1221
|
if (!isRecord3(expression)) return { table: "", noTable: "noSource" };
|
|
@@ -1434,7 +1599,7 @@ var folderHoldsFieldReferences = (folder) => ["definition", "page", "visual", "b
|
|
|
1434
1599
|
);
|
|
1435
1600
|
var PAGE_FOLDER = /^definition\/pages\/([^/]+)\/$/;
|
|
1436
1601
|
var VISUAL_FOLDER = /^definition\/pages\/([^/]+)\/visuals\/([^/]+)\/$/;
|
|
1437
|
-
var definedByPbir = (path) => path
|
|
1602
|
+
var definedByPbir = (path) => path === "definition.pbir" || path === ".platform" || path.endsWith(".pbip") || definitionFile(path);
|
|
1438
1603
|
function buildReport(files, unreadPaths = []) {
|
|
1439
1604
|
const report = {
|
|
1440
1605
|
publicCustomVisuals: [],
|
|
@@ -1508,13 +1673,13 @@ function buildReport(files, unreadPaths = []) {
|
|
|
1508
1673
|
});
|
|
1509
1674
|
}
|
|
1510
1675
|
const json = read.json;
|
|
1511
|
-
if (f.path
|
|
1676
|
+
if (f.path === "definition.pbir") {
|
|
1512
1677
|
report.datasetReference = datasetReferenceOf(json);
|
|
1513
1678
|
continue;
|
|
1514
1679
|
}
|
|
1515
1680
|
if (!isRecord4(json)) continue;
|
|
1516
1681
|
let m;
|
|
1517
|
-
if (f.path
|
|
1682
|
+
if (f.path === ".platform") {
|
|
1518
1683
|
if (isRecord4(json.metadata) && json.metadata.type === "Report")
|
|
1519
1684
|
report.displayName = str2(json.metadata.displayName);
|
|
1520
1685
|
} else if (f.path === "definition/report.json") {
|
|
@@ -1662,7 +1827,7 @@ function factsLines(result) {
|
|
|
1662
1827
|
rule: f.ruleId ?? ""
|
|
1663
1828
|
}));
|
|
1664
1829
|
const labelWidth = Math.max(...rows.map((r) => r.label.length));
|
|
1665
|
-
const valueWidth = Math.max(...rows.map((r) => r.value.length));
|
|
1830
|
+
const valueWidth = Math.max(0, ...rows.filter((r) => r.rule).map((r) => r.value.length));
|
|
1666
1831
|
return [
|
|
1667
1832
|
"Report at a glance",
|
|
1668
1833
|
...rows.map(
|
|
@@ -1728,9 +1893,10 @@ var dataType = (c) => (c.dataType ?? "").toLowerCase();
|
|
|
1728
1893
|
var isNumericType = (c) => ["int64", "decimal", "double"].includes(dataType(c));
|
|
1729
1894
|
var hiddenOrTableHidden = (c) => c.isHidden || c.table.isHidden;
|
|
1730
1895
|
var isBlank = (s) => s === void 0 || s.trim() === "";
|
|
1731
|
-
var
|
|
1896
|
+
var escapeRegExp2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1732
1897
|
var isDirectQueryTable = (t) => t.kind === "table" && t.partitions[0]?.mode === "directquery";
|
|
1733
1898
|
var modelPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropObjects));
|
|
1899
|
+
var tablesPartlyRead = (m) => m.unreadPaths.length > 0 || m.files.some((f) => f.issues.some((i) => i.canDropTableLine));
|
|
1734
1900
|
var tablesInScope = (m) => m.tables.filter((t) => t.kind !== "calculationGroup");
|
|
1735
1901
|
var tableObjectType = (t) => t.kind === "calculated" ? "CalculatedTable" : t.kind === "calculationGroup" ? "CalculationGroupTable" : "Table";
|
|
1736
1902
|
var columnObjectType = (c) => c.kind === "calculated" ? "CalculatedColumn" : c.kind === "calculatedTable" ? "CalculatedTableColumn" : "Column";
|
|
@@ -2056,13 +2222,13 @@ function generalFilter(v, key4) {
|
|
|
2056
2222
|
function slicerSearch(v) {
|
|
2057
2223
|
const found = generalFilter(v, "selfFilter");
|
|
2058
2224
|
if (found === void 0) return void 0;
|
|
2059
|
-
const [
|
|
2060
|
-
const condition = isRecord5(
|
|
2225
|
+
const [only2, ...more] = found.where;
|
|
2226
|
+
const condition = isRecord5(only2) && isRecord5(only2.Condition) ? only2.Condition : {};
|
|
2061
2227
|
const contains = isRecord5(condition.Contains) ? condition.Contains : {};
|
|
2062
2228
|
const right = isRecord5(contains.Right) ? contains.Right : {};
|
|
2063
2229
|
const value = isRecord5(right.Literal) ? right.Literal.Value : void 0;
|
|
2064
|
-
const
|
|
2065
|
-
return
|
|
2230
|
+
const quoted3 = typeof value === "string" ? /^'(.+)'$/s.exec(value) : null;
|
|
2231
|
+
return quoted3 === null || more.length > 0 ? { pointer: found.pointer } : { pointer: found.pointer, term: quoted3[1].replaceAll("''", "'") };
|
|
2066
2232
|
}
|
|
2067
2233
|
var reportMeasuresToMove = (project) => project.model && project.report ? project.report.measures : [];
|
|
2068
2234
|
function openingPage(r) {
|
|
@@ -2123,22 +2289,31 @@ function reportFacts(project, report, known, ruleOptions) {
|
|
|
2123
2289
|
)
|
|
2124
2290
|
);
|
|
2125
2291
|
const pane = filtersPaneState(report);
|
|
2126
|
-
|
|
2127
|
-
|
|
2292
|
+
if (pane === void 0) {
|
|
2293
|
+
facts.push({
|
|
2128
2294
|
layer: "report",
|
|
2129
2295
|
label: "Filters pane",
|
|
2130
2296
|
value: "unknown",
|
|
2131
2297
|
detail: "report.json was not read"
|
|
2132
|
-
}
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2298
|
+
});
|
|
2299
|
+
} else {
|
|
2300
|
+
const policySet = ruleOptions.get("FILTERS_PANE_STATE")?.expect !== void 0;
|
|
2301
|
+
const detail = [
|
|
2302
|
+
pane.recordedAt === void 0 && "read as open; report.json does not record it",
|
|
2303
|
+
!policySet && known.has("FILTERS_PANE_STATE") && "not checked; set an expect policy for FILTERS_PANE_STATE in pbiplint.config.json to check it"
|
|
2304
|
+
].filter(Boolean);
|
|
2305
|
+
facts.push(
|
|
2306
|
+
withRule(
|
|
2307
|
+
{
|
|
2308
|
+
layer: "report",
|
|
2309
|
+
label: "Filters pane",
|
|
2310
|
+
value: pane.state,
|
|
2311
|
+
...detail.length ? { detail: detail.join("; ") } : {}
|
|
2312
|
+
},
|
|
2313
|
+
policySet ? "FILTERS_PANE_STATE" : void 0
|
|
2314
|
+
)
|
|
2315
|
+
);
|
|
2316
|
+
}
|
|
2142
2317
|
const visualUnread2 = visualFileUnread(report);
|
|
2143
2318
|
const unreadVisual = "a visual.json could not be read";
|
|
2144
2319
|
const hidden = pages.filter(isHiddenPage).length;
|
|
@@ -2254,16 +2429,17 @@ function buildFacts(project, indexes, knownRules, ruleOptions = /* @__PURE__ */
|
|
|
2254
2429
|
const facts = reportFacts(project, project.report, knownRules, ruleOptions);
|
|
2255
2430
|
const model = project.model;
|
|
2256
2431
|
if (model) {
|
|
2257
|
-
const
|
|
2258
|
-
const columns2 =
|
|
2259
|
-
const measures =
|
|
2432
|
+
const shown2 = model.tables.filter((t) => !isHiddenAutoDateTable(t));
|
|
2433
|
+
const columns2 = shown2.reduce((s, t) => s + t.columns.length, 0);
|
|
2434
|
+
const measures = shown2.reduce((s, t) => s + t.measures.length, 0);
|
|
2260
2435
|
const partly = modelPartlyRead(model);
|
|
2261
2436
|
const count = (k, noun) => k === 0 && partly ? `${noun}s: unknown` : n(k, noun);
|
|
2262
|
-
const countUnknown = partly && [
|
|
2437
|
+
const countUnknown = partly && [shown2.length, columns2, measures].includes(0);
|
|
2438
|
+
const functions = model.functions.length > 0 ? `, ${n(model.functions.length, "function")}` : "";
|
|
2263
2439
|
const fact = {
|
|
2264
2440
|
layer: "model",
|
|
2265
2441
|
label: "Model",
|
|
2266
|
-
value: `${count(
|
|
2442
|
+
value: `${count(shown2.length, "table")}, ${count(columns2, "column")}, ${count(measures, "measure")}${functions}`
|
|
2267
2443
|
};
|
|
2268
2444
|
const reach = indexes.reachability;
|
|
2269
2445
|
const unreadModel = "a model file could not be fully read";
|
|
@@ -2284,18 +2460,35 @@ function buildFacts(project, indexes, knownRules, ruleOptions = /* @__PURE__ */
|
|
|
2284
2460
|
|
|
2285
2461
|
// ../core/src/project/route.ts
|
|
2286
2462
|
var isModelFile = (path) => path.endsWith(".tmdl");
|
|
2287
|
-
var isReportFile = (path) =>
|
|
2463
|
+
var isReportFile = (path) => /(^|\/)(definition\.pbir|\.platform)$/.test(path) || path.endsWith(".pbip") || /(^|\/)definition\/.*\.json$/.test(path);
|
|
2288
2464
|
var isPbix = (path) => /\.pbix$/i.test(path);
|
|
2289
|
-
var
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2465
|
+
var SAVE_AS_PROJECT_URL = "https://learn.microsoft.com/power-bi/developer/projects/projects-overview#save-as-a-project";
|
|
2466
|
+
var CONVERT_TO_PBIR_URL = "https://learn.microsoft.com/power-bi/developer/projects/projects-report#convert-existing-report-to-pbir";
|
|
2467
|
+
var LEARN_HELP_URLS = Object.freeze([
|
|
2468
|
+
SAVE_AS_PROJECT_URL,
|
|
2469
|
+
CONVERT_TO_PBIR_URL
|
|
2293
2470
|
]);
|
|
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.
|
|
2471
|
+
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
2472
|
function pbixRefusal(path, others = 0) {
|
|
2296
2473
|
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
2474
|
return `${what}, which pbiplint cannot read. ${SAVE_AS_PROJECT}`;
|
|
2298
2475
|
}
|
|
2476
|
+
function legacyReportNotice(name) {
|
|
2477
|
+
return {
|
|
2478
|
+
kind: "legacy-report-format",
|
|
2479
|
+
path: name,
|
|
2480
|
+
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}`
|
|
2481
|
+
};
|
|
2482
|
+
}
|
|
2483
|
+
function legacyModelNotice(name) {
|
|
2484
|
+
return {
|
|
2485
|
+
kind: "legacy-model-format",
|
|
2486
|
+
path: name,
|
|
2487
|
+
message: `${name} is stored as model.bim, which pbiplint cannot read; save it in the TMDL format from Power BI Desktop`
|
|
2488
|
+
};
|
|
2489
|
+
}
|
|
2490
|
+
var LEGACY_REPORT_REASON = "the report is saved in the legacy report.json format";
|
|
2491
|
+
var LEGACY_MODEL_REASON = "the model is saved in the legacy model.bim format";
|
|
2299
2492
|
var listOf2 = (items) => items.length <= 2 ? items.join(" and ") : `${items.slice(0, -1).join(", ")}, and ${items.at(-1)}`;
|
|
2300
2493
|
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
2494
|
var holdNoTmdl = (folders) => `${listOf2(folders)} hold${folders.length === 1 ? "s" : ""} no .tmdl files`;
|
|
@@ -2314,7 +2507,8 @@ function datasetReference(pbirText) {
|
|
|
2314
2507
|
function pairingDecision(ref, siblingModelFolder, reportFolder) {
|
|
2315
2508
|
if (ref.kind === "byConnection")
|
|
2316
2509
|
return { useModel: false, reason: "this report reads a published model" };
|
|
2317
|
-
if (siblingModelFolder === void 0)
|
|
2510
|
+
if (siblingModelFolder === void 0)
|
|
2511
|
+
return ref.kind === "byPath" && ref.path.trim() !== "" ? { useModel: false, reason: `this report reads ${ref.path}, which this run did not include` } : { useModel: false };
|
|
2318
2512
|
if (ref.kind === "none") return { useModel: true };
|
|
2319
2513
|
const named2 = ref.path.replace(/\\/g, "/").replace(/\/+$/, "").split("/").pop() ?? "";
|
|
2320
2514
|
if (named2.toLowerCase() === siblingModelFolder.toLowerCase()) return { useModel: true };
|
|
@@ -3133,11 +3327,12 @@ var RULE_SUMMARIES = {
|
|
|
3133
3327
|
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
3328
|
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
3329
|
"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS": "Visible columns whose name starts with Is and whose type is whole number, and visible columns whose name ends with the word Flag after a space and whose type is not text.",
|
|
3330
|
+
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.",
|
|
3136
3331
|
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
3332
|
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
3333
|
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
3334
|
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
|
|
3335
|
+
INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED: "Inactive relationships that no measure, calculation item, or user-defined function activates with USERELATIONSHIP.",
|
|
3141
3336
|
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
3337
|
ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS: "Hidden columns, or columns in hidden tables, that still have IsAvailableInMdx set to true and are not used to sort another column, in a hierarchy, or in a variation, and do not themselves sort by another column.",
|
|
3143
3338
|
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 +3347,12 @@ var RULE_SUMMARIES = {
|
|
|
3152
3347
|
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
3348
|
"MONTH_(AS_A_STRING)_MUST_BE_SORTED": "Text columns with month in the name, but not months, that have no sort-by column.",
|
|
3154
3349
|
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
|
|
3350
|
+
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
3351
|
NUMERIC_COLUMN_SUMMARIZE_BY: "Visible whole number, decimal, or double columns whose default summarization is anything other than None.",
|
|
3157
3352
|
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
3353
|
OBJECTS_WITH_NO_DESCRIPTION: "Visible tables, columns, measures, and calculation groups with no description. Visibility is the object's own flag.",
|
|
3159
3354
|
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
|
|
3355
|
+
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
3356
|
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
3357
|
PERCENTAGE_FORMATTING: "Measures with a percent format string other than `#,0.0%;-#,0.0%;#,0.0%`.",
|
|
3163
3358
|
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 +3382,7 @@ var RULE_SUMMARIES = {
|
|
|
3187
3382
|
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
3383
|
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
3384
|
TRIM_OBJECT_NAMES: "Names that start or end with a space, across every named object type in the model.",
|
|
3190
|
-
UNNECESSARY_COLUMNS: "Hidden columns, or columns in hidden tables, that nothing references: no DAX expression, relationship, hierarchy, sort-by column, row-level security filter, or object-level security rule.",
|
|
3385
|
+
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
3386
|
UNNECESSARY_MEASURES: "Hidden measures, or measures on hidden tables, that no DAX expression references.",
|
|
3192
3387
|
"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
3388
|
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.",
|
|
@@ -3238,7 +3433,8 @@ var stripCategory = (name) => name.replace(/^\[[^\]]*\]\s*/, "");
|
|
|
3238
3433
|
var extractUrls = (text2) => [
|
|
3239
3434
|
...new Set((text2.match(/https?:\/\/[^\s)"]+/g) ?? []).map((u) => u.replace(/[.,]$/, "")))
|
|
3240
3435
|
];
|
|
3241
|
-
function bpaRule(id,
|
|
3436
|
+
function bpaRule(id, ...args) {
|
|
3437
|
+
const [{ skipWhenModelUnread }, check] = args.length === 1 ? [{}, args[0]] : args;
|
|
3242
3438
|
const meta = metaOf(id);
|
|
3243
3439
|
return {
|
|
3244
3440
|
id,
|
|
@@ -3248,6 +3444,7 @@ function bpaRule(id, check) {
|
|
|
3248
3444
|
scope: mapScope(meta.scope),
|
|
3249
3445
|
layer: "model",
|
|
3250
3446
|
needs: ["model"],
|
|
3447
|
+
...skipWhenModelUnread ? { skipWhenModelUnread } : {},
|
|
3251
3448
|
description: RULE_SUMMARIES[id] ?? stripCategory(meta.name),
|
|
3252
3449
|
fixExpression: meta.fixExpression,
|
|
3253
3450
|
references: extractUrls(meta.description),
|
|
@@ -3297,6 +3494,7 @@ var MONTH_AS_A_STRING_MUST_BE_SORTED = bpaRule(
|
|
|
3297
3494
|
);
|
|
3298
3495
|
var NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule(
|
|
3299
3496
|
"NUMERIC_COLUMN_SUMMARIZE_BY",
|
|
3497
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3300
3498
|
(m) => columns(
|
|
3301
3499
|
m,
|
|
3302
3500
|
(c) => isNumericType(c) && (c.summarizeBy ?? "default").toLowerCase() !== "none" && !hiddenOrTableHidden(c)
|
|
@@ -3304,6 +3502,7 @@ var NUMERIC_COLUMN_SUMMARIZE_BY = bpaRule(
|
|
|
3304
3502
|
);
|
|
3305
3503
|
var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
|
|
3306
3504
|
"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS",
|
|
3505
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3307
3506
|
(m) => columns(
|
|
3308
3507
|
m,
|
|
3309
3508
|
(c) => !hiddenOrTableHidden(c) && (c.name.startsWith("Is") && dataType(c) === "int64" || c.name.endsWith(" Flag") && dataType(c) !== "string")
|
|
@@ -3311,10 +3510,12 @@ var FORMAT_FLAG_COLUMNS_AS_YES_NO_VALUE_STRINGS = bpaRule(
|
|
|
3311
3510
|
);
|
|
3312
3511
|
var DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN = bpaRule(
|
|
3313
3512
|
"DATA_COLUMNS_MUST_HAVE_A_SOURCE_COLUMN",
|
|
3513
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3314
3514
|
(m) => columns(m, (c) => c.kind === "data" && isBlank(c.sourceColumn))
|
|
3315
3515
|
);
|
|
3316
3516
|
var ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS = bpaRule(
|
|
3317
3517
|
"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS",
|
|
3518
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3318
3519
|
(m, { indexes: { usage } }) => columns(
|
|
3319
3520
|
m,
|
|
3320
3521
|
(c) => c.isAvailableInMdx && hiddenOrTableHidden(c) && !usage.usedInSortBy(c) && !usage.usedInHierarchies(c) && !usage.usedInVariations(c) && c.sortByColumn === void 0
|
|
@@ -3327,33 +3528,38 @@ var SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS = bpaRule(
|
|
|
3327
3528
|
(c) => !c.isAvailableInMdx && (usage.usedInSortBy(c) || usage.usedInHierarchies(c) || usage.usedInVariations(c) || c.sortByColumn !== void 0)
|
|
3328
3529
|
)
|
|
3329
3530
|
);
|
|
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
|
-
|
|
3531
|
+
var UNNECESSARY_COLUMNS = bpaRule(
|
|
3532
|
+
"UNNECESSARY_COLUMNS",
|
|
3533
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3534
|
+
(m, { indexes }) => {
|
|
3535
|
+
const permissions = allTablePermissions(m);
|
|
3536
|
+
return columns(m, (c) => {
|
|
3537
|
+
if (!hiddenOrTableHidden(c)) return false;
|
|
3538
|
+
if (indexes.references.columnReferencedBy(c).length > 0) return false;
|
|
3539
|
+
if (indexes.relationships.forColumn(c.table.name, c.name).length > 0) return false;
|
|
3540
|
+
if (indexes.usage.usedInSortBy(c) || indexes.usage.usedInHierarchies(c)) return false;
|
|
3541
|
+
if (indexes.usage.usedInGroupBy(c)) return false;
|
|
3542
|
+
const bare = `[${c.name}]`.toLowerCase();
|
|
3543
|
+
const qualified = [
|
|
3544
|
+
`${c.table.name}[${c.name}]`.toLowerCase(),
|
|
3545
|
+
`'${c.table.name}'[${c.name}]`.toLowerCase()
|
|
3546
|
+
];
|
|
3547
|
+
for (const tp of permissions) {
|
|
3548
|
+
const f = tp.filter?.toLowerCase();
|
|
3549
|
+
if (f === void 0) continue;
|
|
3550
|
+
if (tp.table === c.table.name && f.includes(bare)) return false;
|
|
3551
|
+
if (qualified.some((q2) => f.includes(q2))) return false;
|
|
3552
|
+
}
|
|
3553
|
+
for (const tp of permissions) {
|
|
3554
|
+
if (tp.table !== c.table.name) continue;
|
|
3555
|
+
if (tp.metadataPermission === "none") return false;
|
|
3556
|
+
if (tp.columnPermissions.some((cp) => cp.column === c.name && cp.permission === "none"))
|
|
3557
|
+
return false;
|
|
3558
|
+
}
|
|
3559
|
+
return true;
|
|
3560
|
+
});
|
|
3561
|
+
}
|
|
3562
|
+
);
|
|
3357
3563
|
var AGGREGATIONS = [
|
|
3358
3564
|
"COUNT",
|
|
3359
3565
|
"COUNTBLANK",
|
|
@@ -3374,7 +3580,7 @@ var HIDE_FACT_TABLE_COLUMNS = bpaRule("HIDE_FACT_TABLE_COLUMNS", (m) => {
|
|
|
3374
3580
|
return columns(m, (c) => {
|
|
3375
3581
|
if (c.isHidden || !isNumericType(c)) return false;
|
|
3376
3582
|
const re = new RegExp(
|
|
3377
|
-
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${
|
|
3583
|
+
`(?:${AGGREGATIONS.join("|")})\\s*\\(\\s*'*${escapeRegExp2(c.table.name)}'*\\[${escapeRegExp2(c.name)}\\]\\s*\\)`,
|
|
3378
3584
|
"i"
|
|
3379
3585
|
);
|
|
3380
3586
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -3396,6 +3602,7 @@ var columnRules = [
|
|
|
3396
3602
|
];
|
|
3397
3603
|
|
|
3398
3604
|
// ../core/src/rules/microsoft-bpa/dependencies.ts
|
|
3605
|
+
var isRuleOwner = (o) => o.kind !== "function";
|
|
3399
3606
|
function ownerFinding(o) {
|
|
3400
3607
|
switch (o.kind) {
|
|
3401
3608
|
case "measure":
|
|
@@ -3412,13 +3619,14 @@ function ownerFinding(o) {
|
|
|
3412
3619
|
}
|
|
3413
3620
|
var DAX_COLUMNS_FULLY_QUALIFIED = bpaRule(
|
|
3414
3621
|
"DAX_COLUMNS_FULLY_QUALIFIED",
|
|
3415
|
-
|
|
3622
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3623
|
+
(_m, { indexes: { references } }) => references.owners.filter(isRuleOwner).filter(
|
|
3416
3624
|
(o) => (o.kind === "measure" || o.kind === "tablePermission" || o.kind === "calculationItem") && o.refs.some((r) => r.kind === "column" && !r.qualified)
|
|
3417
3625
|
).map(ownerFinding)
|
|
3418
3626
|
);
|
|
3419
3627
|
var DAX_MEASURES_UNQUALIFIED = bpaRule(
|
|
3420
3628
|
"DAX_MEASURES_UNQUALIFIED",
|
|
3421
|
-
(_m, { indexes: { references } }) => references.owners.filter(
|
|
3629
|
+
(_m, { indexes: { references } }) => references.owners.filter(isRuleOwner).filter(
|
|
3422
3630
|
(o) => o.kind !== "tablePermission" && o.refs.some((r) => r.kind === "measure" && r.qualified)
|
|
3423
3631
|
).map(ownerFinding)
|
|
3424
3632
|
);
|
|
@@ -3442,6 +3650,7 @@ var MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES = bpaRule(
|
|
|
3442
3650
|
);
|
|
3443
3651
|
var UNNECESSARY_MEASURES = bpaRule(
|
|
3444
3652
|
"UNNECESSARY_MEASURES",
|
|
3653
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3445
3654
|
(m, { indexes: { references } }) => allMeasures(m).filter(
|
|
3446
3655
|
(x) => (x.table.isHidden || x.isHidden) && references.measureReferencedBy(x).length === 0
|
|
3447
3656
|
).map(finding.measure)
|
|
@@ -3473,6 +3682,7 @@ var patternRule = (id, kinds, patterns) => bpaRule(
|
|
|
3473
3682
|
);
|
|
3474
3683
|
var PROVIDE_FORMAT_STRING_FOR_MEASURES = bpaRule(
|
|
3475
3684
|
"PROVIDE_FORMAT_STRING_FOR_MEASURES",
|
|
3685
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3476
3686
|
(m) => allMeasures(m).filter(
|
|
3477
3687
|
(x) => !x.isHidden && !x.table.isHidden && isBlank(x.formatString) && isBlank(x.formatStringDefinition)
|
|
3478
3688
|
).map(finding.measure)
|
|
@@ -3564,15 +3774,17 @@ var measureRules = [
|
|
|
3564
3774
|
];
|
|
3565
3775
|
|
|
3566
3776
|
// ../core/src/rules/microsoft-bpa/naming.ts
|
|
3567
|
-
var namedObjectRule = (id, test) => bpaRule(
|
|
3777
|
+
var namedObjectRule = (id, test, spec = {}) => bpaRule(
|
|
3568
3778
|
id,
|
|
3779
|
+
spec,
|
|
3569
3780
|
(m) => namedObjects(m, mapScope(metaOf(id).scope)).filter((o) => test(o.name, o.description)).map((o) => o.finding)
|
|
3570
3781
|
);
|
|
3571
3782
|
var startsOrEndsWithSpace = (name) => name.startsWith(" ") || name.endsWith(" ");
|
|
3572
3783
|
var TRIM_OBJECT_NAMES = namedObjectRule("TRIM_OBJECT_NAMES", startsOrEndsWithSpace);
|
|
3573
3784
|
var OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE = namedObjectRule(
|
|
3574
3785
|
"OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE",
|
|
3575
|
-
startsOrEndsWithSpace
|
|
3786
|
+
startsOrEndsWithSpace,
|
|
3787
|
+
{ skipWhenModelUnread: tablesPartlyRead }
|
|
3576
3788
|
);
|
|
3577
3789
|
var SPECIAL_CHARS_IN_OBJECT_NAMES = namedObjectRule(
|
|
3578
3790
|
"SPECIAL_CHARS_IN_OBJECT_NAMES",
|
|
@@ -3597,6 +3809,7 @@ var PERSPECTIVES_WITH_NO_OBJECTS = bpaRule(
|
|
|
3597
3809
|
);
|
|
3598
3810
|
var CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS = bpaRule(
|
|
3599
3811
|
"CALCULATION_GROUPS_WITH_NO_CALCULATION_ITEMS",
|
|
3812
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3600
3813
|
(m) => m.tables.filter((t) => t.calculationGroup !== void 0 && t.calculationGroup.items.length === 0).map(finding.table)
|
|
3601
3814
|
);
|
|
3602
3815
|
var REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule(
|
|
@@ -3605,6 +3818,7 @@ var REMOVE_ROLES_WITH_NO_MEMBERS = bpaRule(
|
|
|
3605
3818
|
);
|
|
3606
3819
|
var REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS = bpaRule(
|
|
3607
3820
|
"REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS",
|
|
3821
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3608
3822
|
(m) => {
|
|
3609
3823
|
const partitions = allPartitions(m);
|
|
3610
3824
|
return m.dataSources.filter(
|
|
@@ -3649,6 +3863,7 @@ var HIDE_FOREIGN_KEYS = bpaRule(
|
|
|
3649
3863
|
);
|
|
3650
3864
|
var MARK_PRIMARY_KEYS = bpaRule(
|
|
3651
3865
|
"MARK_PRIMARY_KEYS",
|
|
3866
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3652
3867
|
(m, { indexes: { relationships } }) => allColumns(m).filter(
|
|
3653
3868
|
(c) => !c.isKey && c.table.dataCategory !== "Time" && relationships.forColumn(c.table.name, c.name).some(
|
|
3654
3869
|
(r) => r.toTable === c.table.name && r.toColumn === c.name && r.toCardinality === "one"
|
|
@@ -3657,6 +3872,7 @@ var MARK_PRIMARY_KEYS = bpaRule(
|
|
|
3657
3872
|
);
|
|
3658
3873
|
var REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES = bpaRule(
|
|
3659
3874
|
"REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES",
|
|
3875
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3660
3876
|
(m, { indexes: { relationships } }) => {
|
|
3661
3877
|
const all = allColumns(m);
|
|
3662
3878
|
return all.filter(
|
|
@@ -3675,6 +3891,7 @@ var SNOWFLAKE_SCHEMA_ARCHITECTURE = bpaRule(
|
|
|
3675
3891
|
);
|
|
3676
3892
|
var ENSURE_TABLES_HAVE_RELATIONSHIPS = bpaRule(
|
|
3677
3893
|
"ENSURE_TABLES_HAVE_RELATIONSHIPS",
|
|
3894
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3678
3895
|
(m, { indexes: { relationships } }) => tablesInScope(m).filter((t) => relationships.forTable(t.name).length === 0).map(finding.table)
|
|
3679
3896
|
);
|
|
3680
3897
|
var MANY_TO_MANY_RELATIONSHIPS_SHOULD_BE_SINGLE_DIRECTION = bpaRule(
|
|
@@ -3698,15 +3915,17 @@ var RELATIONSHIP_COLUMNS_SAME_DATA_TYPE = bpaRule(
|
|
|
3698
3915
|
);
|
|
3699
3916
|
var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
3700
3917
|
"INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED",
|
|
3918
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3701
3919
|
(m) => {
|
|
3702
3920
|
const expressions = [
|
|
3703
3921
|
...allMeasures(m).map((x) => x.expression),
|
|
3704
|
-
...allCalculationItems(m).map((i) => i.expression)
|
|
3922
|
+
...allCalculationItems(m).map((i) => i.expression),
|
|
3923
|
+
...m.functions.map((f) => f.expression)
|
|
3705
3924
|
];
|
|
3706
3925
|
return m.relationships.filter((r) => {
|
|
3707
3926
|
if (r.isActive) return false;
|
|
3708
3927
|
const re = new RegExp(
|
|
3709
|
-
`USERELATIONSHIP\\s*\\(\\s*'*${
|
|
3928
|
+
`USERELATIONSHIP\\s*\\(\\s*'*${escapeRegExp2(r.fromTable)}'*\\[${escapeRegExp2(r.fromColumn)}\\]\\s*,\\s*'*${escapeRegExp2(r.toTable)}'*\\[${escapeRegExp2(r.toColumn)}\\]`,
|
|
3710
3929
|
"i"
|
|
3711
3930
|
);
|
|
3712
3931
|
return !expressions.some((e) => re.test(e));
|
|
@@ -3715,6 +3934,7 @@ var INACTIVE_RELATIONSHIPS_THAT_ARE_NEVER_ACTIVATED = bpaRule(
|
|
|
3715
3934
|
);
|
|
3716
3935
|
var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
|
|
3717
3936
|
"AVOID_EXCESSIVE_BI-DIRECTIONAL_OR_MANY-TO-MANY_RELATIONSHIPS",
|
|
3937
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3718
3938
|
(m) => {
|
|
3719
3939
|
const rels = m.relationships;
|
|
3720
3940
|
const count = rels.filter(isBidirectional).length + rels.filter(isManyToMany).length;
|
|
@@ -3723,6 +3943,7 @@ var AVOID_EXCESSIVE_BIDIRECTIONAL_OR_MANY_TO_MANY_RELATIONSHIPS = bpaRule(
|
|
|
3723
3943
|
);
|
|
3724
3944
|
var AVOID_USING_MANY_TO_MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY = bpaRule(
|
|
3725
3945
|
"AVOID_USING_MANY-TO-MANY_RELATIONSHIPS_ON_TABLES_USED_FOR_DYNAMIC_ROW_LEVEL_SECURITY",
|
|
3946
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3726
3947
|
(m, { indexes: { relationships } }) => {
|
|
3727
3948
|
const permissions = allTablePermissions(m);
|
|
3728
3949
|
return m.tables.filter(
|
|
@@ -3749,10 +3970,12 @@ var relationshipRules = [
|
|
|
3749
3970
|
var hasDateTimeKey = (t) => t.columns.some((c) => c.isKey && dataType(c) === "datetime");
|
|
3750
3971
|
var MODEL_SHOULD_HAVE_A_DATE_TABLE = bpaRule(
|
|
3751
3972
|
"MODEL_SHOULD_HAVE_A_DATE_TABLE",
|
|
3973
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3752
3974
|
(m) => m.tables.some((t) => t.dataCategory === "Time" && hasDateTimeKey(t)) ? [] : [finding.model(m)]
|
|
3753
3975
|
);
|
|
3754
3976
|
var DATE_CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE = bpaRule(
|
|
3755
3977
|
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE",
|
|
3978
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3756
3979
|
(m) => tablesInScope(m).filter((t) => {
|
|
3757
3980
|
const u = t.name.toUpperCase();
|
|
3758
3981
|
return (u.includes("DATE") || u.includes("CALENDAR")) && (t.dataCategory !== "Time" || !hasDateTimeKey(t));
|
|
@@ -3781,6 +4004,7 @@ var UNPIVOT_PIVOTED_MONTH_DATA = bpaRule(
|
|
|
3781
4004
|
);
|
|
3782
4005
|
var PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES = bpaRule(
|
|
3783
4006
|
"PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES",
|
|
4007
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3784
4008
|
(m) => m.tables.filter(
|
|
3785
4009
|
(t) => t.kind === "table" && t.partitions.length === 1 && t.partitions[0].name !== t.name
|
|
3786
4010
|
).map(finding.table)
|
|
@@ -3809,6 +4033,7 @@ var MINIMIZE_POWER_QUERY_TRANSFORMATIONS = bpaRule(
|
|
|
3809
4033
|
);
|
|
3810
4034
|
var MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS = bpaRule(
|
|
3811
4035
|
"MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS",
|
|
4036
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3812
4037
|
(m) => m.tables.some(isDirectQueryTable) && !allColumns(m).some((c) => c.hasAlternateOf) && String(m.props.defaultpowerbidatasourceversion ?? "").toLowerCase() === "powerbi_v3" ? [finding.model(m)] : []
|
|
3813
4038
|
);
|
|
3814
4039
|
var TIME_INTELLIGENCE_FUNCTIONS = [
|
|
@@ -3853,6 +4078,7 @@ var TIME_INTELLIGENCE_FUNCTIONS = [
|
|
|
3853
4078
|
var TIME_INTELLIGENCE = new RegExp(`(?:${TIME_INTELLIGENCE_FUNCTIONS.join("|")})\\s*\\(`);
|
|
3854
4079
|
var MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY = bpaRule(
|
|
3855
4080
|
"MEASURES_USING_TIME_INTELLIGENCE_AND_MODEL_IS_USING_DIRECT_QUERY",
|
|
4081
|
+
{ skipWhenModelUnread: modelPartlyRead },
|
|
3856
4082
|
(m) => m.tables.some(isDirectQueryTable) ? expressionObjects(m, ["measure", "calculationItem"]).filter((o) => TIME_INTELLIGENCE.test(o.expression)).map((o) => o.finding) : []
|
|
3857
4083
|
);
|
|
3858
4084
|
var RLS_FUNCTIONS = [/RIGHT\s*\(/i, /LEFT\s*\(/i, /UPPER\s*\(/i, /LOWER\s*\(/i, /FIND\s*\(/i];
|
|
@@ -3881,7 +4107,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
|
|
|
3881
4107
|
return tablesInScope(m).filter((t) => {
|
|
3882
4108
|
if (!permissions.some((tp) => tp.table === t.name && tp.filter !== void 0)) return false;
|
|
3883
4109
|
const re = new RegExp(
|
|
3884
|
-
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${
|
|
4110
|
+
`USERELATIONSHIP\\s*\\(\\s*.+?(?=\\])\\]\\s*,\\s*'*${escapeRegExp2(t.name)}'*\\[`,
|
|
3885
4111
|
"i"
|
|
3886
4112
|
);
|
|
3887
4113
|
return measures.some((x) => re.test(x.expression));
|
|
@@ -3890,6 +4116,7 @@ var AVOID_THE_USERELATIONSHIP_FUNCTION_AND_RLS_AGAINST_THE_SAME_TABLE = bpaRule(
|
|
|
3890
4116
|
);
|
|
3891
4117
|
var OBJECTS_WITH_NO_DESCRIPTION = bpaRule(
|
|
3892
4118
|
"OBJECTS_WITH_NO_DESCRIPTION",
|
|
4119
|
+
{ skipWhenModelUnread: tablesPartlyRead },
|
|
3893
4120
|
(m) => m.tables.flatMap((t) => [
|
|
3894
4121
|
...isBlank(t.description) && !t.isHidden ? [finding.table(t)] : [],
|
|
3895
4122
|
...t.columns.filter((c) => isBlank(c.description) && !c.isHidden).map(finding.column),
|
|
@@ -4482,6 +4709,546 @@ var DEFAULT_PAGE_NAME = pbiplintRule({
|
|
|
4482
4709
|
});
|
|
4483
4710
|
var pageRules2 = [DEFAULT_PAGE_NAME];
|
|
4484
4711
|
|
|
4712
|
+
// ../core/src/dax/tokenize.ts
|
|
4713
|
+
var OPERATORS = ["==", "<>", "<=", ">=", "&&", "||", "=", "<", ">", "+", "-", "*", "/", "^", "&"];
|
|
4714
|
+
var NUMBER = /(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/y;
|
|
4715
|
+
var IDENTIFIER = /[\p{L}_][\p{L}\p{M}\p{N}_.]*/uy;
|
|
4716
|
+
var WORD_CHAR = /[\p{L}\p{M}\p{N}_]/u;
|
|
4717
|
+
var DIGIT = /[0-9]/;
|
|
4718
|
+
var isWord = (t, word) => t?.kind === "identifier" && t.text.toUpperCase() === word;
|
|
4719
|
+
var isPunctuation = (t, char) => t?.kind === "punctuation" && t.text === char;
|
|
4720
|
+
function quoted2(text2, from, close) {
|
|
4721
|
+
let value = "";
|
|
4722
|
+
let j = from + 1;
|
|
4723
|
+
while (j < text2.length) {
|
|
4724
|
+
const c = text2[j];
|
|
4725
|
+
if (c === close) {
|
|
4726
|
+
if (text2[j + 1] !== close) return { value, end: j + 1 };
|
|
4727
|
+
j++;
|
|
4728
|
+
}
|
|
4729
|
+
value += c;
|
|
4730
|
+
j++;
|
|
4731
|
+
}
|
|
4732
|
+
return { value, end: text2.length };
|
|
4733
|
+
}
|
|
4734
|
+
function tokenizeDax(expression) {
|
|
4735
|
+
const s = expression;
|
|
4736
|
+
const tokens = [];
|
|
4737
|
+
const push2 = (kind, text2, start, end) => {
|
|
4738
|
+
tokens.push({ kind, text: text2, start, end, depth: 0 });
|
|
4739
|
+
};
|
|
4740
|
+
let i = 0;
|
|
4741
|
+
while (i < s.length) {
|
|
4742
|
+
const c = s[i];
|
|
4743
|
+
if (/\s/.test(c)) {
|
|
4744
|
+
i++;
|
|
4745
|
+
} else if (s.startsWith("//", i) || s.startsWith("--", i)) {
|
|
4746
|
+
const nl = s.indexOf("\n", i);
|
|
4747
|
+
i = nl === -1 ? s.length : nl;
|
|
4748
|
+
} else if (s.startsWith("/*", i)) {
|
|
4749
|
+
const close = s.indexOf("*/", i + 2);
|
|
4750
|
+
i = close === -1 ? s.length : close + 2;
|
|
4751
|
+
} else if (c === '"') {
|
|
4752
|
+
const q2 = quoted2(s, i, '"');
|
|
4753
|
+
push2("string", q2.value, i, q2.end);
|
|
4754
|
+
i = q2.end;
|
|
4755
|
+
} else if ((c === "d" || c === "D") && (s[i + 1] === "t" || s[i + 1] === "T") && s[i + 2] === '"' && !(i > 0 && WORD_CHAR.test(s[i - 1]))) {
|
|
4756
|
+
const q2 = quoted2(s, i + 2, '"');
|
|
4757
|
+
push2("date", q2.value, i, q2.end);
|
|
4758
|
+
i = q2.end;
|
|
4759
|
+
} else if (c === "'" || c === "[") {
|
|
4760
|
+
const q2 = quoted2(s, i, c === "'" ? "'" : "]");
|
|
4761
|
+
push2(c === "'" ? "table" : "column", q2.value, i, q2.end);
|
|
4762
|
+
i = q2.end;
|
|
4763
|
+
} else if (DIGIT.test(c) || c === "." && DIGIT.test(s[i + 1] ?? "")) {
|
|
4764
|
+
NUMBER.lastIndex = i;
|
|
4765
|
+
const text2 = NUMBER.exec(s)[0];
|
|
4766
|
+
push2("number", text2, i, i + text2.length);
|
|
4767
|
+
i += text2.length;
|
|
4768
|
+
} else {
|
|
4769
|
+
IDENTIFIER.lastIndex = i;
|
|
4770
|
+
const id = IDENTIFIER.exec(s)?.[0];
|
|
4771
|
+
const op = id === void 0 ? OPERATORS.find((o) => s.startsWith(o, i)) : void 0;
|
|
4772
|
+
const text2 = id ?? op ?? c;
|
|
4773
|
+
push2(
|
|
4774
|
+
id !== void 0 ? "identifier" : op !== void 0 ? "operator" : "punctuation",
|
|
4775
|
+
text2,
|
|
4776
|
+
i,
|
|
4777
|
+
i + text2.length
|
|
4778
|
+
);
|
|
4779
|
+
i += text2.length;
|
|
4780
|
+
}
|
|
4781
|
+
}
|
|
4782
|
+
annotate(tokens);
|
|
4783
|
+
return tokens;
|
|
4784
|
+
}
|
|
4785
|
+
function annotate(tokens) {
|
|
4786
|
+
const open = [];
|
|
4787
|
+
const args = [];
|
|
4788
|
+
const place = (t) => {
|
|
4789
|
+
t.depth = open.length;
|
|
4790
|
+
if (open.length === 0) return;
|
|
4791
|
+
t.parent = open[open.length - 1];
|
|
4792
|
+
t.arg = args[args.length - 1];
|
|
4793
|
+
};
|
|
4794
|
+
tokens.forEach((t, k) => {
|
|
4795
|
+
if (isPunctuation(t, "(") || isPunctuation(t, "{")) {
|
|
4796
|
+
place(t);
|
|
4797
|
+
const before = tokens[k - 1];
|
|
4798
|
+
if (t.text === "(" && before?.kind === "identifier") t.call = before.text.toUpperCase();
|
|
4799
|
+
open.push(k);
|
|
4800
|
+
args.push(0);
|
|
4801
|
+
} else if (isPunctuation(t, ")") || isPunctuation(t, "}")) {
|
|
4802
|
+
const o = open.pop();
|
|
4803
|
+
args.pop();
|
|
4804
|
+
if (o !== void 0) {
|
|
4805
|
+
tokens[o].close = k;
|
|
4806
|
+
t.open = o;
|
|
4807
|
+
}
|
|
4808
|
+
place(t);
|
|
4809
|
+
} else {
|
|
4810
|
+
place(t);
|
|
4811
|
+
if (isPunctuation(t, ",") && args.length > 0) args[args.length - 1] = args.at(-1) + 1;
|
|
4812
|
+
}
|
|
4813
|
+
});
|
|
4814
|
+
}
|
|
4815
|
+
function opensBlock(tokens, k) {
|
|
4816
|
+
if (isWord(tokens[k - 1], "RETURN")) return true;
|
|
4817
|
+
const eq = tokens[k - 1];
|
|
4818
|
+
return isWord(tokens[k - 3], "VAR") && tokens[k - 2]?.kind === "identifier" && eq?.kind === "operator" && eq.text === "=";
|
|
4819
|
+
}
|
|
4820
|
+
function daxVariables(tokens) {
|
|
4821
|
+
const out = [];
|
|
4822
|
+
tokens.forEach((t, k) => {
|
|
4823
|
+
const name = tokens[k + 1];
|
|
4824
|
+
const eq = tokens[k + 2];
|
|
4825
|
+
if (!isWord(t, "VAR") || name?.kind !== "identifier" || eq?.kind !== "operator") return;
|
|
4826
|
+
if (eq.text !== "=") return;
|
|
4827
|
+
const from = k + 3;
|
|
4828
|
+
let to = from;
|
|
4829
|
+
let nested = 0;
|
|
4830
|
+
for (; to < tokens.length; to++) {
|
|
4831
|
+
const x = tokens[to];
|
|
4832
|
+
if (x.depth < t.depth) break;
|
|
4833
|
+
if (x.depth !== t.depth) continue;
|
|
4834
|
+
if (isWord(x, "VAR")) {
|
|
4835
|
+
if (opensBlock(tokens, to)) nested++;
|
|
4836
|
+
else if (nested === 0) break;
|
|
4837
|
+
} else if (isWord(x, "RETURN")) {
|
|
4838
|
+
if (nested === 0) break;
|
|
4839
|
+
nested--;
|
|
4840
|
+
}
|
|
4841
|
+
}
|
|
4842
|
+
let blockEnd2 = to;
|
|
4843
|
+
for (; blockEnd2 < tokens.length; blockEnd2++) {
|
|
4844
|
+
const x = tokens[blockEnd2];
|
|
4845
|
+
if (x.depth < t.depth || x.depth === t.depth && isPunctuation(x, ",")) break;
|
|
4846
|
+
}
|
|
4847
|
+
out.push({ name: name.text, at: k, from, to, blockEnd: blockEnd2 });
|
|
4848
|
+
});
|
|
4849
|
+
for (const v of out)
|
|
4850
|
+
for (const d of out) if (d.from <= v.at && v.at < d.to && d.to < v.blockEnd) v.blockEnd = d.to;
|
|
4851
|
+
return out;
|
|
4852
|
+
}
|
|
4853
|
+
function variableAt(vars, name, use) {
|
|
4854
|
+
const key4 = name.toUpperCase();
|
|
4855
|
+
let found;
|
|
4856
|
+
for (const v of vars)
|
|
4857
|
+
if (v.name.toUpperCase() === key4 && v.at < use && use < v.blockEnd && !(v.from <= use && use < v.to))
|
|
4858
|
+
found = v;
|
|
4859
|
+
return found;
|
|
4860
|
+
}
|
|
4861
|
+
|
|
4862
|
+
// ../core/src/rules/pbiplint/period-words.ts
|
|
4863
|
+
var YEAR_WORDS = /* @__PURE__ */ new Set([
|
|
4864
|
+
"year",
|
|
4865
|
+
"years",
|
|
4866
|
+
"yr",
|
|
4867
|
+
"yrs",
|
|
4868
|
+
"a\xF1o",
|
|
4869
|
+
"a\xF1os",
|
|
4870
|
+
"ano",
|
|
4871
|
+
"anos",
|
|
4872
|
+
"anio",
|
|
4873
|
+
"jahr",
|
|
4874
|
+
"ann\xE9e",
|
|
4875
|
+
"annee",
|
|
4876
|
+
"anno",
|
|
4877
|
+
"jaar",
|
|
4878
|
+
"\xE5r",
|
|
4879
|
+
"rok",
|
|
4880
|
+
"vuosi",
|
|
4881
|
+
"ejercicio",
|
|
4882
|
+
"exercice",
|
|
4883
|
+
"fy",
|
|
4884
|
+
"ay",
|
|
4885
|
+
"cy",
|
|
4886
|
+
"ly",
|
|
4887
|
+
"py",
|
|
4888
|
+
"yyyy",
|
|
4889
|
+
"y"
|
|
4890
|
+
]);
|
|
4891
|
+
var MONTH_WORDS = /* @__PURE__ */ new Set([
|
|
4892
|
+
"month",
|
|
4893
|
+
"months",
|
|
4894
|
+
"mes",
|
|
4895
|
+
"m\xEAs",
|
|
4896
|
+
"meses",
|
|
4897
|
+
"monat",
|
|
4898
|
+
"mois",
|
|
4899
|
+
"mese",
|
|
4900
|
+
"maand",
|
|
4901
|
+
"mm",
|
|
4902
|
+
"mon",
|
|
4903
|
+
"mth",
|
|
4904
|
+
"period",
|
|
4905
|
+
"periodo",
|
|
4906
|
+
"per\xEDodo"
|
|
4907
|
+
]);
|
|
4908
|
+
var QUARTER_WORDS = /* @__PURE__ */ new Set(["quarter", "qtr", "q", "trimestre", "quartal", "kwartaal"]);
|
|
4909
|
+
var COUNT_WORDS = /* @__PURE__ */ new Set(["of", "service", "experience", "at", "in", "since", "tenure", "old"]);
|
|
4910
|
+
function nameWords(name) {
|
|
4911
|
+
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());
|
|
4912
|
+
}
|
|
4913
|
+
function nameClass(name) {
|
|
4914
|
+
const words = nameWords(name);
|
|
4915
|
+
const low = name.toLowerCase();
|
|
4916
|
+
const squeezed = low.replace(/ /g, "");
|
|
4917
|
+
const year = words.some(
|
|
4918
|
+
(w) => YEAR_WORDS.has(w) || w.startsWith("year") || w.endsWith("year") && w.length > 4
|
|
4919
|
+
);
|
|
4920
|
+
const month = words.some((w) => MONTH_WORDS.has(w) || w.startsWith("month"));
|
|
4921
|
+
const quarter = words.some((w) => QUARTER_WORDS.has(w) || w.startsWith("quarter"));
|
|
4922
|
+
if (year && (month || quarter || squeezed.includes("yearmonth") || squeezed.includes("yearqtr")))
|
|
4923
|
+
return "yearKey";
|
|
4924
|
+
if (year && words.includes("years") && words.some((w) => COUNT_WORDS.has(w))) return "yearCount";
|
|
4925
|
+
if (year) return "year";
|
|
4926
|
+
if (low.includes("yyyymm") || squeezed.includes("yearmonth") || low.includes("periodkey") || low.includes("monthkey"))
|
|
4927
|
+
return "yearKey";
|
|
4928
|
+
if (month) return "month";
|
|
4929
|
+
if (quarter) return "quarter";
|
|
4930
|
+
return void 0;
|
|
4931
|
+
}
|
|
4932
|
+
|
|
4933
|
+
// ../core/src/rules/pbiplint/period-forms.ts
|
|
4934
|
+
var FIRST_YEAR = 1950;
|
|
4935
|
+
var LAST_YEAR = 2049;
|
|
4936
|
+
var COMPARISONS = /* @__PURE__ */ new Set(["=", "==", "<>"]);
|
|
4937
|
+
var WRAPPERS = /* @__PURE__ */ new Set([
|
|
4938
|
+
"SELECTEDVALUE",
|
|
4939
|
+
"MAX",
|
|
4940
|
+
"MIN",
|
|
4941
|
+
"VALUES",
|
|
4942
|
+
"DISTINCT",
|
|
4943
|
+
"FIRSTNONBLANK",
|
|
4944
|
+
"LASTNONBLANK",
|
|
4945
|
+
"MAXX",
|
|
4946
|
+
"MINX",
|
|
4947
|
+
"LOOKUPVALUE",
|
|
4948
|
+
"RELATED",
|
|
4949
|
+
"CALCULATE",
|
|
4950
|
+
"HASONEVALUE",
|
|
4951
|
+
"SUM",
|
|
4952
|
+
"AVERAGE",
|
|
4953
|
+
"CONVERT",
|
|
4954
|
+
"INT",
|
|
4955
|
+
"VALUE",
|
|
4956
|
+
"FORMAT"
|
|
4957
|
+
]);
|
|
4958
|
+
var BOUNDS = /* @__PURE__ */ new Set(["CALENDAR", "GENERATESERIES"]);
|
|
4959
|
+
var YEAR_FORMATS = /* @__PURE__ */ new Set(["general date", "long date", "medium date", "short date"]);
|
|
4960
|
+
var UNIX_EPOCH = { year: 1970, month: 1, day: 1 };
|
|
4961
|
+
var STRING_DATES = /* @__PURE__ */ new Set(["DATEVALUE", "DATETIMEVALUE", "VALUE"]);
|
|
4962
|
+
var YEAR_FIRST = /^(\d{4})([-/])(\d{1,2})\2(\d{1,2})(?:[ T]\d{1,2}:\d{2}.*)?$/;
|
|
4963
|
+
var YEAR_LAST = /^(\d{1,2})[-/.](\d{1,2})[-/.](\d{4})(?:[ T]\d{1,2}:\d{2}.*)?$/;
|
|
4964
|
+
var isWhole = (t) => t?.kind === "number" && /^\d+$/.test(t.text);
|
|
4965
|
+
function yearIn(t, strings) {
|
|
4966
|
+
const text2 = t?.kind === "number" ? t.text : strings && t?.kind === "string" ? t.text.trim() : void 0;
|
|
4967
|
+
if (text2 === void 0 || !/^\d{4}$/.test(text2)) return void 0;
|
|
4968
|
+
const year = Number(text2);
|
|
4969
|
+
return year >= FIRST_YEAR && year <= LAST_YEAR ? year : void 0;
|
|
4970
|
+
}
|
|
4971
|
+
function argumentsOf(tokens, open) {
|
|
4972
|
+
const end = tokens[open]?.close ?? tokens.length;
|
|
4973
|
+
const spans = [];
|
|
4974
|
+
let from = open + 1;
|
|
4975
|
+
for (let k = open + 1; k < end; k++)
|
|
4976
|
+
if (tokens[k].parent === open && isPunctuation(tokens[k], ",")) {
|
|
4977
|
+
spans.push({ from, to: k });
|
|
4978
|
+
from = k + 1;
|
|
4979
|
+
}
|
|
4980
|
+
spans.push({ from, to: end });
|
|
4981
|
+
return spans;
|
|
4982
|
+
}
|
|
4983
|
+
var only = (tokens, span) => span && span.to - span.from === 1 ? tokens[span.from] : void 0;
|
|
4984
|
+
function callGivesYear(tokens, open) {
|
|
4985
|
+
const o = tokens[open];
|
|
4986
|
+
if (o.call === "YEAR") return true;
|
|
4987
|
+
if (o.call === void 0 || !WRAPPERS.has(o.call) || o.close === void 0) return false;
|
|
4988
|
+
const inside = tokens.slice(open + 1, o.close);
|
|
4989
|
+
if (inside.some((x) => x.call === "YEAR")) return true;
|
|
4990
|
+
for (const x of inside) {
|
|
4991
|
+
const named2 = x.kind === "column" ? nameClass(x.text) : void 0;
|
|
4992
|
+
if (named2 !== void 0) return named2 === "year";
|
|
4993
|
+
}
|
|
4994
|
+
return o.call === "FORMAT" && inside.some((x) => x.kind === "string" && /^(?:yy|yyyy)$/i.test(x.text));
|
|
4995
|
+
}
|
|
4996
|
+
function yearBefore(tokens, k) {
|
|
4997
|
+
const t = tokens[k];
|
|
4998
|
+
if (t === void 0) return false;
|
|
4999
|
+
if (t.kind === "column" || t.kind === "identifier") return nameClass(t.text) === "year";
|
|
5000
|
+
return isPunctuation(t, ")") && t.open !== void 0 && callGivesYear(tokens, t.open);
|
|
5001
|
+
}
|
|
5002
|
+
function yearAfter(tokens, k) {
|
|
5003
|
+
const t = tokens[k];
|
|
5004
|
+
const next = tokens[k + 1];
|
|
5005
|
+
if ((t?.kind === "table" || t?.kind === "identifier") && next?.kind === "column")
|
|
5006
|
+
return nameClass(next.text) === "year";
|
|
5007
|
+
if (t?.kind === "column") return nameClass(t.text) === "year";
|
|
5008
|
+
if (t?.kind === "identifier" && isPunctuation(next, "(")) return callGivesYear(tokens, k + 1);
|
|
5009
|
+
return t?.kind === "identifier" && nameClass(t.text) === "year";
|
|
5010
|
+
}
|
|
5011
|
+
function isBound(tokens, open) {
|
|
5012
|
+
for (let p = tokens[open].parent; p !== void 0; p = tokens[p].parent)
|
|
5013
|
+
if (BOUNDS.has(tokens[p].call ?? "")) return true;
|
|
5014
|
+
return false;
|
|
5015
|
+
}
|
|
5016
|
+
function isYearFreeFormat(tokens, open) {
|
|
5017
|
+
const date = tokens[open - 1];
|
|
5018
|
+
const p = date.parent;
|
|
5019
|
+
if (p === void 0 || tokens[p].call !== "FORMAT" || date.arg !== 0) return false;
|
|
5020
|
+
const format = only(tokens, argumentsOf(tokens, p)[1]);
|
|
5021
|
+
return format?.kind === "string" && !/y/i.test(format.text) && !YEAR_FORMATS.has(format.text.toLowerCase());
|
|
5022
|
+
}
|
|
5023
|
+
var isRealDay = (year, month, day) => month >= 1 && month <= 12 && day >= 1 && day <= new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
5024
|
+
function daxDate(year, month, day) {
|
|
5025
|
+
if (year > 9999) return void 0;
|
|
5026
|
+
const full = year < 50 ? year + 2e3 : year < 100 ? year + 1900 : year;
|
|
5027
|
+
const d = new Date(Date.UTC(full, month - 1, day));
|
|
5028
|
+
if (Number.isNaN(d.getTime())) return void 0;
|
|
5029
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
|
5030
|
+
}
|
|
5031
|
+
function dateString(t) {
|
|
5032
|
+
const text2 = t.text.trim();
|
|
5033
|
+
const first = YEAR_FIRST.exec(text2);
|
|
5034
|
+
if (first) {
|
|
5035
|
+
const [year2, month, day] = [Number(first[1]), Number(first[3]), Number(first[4])];
|
|
5036
|
+
return isRealDay(year2, month, day) ? { at: t.start, year: year2, date: { year: year2, month, day } } : void 0;
|
|
5037
|
+
}
|
|
5038
|
+
const last = YEAR_LAST.exec(text2);
|
|
5039
|
+
if (!last) return void 0;
|
|
5040
|
+
const [a, b, year] = [Number(last[1]), Number(last[2]), Number(last[3])];
|
|
5041
|
+
const monthFirst = isRealDay(year, a, b);
|
|
5042
|
+
const dayFirst = isRealDay(year, b, a);
|
|
5043
|
+
if (monthFirst && dayFirst && a !== b) return { at: t.start, year, ambiguous: text2 };
|
|
5044
|
+
if (monthFirst) return { at: t.start, year, date: { year, month: a, day: b } };
|
|
5045
|
+
if (dayFirst) return { at: t.start, year, date: { year, month: b, day: a } };
|
|
5046
|
+
return void 0;
|
|
5047
|
+
}
|
|
5048
|
+
function expressionPeriods(expression) {
|
|
5049
|
+
const tokens = tokenizeDax(expression);
|
|
5050
|
+
const found = [];
|
|
5051
|
+
const year = (t, y) => {
|
|
5052
|
+
found.push({ at: t.start, year: y });
|
|
5053
|
+
};
|
|
5054
|
+
tokens.forEach((t, k) => {
|
|
5055
|
+
if (t.kind === "operator" && COMPARISONS.has(t.text) && !isWord(tokens[k - 2], "VAR")) {
|
|
5056
|
+
const right = yearIn(tokens[k + 1], true);
|
|
5057
|
+
if (right !== void 0 && yearBefore(tokens, k - 1)) year(tokens[k + 1], right);
|
|
5058
|
+
const left = yearIn(tokens[k - 1], true);
|
|
5059
|
+
if (left !== void 0 && yearAfter(tokens, k + 1)) year(tokens[k - 1], left);
|
|
5060
|
+
}
|
|
5061
|
+
const list = tokens[k + 1];
|
|
5062
|
+
if (isWord(t, "IN") && isPunctuation(list, "{") && yearBefore(tokens, k - 1))
|
|
5063
|
+
for (const x of tokens.slice(k + 2, list.close ?? tokens.length)) {
|
|
5064
|
+
const y = yearIn(x, true);
|
|
5065
|
+
if (y !== void 0) year(x, y);
|
|
5066
|
+
}
|
|
5067
|
+
if (t.call === "DATE" && !isBound(tokens, k) && !isYearFreeFormat(tokens, k)) {
|
|
5068
|
+
const [y, m, d] = argumentsOf(tokens, k).map((span) => only(tokens, span));
|
|
5069
|
+
const fixed = yearIn(y, false);
|
|
5070
|
+
if (fixed !== void 0) {
|
|
5071
|
+
if (isWhole(m) && isWhole(d)) {
|
|
5072
|
+
const [month, day] = [Number(m.text), Number(d.text)];
|
|
5073
|
+
const epoch = fixed === UNIX_EPOCH.year && month === UNIX_EPOCH.month && day === UNIX_EPOCH.day;
|
|
5074
|
+
const date = daxDate(fixed, month, day);
|
|
5075
|
+
if (!epoch && date) found.push({ at: tokens[k - 1].start, year: fixed, date });
|
|
5076
|
+
} else year(y, fixed);
|
|
5077
|
+
}
|
|
5078
|
+
}
|
|
5079
|
+
});
|
|
5080
|
+
for (const v of daxVariables(tokens)) {
|
|
5081
|
+
const y = yearIn(only(tokens, v), true);
|
|
5082
|
+
if (y !== void 0 && nameClass(v.name) === "year") year(tokens[v.from], y);
|
|
5083
|
+
}
|
|
5084
|
+
const seen = /* @__PURE__ */ new Set();
|
|
5085
|
+
const periods = [];
|
|
5086
|
+
for (const p of found.sort((a, b) => a.at - b.at))
|
|
5087
|
+
if (!seen.has(p.at)) {
|
|
5088
|
+
seen.add(p.at);
|
|
5089
|
+
periods.push(p);
|
|
5090
|
+
}
|
|
5091
|
+
return periods;
|
|
5092
|
+
}
|
|
5093
|
+
function wholeNumber(tokens, vars, span) {
|
|
5094
|
+
let t = only(tokens, span);
|
|
5095
|
+
if (t?.kind === "identifier") {
|
|
5096
|
+
const v = variableAt(vars, t.text, span.from);
|
|
5097
|
+
t = v ? only(tokens, v) : void 0;
|
|
5098
|
+
}
|
|
5099
|
+
return isWhole(t) ? Number(t.text) : void 0;
|
|
5100
|
+
}
|
|
5101
|
+
function fixedDay(tokens, vars, span, seen) {
|
|
5102
|
+
const first = tokens[span.from];
|
|
5103
|
+
if (first === void 0 || span.to <= span.from) return void 0;
|
|
5104
|
+
if (span.to - span.from === 1) {
|
|
5105
|
+
if (first.kind === "string") return dateString(first);
|
|
5106
|
+
if (first.kind === "date") {
|
|
5107
|
+
const m = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(first.text.trim());
|
|
5108
|
+
if (!m) return void 0;
|
|
5109
|
+
const [year, month, day] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
5110
|
+
return isRealDay(year, month, day) ? { at: first.start, year, date: { year, month, day } } : void 0;
|
|
5111
|
+
}
|
|
5112
|
+
if (first.kind !== "identifier") return void 0;
|
|
5113
|
+
const v = variableAt(vars, first.text, span.from);
|
|
5114
|
+
if (v === void 0 || seen.has(v)) return void 0;
|
|
5115
|
+
seen.add(v);
|
|
5116
|
+
return fixedDay(tokens, vars, v, seen);
|
|
5117
|
+
}
|
|
5118
|
+
const open = span.from + 1;
|
|
5119
|
+
if (first.kind !== "identifier" || tokens[open]?.close !== span.to - 1) return void 0;
|
|
5120
|
+
const call = first.text.toUpperCase();
|
|
5121
|
+
const args = argumentsOf(tokens, open);
|
|
5122
|
+
if (call === "DATE" && args.length === 3) {
|
|
5123
|
+
const [y, m, d] = args.map((a) => wholeNumber(tokens, vars, a));
|
|
5124
|
+
if (y === void 0 || m === void 0 || d === void 0) return void 0;
|
|
5125
|
+
const date = daxDate(y, m, d);
|
|
5126
|
+
return date && { at: first.start, year: y, date };
|
|
5127
|
+
}
|
|
5128
|
+
const text2 = args.length === 1 ? only(tokens, args[0]) : void 0;
|
|
5129
|
+
if (STRING_DATES.has(call) && text2?.kind === "string") return dateString(text2);
|
|
5130
|
+
return void 0;
|
|
5131
|
+
}
|
|
5132
|
+
function calendarEnds(expression) {
|
|
5133
|
+
const tokens = tokenizeDax(expression);
|
|
5134
|
+
const vars = daxVariables(tokens);
|
|
5135
|
+
const ends = [];
|
|
5136
|
+
tokens.forEach((t, k) => {
|
|
5137
|
+
if (t.call !== "CALENDAR") return;
|
|
5138
|
+
const end = argumentsOf(tokens, k)[1];
|
|
5139
|
+
const day = end && fixedDay(tokens, vars, end, /* @__PURE__ */ new Set());
|
|
5140
|
+
if (day) ends.push(day);
|
|
5141
|
+
});
|
|
5142
|
+
return ends;
|
|
5143
|
+
}
|
|
5144
|
+
|
|
5145
|
+
// ../core/src/rules/pbiplint/periods.ts
|
|
5146
|
+
var MONTHS2 = [
|
|
5147
|
+
"January",
|
|
5148
|
+
"February",
|
|
5149
|
+
"March",
|
|
5150
|
+
"April",
|
|
5151
|
+
"May",
|
|
5152
|
+
"June",
|
|
5153
|
+
"July",
|
|
5154
|
+
"August",
|
|
5155
|
+
"September",
|
|
5156
|
+
"October",
|
|
5157
|
+
"November",
|
|
5158
|
+
"December"
|
|
5159
|
+
];
|
|
5160
|
+
var shown = (p) => p.ambiguous !== void 0 ? `"${p.ambiguous}"` : p.date ? `${MONTHS2[p.date.month - 1]} ${p.date.day}, ${p.date.year}` : String(p.year);
|
|
5161
|
+
function englishList(items) {
|
|
5162
|
+
const named2 = items.length > 3 ? [...items.slice(0, 3), `${items.length - 3} more`] : [...items];
|
|
5163
|
+
if (named2.length <= 2) return named2.join(" and ");
|
|
5164
|
+
const sep = named2.some((s) => s.includes(",")) ? "; " : ", ";
|
|
5165
|
+
return `${named2.slice(0, -1).join(sep)}${sep}and ${named2.at(-1)}`;
|
|
5166
|
+
}
|
|
5167
|
+
function namesYear(name, year) {
|
|
5168
|
+
const digits = String(year);
|
|
5169
|
+
if (digits.length !== 4) return false;
|
|
5170
|
+
return name.includes(digits) || new RegExp(`(?:^|\\D)${digits.slice(2)}(?:\\D|$)`).test(name);
|
|
5171
|
+
}
|
|
5172
|
+
function lineAt2(node, offset) {
|
|
5173
|
+
if (node?.valueLine === void 0 || node.value === void 0) return void 0;
|
|
5174
|
+
let line = node.valueLine;
|
|
5175
|
+
for (let k = 0; k < offset && k < node.value.length; k++) if (node.value[k] === "\n") line++;
|
|
5176
|
+
return { file: node.file, line };
|
|
5177
|
+
}
|
|
5178
|
+
function fixesDetail(periods) {
|
|
5179
|
+
const names = [...new Set(periods.map(shown))];
|
|
5180
|
+
const days = periods.filter((p) => p.date !== void 0 || p.ambiguous !== void 0).length;
|
|
5181
|
+
const noun = days === 0 ? "year" : days === periods.length ? "date" : "period";
|
|
5182
|
+
return `fixed ${noun}${names.length > 1 ? "s" : ""} ${englishList(names)}`;
|
|
5183
|
+
}
|
|
5184
|
+
function endsDetail(periods) {
|
|
5185
|
+
const names = [...new Set(periods.map(shown))];
|
|
5186
|
+
return names.length === 1 ? `ends on a fixed date, ${names[0]}` : `ends on fixed dates ${englishList(names)}`;
|
|
5187
|
+
}
|
|
5188
|
+
function fixedFinding(base, name, found, detail) {
|
|
5189
|
+
const periods = found.map((f) => f.period);
|
|
5190
|
+
if (periods.length === 0 || periods.some((p) => namesYear(name, p.year))) return [];
|
|
5191
|
+
const where = lineAt2(found[0].node, found[0].period.at);
|
|
5192
|
+
const own = detail(periods);
|
|
5193
|
+
return [
|
|
5194
|
+
{
|
|
5195
|
+
...base,
|
|
5196
|
+
...where ? { location: where } : {},
|
|
5197
|
+
detail: base.detail === void 0 ? own : `${own} in ${base.detail}`
|
|
5198
|
+
}
|
|
5199
|
+
];
|
|
5200
|
+
}
|
|
5201
|
+
var inNode = (node, expression) => expressionPeriods(expression).map((period) => ({ period, node }));
|
|
5202
|
+
function dateTableEnds(t) {
|
|
5203
|
+
if (t.kind !== "calculated" || isAutoDateTable(t)) return [];
|
|
5204
|
+
return t.partitions.filter((p) => p.sourceType === "calculated").flatMap((p) => {
|
|
5205
|
+
const node = p.node?.children.find((c) => c.kind === "expr" && c.type === "source");
|
|
5206
|
+
return calendarEnds(node?.value ?? p.source ?? "").map((period) => ({ period, node }));
|
|
5207
|
+
});
|
|
5208
|
+
}
|
|
5209
|
+
function periodFindings(model) {
|
|
5210
|
+
const out = [];
|
|
5211
|
+
for (const t of model.tables) {
|
|
5212
|
+
out.push(...fixedFinding(finding.table(t), t.name, dateTableEnds(t), endsDetail));
|
|
5213
|
+
for (const x of t.measures)
|
|
5214
|
+
out.push(
|
|
5215
|
+
...fixedFinding(finding.measure(x), x.name, inNode(x.node, x.expression), fixesDetail)
|
|
5216
|
+
);
|
|
5217
|
+
for (const c of t.columns)
|
|
5218
|
+
if (c.kind === "calculated")
|
|
5219
|
+
out.push(
|
|
5220
|
+
...fixedFinding(
|
|
5221
|
+
finding.column(c),
|
|
5222
|
+
c.name,
|
|
5223
|
+
inNode(c.node, c.expression ?? ""),
|
|
5224
|
+
fixesDetail
|
|
5225
|
+
)
|
|
5226
|
+
);
|
|
5227
|
+
for (const i of t.calculationGroup?.items ?? [])
|
|
5228
|
+
out.push(
|
|
5229
|
+
...fixedFinding(
|
|
5230
|
+
finding.calculationItem(i),
|
|
5231
|
+
i.name,
|
|
5232
|
+
inNode(i.node, i.expression),
|
|
5233
|
+
fixesDetail
|
|
5234
|
+
)
|
|
5235
|
+
);
|
|
5236
|
+
}
|
|
5237
|
+
return out;
|
|
5238
|
+
}
|
|
5239
|
+
var HARDCODED_PERIOD_IN_DAX = pbiplintRule({
|
|
5240
|
+
id: "HARDCODED_PERIOD_IN_DAX",
|
|
5241
|
+
name: "Hardcoded period in DAX",
|
|
5242
|
+
category: "DAX Expressions",
|
|
5243
|
+
severity: 1,
|
|
5244
|
+
scope: ["Measure", "CalculatedColumn", "CalculationItem", "CalculatedTable"],
|
|
5245
|
+
layer: "model",
|
|
5246
|
+
// No skipWhenModelUnread: each finding rests on the object's own expression, so a file the
|
|
5247
|
+
// parser could not read can hide an object from the rule, never put a period in one.
|
|
5248
|
+
check: ({ model }) => model ? periodFindings(model) : []
|
|
5249
|
+
});
|
|
5250
|
+
var periodRules = [HARDCODED_PERIOD_IN_DAX];
|
|
5251
|
+
|
|
4485
5252
|
// ../core/src/rules/pbiplint/references.ts
|
|
4486
5253
|
var fieldLabel = (ref) => {
|
|
4487
5254
|
const inTable = (name) => ref.table === "" ? measureRef(name) : columnRef(ref.table, name);
|
|
@@ -4757,6 +5524,7 @@ var pbiplintRules = [
|
|
|
4757
5524
|
...visualRules2,
|
|
4758
5525
|
...pageRules2,
|
|
4759
5526
|
...measureRules2,
|
|
5527
|
+
...periodRules,
|
|
4760
5528
|
...actionRules,
|
|
4761
5529
|
...tabOrderRules
|
|
4762
5530
|
];
|
|
@@ -4792,6 +5560,14 @@ var NOT_MODELED = [
|
|
|
4792
5560
|
];
|
|
4793
5561
|
var KNOWN = /* @__PURE__ */ new Set([...MODELED, ...NOT_MODELED]);
|
|
4794
5562
|
var isRootType = (type) => KNOWN.has(type.toLowerCase());
|
|
5563
|
+
var isModelChildType = (type) => {
|
|
5564
|
+
const t = type.toLowerCase();
|
|
5565
|
+
return KNOWN.has(t) && t !== "model" && t !== "database" && t !== "createorreplace";
|
|
5566
|
+
};
|
|
5567
|
+
var isNamedRootType = (type) => {
|
|
5568
|
+
const t = type.toLowerCase();
|
|
5569
|
+
return t !== "model" && MODELED.includes(t);
|
|
5570
|
+
};
|
|
4795
5571
|
|
|
4796
5572
|
// ../core/src/tmdl/parse.ts
|
|
4797
5573
|
var HEADER = /^([A-Za-z_]\w*)(?:\s+(.+))?$/;
|
|
@@ -4804,6 +5580,9 @@ var tabIndent = (line) => {
|
|
|
4804
5580
|
};
|
|
4805
5581
|
var leadingWs = (line) => line.length - line.trimStart().length;
|
|
4806
5582
|
var mayBeRootLine = (line) => line.trim() !== "" && tabIndent(line) === 0 && (!/^\s/.test(line) || splitHeader(line)?.type.toLowerCase() === "table");
|
|
5583
|
+
var namesTable = (line) => /^table(?:[\s:=]|$)/i.test(line.trim());
|
|
5584
|
+
var HOLDS_TABLE = /* @__PURE__ */ new Set(["model", "createorreplace"]);
|
|
5585
|
+
var nameReadable = (name) => !name.includes("'") || /^'(?:[^']|'')*'$/.test(name);
|
|
4807
5586
|
function splitHeader(content) {
|
|
4808
5587
|
let inQuote = false;
|
|
4809
5588
|
let eqAt = -1;
|
|
@@ -4822,16 +5601,41 @@ function splitHeader(content) {
|
|
|
4822
5601
|
return {
|
|
4823
5602
|
type: m[1],
|
|
4824
5603
|
name: m[2] === void 0 ? void 0 : unquoteName(m[2]),
|
|
5604
|
+
nameReadable: m[2] === void 0 || nameReadable(m[2].trim()),
|
|
4825
5605
|
hasEq: eqAt >= 0,
|
|
4826
5606
|
inline
|
|
4827
5607
|
};
|
|
4828
5608
|
}
|
|
5609
|
+
var opensFence = (line) => {
|
|
5610
|
+
const content = line.slice(tabIndent(line));
|
|
5611
|
+
if (/^\s/.test(content) || REF.test(content) || PROP.test(content)) return false;
|
|
5612
|
+
const h = splitHeader(content);
|
|
5613
|
+
return h !== null && h.hasEq && h.inline === "```";
|
|
5614
|
+
};
|
|
5615
|
+
var isDescription = (line) => line.slice(tabIndent(line)).startsWith("///");
|
|
5616
|
+
var blockEnd = (lines, from, limit, depth, head) => {
|
|
5617
|
+
const lead = lines[from]?.slice(0, leadingWs(lines[from])) ?? "";
|
|
5618
|
+
const inBlock = (line) => line.trim() === "" || leadingWs(line) >= depth && (line.startsWith(lead) || tabIndent(line) > head + 1 || !namesTable(line));
|
|
5619
|
+
let k = from;
|
|
5620
|
+
while (k < limit && inBlock(lines[k])) k++;
|
|
5621
|
+
return k;
|
|
5622
|
+
};
|
|
5623
|
+
var blockText = (lines, from, end, depth) => {
|
|
5624
|
+
const out = lines.slice(from, end).map((l) => l.trim() === "" ? "" : l.slice(depth));
|
|
5625
|
+
while (out.at(-1) === "") out.pop();
|
|
5626
|
+
return out.join("\n");
|
|
5627
|
+
};
|
|
4829
5628
|
function parseTmdl(file, text2) {
|
|
4830
5629
|
const body = text2.charCodeAt(0) === 65279 ? text2.slice(1) : text2;
|
|
4831
5630
|
const lines = body.replace(/\r\n?/g, "\n").split("\n");
|
|
4832
5631
|
const roots = [];
|
|
4833
5632
|
const issues = [];
|
|
4834
5633
|
const stack = [];
|
|
5634
|
+
const holders = /* @__PURE__ */ new Map();
|
|
5635
|
+
const mayBeModelLevelLine = (line, depth) => {
|
|
5636
|
+
const tabs = tabIndent(line);
|
|
5637
|
+
return mayBeRootLine(line) || tabs > 0 && tabs <= depth && line.trim() !== "" && holders.has(stack[tabs - 1]) && (!/^\s/.test(line.slice(tabs)) || namesTable(line));
|
|
5638
|
+
};
|
|
4835
5639
|
let pendingDescription = null;
|
|
4836
5640
|
const orphanDescription = (pending) => {
|
|
4837
5641
|
issues.push({
|
|
@@ -4869,51 +5673,52 @@ function parseTmdl(file, text2) {
|
|
|
4869
5673
|
text: raw,
|
|
4870
5674
|
reason: "space indentation (TMDL requires tabs)",
|
|
4871
5675
|
canDropObjects: true,
|
|
4872
|
-
canDropTableLine: mayBeRootLine(raw)
|
|
5676
|
+
canDropTableLine: mayBeRootLine(raw) || namesTable(raw)
|
|
4873
5677
|
});
|
|
4874
5678
|
i++;
|
|
4875
5679
|
continue;
|
|
4876
5680
|
}
|
|
5681
|
+
let valueLine = lineNo;
|
|
4877
5682
|
const collectBlock = () => {
|
|
4878
5683
|
let j = i + 1;
|
|
4879
5684
|
while (j < lines.length && lines[j].trim() === "") j++;
|
|
4880
5685
|
if (j >= lines.length) return "";
|
|
4881
5686
|
const blockIndent = leadingWs(lines[j]);
|
|
4882
5687
|
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");
|
|
5688
|
+
valueLine = j + 1;
|
|
5689
|
+
const end = blockEnd(lines, j, lines.length, blockIndent, indent);
|
|
5690
|
+
i = end - 1;
|
|
5691
|
+
return blockText(lines, j, end, blockIndent);
|
|
4897
5692
|
};
|
|
4898
5693
|
const collectFenced = () => {
|
|
4899
|
-
|
|
5694
|
+
valueLine = lineNo + 1;
|
|
4900
5695
|
let j = i + 1;
|
|
4901
|
-
while (j < lines.length && lines[j].trim() !== "```")
|
|
4902
|
-
|
|
4903
|
-
j
|
|
5696
|
+
while (j < lines.length && lines[j].trim() !== "```" && !opensFence(lines[j])) j++;
|
|
5697
|
+
if (j < lines.length && lines[j].trim() === "```") {
|
|
5698
|
+
const boundary = leadingWs(lines[j]);
|
|
5699
|
+
const out = lines.slice(i + 1, j).map((l) => l.slice(Math.min(boundary, leadingWs(l))));
|
|
5700
|
+
i = j;
|
|
5701
|
+
return out.join("\n");
|
|
4904
5702
|
}
|
|
4905
|
-
|
|
4906
|
-
|
|
4907
|
-
|
|
4908
|
-
|
|
4909
|
-
|
|
4910
|
-
|
|
4911
|
-
|
|
4912
|
-
|
|
4913
|
-
|
|
4914
|
-
|
|
4915
|
-
|
|
4916
|
-
|
|
5703
|
+
let first = i + 1;
|
|
5704
|
+
while (first < j && lines[first].trim() === "") first++;
|
|
5705
|
+
const blockIndent = first < j ? leadingWs(lines[first]) : 0;
|
|
5706
|
+
const depth = blockIndent > indent ? blockIndent : 0;
|
|
5707
|
+
let end = depth > 0 ? blockEnd(lines, first, j, depth, indent) : j;
|
|
5708
|
+
if (end === j && j < lines.length)
|
|
5709
|
+
while (end > i + 1 && isDescription(lines[end - 1])) end--;
|
|
5710
|
+
const read = lines.slice(i + 1, end);
|
|
5711
|
+
issues.push({
|
|
5712
|
+
file,
|
|
5713
|
+
line: lineNo,
|
|
5714
|
+
text: raw,
|
|
5715
|
+
reason: "unterminated code fence",
|
|
5716
|
+
canDropObjects: true,
|
|
5717
|
+
canDropTableLine: read.some((l) => mayBeModelLevelLine(l, indent))
|
|
5718
|
+
});
|
|
5719
|
+
const value = blockText(lines, i + 1, end, depth);
|
|
5720
|
+
i = end - 1;
|
|
5721
|
+
return value;
|
|
4917
5722
|
};
|
|
4918
5723
|
const base = {
|
|
4919
5724
|
props: {},
|
|
@@ -4925,6 +5730,7 @@ function parseTmdl(file, text2) {
|
|
|
4925
5730
|
let node;
|
|
4926
5731
|
let m;
|
|
4927
5732
|
let word;
|
|
5733
|
+
let readableName = true;
|
|
4928
5734
|
if (m = REF.exec(content)) {
|
|
4929
5735
|
node = { ...base, kind: "ref", type: m[1].toLowerCase(), name: unquoteName(m[2]) };
|
|
4930
5736
|
} else if (m = PROP.exec(content)) {
|
|
@@ -4939,23 +5745,33 @@ function parseTmdl(file, text2) {
|
|
|
4939
5745
|
text: raw,
|
|
4940
5746
|
reason: "unrecognized line",
|
|
4941
5747
|
canDropObjects: true,
|
|
4942
|
-
canDropTableLine:
|
|
5748
|
+
canDropTableLine: mayBeModelLevelLine(raw, indent)
|
|
4943
5749
|
});
|
|
4944
5750
|
i++;
|
|
4945
5751
|
continue;
|
|
4946
5752
|
}
|
|
4947
5753
|
word = h.type;
|
|
5754
|
+
readableName = h.nameReadable;
|
|
4948
5755
|
if (h.hasEq) {
|
|
4949
5756
|
const value = h.inline === "```" ? collectFenced() : h.inline === "" ? collectBlock() : h.inline;
|
|
4950
|
-
node = h.name === void 0 ? { ...base, kind: "expr", type: h.type.toLowerCase(), value } : {
|
|
5757
|
+
node = h.name === void 0 ? { ...base, kind: "expr", type: h.type.toLowerCase(), value, valueLine } : {
|
|
5758
|
+
...base,
|
|
5759
|
+
kind: "object",
|
|
5760
|
+
type: h.type.toLowerCase(),
|
|
5761
|
+
name: h.name,
|
|
5762
|
+
value,
|
|
5763
|
+
valueLine
|
|
5764
|
+
};
|
|
4951
5765
|
} else if (h.name !== void 0) {
|
|
4952
5766
|
node = { ...base, kind: "object", type: h.type.toLowerCase(), name: h.name };
|
|
4953
5767
|
} else {
|
|
4954
5768
|
node = { ...base, kind: "flag", type: h.type.toLowerCase() };
|
|
4955
5769
|
}
|
|
4956
5770
|
}
|
|
5771
|
+
let rootIssue;
|
|
4957
5772
|
if (indent === 0 && word !== void 0) {
|
|
4958
5773
|
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`;
|
|
5774
|
+
rootIssue = reason;
|
|
4959
5775
|
if (reason !== void 0)
|
|
4960
5776
|
issues.push({
|
|
4961
5777
|
file,
|
|
@@ -4963,7 +5779,7 @@ function parseTmdl(file, text2) {
|
|
|
4963
5779
|
text: raw,
|
|
4964
5780
|
reason,
|
|
4965
5781
|
canDropObjects: true,
|
|
4966
|
-
canDropTableLine: node.kind === "object" || node.kind === "flag"
|
|
5782
|
+
canDropTableLine: node.kind === "object" || node.kind === "flag" || namesTable(raw)
|
|
4967
5783
|
});
|
|
4968
5784
|
}
|
|
4969
5785
|
if (pendingDescription) {
|
|
@@ -4979,11 +5795,44 @@ function parseTmdl(file, text2) {
|
|
|
4979
5795
|
text: raw,
|
|
4980
5796
|
reason: "orphan indentation",
|
|
4981
5797
|
canDropObjects: true,
|
|
4982
|
-
canDropTableLine:
|
|
5798
|
+
canDropTableLine: namesTable(raw) || roots.length === 0 && !issues.some((x) => x.canDropObjects)
|
|
4983
5799
|
});
|
|
4984
5800
|
i++;
|
|
4985
5801
|
continue;
|
|
4986
5802
|
}
|
|
5803
|
+
const level = parent && holders.get(parent);
|
|
5804
|
+
const declaration = node.kind === "object" || node.kind === "flag";
|
|
5805
|
+
const keyword = word?.toLowerCase();
|
|
5806
|
+
let malformed;
|
|
5807
|
+
let mayBeTable = namesTable(raw);
|
|
5808
|
+
if (rootIssue === void 0 && keyword !== void 0) {
|
|
5809
|
+
if (!declaration) {
|
|
5810
|
+
if (level === "model" && isModelChildType(keyword))
|
|
5811
|
+
malformed = `"${word}" is not a property TMDL allows under a model`;
|
|
5812
|
+
} else if (indent > 0 && keyword === "table" && !HOLDS_TABLE.has(parent?.type ?? "")) {
|
|
5813
|
+
malformed = `"${word}" is a type TMDL declares only at the root of a file or under a model`;
|
|
5814
|
+
} else if (level !== void 0 && !(level === "model" ? isModelChildType(keyword) : keyword === "model") && (node.kind === "object" || isRootType(keyword))) {
|
|
5815
|
+
malformed = `"${word}" is not a type TMDL declares under a ${level}`;
|
|
5816
|
+
mayBeTable = true;
|
|
5817
|
+
} else if ((indent === 0 || level === "model") && (node.kind === "flag" || node.name === "") && isNamedRootType(keyword)) {
|
|
5818
|
+
malformed = `"${word}" is declared with no name`;
|
|
5819
|
+
} else if (!readableName) {
|
|
5820
|
+
malformed = "the name is not enclosed in single quotes as TMDL requires";
|
|
5821
|
+
}
|
|
5822
|
+
}
|
|
5823
|
+
if (malformed !== void 0) {
|
|
5824
|
+
issues.push({
|
|
5825
|
+
file,
|
|
5826
|
+
line: lineNo,
|
|
5827
|
+
text: raw,
|
|
5828
|
+
reason: malformed,
|
|
5829
|
+
canDropObjects: true,
|
|
5830
|
+
canDropTableLine: mayBeTable
|
|
5831
|
+
});
|
|
5832
|
+
stack[indent] = node;
|
|
5833
|
+
i++;
|
|
5834
|
+
continue;
|
|
5835
|
+
}
|
|
4987
5836
|
if (parent) {
|
|
4988
5837
|
if (node.kind === "prop" || node.kind === "expr") parent.props[node.type] = node.value ?? "";
|
|
4989
5838
|
else if (node.kind === "flag") parent.props[node.type] = true;
|
|
@@ -4991,19 +5840,31 @@ function parseTmdl(file, text2) {
|
|
|
4991
5840
|
} else {
|
|
4992
5841
|
roots.push(node);
|
|
4993
5842
|
}
|
|
5843
|
+
if (declaration && (!parent && (node.type === "model" || node.type === "database") || level === "database" && node.type === "model"))
|
|
5844
|
+
holders.set(node, node.type);
|
|
4994
5845
|
stack[indent] = node;
|
|
4995
5846
|
i++;
|
|
4996
5847
|
}
|
|
4997
5848
|
if (pendingDescription) orphanDescription(pendingDescription);
|
|
4998
|
-
|
|
4999
|
-
|
|
5000
|
-
|
|
5849
|
+
const modelLevel = [
|
|
5850
|
+
...roots.map((node) => ({ node, root: true })),
|
|
5851
|
+
...[...holders].filter(([, holds]) => holds === "model").flatMap(([m]) => m.children.map((node) => ({ node, root: false })))
|
|
5852
|
+
];
|
|
5853
|
+
for (const { node: r, root } of modelLevel) {
|
|
5854
|
+
if (r.children.length === 0) continue;
|
|
5855
|
+
if (r.kind === "ref") continue;
|
|
5856
|
+
const valued = r.kind === "prop" || r.kind === "expr";
|
|
5857
|
+
const noted = r.type === "annotation" || r.type === "extendedproperty";
|
|
5858
|
+
const holdsDeclaration = !root && r.kind === "flag" && !noted && r.children.some((c) => c.kind === "object");
|
|
5859
|
+
if (!(valued ? !root : noted || holdsDeclaration)) continue;
|
|
5001
5860
|
const text3 = lines[r.line - 1];
|
|
5861
|
+
const where = root ? "at the root of a file" : "under a model";
|
|
5862
|
+
const under = holdsDeclaration ? "a declaration" : "lines";
|
|
5002
5863
|
const issue = {
|
|
5003
5864
|
file,
|
|
5004
5865
|
line: r.line,
|
|
5005
5866
|
text: text3,
|
|
5006
|
-
reason: `"${/^\w+/.exec(text3)[0]}"
|
|
5867
|
+
reason: `"${/^\w+/.exec(text3.trimStart())[0]}" ${where} has ${under} under it, which TMDL does not allow`,
|
|
5007
5868
|
canDropObjects: true,
|
|
5008
5869
|
canDropTableLine: false
|
|
5009
5870
|
};
|
|
@@ -5208,14 +6069,14 @@ var oneLine = (s) => s.replace(/\r\n?|\n/g, " ");
|
|
|
5208
6069
|
var text = (s) => showControls(s.replace(/\\/g, "\\\\")).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/[`[\]*_~|@$]/g, "\\$&").replace(/:(?=\/\/)/g, "\\:").replace(/(www)\./gi, "$1\\.");
|
|
5209
6070
|
var cell = (s) => text(oneLine(s));
|
|
5210
6071
|
function code(name) {
|
|
5211
|
-
const
|
|
6072
|
+
const shown2 = showControls(oneLine(name)).replace(
|
|
5212
6073
|
/(\\*)\|/g,
|
|
5213
6074
|
(_, run) => `${run}${run.length % 2 ? "\\" : ""}\\|`
|
|
5214
6075
|
);
|
|
5215
|
-
const fence = "`".repeat(Math.max(0, ...(
|
|
5216
|
-
const spaced =
|
|
5217
|
-
const pad =
|
|
5218
|
-
return `${fence}${pad}${
|
|
6076
|
+
const fence = "`".repeat(Math.max(0, ...(shown2.match(/`+/g) ?? []).map((r) => r.length)) + 1);
|
|
6077
|
+
const spaced = shown2.startsWith(" ") && shown2.endsWith(" ") && /[^ ]/.test(shown2);
|
|
6078
|
+
const pad = shown2.startsWith("`") || shown2.endsWith("`") || spaced ? " " : "";
|
|
6079
|
+
return `${fence}${pad}${shown2}${pad}${fence}`;
|
|
5219
6080
|
}
|
|
5220
6081
|
function formatMarkdown(result, _options = {}) {
|
|
5221
6082
|
const out = [
|
|
@@ -5830,9 +6691,10 @@ Quirks
|
|
|
5830
6691
|
- 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
6692
|
- The denominator is every relationship in the model, and a model with no relationships at all is never reported.
|
|
5832
6693
|
- 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.
|
|
6694
|
+
- 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
6695
|
|
|
5834
6696
|
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"
|
|
6697
|
+
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
6698
|
},
|
|
5837
6699
|
AVOID_FLOATING_POINT_DATA_TYPES: {
|
|
5838
6700
|
text: `Example
|
|
@@ -6423,9 +7285,10 @@ Quirks
|
|
|
6423
7285
|
- 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
7286
|
- 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
7287
|
- 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.
|
|
7288
|
+
- 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
7289
|
|
|
6427
7290
|
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"
|
|
7291
|
+
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
7292
|
},
|
|
6430
7293
|
AVOID_USING_THE_IFERROR_FUNCTION: {
|
|
6431
7294
|
text: `Example
|
|
@@ -6848,9 +7711,10 @@ Quirks
|
|
|
6848
7711
|
|
|
6849
7712
|
- 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
7713
|
- 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.
|
|
7714
|
+
- 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
7715
|
|
|
6852
7716
|
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"
|
|
7717
|
+
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
7718
|
},
|
|
6855
7719
|
"CHECK_IF_BI-DIRECTIONAL_AND_MANY-TO-MANY_RELATIONSHIPS_ARE_VALID": {
|
|
6856
7720
|
text: `Example
|
|
@@ -7066,9 +7930,10 @@ Quirks
|
|
|
7066
7930
|
- 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
7931
|
- 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
7932
|
- A sourceColumn: line with nothing after it counts as missing, the same as no line at all.
|
|
7933
|
+
- 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
7934
|
|
|
7070
7935
|
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'
|
|
7936
|
+
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
7937
|
},
|
|
7073
7938
|
"DATE/CALENDAR_TABLES_SHOULD_BE_MARKED_AS_A_DATE_TABLE": {
|
|
7074
7939
|
text: `Example
|
|
@@ -7122,9 +7987,10 @@ Quirks
|
|
|
7122
7987
|
- The data category comparison is exact and case-sensitive. A file that says dataCategory: time does not count as marked.
|
|
7123
7988
|
- The key has to be a DateTime column. A calendar keyed on an integer date key is reported however it is categorized.
|
|
7124
7989
|
- 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.
|
|
7990
|
+
- 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
7991
|
|
|
7126
7992
|
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'
|
|
7993
|
+
markdown: '### Example\n\n**Fires the rule**\n\n```tmdl\ntable Date\n column Date\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n**After the fix**\n\n```tmdl\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\nMarking the date table tells the engine which column is the calendar key, and 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
7994
|
},
|
|
7129
7995
|
DATECOLUMN_FORMATSTRING: {
|
|
7130
7996
|
text: `Example
|
|
@@ -7227,13 +8093,14 @@ Quirks
|
|
|
7227
8093
|
|
|
7228
8094
|
- 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
8095
|
- 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
|
|
8096
|
+
- 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.
|
|
7231
8097
|
- References are found by pattern matching, so a bare [Column] inside a string literal or a comment counts.
|
|
7232
8098
|
- A measure's dynamic format string is read together with its expression, so a bare column reference written inside formatStringDefinition reports the measure that carries it.
|
|
7233
8099
|
- Calculated columns and calculated tables are out of scope, so a bare column reference in either is not reported.
|
|
8100
|
+
- 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
8101
|
|
|
7235
8102
|
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
|
|
8103
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM([Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nIn DAX a bare `[Name]` is the convention for a measure. A column written the same way reads as a measure to everyone who maintains the model, and the two behave differently in a row context, so the expression is misread before it is ever debugged. The bare form also breaks when the column moves to another table, or when a measure with the same name is added and the engine binds to that instead.\n\n### How to fix it\n\nPut the table name in front of every column reference:\n\n```\nTotal Sales = SUM ( 'Sales'[Amount] )\n```\n\nIn Power BI Desktop, select the measure in the Data pane and edit it in the formula bar, which completes the qualified form as soon as you start typing the table name. A row-level security filter is edited under Modeling, Manage roles. In the TMDL file, edit the expression after `measure 'Total Sales' =`, or the filter after `tablePermission Sales =` inside the role.\n\n### When to ignore it\n\nThere is no case for the bare form. The rule is worth reading as a warning rather than a style note: the reference that fires it resolved to a column because no measure of that name exists today, and the day someone adds one, the expression silently starts reading the measure instead. Qualifying it is what stops that.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = DAX_COLUMNS_FULLY_QUALIFIED` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"DAX_COLUMNS_FULLY_QUALIFIED\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Calculation items are in the rule's scope but never fire, because Tabular Editor does not resolve bare column references inside calculation items and pbiplint matches that.\n- A bare name that matches any measure in the model is treated as a measure reference, so a column that shares its name with a measure is never flagged.\n- A bare name that matches no measure is looked for on the expression's own table first, then on every other table in the order pbiplint reads the model's files, so a finding can be raised by a column that lives on a table the expression never mentions.\n- References are found by pattern matching, so a bare `[Column]` inside a string literal or a comment counts.\n- A measure's dynamic format string is read together with its expression, so a bare column reference written inside `formatStringDefinition` reports the measure that carries it.\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
8104
|
},
|
|
7238
8105
|
DAX_MEASURES_UNQUALIFIED: {
|
|
7239
8106
|
text: `Example
|
|
@@ -7542,9 +8409,10 @@ Quirks
|
|
|
7542
8409
|
- 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
8410
|
- Visibility is not read. A hidden measures table with a single hidden column is reported like any other table.
|
|
7544
8411
|
- 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.
|
|
8412
|
+
- 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
8413
|
|
|
7546
8414
|
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"
|
|
8415
|
+
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
8416
|
},
|
|
7549
8417
|
ENSURE_THEME_COLOURS: {
|
|
7550
8418
|
text: `Example
|
|
@@ -8124,9 +8992,88 @@ Quirks
|
|
|
8124
8992
|
- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.
|
|
8125
8993
|
- Hidden columns, and columns in hidden tables, are skipped by both halves.
|
|
8126
8994
|
- 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.
|
|
8995
|
+
- 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
8996
|
|
|
8128
8997
|
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"
|
|
8998
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: int64\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: int64\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column IsReturned\n dataType: string\n summarizeBy: none\n sourceColumn: IsReturned\n\n column 'Priority Flag'\n dataType: string\n summarizeBy: none\n sourceColumn: Priority Flag\n```\n\n### Why it matters\n\nA 0 or 1 in a slicer, a legend, or a table column tells the reader nothing without a lookup, and a whole-number flag is summed by default, so a card labeled Is Active shows a count of true rows that looks like something else. Yes and No read correctly everywhere and cannot be aggregated by accident.\n\n### How to fix it\n\nThe values have to change, not just the type, so the fix lives upstream of the model. In Power BI Desktop, choose Transform data, select the query, and either add a conditional column that returns Yes or No or use Replace Values on the column itself, then set the column's type to Text in Power Query. Where the query reads a view or a stored procedure, do the same in the select list and the model gets the text column already formed. The Data type box under Column tools changes the type in place on an import model but leaves the values as 0 and 1, so it is not the fix on its own. If a measure counts the flag, keep the numeric column and hide it, which also takes it out of this rule's reach.\n\n### When to ignore it\n\nA column whose type is already boolean is the common false alarm: Power BI shows it as True and False, which reads as well as Yes and No, and the rule reports it only because it tests for text. The name tests catch words that are not flags at all, so a whole-number Issue Count or Isotope Number is noise on the Is side. The case worth acting on is the one the rule was written for: a visible 0 and 1 column that a report author has to decode.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"FORMAT_FLAG_COLUMNS_AS_YES/NO_VALUE_STRINGS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- Both name tests are case-sensitive. The prefix is the two characters Is, so a whole-number column called Island or Issue Count is reported, and one called isActive is not.\n- The suffix is a space followed by Flag, so Priority Flag is reported and PriorityFlag is not.\n- Hidden columns, and columns in hidden tables, are skipped by both halves.\n- The Is half needs the type to be exactly whole number, so a decimal IsActive is not reported. The Flag half fires on every type that is not text, boolean and DateTime included.\n- The rule also needs every part of a table's declaration, which TMDL lets sit in more than one file (Power BI Desktop writes each table in one). While pbiplint could not open a model file or folder, or a parse issue took a line that could be a `table` line, such as a misspelt `table`, the rule reports nothing, because a part of the table in what pbiplint missed could hide the table, and pbiplint does not guess what a file it could not read says. A parse issue inside a declaration, such as a property indented with spaces, does not stop the rule. The skipped line gives the reason, `a model file could not be fully read`, and a notice names what pbiplint could not open, or the file's own `PARSE_ISSUE` finding names the line.\n\nRead more: https://pbiplint.com/rules/format-flag-columns-as-yes-no-value-strings"
|
|
8999
|
+
},
|
|
9000
|
+
HARDCODED_PERIOD_IN_DAX: {
|
|
9001
|
+
text: `Example
|
|
9002
|
+
|
|
9003
|
+
Fires the rule
|
|
9004
|
+
|
|
9005
|
+
table Sales
|
|
9006
|
+
column Amount
|
|
9007
|
+
dataType: decimal
|
|
9008
|
+
sourceColumn: Amount
|
|
9009
|
+
column 'Order Date'
|
|
9010
|
+
dataType: dateTime
|
|
9011
|
+
sourceColumn: Order Date
|
|
9012
|
+
measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = 2025)
|
|
9013
|
+
formatString: #,0
|
|
9014
|
+
|
|
9015
|
+
After the fix
|
|
9016
|
+
|
|
9017
|
+
table Sales
|
|
9018
|
+
column Amount
|
|
9019
|
+
dataType: decimal
|
|
9020
|
+
sourceColumn: Amount
|
|
9021
|
+
column 'Order Date'
|
|
9022
|
+
dataType: dateTime
|
|
9023
|
+
sourceColumn: Order Date
|
|
9024
|
+
measure 'Current Year Sales' = CALCULATE(SUM(Sales[Amount]), YEAR(Sales[Order Date]) = YEAR(TODAY()))
|
|
9025
|
+
formatString: #,0
|
|
9026
|
+
|
|
9027
|
+
Why it matters
|
|
9028
|
+
|
|
9029
|
+
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.
|
|
9030
|
+
|
|
9031
|
+
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.
|
|
9032
|
+
|
|
9033
|
+
How to fix it
|
|
9034
|
+
|
|
9035
|
+
Take the period from something that moves with time.
|
|
9036
|
+
|
|
9037
|
+
- For the current period, use TODAY(): YEAR(TODAY()) for this year, as the fixed example does, or TODAY() itself for an as-of date.
|
|
9038
|
+
- 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.
|
|
9039
|
+
- 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.
|
|
9040
|
+
|
|
9041
|
+
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:
|
|
9042
|
+
|
|
9043
|
+
Latest Year Sales =
|
|
9044
|
+
VAR LatestYear = YEAR ( CALCULATE ( MAX ( Sales[Order Date] ), REMOVEFILTERS () ) )
|
|
9045
|
+
RETURN
|
|
9046
|
+
CALCULATE ( SUM ( Sales[Amount] ), YEAR ( Sales[Order Date] ) = LatestYear )
|
|
9047
|
+
|
|
9048
|
+
For a date table, end CALENDAR on the data rather than on a day:
|
|
9049
|
+
|
|
9050
|
+
Date = CALENDAR ( DATE ( 2020, 1, 1 ), DATE ( YEAR ( MAX ( Sales[Order Date] ) ), 12, 31 ) )
|
|
9051
|
+
|
|
9052
|
+
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.
|
|
9053
|
+
|
|
9054
|
+
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.
|
|
9055
|
+
|
|
9056
|
+
When to ignore it
|
|
9057
|
+
|
|
9058
|
+
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.
|
|
9059
|
+
|
|
9060
|
+
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.
|
|
9061
|
+
|
|
9062
|
+
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.
|
|
9063
|
+
|
|
9064
|
+
Quirks
|
|
9065
|
+
|
|
9066
|
+
- 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.
|
|
9067
|
+
- 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.
|
|
9068
|
+
- 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.
|
|
9069
|
+
- 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.
|
|
9070
|
+
- 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.
|
|
9071
|
+
- 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").
|
|
9072
|
+
- Year names are read in several languages (year, a\xF1o, anio, jahr, ann\xE9e, anno, jaar, \xE5r, and more), whatever the model's culture.
|
|
9073
|
+
- Desktop's own auto date/time tables are left alone, since Desktop builds them itself.
|
|
9074
|
+
|
|
9075
|
+
Read more: https://pbiplint.com/rules/hardcoded-period-in-dax`,
|
|
9076
|
+
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"
|
|
8130
9077
|
},
|
|
8131
9078
|
HIDDEN_VISUAL_WITH_FIELDS: {
|
|
8132
9079
|
text: `Example
|
|
@@ -8486,20 +9433,22 @@ Where nothing needs the relationship, delete it instead: open the model view, ri
|
|
|
8486
9433
|
|
|
8487
9434
|
When to ignore it
|
|
8488
9435
|
|
|
8489
|
-
|
|
9436
|
+
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
9437
|
|
|
8491
9438
|
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
9439
|
|
|
8493
9440
|
Quirks
|
|
8494
9441
|
|
|
8495
9442
|
- Only USERELATIONSHIP(from column, to column) counts as activation; the reversed argument order does not, even though DAX accepts it.
|
|
8496
|
-
- Only measures
|
|
9443
|
+
- 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.
|
|
9444
|
+
- 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
9445
|
- 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
9446
|
- Either table name may be written bare or in single quotes, and any spacing around the comma is accepted.
|
|
8499
9447
|
- pbiplint escapes table and column names before building the pattern, which the source rule does not, so names with parentheses cannot break the check.
|
|
9448
|
+
- 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
9449
|
|
|
8501
9450
|
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\
|
|
9451
|
+
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
9452
|
},
|
|
8504
9453
|
INTEGER_FORMATTING: {
|
|
8505
9454
|
text: `Example
|
|
@@ -8611,9 +9560,10 @@ Quirks
|
|
|
8611
9560
|
- 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
9561
|
- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.
|
|
8613
9562
|
- Relationships are not read. A hidden foreign key, which is exactly what HIDE_FOREIGN_KEYS asks you to create, is reported here.
|
|
9563
|
+
- 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
9564
|
|
|
8615
9565
|
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"
|
|
9566
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n summarizeBy: none\n sourceColumn: OrderID\n\n column 'Product Key'\n dataType: int64\n isHidden\n isAvailableInMdx: false\n summarizeBy: none\n sourceColumn: ProductKey\n```\n\n### Why it matters\n\nWhen IsAvailableInMdx is true the engine builds an attribute hierarchy for the column at every refresh: a sorted structure that lets Excel and other MDX clients browse the column's values. A hidden column is never browsed, so the structure is built, stored, and rebuilt for nothing. On wide tables with many hidden keys and helper columns that is measurable refresh time and memory.\n\n### How to fix it\n\nAdd `isAvailableInMdx: false` under the column in its table's TMDL file. Power BI Desktop has no setting for the property and never writes it, but it keeps the value once it is in the file, and it keeps it the same way on an import table and a DirectQuery one. The other way to clear a finding is to decide the column should not be hidden after all: clear Is hidden in the Properties pane, or remove `isHidden` from under the column, and the rule stops reading it, as long as its table is visible too. Where a table has dozens of hidden keys, Tabular Editor's property grid sets the property on every selected column in one edit, which is quicker than the same change repeated down a file.\n\n### When to ignore it\n\nSize is the first judgment. The saving is roughly proportional to the column's distinct count, so a hidden flag with two values is not worth an edit and a hidden key with a million is. Work down the list by cardinality and stop where the numbers get small. A variation is the one case where acting on the finding can break something: pbiplint reads a variation's default column only, so a hidden column a variation reaches through its default hierarchy is reported here, and setting the property to false on it takes away the attribute hierarchy the variation needs. Check the date table's hidden columns against its variations before you touch them. A column you are about to unhide is a fair thing to leave, since unhiding it clears the finding anyway, as long as its table is visible.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"ISAVAILABLEINMDX_FALSE_NONATTRIBUTE_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- A column with no `isAvailableInMdx` line counts as true, because that is the default pbiplint applies wherever the property is absent. Power BI Desktop never writes the property, so a Desktop-authored model gets a finding for every hidden column the rest of the condition does not excuse, until they are set by hand.\n- Visibility is the column's own `isHidden` or its table's. A visible column in a hidden table is reported.\n- Both ends of a sort-by pair are out of scope: the column another column sorts by, and the column that names one in `sortByColumn`.\n- Variations are matched on the default column alone, so a column a variation reaches only through its default hierarchy is not protected here.\n- Relationships are not read. A hidden foreign key, which is exactly what `HIDE_FOREIGN_KEYS` asks you to create, is reported here.\n- While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces, or pbiplint could not open a model file or folder at all, the rule reports nothing, because a variation on another table's column that names the column could be in what pbiplint missed, and pbiplint does not guess what a file it could not read says. The skipped line gives the reason, `a model file could not be fully read`, and the file's own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open.\n\nRead more: https://pbiplint.com/rules/isavailableinmdx-false-nonattribute-columns"
|
|
8617
9567
|
},
|
|
8618
9568
|
LANDING_PAGE_NOT_SET: {
|
|
8619
9569
|
text: `Example
|
|
@@ -8921,9 +9871,10 @@ Quirks
|
|
|
8921
9871
|
- 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
9872
|
- Inactive relationships count. A column that is only ever the one side of a relationship no measure activates is still reported.
|
|
8923
9873
|
- The column is reported once however many relationships point at it.
|
|
9874
|
+
- 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
9875
|
|
|
8925
9876
|
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"
|
|
9877
|
+
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
9878
|
},
|
|
8928
9879
|
MEASURES_SHOULD_NOT_BE_DIRECT_REFERENCES_OF_OTHER_MEASURES: {
|
|
8929
9880
|
text: `Example
|
|
@@ -9060,9 +10011,10 @@ Quirks
|
|
|
9060
10011
|
- 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
10012
|
- 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
10013
|
- 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.
|
|
10014
|
+
- 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
10015
|
|
|
9064
10016
|
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"
|
|
10017
|
+
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
10018
|
},
|
|
9067
10019
|
MINIMIZE_POWER_QUERY_TRANSFORMATIONS: {
|
|
9068
10020
|
text: `Example
|
|
@@ -9188,9 +10140,10 @@ Quirks
|
|
|
9188
10140
|
- The data category comparison is exact and case-sensitive, so dataCategory: time leaves the model reported.
|
|
9189
10141
|
- 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
10142
|
- Any table can satisfy it, of any kind. A calculated calendar counts the same as a loaded one.
|
|
10143
|
+
- 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
10144
|
|
|
9192
10145
|
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"
|
|
10146
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order Date'\n dataType: dateTime\n formatString: mm/dd/yyyy\n sourceColumn: OrderDate\n\n column Amount\n dataType: decimal\n summarizeBy: sum\n sourceColumn: Amount\n\ntable Date\n dataCategory: Time\n\n column Date\n dataType: dateTime\n isKey\n formatString: mm/dd/yyyy\n sourceColumn: Date\n\n column Year\n dataType: int64\n summarizeBy: none\n sourceColumn: Year\n```\n\n### Why it matters\n\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
10147
|
},
|
|
9195
10148
|
MODEL_USING_DIRECT_QUERY_AND_NO_AGGREGATIONS: {
|
|
9196
10149
|
text: `Example
|
|
@@ -9280,9 +10233,10 @@ Quirks
|
|
|
9280
10233
|
- 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
10234
|
- 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
10235
|
- 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.
|
|
10236
|
+
- 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
10237
|
|
|
9284
10238
|
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'
|
|
10239
|
+
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
10240
|
},
|
|
9287
10241
|
"MONTH_(AS_A_STRING)_MUST_BE_SORTED": {
|
|
9288
10242
|
text: `Example
|
|
@@ -9467,7 +10421,7 @@ How to fix it
|
|
|
9467
10421
|
|
|
9468
10422
|
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
10423
|
|
|
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
|
|
10424
|
+
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
10425
|
|
|
9472
10426
|
When to ignore it
|
|
9473
10427
|
|
|
@@ -9478,17 +10432,18 @@ To ignore this rule on one object, add annotation pbiplint.ignore = NOT_REACHED_
|
|
|
9478
10432
|
Quirks
|
|
9479
10433
|
|
|
9480
10434
|
- 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,
|
|
10435
|
+
- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an alternateOf mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and UNNECESSARY_MEASURES already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column's mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.
|
|
10436
|
+
- Calculated tables whose names start with LocalDateTable_ or DateTableTemplate_, which pbiplint reads as Power BI Desktop's auto date/time tables the way REMOVE_AUTO-DATE_TABLE does, are left out of the findings, reached or not. So is a composite model's copy of one: a LocalDateTable_ table whose entity partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with showAsVariationsOnly, so it is shown only through a date column's hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and REMOVE_AUTO-DATE_TABLE reports them; the table a copy reads is calculated in the model it extends, and that model's own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.
|
|
10437
|
+
- 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.
|
|
9484
10438
|
- 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.
|
|
9485
10439
|
- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.
|
|
10440
|
+
- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is found by pattern too: the function's name, in any letter case, followed by an opening parenthesis, with no letter, digit, underscore, or dot just before the name.
|
|
9486
10441
|
- The rule compares the report with its model, so it runs only when both are in the input.
|
|
9487
10442
|
- 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
10443
|
- 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
10444
|
|
|
9490
10445
|
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'
|
|
10446
|
+
markdown: '### Example\n\nThe example runs against a model with one table, Sales, holding Amount and Region and the measure Total Sales.\n\n**Fires the rule in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n }\n ]\n }\n }\n }\n }\n}\n```\n\n**After the fix in visual.json**\n\n```json\n{\n "$schema": "https://developer.microsoft.com/json-schemas/fabric/item/report/definition/visualContainer/2.8.0/schema.json",\n "name": "c897ed0802274ab55e2d",\n "position": { "x": 580, "y": 520, "z": 3000, "height": 190, "width": 650, "tabOrder": 3000 },\n "visual": {\n "visualType": "tableEx",\n "query": {\n "queryState": {\n "Values": {\n "projections": [\n {\n "field": { "Column": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Region" } },\n "queryRef": "Sales.Region",\n "nativeQueryRef": "Region"\n },\n {\n "field": { "Measure": { "Expression": { "SourceRef": { "Entity": "Sales" } }, "Property": "Total Sales" } },\n "queryRef": "Sales.Total Sales",\n "nativeQueryRef": "Total Sales"\n }\n ]\n }\n }\n }\n }\n}\n```\n\nWith only Region in the table, two findings come back: `[Total Sales]`, which nothing uses, and `\'Sales\'[Amount]`, which only Total Sales uses. Here the measure was meant to be in the table, so the fix adds it, and that reaches Amount through the measure\'s DAX. When a field really is unused, the fix is to delete it from the model, as How to fix it describes.\n\n### Why it matters\n\nA column earns its place in a model in one of two ways, Microsoft\'s modeling guidance says: a report filters, groups, or summarizes by it, or the model\'s structure needs it, for a relationship, a calculation, a security role, or formatting. A column that does neither can usually be removed, and an imported one is still loaded on every refresh and held in memory, where a smaller model refreshes faster and competes less for capacity. A measure nothing reaches adds no data to the model, but it sits in the Data pane beside the measures that matter, and the next author has to read it, keep it working through model changes, and guess whether something depends on it. The findings list what this report never touches, so that clean-up can start from evidence instead of a guess.\n\n### How to fix it\n\nCheck first that nothing outside this report needs the field: another report built on the same model, a paginated report, or an Excel workbook that reads the model. Removing a column that something else uses breaks that thing, and pbiplint sees only the report in front of it.\n\nThen remove the field from the model. In Power BI Desktop, right-click the measure or calculated column in the Data pane, or select it in Model view, and choose Delete from model. For a column that Power Query loads, open Power Query Editor, select the column in the table\'s query, and choose Remove Columns, so it is no longer loaded at all. If a hierarchy level uses the column, first select the hierarchy in Model view and, in the Properties pane, set its levels without that column, then select Apply Level Changes. In the TMDL files, delete the `measure` or `column` block from the table\'s file, and, for a column Power Query loads, remove it from the table\'s query as well. When the finding names a level of a user hierarchy, also delete that `level` block, which sits under its `hierarchy` block in the same file and names the column on its `column:` line, or point that `column:` at another column of the same table. A dead chain is listed with its measures before its columns, so one pass down the list removes all of it. When a detail names a user-defined function, as in `referenced only by Sales.NetAfterReserve, which nothing reaches either`, nothing reaches that function either, and it has no finding of its own: delete its `function` block from `definition/functions.tmdl` too, or edit it so it no longer names the fields you delete, since a function that names a field the model no longer has breaks.\n\n### When to ignore it\n\nA measure kept for another report on the same model, or for people who analyze the model in Excel, is not dead because this report does not use it, and neither is a column that a paginated report or a workbook reads. When several reports share the model, a finding here says only that this report does not reach the field; weigh it against the others before deleting anything, and ignore it on the fields they need.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = NOT_REACHED_FROM_REPORT` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `"NOT_REACHED_FROM_REPORT": "off"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- The rule reads one report at a time. A model that several reports share lists, for each report, what that report does not reach, even when another report uses it.\n- Both columns of a relationship, the columns and measures that row-level security filters name, the columns that object-level security names, the default column of a variation, and the columns of an aggregation table that carry an `alternateOf` mapping are reached whether or not the report uses them, because the model needs them: a role whose filter names a deleted measure fails, and `UNNECESSARY_MEASURES` already counts such a measure as used. A column that a reached column sorts by or groups by is reached too, and so is the detail column or table that an aggregation column\'s mapping names. Report queries name the detail table, and Power BI answers them from the aggregation table when that table covers the query, so a report can use the mapped columns without naming them. A column of an aggregation table with no mapping is treated like any other column.\n- Calculated tables whose names start with `LocalDateTable_` or `DateTableTemplate_`, which pbiplint reads as Power BI Desktop\'s auto date/time tables the way `REMOVE_AUTO-DATE_TABLE` does, are left out of the findings, reached or not. So is a composite model\'s copy of one: a `LocalDateTable_` table whose `entity` partition reads, in DirectQuery mode, the table of that name in the Power BI semantic model or Analysis Services model it extends, and which Desktop saves with `showAsVariationsOnly`, so it is shown only through a date column\'s hierarchy. Desktop manages these tables and keeps them out of view, so there is nothing here to delete. Turning Auto date/time off removes the calculated ones, and `REMOVE_AUTO-DATE_TABLE` reports them; the table a copy reads is calculated in the model it extends, and that model\'s own run reports it. The relationship Desktop adds from a date column to its auto date/time table, or to a copy, does not count as a use of the date column, so a date column the report never shows is still reported.\n- `UNNECESSARY_MEASURES` and `UNNECESSARY_COLUMNS` keep the one-hop test of the ruleset they are ported from, as Tabular Editor runs it: they look only at hidden fields and at the model\'s own references. This rule reads the report and follows the chain as far as it goes, so it reports visible fields too, and a measure that only another unused measure references.\n- DAX references are found by pattern, the way the model rules find them, so a field named inside a string or a comment of a reached measure counts as reached.\n- A table that nothing reaches has no finding of its own. Each of its columns and measures is reported instead.\n- A user-defined function that nothing reaches has no finding of its own either. Each column and measure that only it uses is reported, with the function named in the detail. A call is found by pattern too: the function\'s name, in any letter case, followed by an opening parenthesis, with no letter, digit, underscore, or dot just before the name.\n- The rule compares the report with its model, so it runs only when both are in the input.\n- The rule also needs every file it reads the report\'s fields from: report.json (the report\'s filters), reportExtensions.json (the report\'s own measures), each page.json (a page\'s filters and its drillthrough or tooltip fields), each visual.json, and each bookmark file. While one of them cannot be read, such as a visual.json holding merge-conflict markers, a reportExtensions.json that is not valid JSON, or a file pbiplint could not open at all, the rule reports nothing, because that file may use any field in the model and pbiplint does not guess what a file it could not read says. A folder under the definition folder that pbiplint could not open counts as every file it could hold. The skipped line gives the reason, `a report file could not be read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. pbiplint reads no field from version.json, pages.json, bookmarks.json, or a visual\'s mobile.json, which hold the report\'s format version, the order of its pages, the order and groups of its bookmarks, and a visual\'s mobile layout, so one of them that cannot be read, a merge conflict in pages.json included, does not stop the rule. Nor does a .platform or definition.pbir that cannot be read, or a JSON file of your own in the definition folder.\n- The rule also needs the whole model. While a model file has a parse issue that can take a declaration out of the model, such as a line indented with spaces or a misspelt `table`, or pbiplint could not open a model file or folder at all, the rule reports nothing, because whatever only the missing declaration reaches, such as a measure that only its DAX uses, would read as reached by nothing. The skipped line gives the reason, `a model file could not be fully read`, the file\'s own `PARSE_ISSUE` finding names it, or a notice does for a file or folder pbiplint could not open, and the Model line of Report at a glance says the count is unknown. A `///` description with a blank line after it takes no declaration out, so it does not stop the rule. When a report file could not be read as well, the skipped line gives that reason instead.\n\nRead more: https://pbiplint.com/rules/not-reached-from-report'
|
|
9492
10447
|
},
|
|
9493
10448
|
NUMERIC_COLUMN_SUMMARIZE_BY: {
|
|
9494
10449
|
text: `Example
|
|
@@ -9528,9 +10483,10 @@ Quirks
|
|
|
9528
10483
|
- The property value is compared without regard to letter case, so summarizeBy: None passes as well as summarizeBy: none.
|
|
9529
10484
|
- Hidden columns, and columns in hidden tables, are skipped.
|
|
9530
10485
|
- Only whole number, decimal, and double columns are in scope, so a DateTime or text column with a summarization set is never reported here.
|
|
10486
|
+
- 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
10487
|
|
|
9532
10488
|
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'
|
|
10489
|
+
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
10490
|
},
|
|
9535
10491
|
OBJECTS_SHOULD_NOT_START_OR_END_WITH_A_SPACE: {
|
|
9536
10492
|
text: `Example
|
|
@@ -9581,9 +10537,10 @@ Quirks
|
|
|
9581
10537
|
- 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
10538
|
- 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
10539
|
- 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.
|
|
10540
|
+
- 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
10541
|
|
|
9585
10542
|
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"
|
|
10543
|
+
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
10544
|
},
|
|
9588
10545
|
OBJECTS_WITH_NO_DESCRIPTION: {
|
|
9589
10546
|
text: `Example
|
|
@@ -9632,9 +10589,10 @@ Quirks
|
|
|
9632
10589
|
- A calculation group table is reported once, as a calculation group.
|
|
9633
10590
|
- A description of only spaces or tabs counts as none, so padding a description to quiet the rule does not work.
|
|
9634
10591
|
- 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.
|
|
10592
|
+
- 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
10593
|
|
|
9636
10594
|
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"
|
|
10595
|
+
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
10596
|
},
|
|
9639
10597
|
OPENING_PAGE_INVALID: {
|
|
9640
10598
|
text: `Example
|
|
@@ -9773,13 +10731,13 @@ The second pair is a page.json saved in the middle of a merge, with both branche
|
|
|
9773
10731
|
|
|
9774
10732
|
Why it matters
|
|
9775
10733
|
|
|
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.
|
|
10734
|
+
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
10735
|
|
|
9778
|
-
In a report, a JSON file that is not valid JSON,
|
|
10736
|
+
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
10737
|
|
|
9780
10738
|
How to fix it
|
|
9781
10739
|
|
|
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.
|
|
10740
|
+
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
10741
|
|
|
9784
10742
|
When to ignore it
|
|
9785
10743
|
|
|
@@ -9788,7 +10746,7 @@ Not on purpose. A parse issue means the model pbiplint checked is not the model
|
|
|
9788
10746
|
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
10747
|
|
|
9790
10748
|
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,
|
|
10749
|
+
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
10750
|
},
|
|
9793
10751
|
PARTITION_NAME_SHOULD_MATCH_TABLE_NAME_FOR_SINGLE_PARTITION_TABLES: {
|
|
9794
10752
|
text: `Example
|
|
@@ -9836,9 +10794,10 @@ To ignore this rule on one object, add annotation pbiplint.ignore = PARTITION_NA
|
|
|
9836
10794
|
Quirks
|
|
9837
10795
|
|
|
9838
10796
|
- 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.
|
|
10797
|
+
- 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
10798
|
|
|
9840
10799
|
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'
|
|
10800
|
+
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
10801
|
},
|
|
9843
10802
|
PERCENTAGE_FORMATTING: {
|
|
9844
10803
|
text: `Example
|
|
@@ -10001,9 +10960,10 @@ Quirks
|
|
|
10001
10960
|
- A format string of nothing but spaces counts as no format string, so formatString: " " is reported.
|
|
10002
10961
|
- A measure with only a dynamic format string passes here but fires INTEGER_FORMATTING, which reads the static format string alone.
|
|
10003
10962
|
- Hidden measures, and measures on hidden tables, are skipped. INTEGER_FORMATTING skips neither.
|
|
10963
|
+
- 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
10964
|
|
|
10005
10965
|
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"
|
|
10966
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n isHidden\n summarizeBy: none\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA measure with no format string is rendered with the client's default, which usually means no thousands separator and a decimal count that varies with the data, so the same measure can look different in two visuals on the same page. Setting the format on the measure fixes the presentation once for every report that will ever use the model, instead of leaving each report author to set it per visual and get it slightly wrong. Hidden measures and measures on hidden tables are not checked, because nothing displays them directly; a measure that has only a dynamic format string is also left alone.\n\n### How to fix it\n\nIn Power BI Desktop, select the measure in the Data pane and set Format under Measure tools. In the TMDL file, add `formatString` under the measure: `#,0` for whole numbers, `#,0.00` for decimals, a currency format such as `$#,0.00`, or `#,0.0%;-#,0.0%;#,0.0%` for percentages. Where the format depends on what the measure returns, a dynamic format string satisfies the rule as well: pick Dynamic in the Format list under Measure tools and write the expression in the formula bar, which the file records as a `formatStringDefinition` block under the measure.\n\n### When to ignore it\n\nA measure that returns text has nothing to format. 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
10967
|
},
|
|
10008
10968
|
REDUCE_ADVANCED_FILTERS: {
|
|
10009
10969
|
text: `Example
|
|
@@ -11201,12 +12161,13 @@ To ignore this rule on one object, add annotation pbiplint.ignore = REMOVE_AUTO-
|
|
|
11201
12161
|
Quirks
|
|
11202
12162
|
|
|
11203
12163
|
- The table has to be a calculated table as well as carry the name. A loaded table someone called DateTableTemplate_Old is not reported.
|
|
12164
|
+
- 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
12165
|
- 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
12166
|
- 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
12167
|
- 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
12168
|
|
|
11208
12169
|
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"
|
|
12170
|
+
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
12171
|
},
|
|
11211
12172
|
REMOVE_DATA_SOURCES_NOT_REFERENCED_BY_ANY_PARTITIONS: {
|
|
11212
12173
|
text: `Example
|
|
@@ -11257,9 +12218,10 @@ Quirks
|
|
|
11257
12218
|
- Power BI Desktop never writes data sources, so this rule matters only for hand-built or migrated models.
|
|
11258
12219
|
- 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
12220
|
- 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.
|
|
12221
|
+
- 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
12222
|
|
|
11261
12223
|
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'
|
|
12224
|
+
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
12225
|
},
|
|
11264
12226
|
REMOVE_REDUNDANT_COLUMNS_IN_RELATED_TABLES: {
|
|
11265
12227
|
text: `Example
|
|
@@ -11344,9 +12306,10 @@ Quirks
|
|
|
11344
12306
|
- Any column on the related table counts as the duplicate, the relationship key and hidden columns included.
|
|
11345
12307
|
- 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
12308
|
- All three column kinds are in scope, so a loaded column, a DAX calculated column, and a calculated table's column are read alike.
|
|
12309
|
+
- 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
12310
|
|
|
11348
12311
|
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"
|
|
12312
|
+
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
12313
|
},
|
|
11351
12314
|
REMOVE_ROLES_WITH_NO_MEMBERS: {
|
|
11352
12315
|
text: `Example
|
|
@@ -11544,12 +12507,12 @@ This rule reports on measures defined in the report, and pbiplint reads no annot
|
|
|
11544
12507
|
|
|
11545
12508
|
Quirks
|
|
11546
12509
|
|
|
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
|
|
12510
|
+
- 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
12511
|
- 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
12512
|
- A measure with an empty expression is reported like any other.
|
|
11550
12513
|
|
|
11551
12514
|
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 `
|
|
12515
|
+
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
12516
|
},
|
|
11554
12517
|
SET_ISAVAILABLEINMDX_TO_TRUE_ON_NECESSARY_COLUMNS: {
|
|
11555
12518
|
text: `Example
|
|
@@ -12316,19 +13279,23 @@ For a data column, stop loading it: in Power BI Desktop, Transform data, select
|
|
|
12316
13279
|
|
|
12317
13280
|
When to ignore it
|
|
12318
13281
|
|
|
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,
|
|
13282
|
+
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
13283
|
|
|
12321
13284
|
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
13285
|
|
|
12323
13286
|
Quirks
|
|
12324
13287
|
|
|
12325
13288
|
- DAX references are approximated by pattern matching: references inside strings or comments count, and a bare [Column] reference resolves measure-first, then the expression's own table, then the first table with that column.
|
|
13289
|
+
- A column that a user-defined function names with its table counts as used, even when nothing calls the function, as Tabular Editor counts it.
|
|
13290
|
+
- A column that a user-defined function names without its table counts as used, on every table with a column of that name, since the caller can hand the function any table. In pbiplint's parity check, Tabular Editor counted such a name inside SUMX ( 'Sales', [Handling Fee] ) but reported the column a function names in MAX ( [Tax Rate] ), though deleting it would break the function.
|
|
13291
|
+
- 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
13292
|
- 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
13293
|
- 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
13294
|
- Row-level security is matched as text, ignoring letter case, the way the source rule matches it. A bare [Column] in any role's filter already counts as a DAX reference (see above), so the text test only adds the qualified forms Table[Column] and 'Table'[Column].
|
|
13295
|
+
- 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
13296
|
|
|
12330
13297
|
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,
|
|
13298
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n column 'Legacy Region Code'\n dataType: string\n isHidden\n sourceColumn: LegacyRegionCode\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column 'Order ID'\n dataType: int64\n sourceColumn: OrderID\n\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden column that nothing uses is loaded, compressed, and refreshed for no reader. Key columns and helper columns pile up this way as a model evolves, and each one costs memory and refresh time in proportion to its cardinality. Removing them is the cheapest model diet there is.\n\n### How to fix it\n\nFor a data column, stop loading it: in Power BI Desktop, Transform data, select the query, and use Choose Columns or Remove Columns, so the column never reaches the model. Where the query reads a view or a stored procedure, drop it from the select list there instead and the refresh gets shorter too. For a calculated column, right-click it in the Data pane and choose Delete from model, or remove its `column` block from the table's TMDL file. If the column turns out to be needed after all, clear Is hidden in the Properties pane, or remove `isHidden` from under the column in the file, and the finding goes with it.\n\n### When to ignore it\n\nReport usage is the case to check first. A hidden column that a visual, a slicer, or a report-level filter binds to is in use, but this rule reads the model only, as the source rule does, so it reports that column all the same. When the report is in the input, `NOT_REACHED_FROM_REPORT` says which fields that report never reaches; other reports on the same model are still yours to open before you delete anything. A column named as the default column of a variation is in the same position: the rule does not read variations, so it reports one that Power BI Desktop is quietly relying on. A staging column you are about to reference is a fair thing to leave for a week. A hidden key that no relationship uses is not: that one is what the rule is for.\n\nTo ignore this rule on one object, add `annotation pbiplint.ignore = UNNECESSARY_COLUMNS` under the object in its TMDL file. Power BI Desktop keeps the annotation. To turn the rule off for a whole project, set `\"UNNECESSARY_COLUMNS\": \"off\"` under `rules` in `pbiplint.config.json`.\n\n### Quirks\n\n- DAX references are approximated by pattern matching: references inside strings or comments count, and a bare `[Column]` reference resolves measure-first, then the expression's own table, then the first table with that column.\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 is matched as text, ignoring letter case, the way the source rule matches it. A bare `[Column]` in any role's filter already counts as a DAX reference (see above), so the text test only adds the qualified forms `Table[Column]` and `'Table'[Column]`.\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
13299
|
},
|
|
12333
13300
|
UNNECESSARY_MEASURES: {
|
|
12334
13301
|
text: `Example
|
|
@@ -12363,11 +13330,11 @@ A hidden measure that no other measure uses can only be reached by a report that
|
|
|
12363
13330
|
|
|
12364
13331
|
How to fix it
|
|
12365
13332
|
|
|
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:
|
|
13333
|
+
In Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the measure block from its table, or remove isHidden from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures and their format strings, 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
13334
|
|
|
12368
13335
|
When to ignore it
|
|
12369
13336
|
|
|
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,
|
|
13337
|
+
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
13338
|
|
|
12372
13339
|
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
13340
|
|
|
@@ -12376,10 +13343,12 @@ Quirks
|
|
|
12376
13343
|
- References from calculation items and from other hidden measures count as usage.
|
|
12377
13344
|
- Report usage is not visible to this rule. A hidden measure used only by a visual is still flagged.
|
|
12378
13345
|
- A row-level security filter counts as a DAX expression, so a measure named in one is used.
|
|
13346
|
+
- 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.
|
|
12379
13347
|
- A bare [Measure] reference resolves by name across the whole model, ignoring letter case, so it counts wherever the measure lives. References are found by pattern, not by parsing, so a measure named inside a string or a comment counts as used too.
|
|
13348
|
+
- 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
13349
|
|
|
12381
13350
|
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:
|
|
13351
|
+
markdown: "### Example\n\n**Fires the rule**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n\n measure 'Total Sales Legacy' = SUMX('Sales', 'Sales'[Amount])\n isHidden\n formatString: #,0\n```\n\n**After the fix**\n\n```tmdl\ntable Sales\n column Amount\n dataType: decimal\n sourceColumn: Amount\n\n measure 'Total Sales' = SUM('Sales'[Amount])\n formatString: #,0\n```\n\n### Why it matters\n\nA hidden measure that no other measure uses can only be reached by a report that already had it, so it is either dead or a hidden dependency that breaks the day someone deletes it as dead. Either way it belongs in the open or in the bin.\n\n### How to fix it\n\nIn Power BI Desktop, right-click the measure in the Data pane and choose Delete from model, or, if reports still use it, clear Is hidden in the Properties pane so the dependency is visible to the next person. In the TMDL file, remove the `measure` block from its table, or remove `isHidden` from under it. Search the project for the measure's name before you delete it: this rule has already searched the model's DAX (measures and their format strings, 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. References are found by pattern, not by parsing, so a measure named inside a string or a comment counts as used too.\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
13352
|
},
|
|
12384
13353
|
"UNPIVOT_PIVOTED_(MONTH)_DATA": {
|
|
12385
13354
|
text: `Example
|
|
@@ -12752,8 +13721,16 @@ Read more: https://pbiplint.com/rules/visual-without-fields`,
|
|
|
12752
13721
|
};
|
|
12753
13722
|
|
|
12754
13723
|
// src/walk.ts
|
|
12755
|
-
import {
|
|
12756
|
-
|
|
13724
|
+
import {
|
|
13725
|
+
accessSync,
|
|
13726
|
+
constants,
|
|
13727
|
+
lstatSync,
|
|
13728
|
+
readdirSync as readdirSync2,
|
|
13729
|
+
readFileSync as readFileSync2,
|
|
13730
|
+
realpathSync,
|
|
13731
|
+
statSync
|
|
13732
|
+
} from "node:fs";
|
|
13733
|
+
import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2 } from "node:path";
|
|
12757
13734
|
var EXPECTED_INPUT = "a PBIP folder, a .pbip file, a .SemanticModel folder, a .Report folder, a definition folder, or one .tmdl file";
|
|
12758
13735
|
var SKIP_DIRS = /* @__PURE__ */ new Set([".git", ".pbi", "node_modules", "StaticResources", "CustomVisuals"]);
|
|
12759
13736
|
var toPosix = (p) => p.split("\\").join("/");
|
|
@@ -12776,23 +13753,55 @@ function folderAt(p) {
|
|
|
12776
13753
|
throw e;
|
|
12777
13754
|
}
|
|
12778
13755
|
}
|
|
12779
|
-
function
|
|
13756
|
+
function refusalOf(e) {
|
|
13757
|
+
const reason = reasonOf(e);
|
|
13758
|
+
return { reason, says: `could not be read (${reason})` };
|
|
13759
|
+
}
|
|
13760
|
+
var LINK = {
|
|
13761
|
+
reason: "it is a symbolic link, which pbiplint does not follow",
|
|
13762
|
+
says: "is a symbolic link, which pbiplint does not follow"
|
|
13763
|
+
};
|
|
13764
|
+
function unread(w, p, why, part, folder = false) {
|
|
12780
13765
|
const path = toPosix(relative(w.base, p));
|
|
12781
|
-
w.refusal ??= { path,
|
|
13766
|
+
w.refusal ??= { path, reason: why.reason };
|
|
13767
|
+
part?.unread.push(toPosix(relative(part.root, p)) + (folder ? "/" : ""));
|
|
12782
13768
|
if (w.project.diagnostics.some((d) => d.kind === "unread-file" && d.path === path)) return;
|
|
12783
13769
|
w.project.diagnostics.push({
|
|
12784
13770
|
kind: "unread-file",
|
|
12785
13771
|
path,
|
|
12786
|
-
message: `${path}
|
|
13772
|
+
message: `${path} ${why.says}, so it was not linted`
|
|
12787
13773
|
});
|
|
12788
13774
|
}
|
|
13775
|
+
function isLink(p) {
|
|
13776
|
+
try {
|
|
13777
|
+
return lstatSync(p, { throwIfNoEntry: false })?.isSymbolicLink() ?? false;
|
|
13778
|
+
} catch (e) {
|
|
13779
|
+
if (isSystemError(e)) return false;
|
|
13780
|
+
throw e;
|
|
13781
|
+
}
|
|
13782
|
+
}
|
|
13783
|
+
function linked(w, p, part, folder = false) {
|
|
13784
|
+
if (p === w.base || !isLink(p)) return false;
|
|
13785
|
+
unread(w, p, LINK, part, folder);
|
|
13786
|
+
return true;
|
|
13787
|
+
}
|
|
13788
|
+
function linkOnWay(w, to) {
|
|
13789
|
+
const way = relative(w.base, to);
|
|
13790
|
+
if (isAbsolute(way)) return isLink(to) ? to : void 0;
|
|
13791
|
+
let at2 = w.base;
|
|
13792
|
+
for (const segment of way.split(/[\\/]/)) {
|
|
13793
|
+
at2 = join3(at2, segment);
|
|
13794
|
+
if (segment !== "" && segment !== ".." && isLink(at2)) return at2;
|
|
13795
|
+
}
|
|
13796
|
+
return void 0;
|
|
13797
|
+
}
|
|
12789
13798
|
function attempt(w, p, call, part, folder = false) {
|
|
13799
|
+
if (linked(w, p, part, folder)) return void 0;
|
|
12790
13800
|
try {
|
|
12791
13801
|
return call();
|
|
12792
13802
|
} catch (e) {
|
|
12793
13803
|
if (!isSystemError(e)) throw e;
|
|
12794
|
-
unread(w, p, e);
|
|
12795
|
-
part?.unread.push(toPosix(relative(part.root, p)) + (folder ? "/" : ""));
|
|
13804
|
+
unread(w, p, refusalOf(e), part, folder);
|
|
12796
13805
|
return void 0;
|
|
12797
13806
|
}
|
|
12798
13807
|
}
|
|
@@ -12802,7 +13811,7 @@ function readTree(w, part, dir, keep, passed) {
|
|
|
12802
13811
|
(a, b) => byName(a.name, b.name)
|
|
12803
13812
|
)) {
|
|
12804
13813
|
const p = join3(dir, entry.name);
|
|
12805
|
-
if (entry.isDirectory()) {
|
|
13814
|
+
if (entry.isSymbolicLink() ? folderAt(p) === true : entry.isDirectory()) {
|
|
12806
13815
|
if (SKIP_DIRS.has(entry.name)) continue;
|
|
12807
13816
|
if (passed && entry.name.endsWith(".SemanticModel"))
|
|
12808
13817
|
passed.models.push(toPosix(relative(w.base, p)));
|
|
@@ -12815,14 +13824,14 @@ function readTree(w, part, dir, keep, passed) {
|
|
|
12815
13824
|
}
|
|
12816
13825
|
function modelPart(w, folder) {
|
|
12817
13826
|
const def = join3(folder, "definition");
|
|
12818
|
-
if (!isDir(def)) return void 0;
|
|
13827
|
+
if (linked(w, def) || !isDir(def)) return void 0;
|
|
12819
13828
|
const part = emptyPart(folder);
|
|
12820
13829
|
readTree(w, part, def, (n2) => n2.endsWith(".tmdl"));
|
|
12821
13830
|
return part.files.length ? part : void 0;
|
|
12822
13831
|
}
|
|
12823
13832
|
function reportPart(w, folder) {
|
|
12824
13833
|
const def = join3(folder, "definition");
|
|
12825
|
-
if (!isDir(def)) return void 0;
|
|
13834
|
+
if (linked(w, def) || !isDir(def)) return void 0;
|
|
12826
13835
|
const part = emptyPart(folder);
|
|
12827
13836
|
for (const name of ["definition.pbir", ".platform"]) {
|
|
12828
13837
|
const p = join3(folder, name);
|
|
@@ -12837,13 +13846,19 @@ var UNREAD_PART = {
|
|
|
12837
13846
|
report: "the report folder could not be read"
|
|
12838
13847
|
};
|
|
12839
13848
|
function readPart(w, layer, folder, read) {
|
|
13849
|
+
const via = linkOnWay(w, folder);
|
|
13850
|
+
if (via !== void 0) {
|
|
13851
|
+
unread(w, via, LINK);
|
|
13852
|
+
if (layer) w.project.absent[layer] = UNREAD_PART[layer];
|
|
13853
|
+
return void 0;
|
|
13854
|
+
}
|
|
12840
13855
|
let part;
|
|
12841
13856
|
try {
|
|
12842
13857
|
accessSync(folder, constants.X_OK);
|
|
12843
13858
|
part = read(w, folder);
|
|
12844
13859
|
} catch (e) {
|
|
12845
13860
|
if (!isSystemError(e)) throw e;
|
|
12846
|
-
unread(w, e.path ?? folder, e);
|
|
13861
|
+
unread(w, e.path ?? folder, refusalOf(e));
|
|
12847
13862
|
}
|
|
12848
13863
|
const at2 = toPosix(relative(w.base, folder));
|
|
12849
13864
|
const refused = w.project.diagnostics.some(
|
|
@@ -12854,7 +13869,9 @@ function readPart(w, layer, folder, read) {
|
|
|
12854
13869
|
}
|
|
12855
13870
|
function pbipIn(input, folder, preferred) {
|
|
12856
13871
|
if (preferred !== void 0) return preferred;
|
|
12857
|
-
const found = readdirSync2(folder, { withFileTypes: true }).filter(
|
|
13872
|
+
const found = readdirSync2(folder, { withFileTypes: true }).filter(
|
|
13873
|
+
(e) => e.name.endsWith(".pbip") && (e.isFile() || e.isSymbolicLink() && folderAt(join3(folder, e.name)) !== true)
|
|
13874
|
+
).map((e) => e.name).sort(byName);
|
|
12858
13875
|
if (found.length > 1)
|
|
12859
13876
|
throw new UsageError(
|
|
12860
13877
|
`${input} contains ${found.length} .pbip files; point at one of them: ${found.join(", ")}`
|
|
@@ -12870,18 +13887,6 @@ function loneReportAbsent(report, folder) {
|
|
|
12870
13887
|
);
|
|
12871
13888
|
return !decision.useModel && decision.reason ? { model: decision.reason } : {};
|
|
12872
13889
|
}
|
|
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
13890
|
function resolveProject(input) {
|
|
12886
13891
|
const path = resolve2(input);
|
|
12887
13892
|
try {
|
|
@@ -12899,7 +13904,8 @@ function resolveProject(input) {
|
|
|
12899
13904
|
absent: {},
|
|
12900
13905
|
diagnostics: []
|
|
12901
13906
|
};
|
|
12902
|
-
if (path.endsWith(".pbip"))
|
|
13907
|
+
if (path.endsWith(".pbip"))
|
|
13908
|
+
return resolvePbip(input, isLink(path) ? realpathSync(path) : path);
|
|
12903
13909
|
if (isPbix(path)) throw new UsageError(pbixRefusal(input));
|
|
12904
13910
|
throw new UsageError(`${input} is not a .tmdl file, a .pbip file, or a folder`);
|
|
12905
13911
|
}
|
|
@@ -12916,9 +13922,9 @@ function walked(input, base, read) {
|
|
|
12916
13922
|
const w = { base, project: { root: base, absent: {}, diagnostics: [] } };
|
|
12917
13923
|
const project = read(w);
|
|
12918
13924
|
if (!project.model && !project.report && w.refusal) {
|
|
12919
|
-
const { path: below,
|
|
13925
|
+
const { path: below, reason } = w.refusal;
|
|
12920
13926
|
const named2 = below === "" ? input : toPosix(join3(input, below));
|
|
12921
|
-
throw new UsageError(`Could not read ${named2}: ${
|
|
13927
|
+
throw new UsageError(`Could not read ${named2}: ${reason}`);
|
|
12922
13928
|
}
|
|
12923
13929
|
return project;
|
|
12924
13930
|
}
|
|
@@ -12953,7 +13959,9 @@ function resolvePbip(input, path) {
|
|
|
12953
13959
|
function pbirOf(w, report, folder) {
|
|
12954
13960
|
if (report) return report.files.find((f) => f.path === "definition.pbir")?.text;
|
|
12955
13961
|
const at2 = toPosix(relative(w.base, folder));
|
|
12956
|
-
if (w.project.diagnostics.some(
|
|
13962
|
+
if (w.project.diagnostics.some(
|
|
13963
|
+
(d) => d.kind === "unread-file" && (d.path === at2 || at2.startsWith(`${d.path}/`))
|
|
13964
|
+
))
|
|
12957
13965
|
return void 0;
|
|
12958
13966
|
const p = join3(folder, "definition.pbir");
|
|
12959
13967
|
return attempt(w, p, () => isFile(p) ? readFileSync2(p, "utf8") : void 0);
|
|
@@ -12964,7 +13972,7 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12964
13972
|
const at2 = (p) => toPosix(relative(w.base, p));
|
|
12965
13973
|
const report = readPart(w, "report", reportFolder, reportPart);
|
|
12966
13974
|
if (!report && !out.absent.report && isFile(join3(reportFolder, "report.json"))) {
|
|
12967
|
-
out.diagnostics.push(
|
|
13975
|
+
out.diagnostics.push(legacyReportNotice(at2(reportFolder)));
|
|
12968
13976
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
12969
13977
|
}
|
|
12970
13978
|
if (report) report.files.push({ path: toPosix(relative(report.root, pbip)), text: text2 });
|
|
@@ -12978,9 +13986,13 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12978
13986
|
if (folderAt(modelFolder)) {
|
|
12979
13987
|
model = readPart(w, "model", modelFolder, modelPart);
|
|
12980
13988
|
if (!model && !out.absent.model && isFile(join3(modelFolder, "model.bim"))) {
|
|
12981
|
-
out.diagnostics.push(
|
|
13989
|
+
out.diagnostics.push(legacyModelNotice(at2(modelFolder)));
|
|
12982
13990
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
12983
13991
|
}
|
|
13992
|
+
if (!model && !out.absent.model) {
|
|
13993
|
+
const { reason } = pairingDecision(ref, void 0, basename(reportFolder));
|
|
13994
|
+
if (reason) out.absent.model = reason;
|
|
13995
|
+
}
|
|
12984
13996
|
} else {
|
|
12985
13997
|
out.absent.model = `this report reads a model that is not there (${ref.path})`;
|
|
12986
13998
|
}
|
|
@@ -12998,7 +14010,9 @@ function readNamed(w, input, pbip, text2, reportFolder, written) {
|
|
|
12998
14010
|
function readFolder(w, input, path, preferred) {
|
|
12999
14011
|
const out = w.project;
|
|
13000
14012
|
const name = basename(path);
|
|
13001
|
-
|
|
14013
|
+
const def = join3(path, "definition");
|
|
14014
|
+
const namedPart = name.endsWith(".Report") || name.endsWith(".SemanticModel");
|
|
14015
|
+
if (isDir(def) || namedPart && isLink(def)) {
|
|
13002
14016
|
const model2 = readPart(
|
|
13003
14017
|
w,
|
|
13004
14018
|
name.endsWith(".SemanticModel") ? "model" : void 0,
|
|
@@ -13023,16 +14037,16 @@ function readFolder(w, input, path, preferred) {
|
|
|
13023
14037
|
};
|
|
13024
14038
|
}
|
|
13025
14039
|
if (name.endsWith(".Report") && isFile(join3(path, "report.json"))) {
|
|
13026
|
-
out.diagnostics.push(
|
|
14040
|
+
out.diagnostics.push(legacyReportNotice(name));
|
|
13027
14041
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
13028
14042
|
return out;
|
|
13029
14043
|
}
|
|
13030
14044
|
if (name.endsWith(".SemanticModel") && isFile(join3(path, "model.bim"))) {
|
|
13031
|
-
out.diagnostics.push(
|
|
14045
|
+
out.diagnostics.push(legacyModelNotice(name));
|
|
13032
14046
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
13033
14047
|
return out;
|
|
13034
14048
|
}
|
|
13035
|
-
const dirs = readdirSync2(path, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
14049
|
+
const dirs = readdirSync2(path, { withFileTypes: true }).filter((e) => e.isDirectory() || e.isSymbolicLink() && folderAt(join3(path, e.name)) === true).map((e) => e.name);
|
|
13036
14050
|
const models = dirs.filter((d) => d.endsWith(".SemanticModel")).sort(byName);
|
|
13037
14051
|
const reports = dirs.filter((d) => d.endsWith(".Report")).sort(byName);
|
|
13038
14052
|
if (models.length > 1)
|
|
@@ -13045,12 +14059,12 @@ function readFolder(w, input, path, preferred) {
|
|
|
13045
14059
|
);
|
|
13046
14060
|
let model = models[0] ? readPart(w, "model", join3(path, models[0]), modelPart) : void 0;
|
|
13047
14061
|
if (models[0] && !model && !out.absent.model && isFile(join3(path, models[0], "model.bim"))) {
|
|
13048
|
-
out.diagnostics.push(
|
|
14062
|
+
out.diagnostics.push(legacyModelNotice(models[0]));
|
|
13049
14063
|
out.absent.model = LEGACY_MODEL_REASON;
|
|
13050
14064
|
}
|
|
13051
14065
|
const report = reports[0] ? readPart(w, "report", join3(path, reports[0]), reportPart) : void 0;
|
|
13052
14066
|
if (reports[0] && !report && !out.absent.report && isFile(join3(path, reports[0], "report.json"))) {
|
|
13053
|
-
out.diagnostics.push(
|
|
14067
|
+
out.diagnostics.push(legacyReportNotice(reports[0]));
|
|
13054
14068
|
out.absent.report = LEGACY_REPORT_REASON;
|
|
13055
14069
|
}
|
|
13056
14070
|
if (report) {
|
|
@@ -13061,14 +14075,12 @@ function readFolder(w, input, path, preferred) {
|
|
|
13061
14075
|
if (text2 !== void 0) report.files.push({ path: toPosix(relative(report.root, p)), text: text2 });
|
|
13062
14076
|
}
|
|
13063
14077
|
const pbir = report.files.find((f) => f.path === "definition.pbir");
|
|
13064
|
-
const
|
|
13065
|
-
|
|
13066
|
-
model ? models[0] : void 0,
|
|
13067
|
-
reports[0]
|
|
13068
|
-
);
|
|
14078
|
+
const ref = pbir ? datasetReference(pbir.text) : { kind: "none" };
|
|
14079
|
+
const decision = pairingDecision(ref, model ? models[0] : void 0, reports[0]);
|
|
13069
14080
|
if (!decision.useModel) {
|
|
13070
14081
|
model = void 0;
|
|
13071
|
-
if (decision.reason
|
|
14082
|
+
if (decision.reason && (ref.kind === "byConnection" || !out.absent.model))
|
|
14083
|
+
out.absent.model = decision.reason;
|
|
13072
14084
|
}
|
|
13073
14085
|
if (decision.diagnostic) out.diagnostics.push(decision.diagnostic);
|
|
13074
14086
|
}
|
|
@@ -13094,7 +14106,7 @@ function readFolder(w, input, path, preferred) {
|
|
|
13094
14106
|
}
|
|
13095
14107
|
|
|
13096
14108
|
// src/main.ts
|
|
13097
|
-
var VERSION2 = true ? "0.2.
|
|
14109
|
+
var VERSION2 = true ? "0.2.1" : "0.0.0-dev";
|
|
13098
14110
|
function listRules() {
|
|
13099
14111
|
const width = Math.max(...defaultRules.map((r) => r.id.length));
|
|
13100
14112
|
return defaultRules.map(
|