Skip to content

Add OrionBelt (OBML) converter - #153

Merged
khush-bhatia merged 8 commits into
apache:mainfrom
ralfbecher:converter/orionbelt
Jul 14, 2026
Merged

Add OrionBelt (OBML) converter#153
khush-bhatia merged 8 commits into
apache:mainfrom
ralfbecher:converter/orionbelt

Conversation

@ralfbecher

Copy link
Copy Markdown
Contributor

Summary

Adds a bidirectional converter between OSI and OrionBelt's OBML (OrionBelt Markup Language) under converters/orionbelt/, following the hub-and-spoke guidance in converters/index.md.

  • OBML to OSI: maps datasets, fields, relationships, and metrics to the OSI core spec. OrionBelt-proprietary constructs (OBML-only filters, settings, owner, refresh, type info, named secondary join paths, many-to-many cardinality) are emitted under the ORIONBELT vendor in custom_extensions so nothing is dropped.
  • OSI to OBML: restores OSI-native fields that OBML can't represent (unique keys, field labels, leftover ai_context) via an OSI vendor stash, so a full OSI to OBML to OSI roundtrip is lossless. Third-party vendor extensions (SNOWFLAKE, DBT, SALESFORCE, GOODDATA, ...) are preserved verbatim at every level.
  • Also supports the OSI ontology export direction.
  • ANSI_SQL dialect for field and metric expressions, with the documented fallback behaviour.

Layout

Mirrors the existing Python converters (gooddata, dbt):

  • src/osi_orionbelt/ (converter, CLI, vendored OSI/OBML/ontology JSON schemas for self-contained builds)
  • tests/ with 120 tests, including a TPC-DS baseline against examples/tpcds_semantic_model.yaml (byte-identical fixture)
  • pyproject.toml, README.md, and OBML <-> OSI mapping analysis docs
cd converters/orionbelt
uv sync && uv run pytest   # 120 passed

CLI (osi-orionbelt) exposes obml-to-osi and osi-to-obml subcommands, mirroring osi-dbt.

Spec impact

None. vendor_name is free-form in the core spec, so the ORIONBELT vendor tag needs no schema or specification change. This is a converter-only (non-spec) contribution.

Notes

The canonical converter source is maintained in the orionbelt-semantic-layer repository (packages/osi-orionbelt) and published to PyPI as osi-orionbelt (Apache-2.0). Documented limitations (many-to-many joins, named secondary join paths, ontology-layer value concepts) are carried losslessly in custom_extensions and listed in the README.

@jbonofre jbonofre left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, I just left some comments about the fact we are loosing part of the model during convertion (whereas the README implies not 😄 ).

Comment thread converters/orionbelt/src/osi_orionbelt/converter.py Outdated
Comment thread converters/orionbelt/src/osi_orionbelt/converter.py Outdated
@ralfbecher

Copy link
Copy Markdown
Contributor Author

Thanks for the careful review, @jbonofre - you were right, and the "we lose part of the model on import" catch was a good one. The root cause was a test blind spot: our corpus only ever round-tripped our own output, which always emits ANSI_SQL and only expressions our parser can re-read, so the two import drop paths you spotted were never exercised. A genuinely third-party OSI model (e.g. Snowflake authored, or a non-SQL dialect) hit them and lost metrics with just a warning.

Pushed a fix to this branch that turns both drop paths into preserve-and-warn, plus a bit more:

  • No silent loss. A metric with no OBML representation (non-SQL dialect only, or an expression that does not decompose into OBML measures/metrics) is no longer dropped. The original OSI metric is preserved verbatim under an OSI-vendor model-level custom_extension (obml_unconverted_metrics) and re-emitted on OBML to OSI, so the OSI to OBML to OSI round trip stays lossless. Each one raises a loud LOSSY: warning, since the metric is not queryable through OBML.
  • Clear warning at import. Exactly your suggestion: the LOSSY: warnings surface through the converter's warnings list (and the OrionBelt REST API's conversion_warnings), so a SNOWFLAKE/MAQL-only model no longer goes quiet.
  • Dialect catching. Rather than only reading ANSI_SQL, the import now reads the first available SQL dialect in the order ANSI_SQL, SNOWFLAKE, DATABRICKS (their aggregations are ANSI-compatible). Non-SQL dialects (MDX, TABLEAU, MAQL) are not parsed as SQL. So a Snowflake- or Databricks-authored aggregation now converts to a real measure instead of being lost.

Two follow-on edge cases got hardened while in here: preserved metrics now contribute their dialects/vendors to the top-level v0.2 dialects / vendors informational arrays, and a preserved metric whose name a real converted metric later owns is skipped on export so the output has no duplicate metric names (passes validate_osi). convert() is also now idempotent on both directions.

Docs updated to scope the fidelity claim, and new regression tests cover both former drop paths, the round trip, the metadata arrays, the name collision, and idempotency (134 passing). Thanks again!

@ralfbecher

Copy link
Copy Markdown
Contributor Author

Follow-up: aligned this branch with #148 (drop non-spec root dialects/vendors).

While addressing the metric-loss feedback I noticed the OrionBelt converter was emitting root-level dialects and vendors, which the published core schema forbids (root is additionalProperties: false, only version + semantic_model) - the same non-conformance #148 fixes for the dbt converter. Our vendored schema copy had wrongly allowed them, so it went unnoticed.

The converter now emits neither root array. Dialects stay per-expression in expression.dialects[] and vendors per-entity in custom_extensions, so no information is lost and a preserved metric still round-trips with its own dialect/vendor intact. The vendored schema is updated to match the published core, and a guard test asserts the root arrays are now rejected.

Note this supersedes the first inline finding above (advertise restored metrics in the top-level dialects/vendors): per the published schema those arrays should not exist, so the right fix is to drop them rather than populate them. The metadata lives in the schema-valid per-expression / per-entity homes instead.

This also keeps us pointed at the single-document-dialect direction in #52. 135 tests passing.

Bidirectional OSI <-> OrionBelt OBML converter, plus a ROADMAP entry.

OSI -> OBML and OBML -> OSI with roundtrip fidelity via vendor custom_extensions.
Highlights from review on this PR:

- Reads ANSI_SQL, SNOWFLAKE, and DATABRICKS metric expressions (their
  aggregations are ANSI-compatible); MDX/TABLEAU/MAQL are not parsed as SQL.
- Resolves dataset.column references against the OSI dataset/field map
  case-insensitively and with SQL quoting stripped; decimal literals are kept
  literal. Unmappable references preserve the metric verbatim.
- Metrics with no OBML representation are preserved verbatim under an OSI-vendor
  customExtension and re-emitted on OBML -> OSI, with a loud LOSSY: warning, so
  the OSI -> OBML -> OSI roundtrip stays lossless.
- Emits schema-conformant OSI: no root-level dialects/vendors (those live
  per-expression and per-entity), matching the published core schema.
- Name-collision safety and idempotent convert() on both directions.
@ralfbecher
ralfbecher force-pushed the converter/orionbelt branch from 35faca4 to b1bf415 Compare June 16, 2026 07:31
Sync the vendored OBML JSON Schema with the canonical OrionBelt schema:
remove the snake_case duplicate properties (max_staleness, intent_tags)
so the contract is camelCase-only. Converter test suite green (141 passed).
Sync the vendored converter with the upstream OrionBelt package, which was
decomposed from a single 2967-line converter.py into a thin facade plus
focused modules. Public API (osi_orionbelt.converter.*) and behavior are
unchanged; the converter test suite passes (141).

- converter.py: now a facade re-exporting every public name + main()
- _common.py: shared constants / type maps
- osi_to_obml.py: OSItoOBML (OSI -> OBML)
- obml_to_osi.py: OBMLtoOSI (OBML -> OSI)
- ontology.py: OBMLtoOSIOntology
- validation.py: ValidationResult + validate_obml/osi/osi_ontology
@ralfbecher

Copy link
Copy Markdown
Contributor Author

Hi @jbonofre, gentle nudge on this one. Since your approval the diff has grown a fair bit in response to review, so I'd value a fresh look before merge:

  • Metric loss fix (preserve-and-warn): metrics with no OBML representation are now preserved verbatim in an OSI-vendor custom_extension and re-emitted, so the OSI to OBML to OSI round trip stays lossless, with loud LOSSY: warnings instead of silent drops.
  • Schema alignment with fix(converters): emit schema-conformant OSI documents (drop non-spec root dialects/vendors) #148: dropped the non-spec root dialects/vendors; dialects stay per-expression, vendors per-entity. Vendored schema updated to match the published core, with a guard test.
  • Re-vendor / refactor (06-23): refreshed the vendored obml-schema.json to camelCase-only and split the converter monolith into focused modules.

The PR is MERGEABLE/CLEAN with all tests passing. If the module split feels like too much to re-review in one pass, I'm happy to land the converter as you originally approved it and move that refactor into a follow-up PR, whichever is easier for you.

@khush-bhatia flagging for merge once the re-approval lands. Thanks both!

Ralf Becher and others added 3 commits July 5, 2026 17:42
Mirrors the canonical converter (packages/osi-orionbelt in the
orionbelt-semantic-layer repo): the OBML count-synthesis knobs
(countable, countLabel on a dataObject; exposeCounts, countLabelPattern
on the model) now roundtrip via the ORIONBELT vendor custom_extensions.
The auto-synthesized count measures themselves are derived on read and
are never exported, so there is no OSI spec or dataset/metric change.

- obml_to_osi.py: emit the four knobs into the ORIONBELT extension.
- osi_to_obml.py: restore them (is-not-None guards so exposeCounts:false
  and countable:false survive).
- schemas/obml-schema.json: add the four fields (vendored snapshot).
- tests: TestCountSynthesisKnobs roundtrip + no-leak coverage.
…ng consistency

Align with the OSI->Ossie incubation rename applied to the other converters:
package dir osi_orionbelt -> ossie_orionbelt, dist name apache-ossie-orionbelt,
CLI ossie-orionbelt. Keeps osi in spec-format file/class names (osi_to_obml.py,
OSItoOBML) as upstream did for dbt/gooddata.
@ralfbecher

Copy link
Copy Markdown
Contributor Author

Rebased on latest main and resolved the merge conflict (it was only in ROADMAP.md, from the OSI -> Ossie rename). Kept the Ossie wording for the existing converters and preserved the OrionBelt entry.

I also renamed the converter to match the incubation naming used by the other converters (dbt, gooddata): package ossie_orionbelt, distribution apache-ossie-orionbelt, CLI ossie-orionbelt. Consistent with upstream, I left osi in the spec-format file and class names (e.g. osi_to_obml.py, OSItoOBML). All 145 converter tests pass and the PR is mergeable again.

Comment on lines +1 to +5
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json",
"title": "OSI Core Metadata Specification",
"description": "JSON Schema for validating OSI (Open Semantic Interoperability) semantic model definitions",

@khush-bhatia khush-bhatia Jul 14, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why make a copy of the schema ? This becomes one more file that needs to be updated for any changes to the spec.

For existing converters, we are planning to consolidate the java/python and other language specific bindings to be generated from the spec and remove the duplication across different language bindings.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point, thanks. Rather than keep a copy, the converter now resolves the OSI core schema via a search path (validation._osi_schema_path): a vendored copy if present, otherwise core-spec/osi-schema.json from the enclosing checkout. I've deleted the vendored osi-schema.json, so OSI-core validation now links the single canonical core-spec/ copy with nothing to keep in sync. I also dropped the vendored osi-ontology-schema.json and made validate_osi_ontology semantic-only, since ossie ships no ontology schema and the converter emits ontology documents but never consumes them. This lines up with your plan to generate the bindings from the spec. Pushed.

r"\s*\.\s*"
r'(?P<col>[A-Za-z_]\w*|"[^"]+"|`[^`]+`|\[[^\]]+\])'
)
_OSI_KNOWN_VENDORS = (

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Where is this used ? Trying to understand the need for maintaining a list of known vendors within the scope of a converter

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

You're right, that list didn't belong here. It was dead: only re-exported, never used. The converter preserves any vendor outside its own internal set (_INTERNAL_VENDORS) verbatim in both directions, so there's nothing to enumerate. Removed both _OSI_KNOWN_VENDORS and the equally unused _OSI_KNOWN_DIALECTS. Pushed.

ralfbecher added a commit to ralforion/orionbelt-semantic-layer that referenced this pull request Jul 14, 2026
Addresses two review comments on apache/ossie#153:

1. Resolve the OSI core + ontology schemas via a search path instead of a
   hard-coded vendored path (validation.py::_osi_schema_path): prefer a copy
   vendored beside the package (needed by the self-contained PyPI wheel and the
   product /v1/convert API, which validates OSI input against it), otherwise
   fall back to core-spec/<name> in an enclosing Ossie monorepo checkout. This
   lets the in-tree converter drop its vendored copy and link the single
   canonical schema. No behaviour change here (the copy is still present), so
   OSI schema validation and its tests keep passing.

2. Remove the unused _OSI_KNOWN_VENDORS and _OSI_KNOWN_DIALECTS constants
   (only re-exported, never used; vendor passthrough keys off _INTERNAL_VENDORS,
   dialect parsing off _SQL_PARSEABLE_DIALECTS).

145 package tests plus the /v1/convert input-validation integration tests pass;
ruff and mypy clean.
…ead constants

Addresses review feedback on this PR:

1. Stop vendoring a copy of the OSI spec. validation._osi_schema_path resolves
   the OSI core schema from a search path: a copy vendored beside the package if
   present, otherwise core-spec/osi-schema.json from the enclosing checkout. The
   vendored osi-schema.json is deleted, so OSI-core validation now links the
   single canonical core-spec copy with nothing to keep in sync.

2. Omit ontology-schema validation. ossie ships no OSI ontology schema and the
   converter emits ontology documents but never consumes them, so the vendored
   osi-ontology-schema.json is removed and validate_osi_ontology runs semantic
   checks only (unique concepts, reference integrity).

3. Remove the unused _OSI_KNOWN_VENDORS and _OSI_KNOWN_DIALECTS constants
   (only re-exported; passthrough uses _INTERNAL_VENDORS, dialect parsing uses
   _SQL_PARSEABLE_DIALECTS).

Only the OBML schema (OrionBelt's own format) stays vendored. mypy override
added for the optional orionbelt engine import. 145 tests pass; ruff clean;
mypy clean with the dev extra.
…spec

The vendored osi-schema.json copy was removed so the source tree carries no
duplicate of the spec. But a pip-installed wheel has no core-spec/ alongside it,
so validate_osi would silently skip schema validation. force-include the single
canonical core-spec/osi-schema.json into the built wheel: no tracked duplicate,
yet a pip install still validates OSI documents self-contained. In-tree runs
still resolve it from core-spec/ via validation._osi_schema_path.
@khush-bhatia
khush-bhatia merged commit a7984d1 into apache:main Jul 14, 2026
ralfbecher added a commit to ralforion/orionbelt-semantic-layer that referenced this pull request Jul 14, 2026
…200)

Addresses two review comments on apache/ossie#153:

1. Resolve the OSI core + ontology schemas via a search path instead of a
   hard-coded vendored path (validation.py::_osi_schema_path): prefer a copy
   vendored beside the package (needed by the self-contained PyPI wheel and the
   product /v1/convert API, which validates OSI input against it), otherwise
   fall back to core-spec/<name> in an enclosing Ossie monorepo checkout. This
   lets the in-tree converter drop its vendored copy and link the single
   canonical schema. No behaviour change here (the copy is still present), so
   OSI schema validation and its tests keep passing.

2. Remove the unused _OSI_KNOWN_VENDORS and _OSI_KNOWN_DIALECTS constants
   (only re-exported, never used; vendor passthrough keys off _INTERNAL_VENDORS,
   dialect parsing off _SQL_PARSEABLE_DIALECTS).

145 package tests plus the /v1/convert input-validation integration tests pass;
ruff and mypy clean.
ralfbecher added a commit to ralforion/orionbelt-semantic-layer that referenced this pull request Jul 15, 2026
…ess (#201) (#202)

* osi-orionbelt: fix converter round-trip fidelity + validation robustness (#201)

Fixes the three pre-existing converter bugs found during the apache/ossie#153
review:

- P1 metric round-trip: OSItoOBML now resolves metric references by BOTH the
  OSI dataset/field name and the physical code (source-table code and
  bare-identifier field expression). The OBMLtoOSI emitter writes metric SQL
  against the physical code (e.g. SUM(fact_orders.amount)), so resolving names
  only dropped such metrics on the return trip when a data object's code
  differed from its display name.

- P2 dimension collision: _extract_dimensions no longer keys dimensions by bare
  field name. When the same field name occurs in more than one dataset
  (Orders.date, Invoices.date) the later one is qualified with its data object
  and a warning is recorded, instead of silently overwriting the first.

- P2 validation robustness: validate_osi guards its semantic loops against
  malformed structures (datasets/fields that are not lists of dicts) so it
  returns schema errors instead of raising AttributeError.

Adds tests/test_osi_converter_roundtrip_robustness.py (7 regression tests).
152 package tests pass; ruff and mypy clean; API convert integration tests pass.

* osi-orionbelt: make metric ref resolution space-safe (review fix)

Addresses a review finding on the P1 fix: resolving a physical code to a
display field name containing a space (e.g. net_amount -> 'Net Amount') spliced
'Orders.Net Amount' into the intermediate SQL string, and the downstream \w+.\w+
parsers split it on the space, emitting invalid OBML ('{[Orders].[Net]} Amount')
with no LOSSY warning.

_resolve_column_refs now bracket-quotes any resolved name that is not a bare SQL
word, and the dataset.column parsers (_parse_simple_agg, _parse_expr_agg,
_decompose_complex_metric, _sql_refs_to_obml) accept bracketed identifiers and
unquote them. A metric over a spaced display-name field now converts to a valid
single-column measure (column 'Net Amount') instead of dangling.

Adds a regression test for a physical code mapped to a display field with a
space. 153 package tests pass; ruff and mypy clean.

* osi-orionbelt: resolve quoted physical identifiers in metric refs (review P3)

Unquote single-identifier field expressions and source table codes before
indexing them for metric-reference resolution. A Snowflake/Databricks quoted
physical column (field expression "net_amount") or quoted source table
(WH.PUBLIC."fact_orders") referenced by its bare code now converts to a
queryable measure instead of being preserved as a LOSSY unconverted metric.

Adds a regression test for quoted source table + field expression. 154 package
tests pass; ruff and mypy clean.
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.

3 participants