Add bidirectional Apache Ossie <-> Cube converter - #289
MikeNitsenko wants to merge 62 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new converters/cube/ Python converter that round-trips between Apache Ossie semantic models and Cube YAML data models, including a CLI and extensive tests/fixtures, and registers CUBE in the supported-vendors list.
Changes:
- Introduces bidirectional conversion logic (
convert_cube_to_ossie/convert_ossie_to_cube) with stash/parking mechanisms to preserve unmapped constructs and enable lossless round-trips. - Adds a full test suite (fixtures, property-based tests with Hypothesis fallback, CLI tests) plus Cube converter documentation and packaging (
pyproject.toml). - Adds a dedicated GitHub Actions workflow to run Cube converter tests in CI and updates
converters/README.mdto listCUBE.
Reviewed changes
Copilot reviewed 28 out of 29 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| converters/README.md | Registers CUBE as a supported vendor extension. |
| converters/cube/src/ossie_cube/_common.py | Shared conversion utilities (YAML handling, stash protocol, expression translation, mappings). |
| converters/cube/src/ossie_cube/cube_to_osi.py | Cube → Ossie conversion implementation (datasets/fields/relationships/metrics + preservation). |
| converters/cube/src/ossie_cube/osi_to_cube.py | Ossie → Cube export implementation (layout, meta parking, joins/measures/view generation). |
| converters/cube/src/ossie_cube/converter_issues.py | Structured issue types + issue log for lossy/unsafe conversions. |
| converters/cube/src/ossie_cube/cli.py | ossie-cube CLI: import/export behavior, IO, error reporting. |
| converters/cube/src/ossie_cube/init.py | Public API surface for the converter package. |
| converters/cube/README.md | Converter documentation: mapping table, fan-out semantics, usage, limitations. |
| converters/cube/pyproject.toml | Packaging + dev dependencies/test config for the converter. |
| converters/cube/tests/** | Comprehensive unit, fixture round-trip, property-based, edge-case, and CLI tests. |
| converters/cube/tests/fixtures/** | Cube model fixtures used for round-trip and baseline tests (including TPC-DS). |
| .github/workflows/converter-cube-ci.yml | CI workflow for the Cube converter test suite. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Both from the Copilot review on apache#289. Dimension names were sanitized separately in _convert_model (to decide which members a cube has) and again in _build_dimensions (to name them). The first pass used a fresh `taken` set per field, so a collision was silently swallowed by a set comprehension there and only rejected later in the second pass -- meaning the member set that decides `{CUBE.member}` vs `{CUBE}.column`, and where a measure lands, could be short a name while measures were being placed. Demonstrated: "Order Status" and "order status" collapsed to one name with no error. Now resolved once in _resolve_dimension_names and reused. That also fixes a defect the review did not mention: the old set included the two halves of a split geo dimension (location_latitude, location_longitude), which never exist as Cube dimensions since they merge back into `location`, so a metric referencing one would emit an unresolvable `{CUBE.location_…}`. The halves now resolve to the dimension they merge into. _HypothesisRnd.chance() ignored its `p` argument and always drew an unweighted boolean, so the Hypothesis driver explored a different distribution than the seeded one despite the docstring claiming they share a generator. Now weighted, and drawn so the minimal value means False -- shrinking toward the smallest model rather than the largest. 231 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both addressed in d362c9e.
Names are now resolved once in This also fixed something not mentioned: the old set included both halves of a split
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 32 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
converters/cube/src/ossie_cube/osi_to_cube.py:721
- Using
queue.pop(0)makes this BFS O(n²) due to repeated list shifting; on larger relationship graphs this can become unnecessarily slow. Use an index cursor (or a deque) to avoid O(n) pops from the front.
queue = [base]
while queue:
current = queue.pop(0)
for neighbor in adjacency.get(current, []):
if neighbor in paths:
continue
paths[neighbor] = f"{paths[current]}.{neighbor}"
entries.append({"join_path": paths[neighbor], "includes": "*"})
queue.append(neighbor)
converters/cube/tests/test_roundtrip.py:42
- Typo: "licence" is misspelled here (the rest of the repo uses "license").
licence headers on the fixtures) are not part of the data model, and key order
Both from the Copilot review on apache#289. Dimension names were sanitized separately in _convert_model (to decide which members a cube has) and again in _build_dimensions (to name them). The first pass used a fresh `taken` set per field, so a collision was silently swallowed by a set comprehension there and only rejected later in the second pass -- meaning the member set that decides `{CUBE.member}` vs `{CUBE}.column`, and where a measure lands, could be short a name while measures were being placed. Demonstrated: "Order Status" and "order status" collapsed to one name with no error. Now resolved once in _resolve_dimension_names and reused. That also fixes a defect the review did not mention: the old set included the two halves of a split geo dimension (location_latitude, location_longitude), which never exist as Cube dimensions since they merge back into `location`, so a metric referencing one would emit an unresolvable `{CUBE.location_…}`. The halves now resolve to the dimension they merge into. _HypothesisRnd.chance() ignored its `p` argument and always drew an unweighted boolean, so the Hypothesis driver explored a different distribution than the seeded one despite the docstring claiming they share a generator. Now weighted, and drawn so the minimal value means False -- shrinking toward the smallest model rather than the largest. 231 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both from the Copilot review on apache#289. The BFS popped from the front of a list, which is O(n) per pop; a deque makes it O(1). Semantic models are small enough that this was never going to matter in practice, but the deque is also the more idiomatic form. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
508c9f5 to
7d981c9
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 31 out of 32 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
converters/cube/src/ossie_cube/osi_to_cube.py:685
- When the model has foreign-vendor
custom_extensionsbut the imported Cube model had multiple views and none was selected (mapped_viewis missing), export currently drops those extensions while loggingPARKED_IN_META. This causes avoidable data loss and the issue type/message is inconsistent ("parked" vs "dropped"). Prefer parking the extensions on a deterministic view (or failing fast) so Ossie -> Cube stays lossless even in the "no mapped view" case.
if foreign and mapped is None:
issues.add(IssueType.PARKED_IN_META, "model",
"no mapped view to park foreign-vendor custom_extensions on; "
"they have no Cube home and are dropped")
converters/cube/README.md:279
- The README hard-codes an exact test count ("234 tests"), but the PR description claims a different number. Since this value will drift over time, it’s better to avoid a specific count (or generate it automatically) to prevent documentation from becoming stale.
234 tests at 96% line coverage: example-based unit tests per direction, CLI
behavior tests, fixture round-trip tests (including the
Both from the Copilot review on apache#289. Model-level foreign-vendor custom_extensions ride on the view that represents the model. When the source Cube model had several views and none was chosen, there is no such view -- and export silently dropped them. Reachable in practice: import a multi-view Cube model, add a SNOWFLAKE extension to the Ossie model, export, and it is gone. Confirmed by reproducing it. Now refused, with the fix in the message (re-import with `--view`). Parking on an arbitrary view was considered and rejected: only the mapped view's parked extensions are read back on import, so it would look lossless while still losing them. The review also noted the issue type contradicted its own message -- PARKED_IN_META for something reported as "dropped". That was true in two places, not one, and it matters: the README defines PARKED_IN_META as preserved-but-invisible-to-Cube, so a pipeline gating on issue types would have concluded the data survived. Adds DROPPED_NO_CUBE_EQUIVALENT for values that genuinely cannot be preserved, and uses it for relationship ai_context -- a Cube join entry takes only name/sql/relationship, with no `meta` field, making it the one construct with nowhere to go. Also drops the hard-coded test count from the README. It had already drifted out of sync with the PR description, which is the reviewer's point: the number carries no information a reader needs, while the description of what the suite covers does. 236 tests, 97% line coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Both worth addressing, and the first one was a real bug. Fixed in 31d49d2. Dropped foreign-vendor extensions. Reproduced it: import a Cube model with two views (so no view is mapped), add a Now refused, with the fix in the message ( The type/message inconsistency was in two places, not one. Hard-coded test count. Removed from the README. It had already drifted out of sync with the PR description, which is exactly the point — the number carries no information a reader needs, while the description of what the suite covers does. |
From the Copilot review on apache#289, which found that the placeholder holding a geo dimension's position was only reserved when the half encountered first happened to be `latitude`. With `longitude` first, the recorded index pointed at whatever real dimension had already been appended, and `dimensions[index] = dim` overwrote it. Reproduced: a `city` dimension between the two halves disappeared from the output entirely. Rather than reserve the placeholder earlier, the index arithmetic is gone. Dimensions are now built into a dict keyed by target name, with order taken from each name's first appearance -- which is well defined however the two halves are arranged, adjacent or not, in either order. Probing around the fix turned up two more silent-corruption paths in the same code, both order-dependent: - A geo base colliding with an ordinary field of the same name emitted two dimensions called `home` (invalid Cube) when the ordinary field came first, but was correctly rejected when it came second. Now checked during name resolution, so order does not decide. - Two fields both claiming the same half silently discarded one. Now rejected. Also validates the geo `part` and `of` values, and moves the missing-half check into name resolution so every geo problem is caught in one place before anything is built. 241 tests, 97% line coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Confirmed and fixed in d92711e. Reproduced it first — with I took the second of your two suggestions rather than the first. Reserving the placeholder earlier would have worked, but the index arithmetic was the fragile part, so it's gone: dimensions are built into a dict keyed by target name, with order taken from each name's first appearance. That's well defined however the halves are arranged — either order, adjacent or not. Probing around it turned up two more order-dependent paths in the same code:
Also validated the geo Six cases pinned by tests: either order, collision in both orders, duplicate half, missing half, unknown part. |
From the Copilot review on apache#289: additional `semantic_model` entries were reported as PARKED_IN_META, but they are neither converted nor preserved anywhere -- a drop. That is the third instance of the same mislabelling, so rather than patch the flagged line I audited all seven export-side uses. Exactly one was a genuine park: unique_keys -> PARKED_IN_META (correct) extra semantic_model entries -> DROPPED (was parked) dimension.is_time role -> DROPPED (was parked) dimension.is_time opt-out -> DROPPED (was parked) synthesized primary-key dimension -> APPROXIMATED (was parked) no datatype -> Cube type 'string' -> APPROXIMATED (was parked) cross-dataset metric placement -> APPROXIMATED (was parked) The import-direction uses were all genuine parks and are unchanged. Adds APPROXIMATED for the middle case, which neither of the existing types described: nothing is lost and nothing is hidden, but Cube requires a value Ossie does not carry, so the converter chose one and the output asserts slightly more than the input did. Calling that "parked" was wrong in the same way as calling a drop "parked" -- nothing was parked. The point of keeping three types apart is that a caller gating on them can distinguish preserved-but-unreadable from actually-lost from emitted-with-a-guess. Two of the three could not be told apart before. 241 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Correct, and it was the third instance of this — so rather than patch the flagged line I audited all seven export-side uses. Exactly one was a genuine park. Fixed in c22e6d2.
The import-direction uses were all genuine parks and are unchanged. Added Three types kept apart on purpose, since that is what a caller gating on them needs: preserved-but-unreadable-by-Cube, actually lost, and emitted-with-a-guess. Two of the three were indistinguishable before. |
…o one No behaviour change; 524 tests and both gates green before and after. Dead, found by counting references rather than by eye: - `FANOUT_SAFE_AGGS` -- unreferenced since fan-out moved to the resolved expression. - `FANOUT_UNSAFE_AGGS` -- a stale import, unused since the same change. - `DOTTED_REF_RE` -- superseded by the quoted-aware pattern, still imported by both converters and used by neither. The survivor takes the plain name back, so there is one notion of "a dotted reference" instead of two that had to be kept in step. - `has_non_idempotent_aggregate` -- unused by the converter since attribution started walking the parse tree, but still tested. Deleted, and its tests re-pointed at `unsafe_aggregate_datasets`, which is what production calls. The real smell: seven dicts keyed by cube name -- dimension names, reference members, all members, the name lookup, dropped fields, inline SQL, primary keys -- built in one loop and then threaded through the build functions one parameter at a time. `_build_measures` took thirteen parameters, `_decompose_measure` thirteen, `_build_cube` ten. They are seven answers about the same cube, so they now travel together as `_CubePlan`, and the four lookups every measure rewrite repeats are assembled once in `_to_cube_sql`. Worst parameter count in osi_to_cube.py: 13 -> 9. `_measure_from_expression` 9 -> 6, `_build_dimensions` 7 -> 4, `_build_cube` 10 -> 7.
No behaviour change; 524 tests and both gates green before and after. Identifier resolution was four helpers that were one idea wearing different shapes: `normalize_identifier` (the spec's normalized form), `_lookup_keys` (a tuple of keys for a *written* token), `lookup_map` (keys for a *declared* name), and `_first` (try a tuple against a dict). Written and declared names were reduced to keys by different code, which is how the exact-spelling key came to exist on one side only. Now `match_keys` reduces either side the same way, `lookup_map` builds from it, and `resolve_identifier` answers "what does this written token name?". The three call sites still doing lookups by hand with a single normalized key -- `referenced_datasets`, the dropped-field check, and fan-out attribution -- go through it too, so they gain the exact-spelling match they were silently missing. That is a small widening rather than a pure refactor, and it is the consistent behaviour. `ossie_expr_to_cube_sql` took six name collections and rebuilt six lookup maps from them on *every call* -- once per measure, over the same names each time. It now takes `ReferenceTables`, prepared once per model: 7 parameters -> 3, and the repeated map building is gone. `_convert_measure` on the import side had the same shape as the export side before `_CubePlan`: nine parameters, four of them model-wide facts. They travel as `_MeasureContext`. `sanitized` was a third spelling of the dataset lookup, alongside `cube_names` and `tables.datasets`; the prepared table answers it via `datasets_in`. Worst parameter count across the package: 13 -> 9. `ossie_expr_to_cube_sql` 7 -> 3, `_convert_measure` 9 -> 5, `_build_dimensions` 7 -> 5.
All four reproduced first. The two P1s are the same hole from two directions: the analysis only understood one shape of aggregate. [P1] Qualified and unqualified operands were not tracked independently. An aggregate can read both, and `SUM(amount + line_items.qty)` reported only `line_items` -- the declaring cube, which the bare `amount` belongs to, went unmentioned, so a fan-out on it passed strict mode. [P1] Only `AggFunc` nodes were examined, which is one of three shapes sqlglot uses: - an *ordered-set* aggregate keeps its value-bearing column in the ORDER BY, on the `WithinGroup` wrapper rather than the inner function, so `PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY users.ltv)` was blamed on the declaring cube; - `LISTAGG(...) WITHIN GROUP (...)` is not modelled as an aggregate at all and disappeared from the analysis entirely. Aggregate *scope* now covers `AggFunc`, `WithinGroup` and unmodelled calls, with nesting resolved so an ordered-set aggregate counts once rather than reporting its inner function separately. An unmodelled call may equally be a scalar UDF, so this over-reports; that is the cheaper error, since the default is to warn rather than refuse, and the alternative is a silently inflated number. - `BOOL_OR`/`BOOL_AND` were reported unsafe. Duplicating a row cannot change whether any or all rows satisfy a predicate. `BIT_OR`/`BIT_AND` added for the same reason. - The inline-SQL table was keyed by the normalized identifier alone, unlike every other table, so an exact-quoted reference to a split geo half -- `users."home_latitude"` -- missed its substitution and came out as a raw column of a name that exists in Ossie and not in the database. It now uses the shared match-key logic. 541 tests with both gates, 516 with neither, 96% coverage. Interop unchanged.
[P1] Model-level metric names were compared as exact strings, but Ossie regular identifiers are case-insensitive (core-spec/expression_language.md:73). So `revenue` on one cube and `Revenue` on another counted as two distinct names and both were emitted unqualified -- a document a consumer may reject or resolve to the wrong metric. Worse, nothing downstream catches it: the spec's own validator compares exact strings too, so its duplicate check passes and the test gate built on it could not see this class at all. Both the collision count and the derived-name check now normalize; the emitted name keeps its original spelling, so `orders__revenue` and `users__Revenue` come out qualified and still spelled as written. Also the follow-up flagged as non-blocking, since it was two lines: DISTINCT now counts for a call SQL parsing does not model, so `LISTAGG(DISTINCT name)` is idempotent for the same reason `SUM(DISTINCT x)` is. `Anonymous` keeps its arguments somewhere the modelled nodes do not, which is why the existing check missed them. 549 tests with both gates, 524 with neither, 96% coverage. Interop unchanged.
…eys as key Found by running the path we care about most end to end -- a Databricks metric view through Ossie into Cube, and back -- which nothing had exercised. Both directions were broken, and both failed *quietly*. - Every expression the Databricks converter emits is `DATABRICKS` with no ANSI_SQL alternative, and export required ANSI. So every field and metric was dropped and the result was an empty Cube model -- which Cube compiles, so neither the round-trip tests nor the compile gate saw anything wrong. Export now falls back to the expression's only dialect when that dialect is warehouse SQL, and reports it. MDX/TABLEAU/MAQL are not SQL a warehouse runs, so those still drop. - Cube refuses a cube that declares a join without a primary key, and a Databricks metric view has no primary-key concept. `unique_keys` identifies a row just as well and was already in the document -- parked in `meta.ossie` while Cube rejected the model for want of exactly it. It is now used as the key, and a dataset with a relationship and neither is reported with Cube's own wording, since nothing can be invented. With both, a metric view survives the full loop: source, joins, dimensions and measures all come back with the same names, and the Cube model compiles. Also a bug in the compile gate itself: it flattened model paths to basenames, so a cube and a view of the same name overwrote each other and a valid model looked malformed. That is exactly the shape this fixture has, since the Ossie model and its fact table share a name. `tests/fixtures/databricks_ossie.yaml` pins the path in this suite -- a document written by another converter, so nothing in it was shaped for Cube. 555 tests with both gates, 529 with neither, 96% coverage.
Found the same way as the Databricks issues: by checking what a spoke actually made of our output rather than that it exited zero. Cube -> Ossie -> Snowflake produced a Cortex Analyst model with **zero dimensions and 27 facts** across TPC-DS -- every categorical column classified as a numeric measure. The cause is on our side. The Snowflake converter classifies "a field with no `dimension` block as a fact regardless of datatype", which is a fair reading: the block is the role marker. Import emitted it only for time dimensions, so everything else looked like a fact. A Cube `dimensions:` entry is a dimension by definition, so the block is now always emitted -- empty for a non-time one, which leaves the consumer to apply the spec's own default instead of this converter asserting `is_time: false`. Snowflake output for the same model, before -> after: store_sales dim=0 fact=9 -> dim=9 fact=0 customer dim=0 fact=6 -> dim=6 fact=0 date_dim dim=0 time=3 fact=2 -> dim=2 time=3 fact=0 which matches the shape of that converter's own committed example. No other spoke's result changed. Also covers the geo halves, whose fields are built on a separate path; and pins that the dialect fallback is not Databricks-specific -- Snowflake and BigQuery alone convert too. The two Ossie snapshot fixtures are regenerated. 559 tests with both gates, 533 with neither, 96% coverage.
All three blockers are regressions from the previous two commits: each fixed the forward direction by making a choice Cube requires, and each choice was one-way, so `Ossie -> Cube -> Ossie` no longer returned the document it was given. The pattern is the one the rest of the converter already uses -- record the choice in `meta.ossie`, undo it on the way back: - A warehouse dialect used in place of ANSI is recorded, so re-import labels the SQL as that dialect instead of calling vendor SQL `ANSI_SQL`. On measures as well as fields; metrics carry expressions too. - A `unique_keys` entry promoted to satisfy Cube's join requirement is recorded, so re-import does not hand back a declared `primary_key` the model never had. The dimension the promotion synthesized is recorded too, so it does not come back as a field for a column the Ossie model never described. - An Ossie field with no `dimension` block is recorded, so it returns as the fact it was rather than as a dimension. Cube has one kind of dimension, so the block still goes out on every member -- that is what the Snowflake classification needs. `test_a_model_from_another_converter_survives_the_round_trip_exactly` pins all of it on the committed Databricks-authored fixture: dialects, keys, fields and roles compared before and after. It would have failed on each of the three. Worth noting why the property tests missed these: the generator draws ANSI expressions, declares a primary key, and gives every field a dimension role -- so none of the three shapes can occur in a generated model. The fixture from another converter is the only thing in the suite that has them. 560 tests with both gates, 533 with neither, 96% coverage. Snowflake classification and the interop matrix unchanged.
Three review rounds found bugs in one blind spot: the generator drew ANSI expressions, always declared a `primary_key`, and gave every field a `dimension` role -- so none of the 210 generated models per run could contain the shapes that were breaking. The committed Databricks-authored fixture was the only thing in the suite that had them, which is why the same class of defect came back three times. The generator now draws all three, since each is a place where export must make a choice Cube requires and then be able to undo it: - a dialect per field and per metric, often a warehouse one with no ANSI alternative; - either `primary_key` or `unique_keys` (never neither -- Cube rightly refuses a cube with a join and no key); - a `dimension` role or none, the latter being a fact. And the property compares what those choices affect -- dialects, keys, roles and datatypes -- not just expressions, which is how one-way fixes slipped past it before. Checked that it can fail: reverting each of the three provenance records in turn breaks 61, 61 and 34 of the 122 property cases. A green test that cannot fail is not a test. 560 tests with both gates, 534 with neither, 96% coverage.
Both blockers were provenance recorded by halves. - Ossie names a primary key by *column*; Cube marks a *dimension*. The two differ whenever the dimension carrying the key is not named after its column -- a field `order_id` reading column `id`, or a synthesized `id_pk` where a computed field shadows the column -- and import rebuilt the key from dimension names, so it came back naming something the table need not have. The column list is recorded when it cannot be read back off the dimensions, and only then, so a model whose names already agree keeps a clean Cube round trip. `_primary_key_of` returns columns now, which also fixes the rebuilt `COUNT(DISTINCT ...)`: it was naming the synthesized dimension, a member the Ossie side does not have at all. - Recording only the chosen dialect's *name* lost the alternatives. Cube holds one `sql` per member, so nothing short of the whole expression object brings a multi-dialect expression back; it is parked entire, on measures as well as fields. The generator now draws several dialects per expression and the property compares them all rather than `dialects[0]`, which is what let the second one through. That immediately found two more: - an expression offering two warehouse dialects and no ANSI was dropped outright, because the fallback insisted on a sole candidate. It takes the first in document order and reports it -- Cube passes SQL to one data source, and the alternatives are parked. - `COUNT(DISTINCT <pk>)` drifted to the synthesized dimension name, above. Also a flaw in the generator itself: it picked an alternative dialect out of a `set`, whose iteration order varies between processes, so the seeded sweep produced different models each run and could not name a reproducible seed. Sorted now -- the same suite ran green and red in consecutive invocations before this. Checked both fixes can fail: reverting each breaks 60 and 9 of 122 property cases. Metric drift across 400 generated models is zero. 560 tests with both gates, 534 with neither, 97% coverage.
[P1] The recorded key column list is *columns*, but the `computed_primary_key` inference read it as dimension names -- so a key column `id` alongside a computed field also named `id` came back flagged as computed, and the second export marked `LOWER(email)` as the key instead of synthesizing `id_pk`. Cube then deduplicated on a different value, which changes the counts it returns. The inference is skipped when `meta.ossie.primary_key` supplied the key, because those entries are columns by construction. Both Ossie documents were identical in that case; only the *Cube* model changed. So the property now runs a second export and requires it to reproduce the first, which is the only way to see a record that one side writes and the other reads differently. That found two more, neither reachable in a single cycle: - A decomposed metric's public measure was stashed verbatim and restored with references to hidden parts the next export no longer generated. Cube's verdict on the second cycle: "fact.crossing_part_1 cannot be resolved" -- a broken model. The public half is marked, so re-import rebuilds it from its expression and both halves are regenerated together. - `COUNT(DISTINCT DIM_0.ID)` was not recognized as the primary-key count because the comparison was case-sensitive, so cycle 1 emitted `count_distinct` and cycle 2 -- reading a canonically regenerated expression -- emitted the bare `count`. Compared on normalized identifiers now, which also means a metric spelling the key in any case gets Cube's fan-out-safe form. Non-blocking wording fixed too: the fallback may pick the first of several warehouse dialects, not only a sole one. Checked the new checks can fail: reverting the inference fix breaks 9 cases across the property sweep and the targeted two-cycle test. 561 tests with both gates, 534 with neither, 97% coverage.
Cube keeps cubes and views in one global namespace, so a view may not share a name with a cube. The exporter did not check, and an Ossie model named after one of its own datasets produced exactly that -- Cube rejected the whole model with "Cannot read properties of undefined (reading 'toString')". Not an exotic input: it is what every Databricks metric view over a same-named table converts to, and `databricks_ossie.yaml` is one. The generated view becomes `<name>_view` and the model's own name is recorded in `meta.ossie.model_name`, since the mapped view's name is the model's name on the way back and the rename would otherwise stick. Renamed rather than refused because a cube is addressed by joins and by every member reference, a generated view by nothing. That went unnoticed because the gate meant to catch it was dropping half its input. `cube_compile.js` keyed model files by basename; Cube's own FileRepository keys them by path relative to the model root, so `cubes/orders.yml` and `views/orders.yml` collided and one was discarded silently. A valid cube plus an invalid same-named view reported COMPILED OK, while the identical pair under distinct names failed as it should. Keyed by relative path now, matching Cube, and duplicate keys are refused outright rather than resolved by chance -- a gate that quietly compiles less than it was given is worse than no gate. The same flattening was already fixed a layer up in `_cube_gate.py`, where the temp files are written; the JS undid it. With the gate honest, a second defect surfaced one cycle out: a `DATABRICKS` metric came back as `ANSI_SQL` on the second export. The verbatim-restore path hands back the Cube SQL a previous import stashed instead of picking a dialect, so it had no dialect to pass to `_park_expression` and the label was dropped. It falls back to the sole declared dialect, which is the one that SQL came from. `Ossie -> Cube -> Ossie` is byte-stable from the first cycle now; the existing one-cycle comparison could not see this, since cycle one was correct. Also, `validation/validate.py` reports a missing `jsonschema` by calling `sys.exit(1)` at import time, and SystemExit does not derive from Exception -- so it escaped the guard around the validator import and aborted pytest *collection*. The whole suite refused to run on any machine without jsonschema, the exact case `validator_gate` exists to skip. Smaller, from the same review: - The CLI's file I/O used the platform default encoding, so a model carrying any non-ASCII text died under a non-UTF-8 locale: `title: Größe` gave `UnicodeDecodeError: 'ascii' codec can't decode byte 0xc3`. Pinned to UTF-8. - The property generator hard-coded the spec version instead of using `OSSIE_VERSION`. - `has_top_level_operator` treated any depth-0 whitespace as structure, including the trailing newline off a YAML block scalar, and parenthesized a lone `SUM(x)` needlessly. - The interop matrix counted failures by adding a bool; made explicit. Checked the new checks can fail. Reverting the gate keying (and its duplicate guard) leaves the collision test passing a model Cube refuses; reverting the view rename breaks 3 tests including the Databricks compile; reverting the dialect fallback breaks the two-cycle stability test; reverting the encoding breaks the non-UTF-8 CLI test. 566 tests with both gates, 399 with neither, 97% coverage.
[P1] The record added for cube/view collisions was scoped to that one cause, and the ordinary causes went unrecorded. `Sales Model` is a legal Ossie name that cannot be a Cube identifier, so it exported as view `sales_model` with nothing parked and came back named `sales_model`. The same for `--name "Sales Model"` over a stashed view already called `sales_model`: the stashed branch compared the mapped name against the *sanitized* model name, the two matched, and the override was silently undone. Keyed on the difference now rather than on the reason for it -- the raw name is preserved whenever it differs from the name of the view that will carry it, whether that difference comes from sanitizing, an override, or a collision. A model whose name is already its view's name still parks nothing, so an ordinary Cube document stays clean. Verified stable over three cycles, since the value has to survive being read back out of the stash and not merely written once. Also non-blocking, from the same review: `test_two_export_cycles_produce_the_same_cube_model` was entirely behind the optional Cube gate, but comparing two exports needs no Cube installation -- so the regression it exists for was unchecked everywhere without a built checkout, CI included. Split, with only `assert_cube_compiles` gated. An audit of every `cube_gate` test found one more of mine with the same mistake (`test_a_renamed_view_still_compiles_and_stays_renamed`); split the same way. The rest are genuinely Cube-only. Checked the new checks can fail: restoring the collision-only rule breaks both name tests, and the two split tests now run (and pass) with no Cube checkout present where they were previously skipped. 571 tests with both gates, 404 with neither, 97% coverage.
[P1] Model-level metadata has no Cube field of its own, so it rides on the view representing the model -- and export emitted no view at all when the stash recorded none mapped. A Cube model need not contain a view, and one with several need not say which is the model, so this is an ordinary input rather than an edge case. Both cases dropped the name, description and AI context in silence, with no issue reported: a cube-only model imported with `--name 'Sales Model'` came back as the synthesized `cube_model`. That contradicts the documented lossless Ossie -> Cube -> Ossie round trip. They ride on a cube now, under `meta.ossie.model`, and import reads them back when no view is mapped. The carrier is the alphabetically first cube -- deterministic, and independent of both dataset ordering and the relationship graph, so every export picks the same one. Import does not depend on the choice; it reads whichever cube carries the record, which cannot accumulate because the record is consumed and stripped from the stash. Only values import could not otherwise recover are parked, so a Cube model that never had model-level metadata still round-trips byte-identical rather than acquiring a `meta.ossie` key it never had. That matters beyond tidiness: every fixture in the feature matrix is cube-only, and their structural round trips would all have started failing. A name equal to the one import synthesizes is recoverable by definition, hence the shared DEFAULT_MODEL_NAME rather than a second copy of the literal. Foreign-vendor `custom_extensions` deliberately keep refusing export in this case, and the README now says why rather than leaving it looking inconsistent: import restores those only from the mapped view, so a cube carrier would not bring them home. That path fails loudly, which was never the complaint here. Checked the new checks can fail: removing the export half breaks 3 tests, removing the import half breaks 2, and both halves are exercised over three cycles because the value has to survive being read back out of a cube's stash rather than merely written once. The carrier's output is put through the Cube compile gate too, since it is new YAML in the emitted model and holds a literal brace -- Cube compiles every string as an f-string. 576 tests with both gates, 408 with neither, 97% coverage. Cross-converter matrix unchanged.
Review: everything parseable from an Ossie metric expression should not
ride in custom_extensions. Three reductions, all symmetric:
- `filters` regenerate from the folded CASE: the fold import writes
(Cube's own applyMeasureFilters shape) is deterministic, so export
unfolds it back into structured filters -- verified by refolding, so a
hand-written CASE that merely looks similar stays one expression. A
filtered measure now travels with no stash at all; when the fold is
not invertible (the operand is itself a CASE), both spellings ride.
- Cube-only measure keys (format, drill_members, public, ...) ride flat,
the protocol dimensions already use, instead of forcing a copy of the
whole measure that duplicated the sql and type the expression carries.
- The owning cube is recorded only when the expression does not say it;
export already places a metric on the sole dataset it references.
A declared type the expression would not regenerate (a calculated
measure whose sql is a single aggregate, a count_distinct over the
primary key) is recorded as a flat `type` entry -- the latter was a
latent round-trip flip to bare `count`.
On the fixtures: completed_amount and cities now carry no extension;
total_amount carries {format}; TPC-DS drops from 7 stash entries to 4.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review: composite metrics should stay decomposed -- an Ossie document
should not render final values. The expression language lists Metric
references among its supported constructs, and a bare identifier in a
model-level metric expression resolves in the metric namespace. So
`{total_amount} / {count}` now imports as `total_amount /
orders__count` (the referenced metrics' Ossie names) rather than as an
inlined copy of every referenced definition -- which is the metric
drift a shared semantic model exists to prevent. Export renders a bare
metric name back as `{measure}` on the same cube, `{cube.measure}`
across cubes; the fixtureA calculated measure now carries no extension
at all.
Because bare identifiers are references at the model level, raw columns
in measure SQL are dataset-qualified on import (parser-based, string
surgery so spellings survive): `SUM(amount * 2)` reads as
`SUM(orders.amount * 2)`, which also removes the column/metric
ambiguity outright.
The resolver now keeps two forms per measure: the emitted one, and the
fully inlined one Cube itself renders -- which is what the fan-out
analysis reads, since a reference hides the aggregates it stands for.
Inlining remains where a reference cannot: generated decomposition
parts, windowed dependencies (both park, as before), keyword-named
metrics, and multi-aggregate expressions authored as a single measure
(whose spelling rides in the stash so export does not decompose them).
Reference cycles are refused in both directions, as Cube refuses them;
a metric referencing a dialect-dropped metric drops with it,
transitively.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review: generated Cube models should use references. Cube interpolates
a member's sql verbatim into generated queries, so a bare column in a
computed expression is ambiguous the moment the cube is joined against
a table sharing the name. Every bare column in generated member SQL --
computed dimension sql, measure operands, reconstructed filters -- is
now qualified as {CUBE}.column, the reference Cube's own documentation
recommends. Parser-based (sqlglot finds the column tokens, string
surgery applies them), so keywords, function names and EXTRACT units
are never touched, and an unparseable expression is left exactly as it
was. {CUBE}.column rather than a {member} reference on purpose: the two
coincide for a plain member, but the column form keeps meaning the
column even when a computed field shadows its name.
A single-column dimension keeps the bare `sql: column` form Cube
models conventionally use. A pleasant side effect: a hand-written
`CONCAT({CUBE}.tenant_id, {CUBE}.id)` key now round-trips
byte-identically instead of coming back bare. The TPC-DS fixture's
computed dimension is updated to the reference form it should have
been written in.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by running Cube -> Ossie -> Databricks -> Ossie -> Cube end to
end: the Databricks importer emits a metric view's source columns
unqualified (SUM(ss_ext_sales_price) -- unqualified *means* the source
there), and reading that as opaque SQL placed the aggregate on whatever
cube the rest of the expression named. TPC-DS's customer_lifetime_value
put SUM(ss_ext_sales_price) on the customer cube -- a measure over a
column that cube does not have, which compiles (SQL is opaque to Cube's
compiler) and reads the wrong table at query time. Pre-existing, not a
regression: the pre-branch converter placed it identically, just
spelled bare.
A bare identifier in a model-level metric expression that is no metric
but is a declared field of exactly one dataset can only mean that
dataset's column. It now renders through the ordinary reference
machinery ({CUBE}.column, {CUBE.member}, or the cross-cube
{other.member} that carries the implicit join), decomposition places
its aggregate on the owning cube, and the metric's own cube derivation
sees it. A name declared on several datasets is never guessed at: it
stays raw SQL of the fallback cube, as before.
With this, the full interop chain ends in a model Cube compiles, with
one repair the Databricks format forces (a metric view cannot carry the
source table's own key; both converters report it).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up: customer_lifetime_value still carried a rendered copy
of its own SQL in custom_extensions -- the one measure shape left where
the extension duplicated the expression. Root causes fixed:
- Cross-cube member references ({customer.c_customer_sk}) are
reversible in model-level metric SQL: export re-emits them verbatim,
so the spelling never needed recording. sql_is_reversible now knows
each cube's members and accepts the canonical spelling.
- The owning cube mirrors export's full derivation, base cube included:
a cross-dataset metric on the FK sink needs no cube record.
- Export decomposes a composite only when its aggregates read different
cubes -- that is where per-aggregate fan-out correction lives. A
single-cube composite (MAX(x) - MIN(x)) stays one calculated measure
and round-trips verbatim, stash-free. An inline-authored cross-cube
composite normalizes to the decomposed form on its first round trip,
a documented normalization whose second cycle is a fixed point; the
tpcds fixture now commits that fixed point.
- A geo half's sql rides in the stash only when the field's expression
would not regenerate it -- {CUBE}.lat is what the expression already
says, so the stash keeps only of/part.
Full-fixture audit: fixtureA carries 7 extension entries, tpcds 4
(views curation, segments, geo structure, name mapping, format,
public:false, and two foreign vendors) -- every one something the
expression genuinely cannot say.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review follow-up: a sub_query dimension's sql references a measure
({orders.count}), which Cube resolves through a correlated subquery.
Emitting the flattened reference as an Ossie field expression
(orders.count) claimed a column no dataset has -- text that reads as
valid SQL and computes nothing anywhere -- softened only by an
APPROXIMATED issue. That was inconsistent with the converter's own
precedents: switch dimensions and multi-stage measures, whose Ossie
renderings would equally claim something they are not, are parked
whole. The sub_query dimension now rides the same protocol
(PARKED_IN_META, dataset stash, original position restored verbatim),
and the aggregate itself still reaches the model as the hoisted metric
the reference points at.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-verified every fixture and test against the review principles.
Fixtures: both Ossie snapshots regenerate byte-identically; exports
match the committed Cube fixtures structurally (remaining byte diffs
are YAML formatting only); hand-authored fixture carries only its
deliberate foreign-vendor extension.
Two behaviors the audit found tested only implicitly are now pinned:
- A canonical cross-cube member reference ({customer.c_sk}) travels
with no stash and returns verbatim; a case-variant spelling
({CUSTOMER.c_sk}) would come back canonicalized, so that one keeps
the original.
- A bare identifier that is both a metric's name and another dataset's
field resolves in the metric namespace -- a measure reference, not a
column read that would silently bypass the metric's definition.
Stale prose retired: _MEASURE_NATIVE_KEYS no longer claims extra keys
force a whole-measure copy; the README's stashed-on-import list moves
sub_query out of dimension extras and into parked-whole; the TPC-DS
stash count is 2, not 4; the property generator's single-cube composite
comment no longer says export splits it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verifying the three fixture identities showed the third one leaking: import(export(hand_authored)) gained a model-level stash recording the view export had just generated -- a rendered copy of regeneration's own output, exactly what the extensions-minimization principle forbids, and a foreign-vendor warning for every other spoke downstream. The view builder (generated_view_cubes, uncollided_view_name) moves to _common so import can predict the generated view with export's own code: when the model's sole view is byte-equal to the prediction -- same base cube, same member lists, same prefix/exclude decisions, the canonical path, no leftover meta -- the view set is not stashed and the next export generates it again. Anything off that shape (a curated includes list, an edited prefix, a second view, an off-layout path) is stashed verbatim exactly as before, so a user's edits in Cube are never dropped. Falls out naturally: the TPC-DS view is exactly the generated shape, so its model-level CUBE extension disappears altogether -- the model now carries only the two foreign vendors -- and the mixed-file test's view, also generated-shaped, keeps only its file-layout record. hand_authored round trip is now asserted as whole-document identity, with an edited-view negative pinning the exact-match guard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
597a8f9 to
78b721d
Compare
_fanned_out_datasets() now takes extra_joins, normalizing orientation the same way _convert_joins does. Values became phrases rather than bare names, since a parked join has no relationship to name — the message for a converted relationship reads identically to before. Parked join now warns, and --strict-fanout refuses (exit 1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
to_cube_sql() restructured so the fragment and the whole go through one convert() chain; column names come from the converted whole and are only applied to the fragment. CASE…END now round-trips exactly; tax_rate in glue still qualifies. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aggregate_spans, which drives export-side decomposition, scanned a hardcoded list of seven names, while the fan-out classifier recognized any AggFunc, WithinGroup or Anonymous node. STDDEV(users.ltv) / SUM(orders.amount) therefore split out only the SUM and left the STDDEV inlined on the declaring cube, so Cube corrected it for row multiplication on the wrong one -- the single thing decomposition exists to get right. The recognition is now shared, as two predicates rather than one, because the callers have opposite cost asymmetries: - _is_modelled_aggregate (AggFunc + WithinGroup) is exact: no scalar call reaches it. Decomposition acts on it, and a rewrite can afford no false positive -- lifting a call onto another cube would otherwise emit a measure built from a scalar expression. - _is_aggregate_scope adds Anonymous, which cannot be told from a scalar UDF. The classifier keeps it: over-reporting costs a warning, and the fan-out policy warns rather than refuses. The scanner now takes any identifier followed by a balanced paren group and confirms each candidate against sqlglot, instead of matching known names. Two span-boundary bugs fell out of that: - PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY users.ltv) was cut down to PERCENTILE_CONT(0.5), which names no dataset at all and was placed on the declaring cube -- the trap _is_aggregate_scope already documented for the classifier, arriving here as an offset rather than a node. - SUM(x) OVER (PARTITION BY y) was matched by name and split, leaving OVER (...) behind in the glue text: the frame is gone and what remains means nothing. Such an expression now stays one calculated measure. Decomposition additionally required two aggregate spans, which contradicted the rule stated beside it: one aggregate on the wrong cube is already what splitting fixes, and an empty span list satisfies the any(...) test on its own, so the count bought nothing and cost a case. SUM(users.ltv) + orders.amount names two datasets, which puts the measure on the base cube, and the sum over users was then corrected on orders. Removing that guard exposed an idempotence break, now that an ordered-set aggregate can be split out and inlined back. has_top_level_operator decides whether an inlined term needs parentheses by scanning for structure at depth 0, and the space in PERCENTILE_CONT(0.5) WITHIN GROUP (...) reads as exactly that -- so every cycle wrapped the term in another pair and the expression grew without bound. It now asks the span scanner first: one aggregate call covering the whole text is a single term by definition, which also keeps the two readings of "one aggregate call" from drifting apart. What still cannot be lifted -- an unmodelled call, or a window function's aggregate -- is named rather than left silent. unsplittable_aggregate_datasets reports the datasets it reads, and export raises APPROXIMATED saying which cube Cube will correct on instead. Eight decomposition shapes verified as exact fixed points over three cycles, ordered-set and windowed among them.
Cube wraps a non-calculated measure's sql in the aggregate function, so
a measure reference inside that sql stands for another aggregate and the
result is a nested one: `type: sum` over `{unit_count}` is Cube's own
SUM(SUM(units)). No Ossie expression says that, and every spelling the
converter reached for instead asserted something false:
- `sql: "{unit_count}"` became SUM(orders.unit_count), naming a column
orders does not have, and re-export wrote that column back into Cube
as `{CUBE}.unit_count` -- a model that compiles and then fails at the
database.
- `sql: "{unit_count} * 2"` became SUM(unit_count * 2), where the bare
name resolves in Ossie's model-level *metric* namespace, so the nested
aggregate survived in a second spelling.
- `type: count_distinct` reached the same place through the other
operand call site.
None of the three was reported. They are now parked whole with
PARKED_IN_META, on the dataset stash with their positions, and come back
verbatim -- the protocol a multi-stage measure already uses. A
calculated measure (`type: number`) is the one shape where the reference
is genuine, and it converts as before.
_NoStaticForm was caught on the calculated branch alone, which two paths
could reach around: a `type: sum` measure naming a windowed one, and any
measure whose `filters` entry named one. Both raised straight out of
convert_cube_to_ossie as a traceback, so a single measure Ossie cannot
express took the whole model with it. Resolution moves into _static_form
with one handler around it, and the exception now carries its own issue
so the two reasons keep their wording while sharing that handler.
Cube -> Ossie -> Cube verified to reproduce all four parked measures
byte-identically, in place, and to be a fixed point on the second cycle.
core-spec/osi-schema.json became core-spec/ossie-schema.json in c02409c ("Rename remaining OSI references to Ossie"), and this converter's tests still read the old path. The consequence was worse than a skipped check, because the two places that read it fail differently: - test_roundtrip.py opens it directly, so those two fixtures raised FileNotFoundError. - _cube_gate.py reads it beside loading validate.py, inside one try, and assigned the module *first*. A schema that could not be read therefore left _VALIDATOR_MODULE set and _SCHEMA undefined -- and the skip gate only asks whether the module loaded, so every validating test ran into `NameError: name '_SCHEMA' is not defined` rather than skipping. Together that is 138 failures wherever jsonschema is importable, and silence wherever it is not. jsonschema is in the dev dependency group precisely so the check runs, and CI does `uv sync` before `uv run pytest` -- so the first CI run on this branch would have failed on all four Python versions, for a reason unrelated to the converter. _SCHEMA is now initialized before the try and the module assigned last, so any future failure between the two skips cleanly instead of raising. With the layer actually running, the suite is 623 passing rather than 471 -- the 138 previously dead tests all pass, which is the useful part: every fixture and generated model this converter emits is valid against the core spec.
Following up the _operand report: the diagnosis there was more general than its example. _operand qualified any result that looked like a bare identifier, and auditing it showed the branch could not reach a real column at all -- _translate has already run qualify_bare_columns and rendered own-cube references with self_prefix, so `units` arrives as `orders.units`, dotted. Nine operand shapes were probed and not one reached the branch. What did reach it was corrupted. A measure reference resolved to its metric name, which is the reported case; but so did every bare token sqlglot declines to call a column, and those had nothing to do with measures: `sql: NULL` became SUM(orders.NULL), `TRUE` and CURRENT_DATE the same. Both are one mistake -- inferring a column from a token's shape -- so the branch is gone rather than narrowed. Measure references are refused upstream by _static_form; anything else _translate leaves bare is not a column and is emitted as it stands. Coverage confirms it was never exercised, before this branch or after, which is how it shipped. Also pinned, from the same audit: - the measure-resolution input errors (a calculated or aggregate measure with no sql, an unknown aggregate type), which report on the measure by name; - cube_reference_bodies, including Cube's escape for a literal brace; - the scanner's outermost-only rule over a genuinely nested pair, SUM(SUM(a.x)), which no case reached -- a lone one is short-circuited as a single aggregate before the scan; - has_top_level_operator on unparseable text, the path where the aggregate check declines to answer and the character scan decides.
Self-review of the parking rule found the operand was only half of it.
`filtered_operand` folds a measure's `filters` into the aggregate as
AGG(CASE WHEN ... THEN ...), so a filter naming a measure nests exactly
as an operand naming one does -- and that half was still converting in
silence: `type: sum` filtered on `{unit_count} > 0` came out as
SUM(CASE WHEN (unit_count > 0) THEN orders.units END), where the bare
name resolves in Ossie's metric namespace, with no issue raised.
The check now covers `sql` and every filter, and precedes the bare
`count` return, since a `count` with no sql has no operand to inspect
and still takes filters. A calculated measure is deliberately untouched:
there is no enclosing aggregate for a reference in its filter to nest
inside, and it still converts.
Also documented, having verified it: a reference inside a string literal
counts, because Cube compiles a YAML `sql` as a Python f-string and
interpolates it there as well -- the same reason export refuses to emit
one into a literal.
has_top_level_operator asks the span scanner only when depth-0
whitespace leaves the question open. An operator there cannot occur
inside a single call, so it settles the matter outright, and the scanner
stays off the hot path -- this runs once per reference while a measure
is inlined, over text that doubles at each step of a reference chain.
Measured on a depth-12 chain: the previous form cost about 1.8x, and
this one is now within the run-to-run variance of the code before any of
it.
Summary
Adds a bidirectional converter between Apache Ossie semantic models and Cube data models, under
converters/cube/. Pure offline YAML transform — no Cube deployment, API token, or network access required, matching the other Python converters in this repo.ossie-cube import): Cube files → Ossie. Cube-only constructs (segments, pre-aggregations, hierarchies, folders, view curation, formats, access policies, …) are preserved incustom_extensions[CUBE], so Cube → Ossie → Cube is lossless.ossie-cube export): Ossie → Cube files. Cube has ametafield at every level, so Ossie constructs Cube has no slot for (unique_keys, foreign-vendorcustom_extensions, the structured form ofai_context) are parked undermeta.ossierather than dropped — making Ossie → Cube → Ossie lossless too.The Ossie
semantic_modelmaps to a Cube view, not a cube. Cube users are view-first, and Cube's own AI agent readsmeta.ai_contextonly from views and individual members — cube-level AI context is explicitly not consumed — so the view is the natural model boundary.Fan-out semantics
Cube corrects for join row-multiplication at query time: when a cube sits on the multiplied side of a join it builds
SELECT DISTINCT <primary key> FROM <join>, joins that key set back to the measure's own cube, and aggregates there, so each source row is counted once. A static Ossie expression has no way to inherit that. Cube also refuses outright when the measures themselves span cubes that fan out.So the converter emits the fan-out-safe form wherever one exists, and reports the cases where none exists:
countCOUNT(DISTINCT <pk>)count(pk)normally andcount(distinct pk)when multiplied;COUNT(DISTINCT pk)equals bothcount_distinct/count_distinct_approxCOUNT(DISTINCT x)/APPROX_COUNT_DISTINCT(x)min/maxMIN(x)/MAX(x)sum,avg,count+sqlSUM(x),AVG(x),COUNT(x)Only the last row is at risk, and only when its cube is the
to(one) side of a relationship in the model. That is computable from the Ossie graph, and the converter records a structuredFANOUT_UNSAFE_METRICissue naming the metric, the dataset, and the responsible relationship. Refusing the model outright would leave the spoke on the other side with nothing, and these are the metrics most worth converting;--strict-fanoutrestores the refusal, mirroring Cube's own.Going the other way, an Ossie metric combining several aggregates is decomposed into one
public: falsemeasure per aggregate, each declared on the cube its own operand reads, plus atype: numbermeasure referencing them. Cube's correction then applies per aggregate instead of once for the whole expression. The parts carrymeta.ossie.part_of, so import skips them and inlines their SQL back through the references, recovering the original expression exactly.This points at a spec gap. Ossie has no additivity or grain declaration to record non-additivity properly. dbt's
non_additive_dimensionis the nearest precedent, and this repo's dbt converter already loses the same information (osi_to_msi.pyhard-codesnon_additive_dimension=None, with a namedCUMULATIVE_SEMANTICS_LOSSissue type). Raised separately as #290.Other design notes
{CUBE.member}when the dataset declares a field of that name (reuses the member's SQL, compile-time checked),{CUBE}.columnfor a raw physical column, and{other_cube.member}across cubes — which is also what gives a cross-dataset Ossie metric its implicit join. The cube's own name is never emitted, so models surviveextends.{other_measure}references, because that is what Cube itself does; Ossie has no metric-to-metric reference. Cycles are rejected. Locating the aggregates inside a composite expression usessqlglot, already a runtime dependency of the dbt and NVIDIA GSF converters for the same purpose.filtersfold intoCASE WHEN … ENDinside the aggregate, matching Cube's ownapplyMeasureFiltersrendering and the filtered-aggregation idiom the Ossie expression language endorses.type: numberomits Ossiedatatyperather than asserting a precision the model does not carry — Cube collapses Integer/Decimal/Float into one type, and the spec says to omit when unknown. The original type is stashed.type: geodimensions split into<name>_latitude/<name>_longitude, since an Ossie field holds one expression and a geo dimension has two.Unsupported constructs
extends— resolving it means reproducing Cube's definition-merge semantics exactly, so it is refused rather than half-applied..js/.tsmodels — preserved verbatim and never half-converted. Jinja is detected per file, the same rule Cube's ownCubeSchemaConverteruses for the Rollup Designer.Losses the converter can absorb are returned as structured
ConverterIssues (following theosi-dbtconverter) rather than printed to stderr and forgotten, so a pipeline can gate on them.Testing
226 tests, 96% line coverage:
examples/tpcds_semantic_model.yamlas the converter guide asks;core-spec/osi-schema.json;hypothesisis unavailable.Also verified by hand against a real production Cube model: round trip content-identical with original filenames preserved, output passing
validation/validate.py, and the fan-out guard correctly flagging the two measures on the joined cube'soneside.Related Issues
Related to #290 (spec has no way to declare a non-additive metric).
Checklist
Specification
core-spec/changesOntology
ontology/changesConverters
CUBEregistered in the supported-vendors table inconverters/README.mdValidation
validation/changes; emitted models are validated by the existingvalidation/validate.pyDocumentation
converters/cube/README.mddocuments the full mapping, the fan-out behavior, requirements, and limitationsExamples
Tests
Compliance
jsonschemadev dependency already ships inconverters/orionbelt/AI assistance
This contribution was developed with AI assistance (Claude). I have reviewed the code and tests and take responsibility for them, per the ASF Generative Tooling Guidance.