Skip to content

feat(snowflake): convert Snowflake Cortex Analyst models to Ossie - #567

Open
RyutoYoda wants to merge 1 commit into
apache:mainfrom
RyutoYoda:feat/snowflake-importer
Open

RyutoYoda wants to merge 1 commit into
apache:mainfrom
RyutoYoda:feat/snowflake-importer

Conversation

@RyutoYoda

@RyutoYoda RyutoYoda commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Summary

The Snowflake converter only exported. This adds the import direction, so a
Cortex Analyst semantic model can be read into an Ossie model and handed to any
other spoke in the hub.

ossie-snowflake snowflake-to-ossie -i cortex.yaml -o model.yaml is the new
subcommand, ossie-to-snowflake names the existing one, and the pre-subcommand
-i/-o spelling keeps working. The full mapping is in the README; three
points worth calling out here:

  • Expressions that are provably standard SQL are labelled ANSI_SQL, and the
    narrower SNOWFLAKE label is kept only for SQL this converter cannot vouch
    for. Every exporter reads its own dialect, then ANSI_SQL, then
    OSSIE_SQL_2026, and drops a field labelled with none of them, so a
    SNOWFLAKE-only label would remove the field from every other spoke. The
    check runs over sqlglot's Snowflake tokenizer, which this package already
    depends on.
  • Snowflake content with no Ossie home is carried in custom_extensions from a
    short allowlist; anything else is dropped with a warning naming it.
  • Ossie has model-level metrics only, so a table's own metrics are lifted to the
    model, with a warning when lifting collides on a name.

The shared data type tables and dialect names moved to _common.py so the two
directions cannot drift apart.

Verification

  • uv run pytest: 310 passed, of which 119 are new. Green on Python 3.11, 3.12,
    3.13 and 3.14; uv lock --check clean; dev/check-license passes.
  • The export direction is untouched in behaviour: the same Ossie model converted
    on main and on this branch gives byte-identical Snowflake YAML.
  • Every imported model in the tests is validated against
    core-spec/ossie-schema.json, the way the Microsoft, Cube, NVIDIA, OrionBelt
    and ThoughtSpot converters validate theirs.
  • End to end on a live Salesforce org: a Cortex Analyst model with both key
    kinds, dimensions, a time dimension, facts, a table-level metric, a
    model-level metric and a relationship imports to Ossie (0 schema errors),
    exports through the Salesforce converter, and is accepted 201. The org reads
    back both data objects, all nine fields, both calculated measurements and the
    relationship joining ORDERS.CUSTOMER_ID to CUSTOMERS.CUSTOMER_ID.
  • Round trip: that model imported to Ossie and exported back to Snowflake keeps
    every table, field role, key, synonym, relationship and metric.

Related Issues

Closes #162. Supersedes #163, whose approach this builds on.

Builds on #566; the diff here carries that commit until it merges.

Checklist

Sections this change does not touch (Specification, Ontology, Validation,
Examples) are omitted rather than left unchecked.

Converters

  • Converter logic updated
  • Tests live under the converter's test directory
  • .github/labeler.yml already covers converters/snowflake/**

Documentation

  • The README documents the new subcommands, the import mapping and the
    metric lifting
  • ROADMAP.md and converters/README.md describe the spoke as
    bidirectional, the way they already describe GoodData and OrionBelt

Tests

  • All existing tests pass: 310 passed locally, green on the CI matrix
  • New functionality is covered by tests

Compliance

  • ASF license headers present on the new files
  • jsonschema is added to the dev group only, for the schema check five
    other converters in this repo already run; nothing is added at runtime
  • No third-party source code is vendored

Comment thread converters/snowflake/src/ossie_snowflake/_common.py
Comment thread converters/snowflake/src/ossie_snowflake/snowflake_to_ossie.py
Comment thread converters/snowflake/src/ossie_snowflake/snowflake_to_ossie.py Outdated
@RyutoYoda
RyutoYoda force-pushed the feat/snowflake-importer branch from b5effe0 to 615532b Compare October 12, 2026 11:35
@RyutoYoda
RyutoYoda requested a review from jbonofre October 12, 2026 11:41
The Snowflake converter only exported. This adds the import direction, so a
Cortex Analyst semantic model can be read into an Ossie model and handed to any
other spoke in the hub.

`ossie-snowflake snowflake-to-ossie -i cortex_analyst.yaml -o semantic_model.yaml`
is the new subcommand; `ossie-to-snowflake` names the existing one, and the
pre-subcommand `-i`/`-o` spelling keeps working.

The mapping is documented in the README. Three points worth calling out:

- Expressions that are provably standard SQL are labelled `ANSI_SQL`, and the
  narrower `SNOWFLAKE` label is kept only for SQL this converter cannot vouch
  for. Every exporter reads its own dialect, then `ANSI_SQL`, then
  `OSSIE_SQL_2026`, and drops a field labelled with none of them, so a
  `SNOWFLAKE`-only label would remove the field from every other spoke.
- Snowflake content with no Ossie home is carried in `custom_extensions` from a
  short allowlist, and anything else is dropped with a warning naming it.
- Ossie has model-level metrics only, so a table's own metrics are lifted to the
  model. Lifting qualifies their column references with the table they came
  from, and warns when it collides on a name.

The shared data type tables and dialect names moved to `_common.py` so the two
directions cannot drift apart.

Closes apache#162
@RyutoYoda
RyutoYoda force-pushed the feat/snowflake-importer branch from 615532b to 56791c8 Compare October 12, 2026 11:47
@RyutoYoda

Copy link
Copy Markdown
Contributor Author

@jbonofre Rebased onto main and addressed the three review points. The workflow runs on 56791c8 are awaiting maintainer approval, so CI has not run yet.

for column in tree.find_all(exp.Column):
if column.table:
continue
start = column.this.meta.get("start")

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.

This splices dataset. in at column.this.meta["start"], which assumes that offset is where the identifier begins. For a quoted identifier like SUM("Amount"), does sqlglot report the offset before or after the opening quote? If after, this yields SUM("dataset.Amount"). A dataset name that needs quoting would also go in unquoted. Could you add a test with quoted column names and a quoted dataset name?

"""
result = _convert_named_expr(entry, "field")

if is_time is not None:

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.

facts pass is_time=None, so no dimension block is written. On re-export, the datatype default classifies a date or time-typed fact as a time_dimension, so its role changes across a Snowflake -> Ossie -> Snowflake round trip. Non-blocking: either stash the original role or add a round-trip test that documents this.

"synonyms", "dimensions", "time_dimensions", "facts", "metrics",
)
_FIELD_MAPPED_KEYS = ("name", "expr", "description", "synonyms", "data_type", "unique")
_RELATIONSHIP_MAPPED_KEYS = (

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.

join_type and relationship_type aren't in _RELATIONSHIP_MAPPED_KEYS, so they're dropped with an "unrecognized key" warning. That wording suggests a newer Snowflake feature, when these are common keys and a known gap. Non-blocking: could they get an explicit "not mapped" message?

})


def is_portable_sql(expr):

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.

IGNORE NULLS tokenizes as plain names, so FIRST_VALUE(x) IGNORE NULLS passes this check and gets labelled ANSI_SQL. It isn't standard SQL, so other warehouses would reject it. Non-blocking: could the check look for the IGNORE/RESPECT NULLS sequence explicitly?

raise ValueError(
f"SNOWFLAKE custom_extensions data is not valid JSON: {e}"
) from e
data.pop("_v", None)

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.

If the stash is valid JSON but not an object (for example a list), data.pop("_v", None) raises AttributeError instead of the ValueError used for bad JSON above. An isinstance(data, dict) check that raises ValueError would keep the error handling consistent.

for column in tree.find_all(exp.Column):
if column.table:
continue
start = column.this.meta.get("start")

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.

This splices dataset. in at column.this.meta["start"], which assumes that offset is where the identifier begins. For a quoted identifier like SUM("Amount"), does sqlglot report the offset before or after the opening quote? If after, this yields SUM("dataset.Amount").

A dataset name that needs quoting would also go in unquoted.

Could you add a test with quoted column names and a quoted dataset name?

"""
result = _convert_named_expr(entry, "field")

if is_time is not None:

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.

facts pass is_time=None, so no dimension block is written. On re-export, the datatype default classifies a date or time-typed fact as a time_dimension, so its role changes across a Snowflake -> Ossie -> Snowflake round trip.

It's non-blocking, just a minor comment.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Snowflake → OSI import converter (snowflake_to_osi)

2 participants