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,69 @@
|
|
|
1
|
+
# Generic helpers for reading a dbt model's own compiled SELECT list, used by
|
|
2
|
+
# check_schema_vs_sql. Requires `dbt compile` (or `dbt run`) to have already
|
|
3
|
+
# produced target/manifest.json and target/compiled/... .
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from sqlglot import exp, parse_one
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def sql_exprs(sql: str, dialect: str = "tsql") -> dict:
|
|
12
|
+
"""A model's own SELECT list: {output column: raw SQL expression text}."""
|
|
13
|
+
stmt = parse_one(sql, dialect=dialect)
|
|
14
|
+
cte_map = {
|
|
15
|
+
cte.alias.lower(): {
|
|
16
|
+
s.alias_or_name.lower(): str(s.this if isinstance(s, exp.Alias) else s)
|
|
17
|
+
for s in cte.this.selects if s.alias_or_name
|
|
18
|
+
}
|
|
19
|
+
for cte in stmt.find_all(exp.CTE)
|
|
20
|
+
}
|
|
21
|
+
result = {}
|
|
22
|
+
for s in stmt.selects:
|
|
23
|
+
alias = s.alias_or_name
|
|
24
|
+
expr = s.this if isinstance(s, exp.Alias) else s
|
|
25
|
+
if isinstance(expr, exp.Column) and not expr.table:
|
|
26
|
+
result[alias] = next(
|
|
27
|
+
(c[expr.name.lower()] for c in cte_map.values() if expr.name.lower() in c),
|
|
28
|
+
str(expr),
|
|
29
|
+
)
|
|
30
|
+
else:
|
|
31
|
+
result[alias] = str(expr)
|
|
32
|
+
return result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_model_data(manifest_path: Path, compiled_dir: Path) -> dict:
|
|
36
|
+
"""dbt model name -> {dbt_db, dbt_schema, sql, compiled_path, compiled_exists}.
|
|
37
|
+
|
|
38
|
+
manifest_path: path to target/manifest.json.
|
|
39
|
+
compiled_dir: the folder compiled SQL is actually written under -- dbt's real
|
|
40
|
+
layout is target/compiled/<dbt_project_name>/<model-paths>/..., and the
|
|
41
|
+
project name and model-paths config aren't generically derivable, so the
|
|
42
|
+
caller must resolve this themselves (it's just target/compiled/<your
|
|
43
|
+
project name>/models for the common case).
|
|
44
|
+
|
|
45
|
+
The compiled path comes from the manifest node's own `path` (relative to
|
|
46
|
+
model-paths), not from the bare model name -- dbt writes to
|
|
47
|
+
<compiled_dir>/<subfolder>/<name>.sql, so a name-only lookup misses the
|
|
48
|
+
moment models live in folders.
|
|
49
|
+
|
|
50
|
+
The raw_code fallback is kept for models that genuinely have no compiled
|
|
51
|
+
output, but it is Jinja, not SQL: sqlglot parses it into garbage rather than
|
|
52
|
+
failing, which yields silently wrong results. compiled_exists lets callers
|
|
53
|
+
refuse instead.
|
|
54
|
+
"""
|
|
55
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
56
|
+
result = {}
|
|
57
|
+
for node in manifest["nodes"].values():
|
|
58
|
+
if node.get("resource_type") != "model":
|
|
59
|
+
continue
|
|
60
|
+
p = compiled_dir / node.get("path", f"{node['name']}.sql")
|
|
61
|
+
exists = p.exists()
|
|
62
|
+
result[node["name"]] = {
|
|
63
|
+
"dbt_db": node.get("database", ""),
|
|
64
|
+
"dbt_schema": node.get("schema", ""),
|
|
65
|
+
"sql": p.read_text(encoding="utf-8") if exists else node.get("raw_code", ""),
|
|
66
|
+
"compiled_path": p,
|
|
67
|
+
"compiled_exists": exists,
|
|
68
|
+
}
|
|
69
|
+
return result
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# Shared dbt schema YAML parser. Every generator/sync function that needs column or
|
|
2
|
+
# table metadata calls `load_schema` from here instead of parsing schema YAML
|
|
3
|
+
# itself -- schema YAML is the single source of truth for column metadata, so
|
|
4
|
+
# there is exactly one reader for it.
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
from . import tom_utils
|
|
9
|
+
from .config import TmdlProjectConfig
|
|
10
|
+
|
|
11
|
+
# schema YAML data_type -> TMDL sourceProviderType + formatString.
|
|
12
|
+
# 'format' is only the default for a bare data_type; meta.format_string overrides it.
|
|
13
|
+
# double has no default format on purpose: a money column must declare one, or it
|
|
14
|
+
# renders as a raw number with no totals.
|
|
15
|
+
DATATYPE_MAP = {
|
|
16
|
+
"int64": {"provider": "int", "format": "0"},
|
|
17
|
+
"string": {"provider": "nvarchar(255)", "format": None},
|
|
18
|
+
"dateTime": {"provider": "datetime2", "format": "General Date"},
|
|
19
|
+
"boolean": {"provider": "bit", "format": '"TRUE";"TRUE";"FALSE"'},
|
|
20
|
+
"double": {"provider": "float", "format": None},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# meta.summarize_by -> TOM AggregateFunction member name. 'none' makes a numeric column
|
|
24
|
+
# behave as an attribute: no implicit aggregation and no totals row. That is correct for
|
|
25
|
+
# semi-additive snapshots (a point-in-time balance must not be summed across periods) and
|
|
26
|
+
# wrong for an ordinary money column.
|
|
27
|
+
SUMMARIZE_MAP = {
|
|
28
|
+
"none": "None", "default": "Default", "sum": "Sum", "average": "Average",
|
|
29
|
+
"count": "Count", "distinctCount": "DistinctCount", "min": "Min", "max": "Max",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
# TOM DataType name -> schema YAML data_type, for the drift check in sync_descriptions.
|
|
33
|
+
TOM_TO_YAML = {"String": "string", "Int64": "int64", "DateTime": "dateTime",
|
|
34
|
+
"Double": "double", "Boolean": "boolean"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def aggregate_function(value, source):
|
|
38
|
+
from Microsoft.AnalysisServices.Tabular import AggregateFunction
|
|
39
|
+
|
|
40
|
+
if value not in SUMMARIZE_MAP:
|
|
41
|
+
raise SystemExit(
|
|
42
|
+
f"Unknown summarize_by {value!r} for column {source!r}. "
|
|
43
|
+
f"Must be one of: {sorted(SUMMARIZE_MAP)}"
|
|
44
|
+
)
|
|
45
|
+
return getattr(AggregateFunction, SUMMARIZE_MAP[value])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _model_blocks(config: TmdlProjectConfig):
|
|
49
|
+
"""Every `models:` entry across the configured schema YAML glob, sorted by model name.
|
|
50
|
+
|
|
51
|
+
Lets schema YAML be split across multiple files (e.g. per layer) without every
|
|
52
|
+
caller needing to know the split. semantic_models_subdir and any `sources.yml`
|
|
53
|
+
are excluded: they are different documents that also carry a top-level `models`-
|
|
54
|
+
shaped key we would otherwise misread.
|
|
55
|
+
"""
|
|
56
|
+
sm_dir = config.semantic_models_dir
|
|
57
|
+
blocks, seen = [], {}
|
|
58
|
+
for path in sorted(config.dbt_project_dir.glob(config.models_glob)):
|
|
59
|
+
if sm_dir in path.parents or path.name == "sources.yml":
|
|
60
|
+
continue
|
|
61
|
+
with open(path, encoding="utf-8") as f:
|
|
62
|
+
doc = yaml.safe_load(f) or {}
|
|
63
|
+
for model in doc.get("models", []) or []:
|
|
64
|
+
name = model.get("name")
|
|
65
|
+
if name in seen:
|
|
66
|
+
raise SystemExit(
|
|
67
|
+
f"duplicate model {name!r} in schema YAML: "
|
|
68
|
+
f"{seen[name]} and {path}. "
|
|
69
|
+
"A silent last-write-wins here loses a model's column metadata."
|
|
70
|
+
)
|
|
71
|
+
seen[name] = path
|
|
72
|
+
blocks.append(model)
|
|
73
|
+
blocks.sort(key=lambda m: m.get("name") or "")
|
|
74
|
+
return blocks
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_schema(config: TmdlProjectConfig) -> dict:
|
|
78
|
+
"""Schema YAML -> {pbi_table: {'entity','grain','columns': [colinfo]}}.
|
|
79
|
+
|
|
80
|
+
colinfo carries everything TMDL needs; the schema YAML is the only source of truth.
|
|
81
|
+
"""
|
|
82
|
+
out = {}
|
|
83
|
+
for model in _model_blocks(config):
|
|
84
|
+
meta = model.get("meta", {}) or {}
|
|
85
|
+
pbi_table = tom_utils.pbi_table_of(meta)
|
|
86
|
+
if not pbi_table:
|
|
87
|
+
continue
|
|
88
|
+
cols = []
|
|
89
|
+
for col in model.get("columns", []):
|
|
90
|
+
cm = col.get("meta", {}) or {}
|
|
91
|
+
colinfo = {
|
|
92
|
+
"source": col["name"],
|
|
93
|
+
"display": cm.get("display_name", col["name"]),
|
|
94
|
+
"desc": col.get("description", ""),
|
|
95
|
+
"folder": cm.get("displayFolder", ""),
|
|
96
|
+
"hidden": bool(cm.get("hidden", False)),
|
|
97
|
+
"data_type": cm.get("data_type"),
|
|
98
|
+
# None means "not declared" -> leave whatever TMDL has. Declaring these
|
|
99
|
+
# is what makes a money column survive a table rebuild.
|
|
100
|
+
"format_string": cm.get("format_string"),
|
|
101
|
+
"summarize_by": cm.get("summarize_by"),
|
|
102
|
+
}
|
|
103
|
+
for key, default in config.extra_column_meta.items():
|
|
104
|
+
colinfo[key] = cm.get(key, default)
|
|
105
|
+
cols.append(colinfo)
|
|
106
|
+
table_info = {
|
|
107
|
+
"entity": meta.get("entity", ""),
|
|
108
|
+
"grain": meta.get("grain", ""),
|
|
109
|
+
# The dbt model name, so generators can emit ref() without reading the
|
|
110
|
+
# TMDL partition to find out which model backs the table.
|
|
111
|
+
"model": model["name"],
|
|
112
|
+
"columns": cols,
|
|
113
|
+
}
|
|
114
|
+
for key, default in config.extra_table_meta.items():
|
|
115
|
+
table_info[key] = meta.get(key, default)
|
|
116
|
+
out[pbi_table] = table_info
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def model_to_table(config: TmdlProjectConfig) -> dict:
|
|
121
|
+
return {spec["model"]: name for name, spec in load_schema(config).items()}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def iter_semantic_models(config: TmdlProjectConfig):
|
|
125
|
+
"""Yield (path, doc, sm, table_name) for every semantic-model YAML file with a
|
|
126
|
+
table resolvable via schema YAML. Shared by every function that reads
|
|
127
|
+
entities/measures out of those files, so the glob-and-parse loop exists once."""
|
|
128
|
+
m2t = model_to_table(config)
|
|
129
|
+
for path in sorted(config.semantic_models_dir.glob("*.yml")):
|
|
130
|
+
if path.name.startswith("_"):
|
|
131
|
+
continue
|
|
132
|
+
with open(path, encoding="utf-8") as f:
|
|
133
|
+
doc = yaml.safe_load(f) or {}
|
|
134
|
+
for sm in doc.get("semantic_models") or []:
|
|
135
|
+
dbt_model = sm["model"][len("ref('"):-len("')")]
|
|
136
|
+
table_name = m2t.get(dbt_model)
|
|
137
|
+
if table_name:
|
|
138
|
+
yield path, doc, sm, table_name
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Sync schema YAML -> TMDL columns, descriptions, folders, hidden flags, format,
|
|
2
|
+
# summarization. Inserts columns that don't exist yet. Descriptions are tagged
|
|
3
|
+
# [synced] so hand-written ones (untagged) are never overwritten.
|
|
4
|
+
#
|
|
5
|
+
# TMDL is read and written through TOM (see tom_utils) -- never regex.
|
|
6
|
+
|
|
7
|
+
import uuid
|
|
8
|
+
|
|
9
|
+
from . import tom_utils
|
|
10
|
+
from .config import TmdlProjectConfig
|
|
11
|
+
from .schema_loader import DATATYPE_MAP, TOM_TO_YAML, aggregate_function, load_schema
|
|
12
|
+
from .validate_tmdl import validate_tmdl
|
|
13
|
+
|
|
14
|
+
SYNCED_TAG = "[synced]"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _new_column(info):
|
|
18
|
+
"""Build a TOM DataColumn from a schema YAML column entry."""
|
|
19
|
+
from Microsoft.AnalysisServices.Tabular import AggregateFunction, Annotation, DataColumn, DataType
|
|
20
|
+
|
|
21
|
+
data_type = info["data_type"]
|
|
22
|
+
if data_type not in DATATYPE_MAP:
|
|
23
|
+
raise SystemExit(
|
|
24
|
+
f"Unknown data_type {data_type!r} for column {info['source']!r}. "
|
|
25
|
+
f"Must be one of: {sorted(DATATYPE_MAP)}"
|
|
26
|
+
)
|
|
27
|
+
spec = DATATYPE_MAP[data_type]
|
|
28
|
+
|
|
29
|
+
col = DataColumn()
|
|
30
|
+
col.Name = info["display"]
|
|
31
|
+
col.DataType = getattr(DataType, data_type[0].upper() + data_type[1:])
|
|
32
|
+
col.SourceColumn = info["source"]
|
|
33
|
+
col.SourceProviderType = spec["provider"]
|
|
34
|
+
fmt = info["format_string"] or spec["format"]
|
|
35
|
+
if fmt:
|
|
36
|
+
col.FormatString = fmt
|
|
37
|
+
if info["folder"]:
|
|
38
|
+
col.DisplayFolder = info["folder"]
|
|
39
|
+
if info["hidden"]:
|
|
40
|
+
col.IsHidden = True
|
|
41
|
+
if info["desc"]:
|
|
42
|
+
col.Description = info["desc"]
|
|
43
|
+
col.LineageTag = str(uuid.uuid4())
|
|
44
|
+
col.SummarizeBy = (
|
|
45
|
+
aggregate_function(info["summarize_by"], info["source"])
|
|
46
|
+
if info["summarize_by"]
|
|
47
|
+
else getattr(AggregateFunction, "None")
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
ann = Annotation()
|
|
51
|
+
ann.Name = "SummarizationSetBy"
|
|
52
|
+
ann.Value = "Automatic"
|
|
53
|
+
col.Annotations.Add(ann)
|
|
54
|
+
return col
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def sync(config: TmdlProjectConfig):
|
|
58
|
+
"""Sync schema YAML -> TMDL: display names, descriptions, folders, hidden flags.
|
|
59
|
+
|
|
60
|
+
Inserts columns that don't exist yet. Applies schema YAML first, then validates
|
|
61
|
+
the result -- validating first would deadlock, since the fix for a TMDL error
|
|
62
|
+
often lives in schema YAML and can only land by syncing.
|
|
63
|
+
"""
|
|
64
|
+
wanted = load_schema(config)
|
|
65
|
+
db, model = tom_utils.load_model(config)
|
|
66
|
+
|
|
67
|
+
inserts, renames, updates = [], [], 0
|
|
68
|
+
type_drift = []
|
|
69
|
+
|
|
70
|
+
for pbi_table, spec in wanted.items():
|
|
71
|
+
if not model.Tables.ContainsName(pbi_table):
|
|
72
|
+
continue
|
|
73
|
+
table = model.Tables[pbi_table]
|
|
74
|
+
by_source = {c.SourceColumn: c for c in table.Columns if c.SourceColumn}
|
|
75
|
+
|
|
76
|
+
for info in spec["columns"]:
|
|
77
|
+
col = by_source.get(info["source"])
|
|
78
|
+
|
|
79
|
+
# A column's type is only ever set at creation, so a declaration that
|
|
80
|
+
# disagrees with TMDL is silently inert. This also catches Power BI
|
|
81
|
+
# Desktop retyping a column behind your back (it does this sometimes
|
|
82
|
+
# when a view's inferred type looks different on refresh).
|
|
83
|
+
if col is not None and info["data_type"]:
|
|
84
|
+
actual = TOM_TO_YAML.get(str(col.DataType))
|
|
85
|
+
if actual and actual != info["data_type"]:
|
|
86
|
+
type_drift.append(
|
|
87
|
+
f"{pbi_table}.{info['display']} ({info['source']}): "
|
|
88
|
+
f"schema YAML says {info['data_type']}, TMDL has {actual}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
if col is None:
|
|
92
|
+
if not info["data_type"]:
|
|
93
|
+
raise SystemExit(
|
|
94
|
+
f"{pbi_table}: new column {info['source']!r} missing "
|
|
95
|
+
f"meta.data_type in schema YAML (required for auto-insert)"
|
|
96
|
+
)
|
|
97
|
+
table.Columns.Add(_new_column(info))
|
|
98
|
+
inserts.append(f"{pbi_table}.{info['source']}")
|
|
99
|
+
continue
|
|
100
|
+
|
|
101
|
+
if col.Name != info["display"]:
|
|
102
|
+
renames.append(f"{pbi_table}: {col.Name!r} -> {info['display']!r}")
|
|
103
|
+
col.Name = info["display"]
|
|
104
|
+
|
|
105
|
+
# Only manage descriptions this sync owns -- leave untagged ones alone.
|
|
106
|
+
current = col.Description or ""
|
|
107
|
+
if info["desc"] and (not current or SYNCED_TAG in current):
|
|
108
|
+
updates += tom_utils.set_if_changed(col, "Description", f"{SYNCED_TAG} {info['desc']}")
|
|
109
|
+
|
|
110
|
+
updates += tom_utils.set_if_changed(col, "IsHidden", info["hidden"], bool)
|
|
111
|
+
|
|
112
|
+
if info["folder"]:
|
|
113
|
+
updates += tom_utils.set_if_changed(col, "DisplayFolder", info["folder"])
|
|
114
|
+
|
|
115
|
+
# Only touch these where schema YAML declares them -- applying a default to
|
|
116
|
+
# every column would strip the format off columns that already carry one.
|
|
117
|
+
if info["format_string"]:
|
|
118
|
+
updates += tom_utils.set_if_changed(col, "FormatString", info["format_string"])
|
|
119
|
+
|
|
120
|
+
if info["summarize_by"]:
|
|
121
|
+
want = aggregate_function(info["summarize_by"], info["source"])
|
|
122
|
+
updates += tom_utils.set_if_changed(col, "SummarizeBy", want, lambda v: v)
|
|
123
|
+
|
|
124
|
+
tom_utils.save_model(config, db)
|
|
125
|
+
|
|
126
|
+
print(f"TMDL: {len(inserts)} new column(s) inserted.")
|
|
127
|
+
for i in inserts:
|
|
128
|
+
print(f" inserted {i}")
|
|
129
|
+
print(f"TMDL: {updates} property update(s).")
|
|
130
|
+
if renames:
|
|
131
|
+
print(f"TMDL: {len(renames)} column display-name rename(s):")
|
|
132
|
+
for r in renames:
|
|
133
|
+
print(f" {r}")
|
|
134
|
+
print("Review DAX/relationship/role refs -- sync only renames the column header.")
|
|
135
|
+
|
|
136
|
+
if type_drift:
|
|
137
|
+
print(f"\nERROR: {len(type_drift)} column data_type mismatch(es):")
|
|
138
|
+
for d in type_drift:
|
|
139
|
+
print(f" {d}")
|
|
140
|
+
print("\nA column type is only applied when the column is first created, so one "
|
|
141
|
+
"of the two is wrong and neither will change on its own.")
|
|
142
|
+
print("Check what the underlying SQL actually returns, then fix whichever side "
|
|
143
|
+
"disagrees with it. Only dataType is checked -- sourceProviderType is left "
|
|
144
|
+
"to Power BI Desktop, which legitimately refines it (e.g. datetime2 -> date).")
|
|
145
|
+
raise SystemExit(1)
|
|
146
|
+
|
|
147
|
+
validate_tmdl(config)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Apply semantic-model YAML `measures:` -> TMDL, and export back the other way.
|
|
2
|
+
#
|
|
3
|
+
# semantic-model YAML is the measure authority -- DAX lives in each measure's
|
|
4
|
+
# `description` (after the trailing "DAX: " marker), format/folder in
|
|
5
|
+
# `config.meta`. Only touches measures -- columns, partitions, relationships,
|
|
6
|
+
# entities and dimensions untouched. Idempotent: rerun with same config = same
|
|
7
|
+
# output.
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .config import TmdlProjectConfig
|
|
14
|
+
from .schema_loader import iter_semantic_models, load_schema
|
|
15
|
+
from .tom_utils import load_model, save_model, set_if_changed
|
|
16
|
+
|
|
17
|
+
_DAX_RE = re.compile(r"DAX:\s*(.*)$", re.DOTALL)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _dax_from_description(description):
|
|
21
|
+
m = _DAX_RE.search(description or "")
|
|
22
|
+
return m.group(1).strip() if m else ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_by_table(config: TmdlProjectConfig) -> dict:
|
|
26
|
+
"""semantic-model YAML -> {tmdl_table_name: [{name, dax, formatString, displayFolder}]}."""
|
|
27
|
+
by_table = {}
|
|
28
|
+
for _path, _doc, sm, table_name in iter_semantic_models(config):
|
|
29
|
+
for m in sm.get("measures") or []:
|
|
30
|
+
meta = ((m.get("config") or {}).get("meta")) or {}
|
|
31
|
+
by_table.setdefault(table_name, []).append({
|
|
32
|
+
"name": m.get("label") or m["name"],
|
|
33
|
+
"dax": _dax_from_description(m.get("description", "")),
|
|
34
|
+
"formatString": meta.get("format_string", ""),
|
|
35
|
+
"displayFolder": meta.get("displayFolder", "Calculations"),
|
|
36
|
+
})
|
|
37
|
+
return by_table
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def export(config: TmdlProjectConfig):
|
|
41
|
+
"""Export TMDL measures back into each table's semantic-model YAML, replacing
|
|
42
|
+
only its `measures:` block -- entities/dimensions/description untouched."""
|
|
43
|
+
table_to_model = {name: spec["model"] for name, spec in load_schema(config).items()}
|
|
44
|
+
|
|
45
|
+
db, model = load_model(config)
|
|
46
|
+
by_table = {}
|
|
47
|
+
for table in model.Tables:
|
|
48
|
+
entries = []
|
|
49
|
+
for m in table.Measures:
|
|
50
|
+
entries.append({
|
|
51
|
+
"name": m.Name,
|
|
52
|
+
"agg": "sum",
|
|
53
|
+
"label": m.Name,
|
|
54
|
+
"description": f"DAX: {m.Expression.strip()}",
|
|
55
|
+
"config": {"meta": {
|
|
56
|
+
"format_string": m.FormatString or "",
|
|
57
|
+
"displayFolder": m.DisplayFolder or "Calculations",
|
|
58
|
+
}},
|
|
59
|
+
})
|
|
60
|
+
if entries:
|
|
61
|
+
entries.sort(key=lambda e: e["name"])
|
|
62
|
+
by_table[table.Name] = entries
|
|
63
|
+
|
|
64
|
+
written = 0
|
|
65
|
+
for table_name, measures in by_table.items():
|
|
66
|
+
dbt_model = table_to_model.get(table_name)
|
|
67
|
+
if not dbt_model:
|
|
68
|
+
print(f' WARNING: "{table_name}" has TMDL measures but no schema YAML entry, skipping')
|
|
69
|
+
continue
|
|
70
|
+
out_file = config.semantic_models_dir / f"{re.sub(r'[^A-Za-z0-9]+', '_', table_name).strip('_').lower()}.yml"
|
|
71
|
+
if not out_file.exists():
|
|
72
|
+
print(f' WARNING: no semantic model file for "{table_name}" at {out_file.name}, skipping')
|
|
73
|
+
continue
|
|
74
|
+
with open(out_file, encoding="utf-8") as f:
|
|
75
|
+
doc = yaml.safe_load(f)
|
|
76
|
+
doc["semantic_models"][0]["measures"] = measures
|
|
77
|
+
text = yaml.safe_dump(doc, default_flow_style=False, sort_keys=False, allow_unicode=True, width=200)
|
|
78
|
+
out_file.write_text(text, encoding="utf-8")
|
|
79
|
+
written += 1
|
|
80
|
+
|
|
81
|
+
total = sum(len(v) for v in by_table.values())
|
|
82
|
+
print(f"Exported {total} measure(s) across {written} file(s)")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def sync(config: TmdlProjectConfig):
|
|
86
|
+
"""Apply semantic-model YAML measures -> TMDL via TOM."""
|
|
87
|
+
by_table = load_by_table(config)
|
|
88
|
+
total = sum(len(v) for v in by_table.values())
|
|
89
|
+
if not total:
|
|
90
|
+
print("No measures found in semantic-model YAML")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
db, model = load_model(config)
|
|
94
|
+
from Microsoft.AnalysisServices.Tabular import Measure
|
|
95
|
+
|
|
96
|
+
updated = created = 0
|
|
97
|
+
|
|
98
|
+
for table_name, entries in by_table.items():
|
|
99
|
+
table = model.Tables.Find(table_name)
|
|
100
|
+
if table is None:
|
|
101
|
+
print(f' WARNING: table "{table_name}" not found, skipping its measures')
|
|
102
|
+
continue
|
|
103
|
+
|
|
104
|
+
for entry in entries:
|
|
105
|
+
measure_name = entry["name"]
|
|
106
|
+
dax = entry["dax"]
|
|
107
|
+
fmt = entry.get("formatString", "")
|
|
108
|
+
folder = entry.get("displayFolder", "Calculations")
|
|
109
|
+
|
|
110
|
+
existing = table.Measures.Find(measure_name)
|
|
111
|
+
if existing:
|
|
112
|
+
changed = set_if_changed(existing, "Expression", dax.strip(), lambda v: (v or "").strip())
|
|
113
|
+
changed |= set_if_changed(existing, "FormatString", fmt)
|
|
114
|
+
changed |= set_if_changed(existing, "DisplayFolder", folder)
|
|
115
|
+
if changed:
|
|
116
|
+
updated += 1
|
|
117
|
+
else:
|
|
118
|
+
m = Measure()
|
|
119
|
+
m.Name = measure_name
|
|
120
|
+
m.Expression = dax
|
|
121
|
+
m.FormatString = fmt
|
|
122
|
+
m.DisplayFolder = folder
|
|
123
|
+
table.Measures.Add(m)
|
|
124
|
+
created += 1
|
|
125
|
+
|
|
126
|
+
save_model(config, db)
|
|
127
|
+
print(f"Measures synced: {created} created, {updated} updated ({total} total in config)")
|