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.
@@ -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.")