Skip to content

Add bidirectional Apache Ossie <-> Cube converter - #289

Open
MikeNitsenko wants to merge 17 commits into
apache:mainfrom
MikeNitsenko:feature/cube-converter
Open

Add bidirectional Apache Ossie <-> Cube converter#289
MikeNitsenko wants to merge 17 commits into
apache:mainfrom
MikeNitsenko:feature/cube-converter

Conversation

@MikeNitsenko

@MikeNitsenko MikeNitsenko commented Jul 29, 2026

Copy link
Copy Markdown

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.

  • Import (ossie-cube import): Cube files → Ossie. Cube-only constructs (segments, pre-aggregations, hierarchies, folders, view curation, formats, access policies, …) are preserved in custom_extensions[CUBE], so Cube → Ossie → Cube is lossless.
  • Export (ossie-cube export): Ossie → Cube files. Cube has a meta field at every level, so Ossie constructs Cube has no slot for (unique_keys, foreign-vendor custom_extensions, the structured form of ai_context) are parked under meta.ossie rather than dropped — making Ossie → Cube → Ossie lossless too.

The Ossie semantic_model maps to a Cube view, not a cube. Cube users are view-first, and Cube's own AI agent reads meta.ai_context only 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 refuses to emit a silently-wrong one:

Cube measure Ossie expression Safe under fan-out?
bare count COUNT(DISTINCT <pk>) Yes, exactly — Cube renders count(pk) normally and count(distinct pk) when multiplied; COUNT(DISTINCT pk) equals both
count_distinct / count_distinct_approx COUNT(DISTINCT x) / APPROX_COUNT_DISTINCT(x) Yes, inherently
min / max MIN(x) / MAX(x) Yes — idempotent under duplication
sum, avg, count + sql SUM(x), AVG(x), COUNT(x) No

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 refuses by default — mirroring Cube's own refusal. --no-strict-fanout downgrades it to a structured issue naming the metric, dataset, and responsible relationship.

This points at a spec gap. Ossie has no additivity or grain declaration to record non-additivity properly. dbt's non_additive_dimension is the nearest precedent, and this repo's dbt converter already loses the same information (osi_to_msi.py hard-codes non_additive_dimension=None, with a named CUMULATIVE_SEMANTICS_LOSS issue type). Raised separately as #290.

Other design notes

  • Member references follow Cube's semantics rather than being uniform: {CUBE.member} when the dataset declares a field of that name (reuses the member's SQL, compile-time checked), {CUBE}.column for 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 survive extends.
  • Calculated measures inline their {other_measure} references, because that is what Cube itself does; Ossie has no metric-to-metric reference. Cycles are rejected.
  • Measure filters fold into CASE WHEN … END inside the aggregate, matching Cube's own applyMeasureFilters rendering and the filtered-aggregation idiom the Ossie expression language endorses.
  • Dimension type: number omits Ossie datatype rather 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: geo dimensions 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.
  • Jinja-templated YAML and .js/.ts models — preserved verbatim and never half-converted. Jinja is detected per file, the same rule Cube's own CubeSchemaConverter uses for the Rollup Designer.

Losses the converter can absorb are returned as structured ConverterIssues (following the osi-dbt converter) rather than printed to stderr and forgotten, so a pipeline can gate on them.

Testing

226 tests, 96% line coverage:

  • example-based unit tests per direction, plus CLI behavior tests;
  • fixture round-trip tests in both directions, including a TPC-DS model derived from examples/tpcds_semantic_model.yaml as the converter guide asks;
  • every emitted Ossie document validated against core-spec/osi-schema.json;
  • Hypothesis property-based round-trip tests over generated Cube models, with a seeded fallback so the properties still run where hypothesis is 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's one side.

Related Issues

Related to #290 (spec has no way to declare a non-additive metric).

Checklist

Specification

  • N/A — no core-spec/ changes

Ontology

  • N/A — no ontology/ changes

Converters

  • New converters include tests under the converter's test directory
  • CUBE registered in the supported-vendors table in converters/README.md

Validation

  • N/A — no validation/ changes; emitted models are validated by the existing validation/validate.py

Documentation

  • converters/cube/README.md documents the full mapping, the fan-out behavior, requirements, and limitations

Examples

  • N/A — no new spec constructs; the existing TPC-DS example is used as the test baseline

Tests

  • All existing tests pass (226 tests, 96% coverage; CI workflow added)
  • New functionality is covered by tests

Compliance

  • ASF license headers are present on all new source files
  • No new runtime dependencies (PyYAML only, as with the other Python converters). The jsonschema dev dependency already ships in converters/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.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.md to list CUBE.

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.

Comment thread converters/cube/tests/test_roundtrip_properties.py Outdated
Comment thread converters/cube/src/ossie_cube/osi_to_cube.py
MikeNitsenko added a commit to MikeNitsenko/ossie that referenced this pull request Jul 30, 2026
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>
@MikeNitsenko

Copy link
Copy Markdown
Author

Both addressed in d362c9e.

members_by_cube collisions. Correct, and worse than described. The two sanitization passes could disagree: {"Order Status", "order status"} collapsed to a single order_status with no error in _convert_model, and was only rejected later in _build_dimensions — after measures had already been placed against the short member set.

Names are now resolved once in _resolve_dimension_names and reused by every stage, so sanitization and collision detection happen in exactly one place. Added a test that a collision is rejected with a metric present, which is the ordering that was fragile.

This also fixed something not mentioned: the old set included both halves of a split geo dimension (location_latitude, location_longitude). Those never exist as Cube dimensions — they merge back into location — so a metric referencing one emitted an unresolvable {CUBE.location_latitude}. The halves now resolve to the dimension they merge into.

_HypothesisRnd.chance() ignoring p. Also correct. Now weighted, and drawn so the minimal value means False, which shrinks toward the smallest model rather than the largest. Verified the realised probability matches p exactly for every value the builders use.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

MikeNitsenko and others added 11 commits July 30, 2026 12:46
Scaffolds converters/cube/ following the osi-omni and osi-databricks
converters: a pure offline YAML transform with no Cube deployment, API
token, or network access required.

This commit lands the import direction (Cube -> Ossie). Cubes become
datasets, cube joins become relationships, cube measures are hoisted to
model-level metrics, and the mapped view supplies the model's name,
description, and AI context -- the view is the model boundary because
Cube users are view-first and Cube's agent reads meta.ai_context only
from views and members, not cubes.

Design decisions worth calling out:

- Fan-out. Cube corrects row multiplication at query time by
  deduplicating on declared primary keys, which a static Ossie
  expression cannot inherit. So a bare `type: count` maps to
  COUNT(DISTINCT <pk>) -- exactly equal to both forms Cube renders, and
  correct in every join context -- and a non-idempotent aggregate on a
  dataset the graph fans out is refused by default, mirroring Cube's own
  refusal. --no-strict-fanout downgrades it to a recorded issue.

- Calculated measures inline their {other_measure} references, because
  that is what Cube itself does; Ossie has no metric-to-metric
  reference. Cycles are rejected.

- Measure filters fold into CASE WHEN ... END inside the aggregate,
  matching Cube's own applyMeasureFilters rendering.

- Dimension `type: number` omits Ossie `datatype` rather than assert a
  precision the model does not carry; `type: geo` splits into two
  fields since an Ossie field holds one expression.

- Losses that cannot be avoided surface as structured ConverterIssues
  rather than bare warnings, following the osi-dbt converter, so a
  pipeline can gate on them.

Jinja-templated YAML, .js/.ts models, and `extends` are refused or
preserved verbatim rather than half-converted. Everything Cube-only
round-trips through custom_extensions[CUBE].

49 tests pass; the fixture output validates against core-spec/osi-schema.json
via validation/validate.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both directions are now implemented and losslessly round-trip.

Export emits one `model/cubes/<name>.yml` per dataset plus a
`model/views/<name>.yml` for the model itself -- the view is always
emitted, not optional, because it is the model boundary for view-first
Cube users. A model imported from Cube restores its original file paths,
view curation, segments, pre-aggregations, hierarchies, and every other
Cube-only construct from the stash; a hand-authored Ossie model gets a
view generated at the FK sink with each cube addressed by its join path.

Because Cube has a `meta` field at every level, Ossie constructs Cube has
no slot for (`unique_keys`, foreign-vendor `custom_extensions`, the
structured form of `ai_context`) are parked under `meta.ossie` instead of
being dropped. So Ossie -> Cube -> Ossie is lossless too, not just
Cube -> Ossie -> Cube.

Reference forms follow Cube's semantics rather than being uniform:
`{CUBE.member}` when the dataset declares a field of that name (reusing
the member's SQL, compile-time checked), `{CUBE}.column` for a raw
physical column, and `{other_cube.member}` across cubes -- which is also
what gives a cross-dataset metric its implicit join. The cube's own name
is never spelled out, so the model survives `extends`.

COUNT(DISTINCT <primary key>) converts back to Cube's bare `type: count`,
closing the loop on the fan-out mapping in both directions.

Tests (160): per-direction unit tests, fixture round-trips including a
TPC-DS model generated from examples/tpcds_semantic_model.yaml as the
guide asks, core-spec JSON Schema validation of every emitted Ossie
document, and Hypothesis property-based round-trips over generated Cube
models with a seeded fallback when hypothesis is unavailable.

The property tests found two real defects: a `{{` anywhere in a file
disqualifies it as Jinja (matching Cube's own file-level check), which
could leave a join pointing at a cube that was never converted -- the
error now names the skipped file instead of just reporting a missing
cube.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage was 91% with several load-bearing branches never executed. Adds
test_edge_cases.py (45 tests) for the paths the fixtures and property
tests cannot reach, since those generate inside the round-trippable
subset by design:

- composite primary keys -- the COUNT(DISTINCT CONCAT(CAST(...))) form is
  central to the fan-out mapping and was never run in either direction;
- `count` with `sql` (COUNT(x)), including that it is fan-out-unsafe
  where a bare count is not;
- the export side of the one_to_many flip -- import flipping it was
  tested, export flipping it back was not;
- off-layout file grouping: several cubes in one oddly-named file have to
  return to that same file, not be split into the canonical layout;
- the JavaScript-style mapping form of dimensions/measures/joins;
- legacy belongsTo/hasMany/hasOne spellings;
- unconvertible joins restored at their original positions;
- geo dimension extras, measure `title`, `ai_context.examples`, a bare
  string `ai_context`, multiple views, and the malformed-input errors.

Two dead paths removed, both found by the same pass:

- member-level Jinja handling was unreachable. JINJA_RE is checked per
  file (as Cube's own CubeSchemaConverter does), so a templated member's
  file never reaches the per-member branch. Renamed the issue type
  TEMPLATED_MEMBER_DROPPED -> TEMPLATED_FILE_SKIPPED to match what it
  actually reports, and dropped the matching `extra_dimensions` restore.
- `is_cube_name` was never called.

Coverage 91% -> 96%; the remaining 40 lines are defensive ConversionError
branches on malformed input. 201 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answering "what happens if I pass only a view file?" turned up three
things.

The behavior was right -- a Cube view projects members from cubes and
defines none, so it cannot become an Ossie model on its own and the
conversion is refused. But the message said "no convertible cubes found",
which reads as if the file was not recognized at all. It now says a view
was found, explains why that is not enough, and names the cubes its
join_paths reference so the user knows which files to add.

Pointing `-i` at a single `.yml` was refused as "not a directory". There
is nothing ambiguous about a single model file, so it is now accepted.

And cli.py had no tests at all -- 0% coverage. Adds test_cli.py (15
tests) covering the input shapes people reach for first (directory,
single file, view-only), that stdout stays pipeable while issues go to
stderr, that a fan-out refusal exits non-zero and --no-strict-fanout
downgrades it, that node_modules and dotfiles are skipped, and a CLI
round trip that reproduces the TPC-DS fixture.

216 tests; coverage 96% -> 97%, cli.py 0% -> 99%.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Cube itself has a single model root (`CUBEJS_SCHEMA_PATH` is one string,
default `model`), so pointing at that root is the idiomatic whole-project
case and the recursive walk already handles files spread across
subdirectories under it. But *requiring* one path was friction: converting
two cubes out of fifty, or files that live in separate trees, meant
assembling a directory first just to satisfy the CLI.

`-i` now takes any number of files and/or directories, so globs work too.

Files are keyed relative to the deepest directory containing every input,
because those keys decide where export writes them back. That
generalization is deliberately behavior-preserving: one directory anchors
to itself and one file to its own directory, so existing round trips key
exactly as before. Two files from `cubes/` and `views/` key as
`cubes/orders.yml` and `views/sales.yml`, and export reproduces that tree.

Overlapping inputs (a directory plus a file inside it) are now an error
rather than reading the same file twice.

Verified on a real Cube model passed as two explicit file paths: round
trip is content-identical with the original filenames preserved, the Ossie
output passes validation/validate.py, and the fan-out guard correctly
refuses both measures on the joined cube's `one` side.

226 tests, 96% line coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The converter emits vendor_name: CUBE in custom_extensions, so it belongs
in the table converters/README.md keeps of vendors with defined extensions.

Deliberately not touching the parallel list in core-spec/spec.md: vendor_name
is a free-form string, so no spec change is needed, and edits under core-spec/
carry the heavier review process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other bidirectional converter in the repo pairs a vendor fixture
with an Ossie one (databricks, omni, gooddata, orionbelt); this converter
was the only one keeping its Ossie inputs as inline Python strings.

Adds fixtureA_ossie.yaml and tpcds_ossie.yaml, and moves the hand-authored
Ossie model out of test_roundtrip.py into hand_authored_ossie.yaml.

The point is not just tidiness. Following the databricks pattern, the
fixtures are asserted as whole-document snapshots in both directions:
import must reproduce the Ossie fixture, and exporting that fixture must
reproduce the Cube fixture. Field-level assertions cannot see an
unintended change elsewhere in the document; a snapshot shows it as a
readable diff.

Each fixture carries the command to regenerate it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
A Cube `type: geo` dimension holds two SQL expressions where an Ossie
field holds one, so import splits it into `<name>_latitude` /
`<name>_longitude`. Export merges them back, so the round trip was already
exact.

But those half names exist only in Ossie. Cube has neither a column nor a
member called `home_latitude` -- the halves merge into `home` -- so a
metric or field expression referencing one had nothing valid to emit:

    AVG(users.home_latitude)  ->  sql: '{CUBE}.home_latitude'

which names a column that does not exist (the column is `lat`). The
member-reference form would have been just as wrong, failing at Cube
compile time instead of in the database.

The half's real SQL is already in the stash, so a reference to one is now
replaced by that SQL:

    AVG(users.home_latitude)                    ->  AVG({CUBE}.lat)
    AVG(users.home_latitude) - MIN(orders.amt)  ->  AVG({users}.lat) - MIN({CUBE.amt})

`{CUBE}` means "the cube this is declared on", so an inlined snippet is
requalified to name its original cube when it crosses into another cube's
SQL -- otherwise it would silently rebind to the wrong cube.

One normalization follows and is documented: after a round trip such a
metric names the column the half actually reads (`users.lat`) rather than
the Ossie-only field name. Same reference, and the only form Cube can
express.

234 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>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_extensions but the imported Cube model had multiple views and none was selected (mapped_view is missing), export currently drops those extensions while logging PARKED_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>
@MikeNitsenko

Copy link
Copy Markdown
Author

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 SNOWFLAKE extension to the Ossie model, export — and the extension is gone, reported under an issue type that says "parked".

Now refused, with the fix in the message (--view <name> on the import so a view is mapped). I considered parking on a deterministic view as suggested, but rejected it: import only reads parked extensions back from the mapped view, so that would have looked lossless while still losing them. Refusing is the honest option and matches the converter's stated contract of never dropping a field silently.

The type/message inconsistency was in two places, not one. PARKED_IN_META is documented as preserved-but-invisible-to-Cube, so reporting a genuine drop under it would let a caller gating on issue types conclude the data survived. Added DROPPED_NO_CUBE_EQUIVALENT and used it for relationship ai_context — a Cube join entry takes only name/sql/relationship, with no meta, which makes it the one construct here with nowhere to go.

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.

@MikeNitsenko
MikeNitsenko requested a review from Copilot July 30, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 32 changed files in this pull request and generated 1 comment.

Comment thread converters/cube/src/ossie_cube/osi_to_cube.py Outdated
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>
@MikeNitsenko

Copy link
Copy Markdown
Author

Confirmed and fixed in d92711e. Reproduced it first — with longitude first and a city dimension between the halves, city disappeared from the output entirely:

emitted dimensions:
   {'name': 'home', 'type': 'geo', 'latitude': {...}, 'longitude': {...}}
'city' present? False

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:

  • A geo base colliding with an ordinary field of the same name. With the ordinary field first, export emitted two dimensions called home (invalid Cube); with it second, sanitize_name correctly rejected it. Order was deciding whether an invalid model was produced. Now checked during name resolution.
  • Two fields both claiming the same half silently discarded one. Now rejected.

Also validated the geo part and of values, and moved the missing-half check into name resolution so every geo problem is caught in one place before anything is built.

Six cases pinned by tests: either order, collision in both orders, duplicate half, missing half, unknown part.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 32 changed files in this pull request and generated 1 comment.

Comment thread converters/cube/src/ossie_cube/osi_to_cube.py
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>
@MikeNitsenko

Copy link
Copy Markdown
Author

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.

site was now
unique_keys parked parked (correct)
extra semantic_model entries parked dropped
dimension.is_time role parked dropped
dimension.is_time opt-out parked dropped
synthesized primary-key dimension parked approximated
no datatype → Cube type: string parked approximated
cross-dataset metric placement parked approximated

The import-direction uses were all genuine parks and are unchanged.

Added APPROXIMATED for the middle group, which neither existing type 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. Labelling that "parked" was wrong in the same way as labelling a drop "parked" — nothing was parked in either case.

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.

All five reproduce; each was confirmed before being fixed.

1. Multi-stage measures were lost outright. They get no `metrics` entry --
   correctly, a window function over another grain has no static Ossie
   expression -- but nothing stashed them either, and `measures` is a
   natively-mapped key so `cube_extras` did not carry it. The issue message
   meanwhile claimed the measure had been preserved, which was simply
   untrue. They now ride on the owning dataset's stash with their index,
   the protocol unconvertible joins already use, and export interleaves
   them back among the measures rebuilt from metrics. Measure conversion
   moved ahead of dataset construction so the stash is known in time, which
   meant reading primary keys straight off the dimensions instead of taking
   them from _convert_cube's return.

   Renamed MULTI_STAGE_MEASURE_DROPPED -> MULTI_STAGE_MEASURE_PARKED: with
   the measure genuinely preserved, "dropped" was the same mislabelling
   corrected in c22e6d2.

2. One-to-one joins were treated as fan-out paths. Neither side of a
   one-to-one multiplies, so a valid `sum` on the `to` side was refused
   under strict mode. Now excluded, keyed off the normalized cardinality in
   the stash so `one_to_one` and legacy `hasOne` both count. A
   hand-authored relationship carries no Cube cardinality and keeps the
   conservative assumption.

3. Export could emit Cube its own importer rejects. `COUNT(*)` became a
   bare `type: count`, but that form is this converter's representation of
   `COUNT(DISTINCT <pk>)`, so importing it demanded a primary key the
   dataset need not have. `COUNT(*)` now becomes `type: number` with the
   expression intact -- a pair Cube's own BaseQuery special-cases -- so it
   round-trips exactly and needs no key.

4./5. Foreign-vendor extensions were parked under `meta.ossie` at field and
   metric level but only read back at dataset level, so they were dropped
   on re-import. Restored via one shared helper, after write_stash, so the
   CUBE entry stays first as it already did for datasets.

248 tests, 97% line coverage. `git diff --check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:456

  • Geo dimensions don’t correspond to a single primary-key column (they have separate latitude/longitude SQL), so treating the merged geo dimension name as “covered” for dataset.primary_key can incorrectly satisfy a PK column and prevent synthesizing the required column dimension. covered should only mark actual column references (simple identifiers) that can back a PK.
        built[base] = dim
        covered[base] = base

converters/cube/src/ossie_cube/osi_to_cube.py:445

  • covered is used to decide whether each dataset.primary_key entry (which the Ossie schema defines as column names) is already represented by an emitted dimension. Adding covered[dname] = dname makes any dimension whose name happens to match a PK column count as “covered” even if its expression/sql is computed (not that column), which can incorrectly mark a computed dimension as primary_key: true instead of synthesizing a column dimension for the PK.

This issue also appears on line 455 of the same file.

        built[dname] = dim
        covered[dname] = dname
        if is_simple_identifier(expr):
            covered[expr.strip()] = dname

MikeNitsenko and others added 2 commits July 30, 2026 17:03
_build_views keyed one view per file path, so two views in the same file
collapsed to whichever was written last. Reproduced: a file with `alpha`
and `beta` came back holding only `beta`.

Worse than a plain drop, because the survivor is arbitrary. In the
reproduction the lost view was `alpha` -- the *mapped* one, which is where
the model's description and AI context live, so the model's own metadata
lost its home too.

Now grouped path -> [views] and extended into files_content, preserving
declaration order.

The existing two-view test did not catch this: it put each view in its own
file, so the paths never collided. The new tests use one shared file and
assert both the round trip and that the mapped view is still the one
carrying model metadata.

250 tests, 97% line coverage. `git diff --check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`primary_key: true` in Cube declares that dimension's own `sql` to be the
key, but coverage was satisfied by name equality alone. Two ways that
declared the wrong thing, both reproduced:

  primary_key: [id]        + dimension `id` sql `LOWER(email)`
    -> {name: id, sql: LOWER(email), primary_key: true}
       the lowercased email became the key

  primary_key: [location]  + merged geo dimension `location`
    -> {name: location, type: geo, primary_key: true}
       a dimension with two sql expressions and no single one

Coverage now requires the dimension to be *scalar* -- its expression a
single source column -- reachable either by that column's name or by its
own name. A computed dimension and a merged geo dimension qualify under
neither.

Removing the name match outright would have regressed the round trip:
import records the *dimension name* in `primary_key`, not the column, so a
Cube dimension `order_id` with `sql: id` comes back as
`primary_key: [order_id]`. Gating the name match on the dimension being
scalar keeps that working while excluding the two bad cases -- verified
against a Cube -> Ossie -> Cube trip that stays exact.

Synthesizing the replacement now avoids collisions. The obvious name is
the key entry itself, but a computed or geo dimension may already own it,
and emitting a second dimension of that name would produce an invalid cube
while overwriting would lose a member. A `_pk` suffix is added until the
name is free, and the issue says so when it happens.

255 tests, 97% line coverage. `git diff --check` clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MikeNitsenko
MikeNitsenko requested a review from Copilot July 30, 2026 12:44

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

.github/workflows/converter-cube-ci.yml:53

  • Piping a remote install script directly into sh in CI increases supply-chain risk and reduces reproducibility. Prefer using a pinned GitHub Action for uv installation (or pinning an explicit uv version + checksum) so builds are auditable and deterministic.
      - name: Install uv
        run: |
          curl -LsSf https://astral.sh/uv/install.sh | sh
          echo "${HOME}/.local/bin" >> "${GITHUB_PATH}"

converters/cube/src/ossie_cube/cli.py:143

  • The CLI reads model files without an explicit encoding, which can cause inconsistent behavior across platforms/locales (especially on Windows). Consider opening files with encoding=\"utf-8\" (and similarly when writing outputs) since Cube/Ossie YAML is expected to be UTF-8.
    with open(path) as fh:
        files[rel] = fh.read()

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants