lexis-cli 0.4.2__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.
lexis/sml/models.py ADDED
@@ -0,0 +1,172 @@
1
+ """Hand-written pydantic models for the SML (Semantic Modeling Language) object
2
+ subset this converter supports.
3
+
4
+ SML (github.com/semanticdatalayer/SML) has no public JSON Schema and no
5
+ open-source reference parser/validator - AtScale's `sml-sdk`/`sml-cli` tooling
6
+ is closed-source. These models are derived from the spec's Markdown
7
+ documentation, not validated against any canonical schema (see
8
+ SML_OSSIE_CONVERTER_PLAN.md, Edge Case #5). `extra="allow"` on every model so a
9
+ real-world SML file with fields beyond this documented subset still round-trips
10
+ through `model_validate`/`model_dump` rather than being rejected or silently
11
+ truncated once Phase 2's parser exists.
12
+
13
+ Only the object types/fields the v1 "documented supported subset" needs are
14
+ modeled here (catalog, connection, dataset, dimension, metric, metric_calc,
15
+ model, and the relationship shapes). Everything else SML has (row_security,
16
+ perspectives, drillthroughs, composite_model, packages, ...) has no Ossie
17
+ equivalent and is out of scope for this converter entirely - not just for
18
+ these models.
19
+ """
20
+
21
+ from typing import Literal, Optional
22
+
23
+ from pydantic import BaseModel, ConfigDict, Field
24
+
25
+
26
+ class SmlDialectSql(BaseModel):
27
+ model_config = ConfigDict(extra="allow")
28
+
29
+ dialect: str
30
+ sql: str
31
+
32
+
33
+ class SmlDatasetColumn(BaseModel):
34
+ model_config = ConfigDict(extra="allow")
35
+
36
+ name: str
37
+ data_type: Optional[str] = None
38
+ sql: Optional[str] = None
39
+ dialects: Optional[list[SmlDialectSql]] = None
40
+
41
+
42
+ class SmlConnection(BaseModel):
43
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
44
+
45
+ unique_name: str
46
+ object_type: Literal["connection"] = "connection"
47
+ label: Optional[str] = None
48
+ as_connection: Optional[str] = None
49
+ database: Optional[str] = None
50
+ schema_name: Optional[str] = Field(None, alias="schema")
51
+
52
+
53
+ class SmlDataset(BaseModel):
54
+ model_config = ConfigDict(extra="allow")
55
+
56
+ unique_name: str
57
+ object_type: Literal["dataset"] = "dataset"
58
+ label: Optional[str] = None
59
+ description: Optional[str] = None
60
+ connection_id: Optional[str] = None
61
+ table: Optional[str] = None
62
+ sql: Optional[str] = None
63
+ columns: list[SmlDatasetColumn] = []
64
+
65
+
66
+ class SmlLevelAttribute(BaseModel):
67
+ model_config = ConfigDict(extra="allow")
68
+
69
+ unique_name: str
70
+ dataset: str
71
+ name_column: str
72
+ key_columns: list[str]
73
+ is_unique_key: Optional[bool] = None
74
+
75
+
76
+ class SmlLevel(BaseModel):
77
+ model_config = ConfigDict(extra="allow")
78
+
79
+ unique_name: str
80
+ # Per SML's dimension.md spec, a level's secondary_attributes are fully
81
+ # inlined attribute objects (unique_name/dataset/name_column/key_columns/...),
82
+ # not name-string references into dimension.level_attributes - only
83
+ # level_attributes themselves can be referenced that way ("Only level
84
+ # attributes can be used to define relationships between datasets and
85
+ # other dimensions"). Reusing SmlLevelAttribute's shape here rather than a
86
+ # separate near-identical model.
87
+ secondary_attributes: Optional[list[SmlLevelAttribute]] = None
88
+
89
+
90
+ class SmlHierarchy(BaseModel):
91
+ model_config = ConfigDict(extra="allow")
92
+
93
+ unique_name: str
94
+ label: Optional[str] = None
95
+ levels: list[SmlLevel] = []
96
+
97
+
98
+ class SmlDimension(BaseModel):
99
+ model_config = ConfigDict(extra="allow")
100
+
101
+ unique_name: str
102
+ object_type: Literal["dimension"] = "dimension"
103
+ label: Optional[str] = None
104
+ type: Literal["standard", "time"] = "standard"
105
+ hierarchies: list[SmlHierarchy] = []
106
+ level_attributes: list[SmlLevelAttribute] = []
107
+
108
+
109
+ class SmlMetric(BaseModel):
110
+ model_config = ConfigDict(extra="allow")
111
+
112
+ unique_name: str
113
+ object_type: Literal["metric"] = "metric"
114
+ label: Optional[str] = None
115
+ description: Optional[str] = None
116
+ calculation_method: str
117
+ dataset: str
118
+ column: str
119
+
120
+
121
+ class SmlMetricCalc(BaseModel):
122
+ model_config = ConfigDict(extra="allow")
123
+
124
+ unique_name: str
125
+ object_type: Literal["metric_calc"] = "metric_calc"
126
+ label: Optional[str] = None
127
+ description: Optional[str] = None
128
+ expression: str
129
+
130
+
131
+ class SmlRelationshipEnd(BaseModel):
132
+ model_config = ConfigDict(extra="allow")
133
+
134
+ dataset: Optional[str] = None
135
+ join_columns: Optional[list[str]] = None
136
+ dimension: Optional[str] = None
137
+ level: Optional[str] = None
138
+
139
+
140
+ class SmlModelRelationship(BaseModel):
141
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
142
+
143
+ unique_name: str
144
+ from_end: SmlRelationshipEnd = Field(..., alias="from")
145
+ to: SmlRelationshipEnd
146
+ role_play: Optional[str] = None
147
+
148
+
149
+ class SmlModelMetricRef(BaseModel):
150
+ model_config = ConfigDict(extra="allow")
151
+
152
+ unique_name: str
153
+
154
+
155
+ class SmlModel(BaseModel):
156
+ model_config = ConfigDict(extra="allow")
157
+
158
+ unique_name: str
159
+ object_type: Literal["model"] = "model"
160
+ label: Optional[str] = None
161
+ description: Optional[str] = None
162
+ relationships: list[SmlModelRelationship] = []
163
+ metrics: list[SmlModelMetricRef] = []
164
+
165
+
166
+ class SmlCatalog(BaseModel):
167
+ model_config = ConfigDict(extra="allow")
168
+
169
+ unique_name: str
170
+ object_type: Literal["catalog"] = "catalog"
171
+ label: Optional[str] = None
172
+ description: Optional[str] = None
lexis/sml/parse.py ADDED
@@ -0,0 +1,382 @@
1
+ """SML (Semantic Modeling Language) -> Ossie parser.
2
+
3
+ Reads a directory of `*.yml` SML files (one object per file, discriminated by
4
+ `object_type`, resolved through a global `unique_name` registry rather than a
5
+ folder layout - see SML_OSSIE_CONVERTER_PLAN.md at the repo root) and produces
6
+ one OssieDocument.
7
+
8
+ Flattening strategy (Ossie has no dimension/hierarchy concept at all):
9
+
10
+ - `connection` + `dataset` -> `OssieDataset` (`database.schema.table` source,
11
+ same convention as `emit.py`'s reverse).
12
+ - Every `dimension.level_attributes[]` entry (the one joinable key attribute
13
+ per level) and every `hierarchy.levels[].secondary_attributes[]` entry (per
14
+ SML's own spec, these are fully inlined attribute objects, not name
15
+ references) becomes a plain `OssieField` on whichever dataset *it* names -
16
+ not necessarily the "same" dataset for every attribute of one dimension,
17
+ which is what makes a snowflaked dimension fall out for free. The
18
+ hierarchy/level grouping itself (order, multiple hierarchies, calculation
19
+ groups) has no Ossie equivalent and is discarded from every Ossie-side
20
+ consumer's perspective, but is stashed under `custom_extensions` so a later
21
+ Ossie -> SML re-emission of *this same document* could reconstruct it.
22
+ - `model.relationships[]` (fact -> dimension) and every `dimension.relationships[]`
23
+ (snowflake/embedded joins between a dimension's own backing datasets) share
24
+ the exact same `{from: {dataset, join_columns}, to: {dimension, level}}`
25
+ shape and are resolved identically: `to.level` is looked up in that
26
+ dimension's `level_attributes` to find the backing dataset + key columns.
27
+ - `metric` -> `AGG(dataset.column)` ANSI_SQL (reverse of `synthesize_aggregate_sql`).
28
+ `metric_calc` -> the `"[Measures].[a]/[Measures].[b]"` ratio shape this
29
+ converter's own Ossie -> SML direction emits is decomposed back into
30
+ `a_sql / b_sql` ANSI_SQL; any other MDX is passed through verbatim under
31
+ Ossie's `MDX` dialect slot (never translated, per SML_OSSIE_CONVERTER_PLAN.md
32
+ Edge Case #2).
33
+ - Object types with no Ossie equivalent at all (`row_security`, `perspectives`,
34
+ `composite_model`, `package`, ...) and model/catalog-level fields this
35
+ converter doesn't interpret (`perspectives`, `drillthroughs`, `aggregates`,
36
+ `partitions`, `overrides`, ...) are preserved opaquely under
37
+ `custom_extensions` rather than silently dropped.
38
+
39
+ v1 scope requires exactly one `catalog` and one `model` object - multi-model/
40
+ composite repos are not supported (see SML_OSSIE_CONVERTER_PLAN.md Edge Case #8).
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import re
46
+ from dataclasses import dataclass
47
+ from pathlib import Path
48
+ from typing import Any
49
+
50
+ import yaml
51
+
52
+ from lexis._vendor.ossie import OssieDocument
53
+ from lexis.sml._common import (
54
+ ConversionError,
55
+ make_stash_extension,
56
+ require,
57
+ sml_datatype_to_ossie,
58
+ synthesize_aggregate_sql,
59
+ )
60
+
61
+ # object_types this converter interprets functionally; anything else has no
62
+ # Ossie equivalent and is stashed opaquely (see module docstring).
63
+ _SUPPORTED_OBJECT_TYPES = {"catalog", "connection", "dataset", "dimension", "metric", "metric_calc", "model"}
64
+
65
+ # The one MDX shape this converter's own Ossie -> SML direction produces for a
66
+ # ratio metric (see emit.py) - decomposed back into ANSI_SQL when both operands
67
+ # resolve to already-converted metrics.
68
+ _RATIO_MDX_RE = re.compile(r"^\[Measures\]\.\[(?P<numerator>.+)\]\s*/\s*\[Measures\]\.\[(?P<denominator>.+)\]$")
69
+
70
+ # model/catalog fields this converter itself sets or reads; anything else on
71
+ # those two object types (perspectives, drillthroughs, aggregates, partitions,
72
+ # overrides, dataset_properties, ...) is stashed rather than dropped.
73
+ _HANDLED_MODEL_FIELDS = {
74
+ "unique_name",
75
+ "object_type",
76
+ "label",
77
+ "description",
78
+ "relationships",
79
+ "metrics",
80
+ "x_lexis_unconverted_metrics",
81
+ }
82
+ _HANDLED_CATALOG_FIELDS = {"unique_name", "object_type", "label", "description"}
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class SmlParseResult:
87
+ document: OssieDocument
88
+ warnings: list[str]
89
+
90
+
91
+ def _load_objects(directory: Path) -> dict[str, dict[str, dict]]:
92
+ """Read every `*.yml` under `directory`, grouped by object_type then unique_name."""
93
+ by_type: dict[str, dict[str, dict]] = {}
94
+ for path in sorted(directory.rglob("*.yml")):
95
+ raw = yaml.safe_load(path.read_text())
96
+ if not isinstance(raw, dict):
97
+ continue
98
+ object_type = require(raw, "object_type", f"{path}")
99
+ name = require(raw, "unique_name", f"{path} ({object_type})")
100
+ bucket = by_type.setdefault(object_type, {})
101
+ if name in bucket:
102
+ raise ConversionError(f"duplicate unique_name {name!r} for object_type {object_type!r}")
103
+ bucket[name] = raw
104
+ return by_type
105
+
106
+
107
+ def parse_sml_repo(directory: str | Path) -> SmlParseResult:
108
+ """Parse an SML repo directory into a single OssieDocument."""
109
+ by_type = _load_objects(Path(directory))
110
+ warnings: list[str] = []
111
+
112
+ unsupported_objects: dict[str, dict] = {}
113
+ for object_type in sorted(set(by_type) - _SUPPORTED_OBJECT_TYPES):
114
+ for name, raw in by_type[object_type].items():
115
+ unsupported_objects[name] = raw
116
+ warnings.append(
117
+ f"LOSSY: {object_type} {name!r} has no Ossie equivalent; preserved opaquely under "
118
+ "custom_extensions but not interpreted by any Ossie-side consumer."
119
+ )
120
+
121
+ catalogs = by_type.get("catalog", {})
122
+ models = by_type.get("model", {})
123
+ if len(catalogs) != 1:
124
+ raise ConversionError(f"expected exactly one catalog object, found {len(catalogs)}")
125
+ if len(models) != 1:
126
+ raise ConversionError(
127
+ "expected exactly one model object - multi-model/composite repos are not supported "
128
+ "in v1 (see SML_OSSIE_CONVERTER_PLAN.md Edge Case #8)"
129
+ )
130
+ catalog = next(iter(catalogs.values()))
131
+ model = next(iter(models.values()))
132
+
133
+ connections = by_type.get("connection", {})
134
+ datasets_raw = by_type.get("dataset", {})
135
+ dimensions_raw = by_type.get("dimension", {})
136
+ metrics_raw = by_type.get("metric", {})
137
+ metric_calcs_raw = by_type.get("metric_calc", {})
138
+
139
+ # --- dataset source (database.schema.table) ---
140
+ dataset_source: dict[str, str] = {}
141
+ for name, raw in datasets_raw.items():
142
+ conn_id, table = raw.get("connection_id"), raw.get("table")
143
+ if not conn_id or not table:
144
+ warnings.append(
145
+ f"LOSSY: dataset {name!r} has no connection_id/table (a query dataset with inline "
146
+ "`sql` instead of a physical table?) - excluded, no 'database.schema.table' Ossie "
147
+ "source can be derived (see SML_OSSIE_CONVERTER_PLAN.md Edge Case #4)."
148
+ )
149
+ continue
150
+ conn = connections.get(conn_id)
151
+ if conn is None:
152
+ raise ConversionError(f"dataset {name!r} references unknown connection_id {conn_id!r}")
153
+ db = require(conn, "database", f"connection {conn_id!r}")
154
+ schema = require(conn, "schema", f"connection {conn_id!r}")
155
+ dataset_source[name] = f"{db}.{schema}.{table}"
156
+
157
+ # A dataset that's the `from` side of some relationship is a fact table - its
158
+ # `is_unique_key` level_attribute (if any, e.g. a degenerate dimension like a
159
+ # date column) marks a dimension's *grain*, not a row-unique key of the fact
160
+ # table itself, so it must not become that dataset's Ossie `primary_key`
161
+ # (which the Snowflake Semantic View emitter turns into a literal
162
+ # `PRIMARY KEY (...)` DDL clause - asserting uniqueness that doesn't hold).
163
+ fact_datasets = {
164
+ rel["from"]["dataset"]
165
+ for rels in (model.get("relationships"), *(d.get("relationships") for d in dimensions_raw.values()))
166
+ for rel in (rels or [])
167
+ if isinstance(rel.get("from"), dict) and rel["from"].get("dataset")
168
+ }
169
+
170
+ # --- gather every (dataset, column) Ossie needs a field for, and resolve
171
+ # each dimension's per-level join key (dataset + key_columns) for relationship
172
+ # resolution below ---
173
+ fields_wanted: dict[str, dict[str, bool]] = {} # dataset -> {name_column: is_time}
174
+ primary_key_by_dataset: dict[str, list[str]] = {}
175
+ level_key: dict[str, dict[str, tuple[str, list[str]]]] = {} # dim -> {level: (dataset, key_columns)}
176
+
177
+ def _want(dataset_name: str, name_column: str, is_time: bool = False) -> None:
178
+ bucket = fields_wanted.setdefault(dataset_name, {})
179
+ bucket[name_column] = bucket.get(name_column, False) or is_time
180
+
181
+ # Seed from every dataset's own declared columns first - for our own Phase 1
182
+ # output this is exactly the original (typically partial) field list; for a
183
+ # hand-authored repo SML requires it to be schema-exhaustive, which is fine,
184
+ # just more fields than a hand-written Ossie model would usually declare.
185
+ for name, raw in datasets_raw.items():
186
+ for column in raw.get("columns") or []:
187
+ if isinstance(column, dict) and column.get("name"):
188
+ _want(name, column["name"])
189
+
190
+ for dim_name, dim in dimensions_raw.items():
191
+ dim_is_time = dim.get("type") == "time"
192
+ level_key[dim_name] = {}
193
+ for attr in dim.get("level_attributes") or []:
194
+ ds = require(attr, "dataset", f"dimension {dim_name!r} level_attribute {attr.get('unique_name')!r}")
195
+ col = require(
196
+ attr, "name_column", f"dimension {dim_name!r} level_attribute {attr.get('unique_name')!r}"
197
+ )
198
+ key_columns = attr.get("key_columns") or [col]
199
+ _want(ds, col, is_time=dim_is_time and bool(attr.get("is_unique_key")))
200
+ level_key[dim_name][attr["unique_name"]] = (ds, key_columns)
201
+ if attr.get("is_unique_key") and ds not in fact_datasets:
202
+ primary_key_by_dataset[ds] = key_columns
203
+
204
+ for hierarchy in dim.get("hierarchies") or []:
205
+ for level in hierarchy.get("levels") or []:
206
+ for attr in level.get("secondary_attributes") or []:
207
+ ds, col = attr.get("dataset"), attr.get("name_column")
208
+ if ds and col:
209
+ _want(ds, col)
210
+
211
+ for name, raw in metrics_raw.items():
212
+ ds, col = raw.get("dataset"), raw.get("column")
213
+ if ds and col:
214
+ _want(ds, col)
215
+
216
+ # --- assemble Ossie datasets ---
217
+ ossie_datasets: list[dict[str, Any]] = []
218
+ for name, source in dataset_source.items():
219
+ sml_dataset = datasets_raw[name]
220
+ columns_by_name = {c["name"]: c for c in sml_dataset.get("columns") or [] if isinstance(c, dict)}
221
+ wanted = fields_wanted.get(name, {})
222
+ fields = []
223
+ for col_name in sorted(wanted):
224
+ column = columns_by_name.get(col_name, {})
225
+ sql = column.get("sql") or f"{name}.{col_name}"
226
+ field: dict[str, Any] = {
227
+ "name": col_name,
228
+ "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": sql}]},
229
+ }
230
+ datatype = sml_datatype_to_ossie(column.get("data_type"))
231
+ if datatype:
232
+ field["datatype"] = datatype.value
233
+ if wanted[col_name]:
234
+ field["dimension"] = {"is_time": True}
235
+ fields.append(field)
236
+
237
+ ds_dict: dict[str, Any] = {"name": name, "source": source}
238
+ if sml_dataset.get("description"):
239
+ ds_dict["description"] = sml_dataset["description"]
240
+ if fields:
241
+ ds_dict["fields"] = fields
242
+ if name in primary_key_by_dataset:
243
+ ds_dict["primary_key"] = primary_key_by_dataset[name]
244
+ ossie_datasets.append(ds_dict)
245
+
246
+ if not ossie_datasets:
247
+ raise ConversionError("no dataset with a resolvable 'database.schema.table' source was found")
248
+
249
+ # --- relationships: model-level (fact -> dimension) + dimension-level (snowflake/embedded) ---
250
+ def _convert_relationship(rel: dict, context: str) -> dict[str, Any] | None:
251
+ frm, to = rel.get("from") or {}, rel.get("to") or {}
252
+ name = rel.get("unique_name") or f"{frm.get('dataset')}_{to.get('dimension')}"
253
+ if to.get("row_security"):
254
+ warnings.append(
255
+ f"LOSSY: {context} relationship {name!r} targets row_security, which has no Ossie "
256
+ "equivalent; excluded."
257
+ )
258
+ return None
259
+ from_dataset = require(frm, "dataset", f"{context} relationship {name!r} 'from'")
260
+ join_columns = require(frm, "join_columns", f"{context} relationship {name!r} 'from'")
261
+ dim_name = require(to, "dimension", f"{context} relationship {name!r} 'to'")
262
+ level_name = require(to, "level", f"{context} relationship {name!r} 'to'")
263
+ levels = level_key.get(dim_name)
264
+ if levels is None or level_name not in levels:
265
+ raise ConversionError(
266
+ f"{context} relationship {name!r} targets level {level_name!r} of dimension "
267
+ f"{dim_name!r}, which has no matching level_attributes entry"
268
+ )
269
+ to_dataset, to_columns = levels[level_name]
270
+ result: dict[str, Any] = {
271
+ "name": name,
272
+ "from": from_dataset,
273
+ "to": to_dataset,
274
+ "from_columns": join_columns,
275
+ "to_columns": to_columns,
276
+ }
277
+ if rel.get("role_play"):
278
+ # Ossie relationships have no aliasing concept - stash the SML
279
+ # role_play template so a re-emission can reconstruct it.
280
+ result["custom_extensions"] = [make_stash_extension({"role_play": rel["role_play"]}).model_dump()]
281
+ return result
282
+
283
+ ossie_relationships = [
284
+ converted
285
+ for rel in model.get("relationships") or []
286
+ if (converted := _convert_relationship(rel, "model")) is not None
287
+ ]
288
+ for dim_name, dim in dimensions_raw.items():
289
+ for rel in dim.get("relationships") or []:
290
+ converted = _convert_relationship(rel, f"dimension {dim_name!r}")
291
+ if converted is not None:
292
+ ossie_relationships.append(converted)
293
+
294
+ # --- metrics: plain aggregates first, so a ratio metric_calc can resolve
295
+ # its operands' SQL regardless of file processing order ---
296
+ ossie_metrics: list[dict[str, Any]] = []
297
+ metric_sql_by_name: dict[str, str] = {}
298
+ for name, raw in metrics_raw.items():
299
+ calc_method, ds, col = raw.get("calculation_method"), raw.get("dataset"), raw.get("column")
300
+ sql = synthesize_aggregate_sql(calc_method, ds, col) if calc_method and ds and col else None
301
+ if sql is None:
302
+ warnings.append(
303
+ f"LOSSY: metric {name!r} has calculation_method {calc_method!r}, which has no single "
304
+ "ANSI SQL aggregate equivalent; excluded."
305
+ )
306
+ continue
307
+ metric_sql_by_name[name] = sql
308
+ metric_dict: dict[str, Any] = {"name": name, "expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": sql}]}}
309
+ if raw.get("description"):
310
+ metric_dict["description"] = raw["description"]
311
+ ossie_metrics.append(metric_dict)
312
+
313
+ for name, raw in metric_calcs_raw.items():
314
+ expr = raw.get("expression") or ""
315
+ match = _RATIO_MDX_RE.match(expr.strip())
316
+ sql = None
317
+ if match:
318
+ num_sql = metric_sql_by_name.get(match.group("numerator"))
319
+ den_sql = metric_sql_by_name.get(match.group("denominator"))
320
+ if num_sql and den_sql:
321
+ sql = f"{num_sql} / {den_sql}"
322
+ metric_dict = {"name": name}
323
+ if sql is not None:
324
+ metric_dict["expression"] = {"dialects": [{"dialect": "ANSI_SQL", "expression": sql}]}
325
+ else:
326
+ metric_dict["expression"] = {"dialects": [{"dialect": "MDX", "expression": expr}]}
327
+ warnings.append(
328
+ f"metric_calc {name!r} MDX expression {expr!r} passed through verbatim under the MDX "
329
+ "dialect - not executable by any Ossie SQL emitter."
330
+ )
331
+ if raw.get("description"):
332
+ metric_dict["description"] = raw["description"]
333
+ ossie_metrics.append(metric_dict)
334
+
335
+ # A metric emit.py couldn't express as a metric/metric_calc at all is
336
+ # preserved verbatim under model.yml's x_lexis_unconverted_metrics escape
337
+ # hatch (see emit.py's module docstring) - restore it, unless a real
338
+ # metric/metric_calc of the same name now exists in this repo (e.g. a human
339
+ # hand-authored a replacement), in which case that one wins and the stale
340
+ # stash entry is dropped with a warning, never silently duplicated.
341
+ existing_metric_names = {m["name"] for m in ossie_metrics}
342
+ for stashed in model.get("x_lexis_unconverted_metrics") or []:
343
+ name = stashed.get("name")
344
+ if name in existing_metric_names:
345
+ warnings.append(
346
+ f"a stale preserved-metric stash entry for {name!r} was dropped: a real metric/"
347
+ "metric_calc of that name now exists in this repo and takes precedence."
348
+ )
349
+ continue
350
+ ossie_metrics.append(stashed)
351
+ existing_metric_names.add(name)
352
+
353
+ # --- semantic model + stash for everything with no Ossie equivalent ---
354
+ sem_model: dict[str, Any] = {"name": model.get("unique_name"), "datasets": ossie_datasets}
355
+ description = model.get("description") or catalog.get("description")
356
+ if description:
357
+ sem_model["description"] = description
358
+ if ossie_relationships:
359
+ sem_model["relationships"] = ossie_relationships
360
+ if ossie_metrics:
361
+ sem_model["metrics"] = ossie_metrics
362
+
363
+ stash: dict[str, Any] = {}
364
+ if dimensions_raw:
365
+ # Full raw dimension dicts, not just the parts this converter reads -
366
+ # hierarchy/level order, multiple hierarchies, calculation_groups, and
367
+ # anything else, so a future Ossie -> SML re-emission of *this document*
368
+ # could reconstruct them exactly.
369
+ stash["dimensions"] = dimensions_raw
370
+ if unsupported_objects:
371
+ stash["unsupported_objects"] = unsupported_objects
372
+ model_extra = {k: v for k, v in model.items() if k not in _HANDLED_MODEL_FIELDS}
373
+ if model_extra:
374
+ stash["model_extra"] = model_extra
375
+ catalog_extra = {k: v for k, v in catalog.items() if k not in _HANDLED_CATALOG_FIELDS}
376
+ if catalog_extra:
377
+ stash["catalog_extra"] = catalog_extra
378
+ if stash:
379
+ sem_model["custom_extensions"] = [make_stash_extension(stash).model_dump()]
380
+
381
+ document = OssieDocument.model_validate({"semantic_model": [sem_model]})
382
+ return SmlParseResult(document=document, warnings=warnings)
@@ -0,0 +1,11 @@
1
+ """Shared types for Lexis transpilers."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Artifact:
8
+ """A single emitted output file: a name and its text content."""
9
+
10
+ filename: str
11
+ content: str
@@ -0,0 +1,119 @@
1
+ """Ossie -> Cube.js schema (YAML) emitter.
2
+
3
+ Cube models data as a set of "cubes" (one per table) with dimensions/measures/joins,
4
+ so the mapping from Ossie is direct for datasets/fields/relationships. Ossie metrics are
5
+ looser (arbitrary multi-dialect aggregate SQL, possibly spanning multiple datasets) than
6
+ Cube measures (per-cube, typically `type: sum/avg/...` over a single column), so:
7
+
8
+ - A metric matching the simple pattern `FUNC(dataset.column)` becomes a properly typed
9
+ Cube measure (`type: sum|avg|count|min|max`) on that dataset's cube.
10
+ - Anything else (multi-column expressions, cross-dataset expressions like
11
+ `customer_lifetime_value`) becomes a `type: number` measure carrying the raw SQL
12
+ expression verbatim, attached to the metric's first referenced dataset's cube. Cube
13
+ has no native concept of an ad-hoc cross-cube raw-SQL measure, so this is a
14
+ best-effort placement users should review, not a guaranteed-correct Cube measure.
15
+ """
16
+
17
+ import re
18
+
19
+ import yaml
20
+
21
+ from lexis._vendor.ossie import OssieDialect
22
+ from lexis.resolved_model import ResolvedModel
23
+
24
+ _SIMPLE_AGGREGATE_RE = re.compile(
25
+ r"^(SUM|AVG|COUNT|MIN|MAX)\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*\)$",
26
+ re.IGNORECASE,
27
+ )
28
+
29
+ _CUBE_MEASURE_TYPE = {
30
+ "SUM": "sum",
31
+ "AVG": "avg",
32
+ "COUNT": "count",
33
+ "MIN": "min",
34
+ "MAX": "max",
35
+ }
36
+
37
+
38
+ def _cube_dimensions(model: ResolvedModel, dataset_name: str) -> list[dict]:
39
+ dataset = model.datasets[dataset_name]
40
+ dimensions = []
41
+ for f in dataset.fields or []:
42
+ try:
43
+ expr = model.resolve_expression(f.expression, OssieDialect.ANSI_SQL)
44
+ except Exception:
45
+ continue
46
+ is_time = f.is_time_dimension()
47
+ dimensions.append(
48
+ {
49
+ "name": f.name,
50
+ "sql": expr,
51
+ "type": "time" if is_time else "string",
52
+ }
53
+ )
54
+ return dimensions
55
+
56
+
57
+ def _cube_joins(model: ResolvedModel, dataset_name: str) -> list[dict]:
58
+ joins = []
59
+ for rel in model.relationships:
60
+ if rel.from_dataset != dataset_name:
61
+ continue
62
+ conditions = " AND ".join(
63
+ f"{{{rel.from_dataset}.{lc}}} = {{{rel.to}.{rc}}}"
64
+ for lc, rc in zip(rel.from_columns, rel.to_columns)
65
+ )
66
+ joins.append(
67
+ {"name": rel.to, "relationship": "many_to_one", "sql": conditions}
68
+ )
69
+ return joins
70
+
71
+
72
+ def emit_cube_yaml(model: ResolvedModel) -> str:
73
+ """Render the full model as Cube.js schema YAML (one cube per dataset)."""
74
+ cubes: dict[str, dict] = {}
75
+ for name, dataset in model.datasets.items():
76
+ cubes[name] = {
77
+ "name": name,
78
+ "sql_table": dataset.source,
79
+ "joins": _cube_joins(model, name),
80
+ "dimensions": _cube_dimensions(model, name),
81
+ "measures": [{"name": "count", "type": "count"}],
82
+ }
83
+
84
+ for metric in model.metrics.values():
85
+ expr = model.resolve_expression(metric.expression, OssieDialect.ANSI_SQL)
86
+ match = _SIMPLE_AGGREGATE_RE.match(expr.strip())
87
+ if match:
88
+ func, dataset_name, column = match.groups()
89
+ if dataset_name in cubes:
90
+ cubes[dataset_name]["measures"].append(
91
+ {
92
+ "name": metric.name,
93
+ "sql": column,
94
+ "type": _CUBE_MEASURE_TYPE[func.upper()],
95
+ "description": metric.description,
96
+ }
97
+ )
98
+ continue
99
+
100
+ referenced = model.referenced_datasets(expr)
101
+ target_dataset = referenced[0] if referenced else next(iter(cubes))
102
+ cubes[target_dataset]["measures"].append(
103
+ {
104
+ "name": metric.name,
105
+ "sql": expr,
106
+ "type": "number",
107
+ "description": metric.description,
108
+ }
109
+ )
110
+
111
+ for cube in cubes.values():
112
+ if not cube["joins"]:
113
+ del cube["joins"]
114
+ cube["measures"] = [
115
+ {k: v for k, v in m.items() if v is not None} for m in cube["measures"]
116
+ ]
117
+
118
+ doc = {"cubes": list(cubes.values())}
119
+ return yaml.dump(doc, sort_keys=False, default_flow_style=False, allow_unicode=True)