Skip to content

feat: add lightdash converter - #360

Open
Spissable wants to merge 35 commits into
apache:mainfrom
Spissable:add-lightdash-converter
Open

feat: add lightdash converter#360
Spissable wants to merge 35 commits into
apache:mainfrom
Spissable:add-lightdash-converter

Conversation

@Spissable

Copy link
Copy Markdown

Summary

Adds the bidirectional apache-ossie-lightdash converter and ossie-lightdash CLI.

  • Imports dbt metadata, seeds, catalog column types, and dbt-free Lightdash model files.
  • Converts datasets, dimensions, metrics, joins, primary keys, datatypes, and AI hints in both directions.
  • Preserves Lightdash-specific metadata in the registered LIGHTDASH extension.
  • Reports unsupported or lossy constructs instead of silently producing misleading output.
  • Exports standalone Lightdash model files by default, with dbt meta output available via --format dbt-meta.
  • Includes mapping documentation and dedicated Python 3.11–3.14 CI.

Related Issues

Closes #227
Supersedes #253 (with permission from @ota2000

Checklist

  • Spec and Python model register LIGHTDASH as a well-known vendor.
  • Converter behavior is covered by focused tests.
  • User-facing behavior, examples, and fidelity limitations are documented.
  • ASF license headers are present on new source files.
  • No ontology or validation-rule changes are required.

ota2000 and others added 30 commits July 23, 2026 14:09
Bidirectional converter between Ossie documents and Lightdash semantic
definitions (dbt schema.yml meta blocks): datasets/fields/metrics/
relationships map to models/columns/meta.metrics/meta.joins, and
Lightdash presentation attributes round-trip through custom_extensions
with vendor_name "lightdash". Follows the apache-ossie-<vendor> uv
packaging layout and ships tests off the TPC-DS example, including a
structural round-trip.
…elity

- Add converter-lightdash-ci.yml mirroring the other converters (tests
  were otherwise not exercised by CI)
- Rewrite ${other_table.column} references into cross-dataset
  references on import, not just ${TABLE}.column
- Keep inexpressible metric types (percentile) in the extension even
  when the metric carries custom SQL, on both column-level and
  model-level metrics
- Drop the redundant per-converter .gitignore (covered by the root
  one) and unused helpers; document the not-yet-carried model-level
  meta keys in the README
…ip columns

- Drop structural keys (sql/label on dimensions, sql/description on
  metrics) from the extension overlay on export, so a hand-authored
  extension can never override the OSI-derived definition; metric type
  stays overridable as the documented channel for inexpressible types
- Skip relationships whose from_columns/to_columns lengths differ,
  reporting RELATIONSHIP_COLUMNS_MISMATCHED instead of silently
  truncating the join
- Document that dataset names are not preserved when they differ from
  the source table name
… model metrics, add [tool.uv]

- Surface unparseable lightdash extension data as an
  EXTENSION_DATA_INVALID issue instead of silently dropping it
- Skip model-level metrics without sql on import, reporting
  METRIC_SQL_MISSING rather than emitting an empty OSI expression
- Add the [tool.uv] block (required-version, default-groups) used by
  the other converters so uv sync reliably installs the dev group in CI
Mirror the change from the recent CI actions path cleanup: trigger on
this workflow's own file instead of the whole .github tree.
* upstream/main:
  CI for databricks converter (apache#261)
  fix(dbt): resolve SUM_BOOLEAN semantic model for qualified columns (apache#292)
  [OSSIE][NVIDIA_GSF] Add bidirectional GSF semantic model converter (apache#247)
  Fix typo in python README.md (apache#284)
  Bump Java from 17 to 21 (apache#283)
  Adding AI disclosures updates (apache#259)
  Fix Omni pytest (apache#263)
  Updating CI Actions path (apache#276)
  Bump java version from 11 to 17 for Polaris converter (apache#278)
  Make the spec's own examples validate against osi-schema.json (apache#209)
  [Ontology] Flatten concept declarations in ontology spec (apache#257)
  feat(cli): add plugin invocation protocol (apache#155)
  Add datatype field to Field and Metric; reframe is_time as role marker (apache#113)
  Cleanup impl dir (apache#252)
Spec change apache#113 added `datatype` to fields and metrics and reframed
`dimension.is_time` as a role marker rather than a type flag, which broke
the TPC-DS round-trip: the converter inferred `is_time` from the Lightdash
type and wrote `false` where the source left it unset.

- Map Lightdash dimension types to and from `datatype`, so the type is
  carried in standard vocabulary instead of a vendor extension
- Leave `is_time` unset on import (Lightdash has no equivalent marker) and
  report TIME_ROLE_NOT_REPRESENTABLE when a non-temporal field is flagged
  as a time axis on export
- Emit a Lightdash type only for fields that are dimensions, so a
  measure-only field is not turned into a dimension by a round-trip
- Document that datatypes round-trip by category rather than exact type
…name

Upstream apache#288 renamed the Python models (OSIDocument -> OssieDocument, ...)
and the osi_to_<vendor> / <vendor>_to_osi module convention. Apply the same
rename to the Lightdash converter so it imports and runs against main.
The import command dumped the pydantic model without mode="json", so a
.yaml target raised RepresenterError on OssieDialect. Take argv in main()
and cover both output formats with a CLI test.
…port

Real Lightdash projects alias most joins, reference sibling fields as
${column}, other metrics as ${metric} and use typed metrics over custom SQL.
The importer matched joins on the model name only, left every non-${TABLE}
reference in the Ossie expression, ignored the sql of typed metrics and
emitted percentile and distinct metrics as a bare column.

- resolve join aliases when parsing sql_on; stash the alias and other join
  attributes in the relationship's lightdash extension and restore them on
  export, aliasing repeated dataset pairs so Lightdash can compile them
- rewrite ${column}, ${TABLE} and ${alias.column} references; inline
  ${metric} references; skip elements that use parameters or user
  attributes with an EXPRESSION_NOT_PORTABLE issue
- express every typed metric as an aggregation over its operand, including
  SUM(DISTINCT ...), AVG(DISTINCT ...) and PERCENTILE_CONT ... WITHIN GROUP,
  and parse those shapes back into typed metrics on export

On the 37-model jaffle-shop project this takes relationships from 1 of 21 to
21 of 21 and removes every unrewritten reference from the document.
…oins

- export prefers --dialect, then ANSI_SQL, and reports DIALECT_UNAVAILABLE
  instead of silently taking whatever dialect comes first
- import labels expressions with --dialect, since Lightdash SQL is written
  for the project's warehouse rather than ANSI
- a metric spanning several datasets is hosted on the dataset that joins
  all the others directly, with ${joined_model.column} references, instead
  of being dropped; TPC-DS's two cross-dataset metrics now export
- field expressions resolve joined datasets the same way and report
  FIELD_REFERENCE_UNJOINED when no join exists
Lightdash has vocabulary for more of the Ossie model than the README
claimed:

- dataset.primary_key <-> meta.primary_key (string or composite list)
- ai_context <-> ai_hint on models, dimensions and metrics; multi-line
  instructions become a list of hints, structured synonyms and examples
  are rendered as extra hints on export
- exported joins carry relationship: many-to-one, the cardinality an Ossie
  relationship defines, unless a stashed Lightdash value overrides it
- time_intervals: OFF <-> an explicit is_time: false on a temporal column
- metric datatype is derived from the aggregation and column type on import

A label or ai_context on a measure-only field is now dropped with a
FIELD_ATTRIBUTE_NOT_REPRESENTABLE issue instead of turning the field into
a dimension on export.
Lightdash evaluates {% ... %} / {{ ... }} tags and bare ld.parameters,
ld.query and ld.user references at query time; a dimension or metric using
them is skipped with EXPRESSION_NOT_PORTABLE like ${ld....} references.
dbt 1.10+ nests meta under config; Lightdash merges meta and config.meta
with config.meta winning. The importer only read the top-level key and
saw an empty project when every model used the new placement. Export gains
--meta-under-config to write that placement.
Lightdash scopes metric names per model, Ossie per semantic model, so any
real project (count, total_events, unique_users on many models) produced
an invalid document. The Ossie name is now Lightdash's own field id,
<model>_<metric>, which is stable regardless of what other models define
and matches the id users see in the API and URLs. The bare name travels in
the lightdash extension and is restored on export; an Ossie metric without
a stash exports under its name minus a <model>_ prefix. Names that still
collide after qualification are suffixed with a METRIC_NAME_COLLISION issue.
…rgets

Lightdash treats dbt seeds as models, so a schema file's seeds: entries
become datasets too. A join whose target is not in the input would leave a
dangling relationship (the validator rejects it); it is skipped with a
JOIN_TARGET_UNKNOWN issue instead.
Lightdash SQL may name a column of the current model without ${TABLE}
(SUM(budget_use)); the Ossie expression then carried no dataset qualifier
and export could not place the metric in a multi-dataset document (67 of
390 jaffle-shop metrics were dropped). Bare identifiers that name one of
the model's columns are now qualified, outside string literals and never
when called as a function, and the hosting model is stashed in the
extension so an expression that names no dataset at all is still placed.
…ield

Lightdash explores compose joins per base model and carry model meta with
no Ossie vocabulary; both were dropped silently. Now:

- a join through another joined model (${projects.org_id} =
  ${organizations.org_id} on the queries explore) derives the edge between
  the two models it names, unless that model declares it itself, and the
  join is stashed verbatim on the dataset's lightdash extension; expression
  joins and joins with extra conditions are stashed the same way
- export restores stashed joins, replacing the generated join to the same
  target and alias, and lets field and metric expressions resolve through
  them
- model meta without Ossie vocabulary (label, hidden, sql_filter,
  group_details, default_time_dimension, required_filters, ...) and column
  meta outside dimension/metrics (additional_dimensions, ...) travel in the
  dataset's and field's extension and come back as they were
- aliased joins on the same columns stay distinct relationships; pair and
  side order in sql_on no longer force a stash

On the analytics project every one of 140 joins and all 60 models' meta now
round-trip; jaffle-shop and demo-f1 likewise.
Lightdash treats every dbt column as a dimension by default; only
meta.dimension.hidden withdraws it from grouping. The converter read the
presence of meta.dimension as the dimension marker instead, so a real
project exported most of its columns as measure-only fields (5,481 of
7,435 on the Lightdash analytics project).

Import now emits dimension: {} for every column that is not hidden, and no
dimension for a hidden one. Export writes a measure-only Ossie field as a
hidden dimension, keeping its type, label and AI hint, and writes nothing
for a dimension with nothing else to say. FIELD_ATTRIBUTE_NOT_REPRESENTABLE
is gone: labels and hints always have a home now.
A metric's filters and a model's sql_filter / sql_where / required_filters
travel in the lightdash extension and round-trip, but every other consumer
of the document sees the unfiltered aggregate or the unrestricted dataset.
Both are now reported on import (METRIC_FILTER_NOT_PORTABLE,
ROW_FILTER_NOT_PORTABLE) instead of passing silently.
Add LIGHTDASH to the spec's vendor-name table, the schema examples, the
Python OssieVendor enum and the converters README, and make the Lightdash
converter emit the registered token. Documents written with the earlier
lowercase name are still read.
Restructure the README around usage, the Python API, the mapping, and a
section on why each loss is unavoidable (name scope, what a dimension is,
joins versus relationships, query-time evaluation, types), followed by what
is kept for Lightdash only, approximated, and not carried.
An Ossie document now becomes a deployable Lightdash project without a dbt
project around it: export writes lightdash/models/<model>.yml per dataset
(type: model, sql_from = the dataset's source, typed dimensions with their
own sql, metrics and joins in place) plus a starter lightdash.config.yml
whose warehouse type follows --dialect or --warehouse. Model meta the dbt
flavour has to stash becomes ordinary top-level keys. --format dbt-meta
keeps the single schema.yml output for dbt projects.

lightdash compile accepts the exported TPC-DS project: 5 explores, 0 errors.
dbt projects keep one schema YAML per model, so import now accepts a
directory as well as a file: it walks it in sorted order, merges every
list-valued models: and seeds: entry, and ignores target/, dbt_packages/
and dbt_project.yml. load_schema is exported for the Python API.
The CLI printed nothing on success and bare issue codes. It now reports
what it wrote (model files and config, or datasets, metrics and
relationships), explains each issue type the first time it appears, and
closes with a count. Everything goes to stderr, issues first, like the
other converters; stdout stays clean.
Lightdash learns most dimension types from the warehouse, not from YAML,
so a Lightdash project imports with most fields untyped and the model-file
export has to assume string for them. --catalog reads the catalog.json
that dbt docs generate writes and fills datatype for every column without
an authored type, reducing the physical type to Ossie's vocabulary
(INT64 -> Integer, NUMBER(12,2) -> Decimal, TIMESTAMP_TZ -> DateTimeTz).
Authored types win. A model missing from the catalog is reported, which is
how a stale catalog shows up.

On demo-f1 this takes the model-file export from 78 assumed types to 1.
A config written without a warehouse type carried CHANGE_ME and was then
never rewritten, so re-running with --dialect fixed the models but left
lightdash compile refusing the placeholder. The config is now rewritten
while it still holds the placeholder and kept once a real type is in it.
…y element

A real project produced hundreds of one-line issues. The report is now one
block per issue type: a header with the count, the explanation, and the
affected elements wrapped to a readable width (the first few, or all of
them with -v/--verbose).
…project

A template with placeholders or a Jinja-only file crashed the walk; it is
now skipped with a note naming the file. Virtualenv directories (env,
venv, .venv, site-packages) are ignored like target/ and dbt_packages/.
import now reads Lightdash model files (type: model, sql_from, a dimensions
list) as well as dbt schema files, mixed freely in one directory. A model
file is folded into the dbt model shape: dimensions become columns, its
sql_from is the dataset source verbatim (so --database/--schema are not
needed for it), and the rest becomes model meta. This closes the round trip
for dbt-less Lightdash projects and for anything export writes.
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.

Add a Lightdash converter

2 participants