codepbi 0.1.7__py3-none-any.whl
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.
- codepbi/__init__.py +17 -0
- codepbi/ids.py +11 -0
- codepbi/model.py +408 -0
- codepbi/semantic/__init__.py +48 -0
- codepbi/semantic/check_drift.py +48 -0
- codepbi/semantic/check_schema_vs_sql.py +61 -0
- codepbi/semantic/config.py +63 -0
- codepbi/semantic/dbt_sql.py +69 -0
- codepbi/semantic/schema_loader.py +138 -0
- codepbi/semantic/sync_descriptions.py +147 -0
- codepbi/semantic/sync_measures.py +127 -0
- codepbi/semantic/sync_relationships.py +232 -0
- codepbi/semantic/tom_utils.py +120 -0
- codepbi/semantic/validate_tmdl.py +231 -0
- codepbi/writer.py +238 -0
- codepbi-0.1.7.dist-info/METADATA +73 -0
- codepbi-0.1.7.dist-info/RECORD +20 -0
- codepbi-0.1.7.dist-info/WHEEL +5 -0
- codepbi-0.1.7.dist-info/licenses/LICENSE +21 -0
- codepbi-0.1.7.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Apply semantic-model YAML `entities:` -> TMDL relationships, and export back
|
|
2
|
+
# the other way.
|
|
3
|
+
#
|
|
4
|
+
# semantic-model YAML `entities:` blocks are the source of truth for joins --
|
|
5
|
+
# the same role schema YAML plays for columns and each measure's own block plays
|
|
6
|
+
# for measures. A join is declared the way dbt/MetricFlow natively does it: a
|
|
7
|
+
# `primary` entity in the "one" side's file and a `foreign` entity of the same
|
|
8
|
+
# name in the "many" side's file. TMDL is the build artifact: Power BI still
|
|
9
|
+
# reads relationships.tmdl, this module is what keeps it in sync.
|
|
10
|
+
#
|
|
11
|
+
# Cardinality isn't declared here: every relationship this module creates is
|
|
12
|
+
# Many -> One. If your model needs other cardinalities, extend `sync()` with an
|
|
13
|
+
# explicit cardinality field rather than assuming.
|
|
14
|
+
#
|
|
15
|
+
# inactive (config.meta.inactive on the foreign entity): a relationship whose
|
|
16
|
+
# two tables already connect via a different active path. Analysis Services
|
|
17
|
+
# refuses to save a second *active* path between the same two tables (a real
|
|
18
|
+
# engine-level rejection, not something this module checks) -- inactive lets
|
|
19
|
+
# the join exist for DAX measures to opt into via USERELATIONSHIP, without
|
|
20
|
+
# fighting the existing default path.
|
|
21
|
+
|
|
22
|
+
import re
|
|
23
|
+
import uuid
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
|
|
27
|
+
from . import tom_utils
|
|
28
|
+
from .config import TmdlProjectConfig
|
|
29
|
+
from .schema_loader import iter_semantic_models, load_schema
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _entity_name(column_friendly):
|
|
33
|
+
name = re.sub(r"[^A-Za-z0-9]+", "_", column_friendly).strip("_").lower()
|
|
34
|
+
if name.endswith("_id"):
|
|
35
|
+
name = name[:-3]
|
|
36
|
+
return name or "id"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def validate(config: TmdlProjectConfig) -> list[str]:
|
|
40
|
+
"""Every entity's expr must be a real column source on its table, every
|
|
41
|
+
'foreign' entity must have a matching same-named 'primary' entity somewhere.
|
|
42
|
+
|
|
43
|
+
Returns a list of error strings; empty means clean. Called both standalone
|
|
44
|
+
(sync() refuses to touch TOM if this fails) and from validate_tmdl.
|
|
45
|
+
"""
|
|
46
|
+
schema = load_schema(config)
|
|
47
|
+
source_by_table = {t: {c["source"] for c in spec["columns"]} for t, spec in schema.items()}
|
|
48
|
+
|
|
49
|
+
errors = []
|
|
50
|
+
primaries = {} # entity name -> table
|
|
51
|
+
foreigns = [] # (entity name, table)
|
|
52
|
+
|
|
53
|
+
for path, _doc, sm, table_name in iter_semantic_models(config):
|
|
54
|
+
valid_sources = source_by_table.get(table_name, set())
|
|
55
|
+
for e in sm.get("entities") or []:
|
|
56
|
+
if e.get("expr") not in valid_sources:
|
|
57
|
+
errors.append(
|
|
58
|
+
f"{path.name} entity {e['name']!r}: expr {e.get('expr')!r} "
|
|
59
|
+
f"is not a column on {table_name!r}"
|
|
60
|
+
)
|
|
61
|
+
if e["type"] == "primary":
|
|
62
|
+
if e["name"] in primaries:
|
|
63
|
+
errors.append(
|
|
64
|
+
f"duplicate primary entity {e['name']!r}: "
|
|
65
|
+
f"{primaries[e['name']]!r} and {table_name!r}"
|
|
66
|
+
)
|
|
67
|
+
primaries[e["name"]] = table_name
|
|
68
|
+
elif e["type"] == "foreign":
|
|
69
|
+
foreigns.append((e["name"], table_name))
|
|
70
|
+
|
|
71
|
+
for name, table_name in foreigns:
|
|
72
|
+
if name not in primaries:
|
|
73
|
+
errors.append(
|
|
74
|
+
f"foreign entity {name!r} on {table_name!r} has no matching "
|
|
75
|
+
f"primary entity anywhere in the model"
|
|
76
|
+
)
|
|
77
|
+
return errors
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def load(config: TmdlProjectConfig) -> list[dict]:
|
|
81
|
+
"""Reconstruct joins by pairing same-named primary/foreign entities across
|
|
82
|
+
semantic-model YAML -> list of {from, from_column, to, to_column, both_directions?}."""
|
|
83
|
+
schema = load_schema(config)
|
|
84
|
+
display_by_source = {t: {c["source"]: c["display"] for c in spec["columns"]} for t, spec in schema.items()}
|
|
85
|
+
|
|
86
|
+
primaries = {} # entity name -> (table, column_friendly)
|
|
87
|
+
foreigns = [] # (entity name, table, column_friendly, both_directions)
|
|
88
|
+
|
|
89
|
+
for _path, _doc, sm, table_name in iter_semantic_models(config):
|
|
90
|
+
col_lookup = display_by_source.get(table_name, {})
|
|
91
|
+
for e in sm.get("entities") or []:
|
|
92
|
+
col_friendly = col_lookup.get(e["expr"], e["expr"])
|
|
93
|
+
if e["type"] == "primary":
|
|
94
|
+
primaries[e["name"]] = (table_name, col_friendly)
|
|
95
|
+
elif e["type"] == "foreign":
|
|
96
|
+
meta = ((e.get("config") or {}).get("meta")) or {}
|
|
97
|
+
foreigns.append((e["name"], table_name, col_friendly,
|
|
98
|
+
bool(meta.get("both_directions")), bool(meta.get("inactive"))))
|
|
99
|
+
|
|
100
|
+
rels = []
|
|
101
|
+
for name, from_table, from_col, both_dir, inactive in foreigns:
|
|
102
|
+
if name not in primaries:
|
|
103
|
+
continue # validate() already flags this
|
|
104
|
+
to_table, to_col = primaries[name]
|
|
105
|
+
row = {"from": from_table, "from_column": from_col, "to": to_table, "to_column": to_col}
|
|
106
|
+
if both_dir:
|
|
107
|
+
row["both_directions"] = True
|
|
108
|
+
if inactive:
|
|
109
|
+
row["inactive"] = True
|
|
110
|
+
rels.append(row)
|
|
111
|
+
return rels
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _key(r):
|
|
115
|
+
return (r["from"], r["from_column"], r["to"], r["to_column"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def export(config: TmdlProjectConfig):
|
|
119
|
+
"""Dump the current TMDL relationships into each table's semantic-model YAML,
|
|
120
|
+
replacing only its `entities:` block -- dimensions/measures/description untouched.
|
|
121
|
+
Use this to seed the files the first time, or to recover if TMDL and the YAML
|
|
122
|
+
ever disagree about what 'current' means."""
|
|
123
|
+
schema = load_schema(config)
|
|
124
|
+
table_to_model = {name: spec["model"] for name, spec in schema.items()}
|
|
125
|
+
|
|
126
|
+
_, model = tom_utils.load_model(config)
|
|
127
|
+
|
|
128
|
+
entities_by_table = {} # table -> {entity_name: entity dict}
|
|
129
|
+
for r in model.Relationships:
|
|
130
|
+
to_table, to_col = r.ToTable.Name, r.ToColumn.Name
|
|
131
|
+
from_table, from_col = r.FromTable.Name, r.FromColumn.Name
|
|
132
|
+
name = _entity_name(to_col)
|
|
133
|
+
|
|
134
|
+
entities_by_table.setdefault(to_table, {})[name] = {
|
|
135
|
+
"name": name, "type": "primary", "expr": to_col,
|
|
136
|
+
}
|
|
137
|
+
foreign_entity = {"name": name, "type": "foreign", "expr": from_col}
|
|
138
|
+
fmeta = {}
|
|
139
|
+
if str(r.CrossFilteringBehavior) != "OneDirection":
|
|
140
|
+
fmeta["both_directions"] = True
|
|
141
|
+
if not r.IsActive:
|
|
142
|
+
fmeta["inactive"] = True
|
|
143
|
+
if fmeta:
|
|
144
|
+
foreign_entity["config"] = {"meta": fmeta}
|
|
145
|
+
entities_by_table.setdefault(from_table, {})[name] = foreign_entity
|
|
146
|
+
|
|
147
|
+
written = 0
|
|
148
|
+
for table_name, entities in entities_by_table.items():
|
|
149
|
+
dbt_model = table_to_model.get(table_name)
|
|
150
|
+
if not dbt_model:
|
|
151
|
+
print(f' WARNING: "{table_name}" has TMDL relationships but no schema YAML entry, skipping')
|
|
152
|
+
continue
|
|
153
|
+
out_file = config.semantic_models_dir / f"{re.sub(r'[^A-Za-z0-9]+', '_', table_name).strip('_').lower()}.yml"
|
|
154
|
+
if not out_file.exists():
|
|
155
|
+
print(f' WARNING: no semantic model file for "{table_name}" at {out_file.name}, skipping')
|
|
156
|
+
continue
|
|
157
|
+
with open(out_file, encoding="utf-8") as f:
|
|
158
|
+
doc = yaml.safe_load(f)
|
|
159
|
+
ordered = sorted(entities.values(), key=lambda e: e["name"])
|
|
160
|
+
doc["semantic_models"][0]["entities"] = ordered
|
|
161
|
+
text = yaml.safe_dump(doc, default_flow_style=False, sort_keys=False, allow_unicode=True, width=200)
|
|
162
|
+
out_file.write_text(text, encoding="utf-8")
|
|
163
|
+
written += 1
|
|
164
|
+
|
|
165
|
+
print(f"Exported {len(model.Relationships)} relationship(s) across {written} file(s)")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def sync(config: TmdlProjectConfig):
|
|
169
|
+
"""Apply semantic-model YAML entities to TMDL: add/remove/update so TMDL matches."""
|
|
170
|
+
wanted_rows = load(config)
|
|
171
|
+
errors = validate(config)
|
|
172
|
+
if errors:
|
|
173
|
+
for e in errors:
|
|
174
|
+
print(f"RELATIONSHIPS: {e}")
|
|
175
|
+
raise SystemExit(f"semantic-model YAML relationship validation failed: {len(errors)} issue(s)")
|
|
176
|
+
|
|
177
|
+
wanted = {_key(r): r for r in wanted_rows}
|
|
178
|
+
|
|
179
|
+
# load_model() triggers tom_utils._ensure_loaded(), which adds the CLR
|
|
180
|
+
# references -- this import must come after, or it fails with
|
|
181
|
+
# ModuleNotFoundError: No module named 'Microsoft'.
|
|
182
|
+
db, model = tom_utils.load_model(config)
|
|
183
|
+
from Microsoft.AnalysisServices.Tabular import (
|
|
184
|
+
CrossFilteringBehavior, RelationshipEndCardinality, SingleColumnRelationship)
|
|
185
|
+
|
|
186
|
+
existing = {}
|
|
187
|
+
for r in model.Relationships:
|
|
188
|
+
k = (r.FromTable.Name, r.FromColumn.Name, r.ToTable.Name, r.ToColumn.Name)
|
|
189
|
+
existing[k] = r
|
|
190
|
+
|
|
191
|
+
removed = added = updated = 0
|
|
192
|
+
|
|
193
|
+
# Relationships out first -- same discipline as removing a column that one
|
|
194
|
+
# still references, just applied to the relationship side.
|
|
195
|
+
for k, r in list(existing.items()):
|
|
196
|
+
if k not in wanted:
|
|
197
|
+
model.Relationships.Remove(r)
|
|
198
|
+
removed += 1
|
|
199
|
+
|
|
200
|
+
for k, spec in wanted.items():
|
|
201
|
+
want_cf = (CrossFilteringBehavior.BothDirections if spec.get("both_directions")
|
|
202
|
+
else CrossFilteringBehavior.OneDirection)
|
|
203
|
+
want_active = not spec.get("inactive")
|
|
204
|
+
if k in existing:
|
|
205
|
+
r = existing[k]
|
|
206
|
+
changed = False
|
|
207
|
+
if r.CrossFilteringBehavior != want_cf:
|
|
208
|
+
r.CrossFilteringBehavior = want_cf
|
|
209
|
+
changed = True
|
|
210
|
+
if r.IsActive != want_active:
|
|
211
|
+
r.IsActive = want_active
|
|
212
|
+
changed = True
|
|
213
|
+
if changed:
|
|
214
|
+
updated += 1
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
from_table, from_col, to_table, to_col = k
|
|
218
|
+
rel = SingleColumnRelationship()
|
|
219
|
+
rel.Name = str(uuid.uuid4())
|
|
220
|
+
rel.FromTable = model.Tables[from_table]
|
|
221
|
+
rel.FromColumn = model.Tables[from_table].Columns[from_col]
|
|
222
|
+
rel.ToTable = model.Tables[to_table]
|
|
223
|
+
rel.ToColumn = model.Tables[to_table].Columns[to_col]
|
|
224
|
+
rel.FromCardinality = RelationshipEndCardinality.Many
|
|
225
|
+
rel.ToCardinality = RelationshipEndCardinality.One
|
|
226
|
+
rel.CrossFilteringBehavior = want_cf
|
|
227
|
+
rel.IsActive = want_active
|
|
228
|
+
model.Relationships.Add(rel)
|
|
229
|
+
added += 1
|
|
230
|
+
|
|
231
|
+
tom_utils.save_model(config, db)
|
|
232
|
+
print(f"RELATIONSHIPS: {removed} removed, {added} added, {updated} updated.")
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# Shared TOM (Tabular Object Model) utilities via pythonnet.
|
|
2
|
+
#
|
|
3
|
+
# Requires the Microsoft.AnalysisServices .NET DLLs (NuGet package
|
|
4
|
+
# Microsoft.AnalysisServices.NetCore.retail.amd64) at config.tom_lib_dir. Not
|
|
5
|
+
# redistributed by this package -- obtain them yourself and point tom_lib_dir
|
|
6
|
+
# at the folder you placed them in.
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
from .config import TmdlProjectConfig
|
|
11
|
+
|
|
12
|
+
_DLLS = [
|
|
13
|
+
"Microsoft.AnalysisServices.Core.dll",
|
|
14
|
+
"Microsoft.AnalysisServices.Tabular.dll",
|
|
15
|
+
"Microsoft.AnalysisServices.Tabular.Json.dll",
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
_loaded = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _ensure_loaded(config: TmdlProjectConfig) -> None:
|
|
22
|
+
global _loaded
|
|
23
|
+
if _loaded:
|
|
24
|
+
return
|
|
25
|
+
|
|
26
|
+
import clr
|
|
27
|
+
|
|
28
|
+
for name in _DLLS:
|
|
29
|
+
dll = config.tom_lib_dir / name
|
|
30
|
+
if dll.exists():
|
|
31
|
+
clr.AddReference(str(dll))
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
from Microsoft.AnalysisServices.Tabular import TmdlSerializer # noqa: F401
|
|
35
|
+
except ImportError:
|
|
36
|
+
raise SystemExit(
|
|
37
|
+
f"TOM DLLs not found in {config.tom_lib_dir}. Install the "
|
|
38
|
+
f"Microsoft.AnalysisServices.NetCore.retail.amd64 NuGet package and point "
|
|
39
|
+
f"tom_lib_dir at its lib folder."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_loaded = True
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def load_model(config: TmdlProjectConfig):
|
|
46
|
+
_ensure_loaded(config)
|
|
47
|
+
from Microsoft.AnalysisServices.Tabular import TmdlSerializer
|
|
48
|
+
|
|
49
|
+
db = TmdlSerializer.DeserializeDatabaseFromFolder(str(config.semantic_model_definition_dir))
|
|
50
|
+
return db, db.Model
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def save_model(config: TmdlProjectConfig, db) -> None:
|
|
54
|
+
_ensure_loaded(config)
|
|
55
|
+
from Microsoft.AnalysisServices.Tabular import TmdlSerializer
|
|
56
|
+
|
|
57
|
+
TmdlSerializer.SerializeDatabaseToFolder(db, str(config.semantic_model_definition_dir))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def set_if_changed(obj, attr, want, normalize=lambda v: (v or "")):
|
|
61
|
+
"""Set obj.attr to want if it differs (after normalize); return whether it changed.
|
|
62
|
+
|
|
63
|
+
Shared shape behind every sync's per-property TOM update: compare, set, flag.
|
|
64
|
+
normalize handles the None-vs-'' and .strip() variations between properties
|
|
65
|
+
(an Expression is stripped, FormatString/DisplayFolder are not).
|
|
66
|
+
"""
|
|
67
|
+
if normalize(getattr(obj, attr)) != want:
|
|
68
|
+
setattr(obj, attr, want)
|
|
69
|
+
return True
|
|
70
|
+
return False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def pbi_table_of(meta: dict) -> str | None:
|
|
74
|
+
"""Derive the PBI table name from schema YAML meta {entity, grain}."""
|
|
75
|
+
if not meta:
|
|
76
|
+
return None
|
|
77
|
+
entity = meta.get("entity")
|
|
78
|
+
if not entity:
|
|
79
|
+
return None
|
|
80
|
+
grain = meta.get("grain")
|
|
81
|
+
return f"{entity} - {grain}" if grain else entity
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
_ITEM_RE = re.compile(r'Item="([^"]+)"')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _source_model(table) -> str | None:
|
|
88
|
+
"""dbt model name behind a table, read from its M partition expression."""
|
|
89
|
+
for p in table.Partitions:
|
|
90
|
+
expr = getattr(p.Source, "Expression", None)
|
|
91
|
+
if expr:
|
|
92
|
+
m = _ITEM_RE.search(str(expr))
|
|
93
|
+
if m:
|
|
94
|
+
return m.group(1)
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_tables(config: TmdlProjectConfig, skip: bool = True) -> dict:
|
|
99
|
+
"""Read the model into plain dicts: {table_name: {model, columns}}.
|
|
100
|
+
|
|
101
|
+
columns maps display name -> {source, hidden, folder, desc, data_type}.
|
|
102
|
+
Loads the whole model once via TOM rather than parsing TMDL text.
|
|
103
|
+
"""
|
|
104
|
+
_, model = load_model(config)
|
|
105
|
+
out = {}
|
|
106
|
+
for table in model.Tables:
|
|
107
|
+
if skip and table.Name in config.skip_tables:
|
|
108
|
+
continue
|
|
109
|
+
columns = {}
|
|
110
|
+
for col in table.Columns:
|
|
111
|
+
dt = str(col.DataType)
|
|
112
|
+
columns[col.Name] = {
|
|
113
|
+
"source": col.SourceColumn or None,
|
|
114
|
+
"hidden": bool(col.IsHidden),
|
|
115
|
+
"folder": col.DisplayFolder or "",
|
|
116
|
+
"desc": col.Description or "",
|
|
117
|
+
"data_type": dt[0].lower() + dt[1:],
|
|
118
|
+
}
|
|
119
|
+
out[table.Name] = {"model": _source_model(table), "columns": columns}
|
|
120
|
+
return out
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# Validates the TMDL semantic model: file hygiene, duplicate lineageTags,
|
|
2
|
+
# ambiguous display names, duplicate/ambiguous relationships, DAX references
|
|
3
|
+
# that don't resolve, and measures missing from semantic-model YAML.
|
|
4
|
+
# Raises SystemExit on any failure. Called both standalone and from
|
|
5
|
+
# sync_descriptions.sync() at the end of a column sync.
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
from collections import Counter, defaultdict
|
|
9
|
+
|
|
10
|
+
from . import tom_utils
|
|
11
|
+
from .config import TmdlProjectConfig
|
|
12
|
+
|
|
13
|
+
_BAD_LINE_LITERALS = {r"\r", r"\n", r"\t", r"\\r", r"\\n", r"\\t"}
|
|
14
|
+
|
|
15
|
+
# A DAX column reference: 'Table Name'[Column Name]
|
|
16
|
+
_DAX_REF_RE = re.compile(r"'([^']+)'\[([^\]]+)\]")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _check_file_hygiene(config: TmdlProjectConfig) -> list[str]:
|
|
20
|
+
"""Byte-level tripwires for hand edits: BOMs, trailing whitespace, stray escapes."""
|
|
21
|
+
errors = []
|
|
22
|
+
for path in sorted(config.tables_dir.glob("*.tmdl")):
|
|
23
|
+
raw = path.read_bytes()
|
|
24
|
+
if raw.startswith(b"\xef\xbb\xbf"):
|
|
25
|
+
errors.append(f"{path.name}: BOM at file start")
|
|
26
|
+
sep = b"\r\n" if b"\r\n" in raw else b"\n"
|
|
27
|
+
for i, line in enumerate(raw.split(sep), 1):
|
|
28
|
+
try:
|
|
29
|
+
text = line.decode("utf-8")
|
|
30
|
+
except UnicodeDecodeError:
|
|
31
|
+
errors.append(f"{path.name}:{i}: non-UTF-8 bytes")
|
|
32
|
+
continue
|
|
33
|
+
if text.strip() in _BAD_LINE_LITERALS:
|
|
34
|
+
errors.append(
|
|
35
|
+
f"{path.name}:{i}: literal escape sequence as line content ({text.strip()!r})"
|
|
36
|
+
)
|
|
37
|
+
if text != text.rstrip() and text.strip():
|
|
38
|
+
errors.append(f"{path.name}:{i}: trailing whitespace")
|
|
39
|
+
return errors
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ambiguous_paths(model):
|
|
43
|
+
"""Table pairs reachable by more than one filter path.
|
|
44
|
+
|
|
45
|
+
Filters propagate from the 'one' side to the 'many' side -- in TMDL that is
|
|
46
|
+
toColumn -> fromColumn -- and both ways on a bidirectional relationship. Power
|
|
47
|
+
BI permits exactly one path between any two tables; more than one and it
|
|
48
|
+
refuses to load the model.
|
|
49
|
+
|
|
50
|
+
Returns [(from_table, to_table, [path, ...]), ...] with each pair reported once.
|
|
51
|
+
"""
|
|
52
|
+
edges = defaultdict(set)
|
|
53
|
+
for rel in model.Relationships:
|
|
54
|
+
if not rel.IsActive:
|
|
55
|
+
continue
|
|
56
|
+
one, many = rel.ToTable.Name, rel.FromTable.Name
|
|
57
|
+
edges[one].add(many)
|
|
58
|
+
if str(rel.CrossFilteringBehavior) != "OneDirection":
|
|
59
|
+
edges[many].add(one)
|
|
60
|
+
|
|
61
|
+
def walk(src, dst, seen):
|
|
62
|
+
if src == dst:
|
|
63
|
+
return [[src]]
|
|
64
|
+
found = []
|
|
65
|
+
for nxt in edges.get(src, ()):
|
|
66
|
+
if nxt not in seen:
|
|
67
|
+
found += [[src] + p for p in walk(nxt, dst, seen | {nxt})]
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
names = [t.Name for t in model.Tables]
|
|
71
|
+
out = []
|
|
72
|
+
for a in names:
|
|
73
|
+
for b in names:
|
|
74
|
+
if a == b:
|
|
75
|
+
continue
|
|
76
|
+
routes = walk(a, b, {a})
|
|
77
|
+
if len(routes) > 1:
|
|
78
|
+
out.append((a, b, routes))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def validate_tmdl(config: TmdlProjectConfig) -> None:
|
|
83
|
+
"""Validate the semantic model. Raises SystemExit on any failure.
|
|
84
|
+
|
|
85
|
+
A dangling relationship endpoint makes TOM deserialization fail outright,
|
|
86
|
+
so that class of error is caught by simply loading the model.
|
|
87
|
+
"""
|
|
88
|
+
from .sync_measures import load_by_table as _load_measures_by_table
|
|
89
|
+
from .sync_relationships import validate as validate_relationships
|
|
90
|
+
|
|
91
|
+
errors = _check_file_hygiene(config)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
_, model = tom_utils.load_model(config)
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
for e in errors:
|
|
97
|
+
print(f"TMDL VALIDATE: {e}")
|
|
98
|
+
raise SystemExit(
|
|
99
|
+
f"TMDL failed to load -- a reference does not resolve (often a relationship "
|
|
100
|
+
f"pointing at a deleted table or renamed column):\n {exc}"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
tables = tom_utils.read_tables(config)
|
|
104
|
+
|
|
105
|
+
# Duplicate lineageTags across the model
|
|
106
|
+
tags = defaultdict(list)
|
|
107
|
+
for table in model.Tables:
|
|
108
|
+
for col in table.Columns:
|
|
109
|
+
if col.LineageTag:
|
|
110
|
+
tags[col.LineageTag].append(f"{table.Name}[{col.Name}]")
|
|
111
|
+
for tag, locs in tags.items():
|
|
112
|
+
if len(locs) > 1:
|
|
113
|
+
errors.append(f"duplicate lineageTag {tag!r} at {', '.join(locs)}")
|
|
114
|
+
|
|
115
|
+
# No display name may be visible on two tables at once. Two variants:
|
|
116
|
+
# A: same name, different sourceColumn -> genuinely different things
|
|
117
|
+
# B: same name, same sourceColumn -> the same thing in two places
|
|
118
|
+
# Both confuse the field list, so both are errors. Measures share the same
|
|
119
|
+
# namespace as columns (a table object model requires every column AND
|
|
120
|
+
# measure name to be unique within that table), so they go in the same map --
|
|
121
|
+
# a column/measure pair with the same name on the same table is a Desktop
|
|
122
|
+
# "Issues were found" failure on open, not just a field-list ambiguity.
|
|
123
|
+
name_map = defaultdict(list)
|
|
124
|
+
for tname, t in tables.items():
|
|
125
|
+
for cname, c in t["columns"].items():
|
|
126
|
+
name_map[cname].append((tname, c["source"] or "", c["hidden"], "column"))
|
|
127
|
+
for table in model.Tables:
|
|
128
|
+
for measure in table.Measures:
|
|
129
|
+
name_map[measure.Name].append((table.Name, "", False, "measure"))
|
|
130
|
+
|
|
131
|
+
for name, entries in name_map.items():
|
|
132
|
+
by_table = defaultdict(list)
|
|
133
|
+
for t, s, h, kind in entries:
|
|
134
|
+
by_table[t].append((s, h, kind))
|
|
135
|
+
for tname, items in by_table.items():
|
|
136
|
+
if len(items) > 1:
|
|
137
|
+
kinds = ", ".join(f'{kind} ({"hidden" if h else "visible"})' for _, h, kind in items)
|
|
138
|
+
errors.append(
|
|
139
|
+
f"{name!r} defined more than once on table {tname!r}: {kinds}. "
|
|
140
|
+
f"A column and a measure (or two measures) cannot share a name "
|
|
141
|
+
f"on the same table -- rename or remove one."
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
visible = [(t, s) for t, s, h, kind in entries if not h]
|
|
145
|
+
if len(visible) < 2:
|
|
146
|
+
continue
|
|
147
|
+
if len({s for _, s in visible}) > 1:
|
|
148
|
+
details = ", ".join(f'{t}[{s or "-"}]' for t, s in visible)
|
|
149
|
+
errors.append(
|
|
150
|
+
f"ambiguous display name {name!r} on visible tables with "
|
|
151
|
+
f"different sourceColumns: {details}. Rename to disambiguate grain."
|
|
152
|
+
)
|
|
153
|
+
else:
|
|
154
|
+
errors.append(
|
|
155
|
+
f"column {name!r} visible on multiple tables (same source): "
|
|
156
|
+
f"{sorted(t for t, _ in visible)}. Either hide it where a "
|
|
157
|
+
f"relationship already supplies it, or rename to make the grain "
|
|
158
|
+
f"explicit. Do not hide it if nothing else supplies it."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# semantic-model YAML owns measures. A measure only in TMDL -- hand-written, or
|
|
162
|
+
# built in Power BI Desktop -- is outside source control, so flag it rather than
|
|
163
|
+
# delete it: destroying someone's Desktop work on a sync would be worse.
|
|
164
|
+
in_yaml = {
|
|
165
|
+
(table, m["name"])
|
|
166
|
+
for table, entries in _load_measures_by_table(config).items()
|
|
167
|
+
for m in entries
|
|
168
|
+
}
|
|
169
|
+
for table in model.Tables:
|
|
170
|
+
for measure in table.Measures:
|
|
171
|
+
if (table.Name, measure.Name) not in in_yaml:
|
|
172
|
+
errors.append(
|
|
173
|
+
f"measure {table.Name}[{measure.Name}] is not in semantic-model YAML. "
|
|
174
|
+
f"Add it there (semantic-model YAML is the source of truth for "
|
|
175
|
+
f"measures) or delete it from TMDL."
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Every 'Table'[Column] reference in measure DAX must resolve. Renaming a
|
|
179
|
+
# column only rewrites the column header, so measures silently rot otherwise.
|
|
180
|
+
known = {
|
|
181
|
+
t.Name: {c.Name for c in t.Columns} | {x.Name for x in t.Measures}
|
|
182
|
+
for t in model.Tables
|
|
183
|
+
}
|
|
184
|
+
for table in model.Tables:
|
|
185
|
+
for measure in table.Measures:
|
|
186
|
+
for ref_tbl, ref_col in _DAX_REF_RE.findall(str(measure.Expression)):
|
|
187
|
+
if ref_tbl not in known:
|
|
188
|
+
errors.append(
|
|
189
|
+
f"measure {table.Name}[{measure.Name}] references table "
|
|
190
|
+
f"{ref_tbl!r}, which does not exist."
|
|
191
|
+
)
|
|
192
|
+
elif ref_col not in known[ref_tbl]:
|
|
193
|
+
errors.append(
|
|
194
|
+
f"measure {table.Name}[{measure.Name}] references "
|
|
195
|
+
f"{ref_tbl!r}[{ref_col!r}], which does not exist. "
|
|
196
|
+
f"A rename only rewrites the column header -- update the DAX too."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# 2+ active relationships between the same table pair (PBI ambiguity)
|
|
200
|
+
pair_counts = Counter(
|
|
201
|
+
frozenset((r.FromTable.Name, r.ToTable.Name))
|
|
202
|
+
for r in model.Relationships if r.IsActive
|
|
203
|
+
)
|
|
204
|
+
for pair, n in pair_counts.items():
|
|
205
|
+
if n >= 2:
|
|
206
|
+
errors.append(
|
|
207
|
+
f"{n} active relationships between {sorted(pair)}. "
|
|
208
|
+
f"PBI will throw 'ambiguous paths'. Mark at most one active per pair."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# More than one filter path between any two tables, however many hops -- the
|
|
212
|
+
# pair-count check above only sees duplicate relationships on a single pair,
|
|
213
|
+
# so it misses longer cycles.
|
|
214
|
+
for a, b, routes in _ambiguous_paths(model):
|
|
215
|
+
shown = "; ".join(" -> ".join(p) for p in routes[:3])
|
|
216
|
+
errors.append(
|
|
217
|
+
f"{len(routes)} filter paths from {a!r} to {b!r}: {shown}. "
|
|
218
|
+
f"PBI allows only one. Drop a relationship or mark one inactive."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# semantic-model YAML entities are the join authority; every entity's expr
|
|
222
|
+
# must name a real column, and every foreign entity needs a matching primary
|
|
223
|
+
# elsewhere, or a typo silently drops a join instead of failing loudly.
|
|
224
|
+
for e in validate_relationships(config):
|
|
225
|
+
errors.append(f"semantic model: {e}")
|
|
226
|
+
|
|
227
|
+
if errors:
|
|
228
|
+
for e in errors:
|
|
229
|
+
print(f"TMDL VALIDATE: {e}")
|
|
230
|
+
raise SystemExit(f"TMDL validation failed: {len(errors)} issue(s)")
|
|
231
|
+
print("TMDL: validation passed.")
|