You are viewing a plain text version of this content. The canonical link for it is here.
Posted to issues@ossie.apache.org by "jbonofre (via GitHub)" <gi...@apache.org> on 2026/10/08 16:30:44 UTC

Re: [PR] Add the wisdom-semantic-view converter [ossie]

jbonofre commented on code in PR #490:
URL: https://github.com/apache/ossie/pull/490#discussion_r4221508691


##########
converters/wisdom-semantic-view/src/ossie_wisdom_semantic_view/wisdom.py:
##########
@@ -0,0 +1,405 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Wisdom domain-export JSON, format 1.0, both directions, for this finance model."""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+from ossie_wisdom_semantic_view.model import (
+    SPEC_VERSION,
+    WISDOM_TO_OSSIE,
+    check_expression,
+    check_metric_expression,
+    die,
+    dimension_for_datatype,
+    only_snowflake,
+    OSSIE_TO_WISDOM,
+    snowflake_expression,
+)
+from ossie_wisdom_semantic_view.semantic_view import MANY_SIDE, ONE_SIDE, RELATIONSHIP_NAME
+
+# Constants for this offline demo. They are not read from a Wisdom tenant.
+# domain_name is the model name. For this fixture that name is finance_cfo_cockpit.
+EXPORTED_AT = "2026-01-01T00:00:00+00:00"
+SOURCE_DOMAIN_ID = "demo-finance-cfo"
+CONNECTION_ID = "et-connection-snowflake"
+BOARD_REVENUE = "Board revenue"
+SYNONYM_PREFIX = "Synonyms for "
+
+
+def dump_wisdom(export: dict) -> str:
+    return json.dumps(export, indent=2) + "\n"
+
+
+def load_wisdom(text: str) -> dict:
+    loaded = json.loads(text)
+    if not isinstance(loaded, dict):
+        die("version")
+    return loaded
+
+
+def domain_uuid(name: str) -> str:
+    return f"ET_DOMAIN_{name}"
+
+
+def zsheet_uuid(name: str) -> str:
+    return f"ET_ZSHEET_{name}"
+
+
+def knowledge_id(name: str) -> str:
+    slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
+    return f"ET_UNSTRUCTURED_KNOWLEDGE_{slug}"
+
+
+def measure_id(name: str) -> str:
+    return f"MEASURE_{name}"
+
+
+def empty_item_list() -> dict:
+    return {"ref": None, "items_json": "{}"}
+
+
+def reject_nonempty_synonym_sets(export: dict) -> None:
+    raw = export.get("synonym_sets", {}).get("items_json", "{}")

Review Comment:
   `export.get("synonym_sets", {})` returns `None` when the key is present but null, so this raises `AttributeError`. A non-dict result from `json.loads` of `items_json`, such as `[]`, gets past the `parsed != {}` check.
   Both print a traceback instead of the clean `exit 1`.
   
   Could we guard with `isinstance(..., dict)` and treat null as empty?



##########
converters/wisdom-semantic-view/src/ossie_wisdom_semantic_view/semantic_view.py:
##########
@@ -0,0 +1,339 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Snowflake semantic-view YAML, both directions, for this finance model only."""
+
+from __future__ import annotations
+
+import yaml
+
+from ossie_wisdom_semantic_view.model import (
+    SPEC_VERSION,
+    check_expression,
+    check_metric_expression,
+    die,
+    dimension_for_datatype,
+    only_snowflake,
+    require_keys,
+    snowflake_expression,
+    SNOWFLAKE_TO_OSSIE,
+    OSSIE_TO_SNOWFLAKE,
+)
+
+RELATIONSHIP_NAME = "bookings_to_fiscal_calendar"
+MANY_SIDE = "bookings"
+ONE_SIDE = "fiscal_calendar"
+
+_VIEW_KEYS = {
+    "name",
+    "description",
+    "tables",
+    "relationships",
+    "metrics",
+    "module_custom_instructions",
+}
+_TABLE_KEYS = {
+    "name",
+    "base_table",
+    "primary_key",
+    "dimensions",
+    "time_dimensions",
+    "facts",
+}
+_BASE_TABLE_KEYS = {"database", "schema", "table"}
+_PRIMARY_KEY_KEYS = {"columns"}
+_COLUMN_KEYS = {"name", "expr", "data_type"}
+_RELATIONSHIP_KEYS = {"name", "left_table", "right_table", "relationship_columns"}
+_RELATIONSHIP_COLUMN_KEYS = {"left_column", "right_column"}
+_METRIC_KEYS = {"name", "description", "expr", "synonyms"}
+_MODULE_KEYS = {"sql_generation"}
+
+
+def load_semantic_view(text: str) -> dict:
+    loaded = yaml.safe_load(text)
+    if not isinstance(loaded, dict):
+        die("name")
+    return loaded
+
+
+def dump_semantic_view(view: dict) -> str:
+    return yaml.safe_dump(view, sort_keys=False, width=4096, allow_unicode=True)
+
+
+def semantic_view_to_ossie(view: dict) -> dict:
+    require_keys(view, _VIEW_KEYS)
+    for key in ("name", "description", "tables", "relationships", "metrics"):
+        if key not in view:
+            die(key)
+    datasets = [_table_to_dataset(table) for table in _as_list(view["tables"], "tables")]
+    relationships = [
+        _relationship_to_ossie(item) for item in _as_list(view["relationships"], "relationships")
+    ]
+    dataset_names = [dataset["name"] for dataset in datasets]
+    metrics = [
+        _metric_to_ossie(item, dataset_names) for item in _as_list(view["metrics"], "metrics")
+    ]
+    model = {
+        "version": SPEC_VERSION,
+        "name": view["name"],
+        "description": view["description"],
+    }
+    instructions = view.get("module_custom_instructions")
+    if instructions is not None:
+        require_keys(instructions, _MODULE_KEYS)
+        if "sql_generation" not in instructions:
+            die("sql_generation")
+        model["ai_context"] = {"instructions": instructions["sql_generation"]}
+    model["datasets"] = datasets
+    model["relationships"] = relationships
+    model["metrics"] = metrics
+    return model
+
+
+def ossie_to_semantic_view(model: dict) -> dict:
+    if model.get("version") != SPEC_VERSION:
+        die(str(model.get("version")))
+    tables = [_dataset_to_table(dataset) for dataset in model["datasets"]]
+    relationships = [_ossie_relationship_to_view(item) for item in model.get("relationships") or []]
+    dataset_names = [dataset["name"] for dataset in model["datasets"]]
+    metrics = [
+        _ossie_metric_to_view(item, dataset_names) for item in model.get("metrics") or []
+    ]
+    view = {
+        "name": model["name"],
+        "description": model["description"],
+        "tables": tables,
+        "relationships": relationships,
+        "metrics": metrics,
+    }
+    ai_context = model.get("ai_context")
+    if ai_context is not None:
+        require_keys(ai_context, {"instructions"})
+        if "instructions" not in ai_context:
+            die("instructions")
+        view["module_custom_instructions"] = {"sql_generation": ai_context["instructions"]}
+    return view
+
+
+def _table_to_dataset(table: dict) -> dict:
+    require_keys(table, _TABLE_KEYS)
+    for key in ("name", "base_table", "primary_key"):
+        if key not in table:
+            die(key)
+    base_table = require_keys(table["base_table"], _BASE_TABLE_KEYS)
+    for key in ("database", "schema", "table"):
+        if key not in base_table:
+            die(key)
+    primary_key = require_keys(table["primary_key"], _PRIMARY_KEY_KEYS)
+    columns = primary_key.get("columns")
+    if not isinstance(columns, list) or not columns:
+        die("columns")
+    fields = []
+    for column in _as_list(table.get("dimensions", []), "dimensions"):
+        fields.append(_attribute_to_field(column, is_time=False))
+    for column in _as_list(table.get("time_dimensions", []), "time_dimensions"):
+        fields.append(_attribute_to_field(column, is_time=True))
+    for column in _as_list(table.get("facts", []), "facts"):
+        fields.append(_fact_to_field(column))
+    return {
+        "name": table["name"],
+        "source": f"{base_table['database']}.{base_table['schema']}.{base_table['table']}",
+        "primary_key": list(columns),
+        "fields": fields,
+    }
+
+
+def _attribute_to_field(column: dict, *, is_time: bool) -> dict:
+    name, expression, datatype = _column_parts(column)
+    if expression != name:
+        die(expression)
+    expected = dimension_for_datatype(datatype)
+    if expected != {"is_time": is_time}:
+        die(column["data_type"])
+    return {
+        "name": name,
+        "expression": snowflake_expression(expression),
+        "datatype": datatype,
+        "dimension": {"is_time": is_time},
+    }
+
+
+def _fact_to_field(column: dict) -> dict:
+    name, expression, datatype = _column_parts(column)
+    if expression != name:
+        die(expression)
+    if dimension_for_datatype(datatype) is not None:
+        die(column["data_type"])
+    return {
+        "name": name,
+        "expression": snowflake_expression(expression),
+        "datatype": datatype,
+    }
+
+
+def _column_parts(column: dict) -> tuple[str, str, str]:
+    require_keys(column, _COLUMN_KEYS)
+    for key in ("name", "expr", "data_type"):
+        if key not in column:
+            die(key)
+    expression = column["expr"]
+    if not isinstance(expression, str):
+        die("expr")
+    check_expression(expression)
+    data_type = column["data_type"]
+    if data_type not in SNOWFLAKE_TO_OSSIE:
+        die(str(data_type))
+    return column["name"], expression, SNOWFLAKE_TO_OSSIE[data_type]
+
+
+def _relationship_to_ossie(relationship: dict) -> dict:
+    require_keys(relationship, _RELATIONSHIP_KEYS)
+    for key in ("name", "left_table", "right_table", "relationship_columns"):
+        if key not in relationship:
+            die(key)
+    # This file is many-to-one with bookings on the left. Do not infer
+    # cardinality for any other relationship.
+    if (
+        relationship["name"] != RELATIONSHIP_NAME
+        or relationship["left_table"] != MANY_SIDE
+        or relationship["right_table"] != ONE_SIDE
+    ):
+        die(str(relationship["name"]))
+    pairs = _as_list(relationship["relationship_columns"], "relationship_columns")
+    if len(pairs) != 1:
+        die("relationship_columns")
+    pair = require_keys(pairs[0], _RELATIONSHIP_COLUMN_KEYS)
+    for key in ("left_column", "right_column"):
+        if key not in pair:
+            die(key)
+    return {
+        "name": relationship["name"],
+        "from": relationship["left_table"],
+        "to": relationship["right_table"],
+        "from_columns": [pair["left_column"]],
+        "to_columns": [pair["right_column"]],
+    }
+
+
+def _metric_to_ossie(metric: dict, dataset_names: list[str]) -> dict:
+    require_keys(metric, _METRIC_KEYS)
+    for key in ("name", "description", "expr"):
+        if key not in metric:
+            die(key)
+    expression = metric["expr"]
+    if not isinstance(expression, str):
+        die("expr")
+    check_metric_expression(expression, dataset_names)
+    converted = {
+        "name": metric["name"],
+        "description": metric["description"],
+        "expression": snowflake_expression(expression),
+    }
+    if "synonyms" in metric:

Review Comment:
   Also, another nit: a metric with `synonyms: []` becomes `ai_context.synonyms == []`, but `_knowledge_from_model` skips empty lists.
   The key disappears on the way back, so the round trip is not faithful.
   
   Either normalize empty to absent on import or emit the key on export.



##########
converters/wisdom-semantic-view/src/ossie_wisdom_semantic_view/wisdom.py:
##########
@@ -0,0 +1,405 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Wisdom domain-export JSON, format 1.0, both directions, for this finance model."""
+
+from __future__ import annotations
+
+import json
+import re
+import sys
+
+from ossie_wisdom_semantic_view.model import (
+    SPEC_VERSION,
+    WISDOM_TO_OSSIE,
+    check_expression,
+    check_metric_expression,
+    die,
+    dimension_for_datatype,
+    only_snowflake,
+    OSSIE_TO_WISDOM,
+    snowflake_expression,
+)
+from ossie_wisdom_semantic_view.semantic_view import MANY_SIDE, ONE_SIDE, RELATIONSHIP_NAME
+
+# Constants for this offline demo. They are not read from a Wisdom tenant.
+# domain_name is the model name. For this fixture that name is finance_cfo_cockpit.
+EXPORTED_AT = "2026-01-01T00:00:00+00:00"
+SOURCE_DOMAIN_ID = "demo-finance-cfo"
+CONNECTION_ID = "et-connection-snowflake"
+BOARD_REVENUE = "Board revenue"
+SYNONYM_PREFIX = "Synonyms for "
+
+
+def dump_wisdom(export: dict) -> str:
+    return json.dumps(export, indent=2) + "\n"
+
+
+def load_wisdom(text: str) -> dict:
+    loaded = json.loads(text)
+    if not isinstance(loaded, dict):
+        die("version")
+    return loaded
+
+
+def domain_uuid(name: str) -> str:
+    return f"ET_DOMAIN_{name}"
+
+
+def zsheet_uuid(name: str) -> str:
+    return f"ET_ZSHEET_{name}"
+
+
+def knowledge_id(name: str) -> str:
+    slug = re.sub(r"[^a-z0-9]+", "_", name.lower()).strip("_")
+    return f"ET_UNSTRUCTURED_KNOWLEDGE_{slug}"
+
+
+def measure_id(name: str) -> str:
+    return f"MEASURE_{name}"

Review Comment:
   Nit: `knowledge_id` slugifies names, so `Synonyms for net-revenue` and `Synonyms for net_revenue` get the same id. Nothing checks that ids are unique.
   
   Could we add a uniqueness check, or a suffix on collision?



##########
converters/wisdom-semantic-view/src/ossie_wisdom_semantic_view/enrich.py:
##########
@@ -0,0 +1,98 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""The only place a name that was not in the semantic view is added."""
+
+from __future__ import annotations
+
+import copy
+
+from ossie_wisdom_semantic_view.model import check_metric_expression, die, require_keys
+from ossie_wisdom_semantic_view.semantic_view import MANY_SIDE
+from ossie_wisdom_semantic_view.wisdom import knowledge_id, measure_id, reject_nonempty_synonym_sets
+
+_ENRICHMENT_KEYS = {"metric", "knowledge"}
+_METRIC_KEYS = {"name", "expr", "description"}
+_KNOWLEDGE_KEYS = {"name", "content"}
+
+
+def load_enrichment(text: str) -> dict:
+    import yaml
+
+    loaded = yaml.safe_load(text)
+    require_keys(loaded, _ENRICHMENT_KEYS)
+    for key in ("metric", "knowledge"):
+        if key not in loaded:
+            die(key)
+    return loaded
+
+
+def apply_enrichment(export: dict, enrichment: dict) -> dict:
+    require_keys(enrichment, _ENRICHMENT_KEYS)
+    metric = require_keys(enrichment["metric"], _METRIC_KEYS)
+    for key in ("name", "expr", "description"):
+        if key not in metric:
+            die(key)
+    dataset_names = [table["zsheet_json"]["ref"]["name"] for table in export["tables"]]
+    expression = metric["expr"]
+    if not isinstance(expression, str):
+        die("expr")
+    check_metric_expression(expression, dataset_names)
+
+    enriched = copy.deepcopy(export)
+    reject_nonempty_synonym_sets(enriched)
+    bookings = None
+    for table in enriched["tables"]:
+        zsheet = table["zsheet_json"]
+        if zsheet["ref"]["name"] == MANY_SIDE:
+            bookings = zsheet
+    if bookings is None:
+        die(MANY_SIDE)
+    measures = bookings.setdefault("measures", [])
+    if any(item["name"] == metric["name"] for item in measures):
+        die(metric["name"])
+    measures.append(
+        {
+            "name": metric["name"],
+            "expression": expression,
+            "description": metric["description"],
+            "id": measure_id(metric["name"]),
+        }
+    )

Review Comment:
   This appends enrichment knowledge blocks without checking for existing items. If the source view already has `module_custom_instructions.sql_generation`, the export already contains a `Board revenue` item, so enrichment adds a second one with the same name and id. `to-semantic-view` then hits `die("Board revenue")` in `wisdom_to_ossie` and exists 1. It also happens if an enrichment block repeats any existing name.
   
   Could we merge into or replace the existing item by name, or fail early with a clear message? A test with a pre-existing `sql_generation` would cover it.



##########
converters/wisdom-semantic-view/src/ossie_wisdom_semantic_view/semantic_view.py:
##########
@@ -0,0 +1,339 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Snowflake semantic-view YAML, both directions, for this finance model only."""
+
+from __future__ import annotations
+
+import yaml
+
+from ossie_wisdom_semantic_view.model import (
+    SPEC_VERSION,
+    check_expression,
+    check_metric_expression,
+    die,
+    dimension_for_datatype,
+    only_snowflake,
+    require_keys,
+    snowflake_expression,
+    SNOWFLAKE_TO_OSSIE,
+    OSSIE_TO_SNOWFLAKE,
+)
+
+RELATIONSHIP_NAME = "bookings_to_fiscal_calendar"
+MANY_SIDE = "bookings"
+ONE_SIDE = "fiscal_calendar"
+
+_VIEW_KEYS = {
+    "name",
+    "description",
+    "tables",
+    "relationships",
+    "metrics",
+    "module_custom_instructions",
+}
+_TABLE_KEYS = {
+    "name",
+    "base_table",
+    "primary_key",
+    "dimensions",
+    "time_dimensions",
+    "facts",
+}
+_BASE_TABLE_KEYS = {"database", "schema", "table"}
+_PRIMARY_KEY_KEYS = {"columns"}
+_COLUMN_KEYS = {"name", "expr", "data_type"}
+_RELATIONSHIP_KEYS = {"name", "left_table", "right_table", "relationship_columns"}
+_RELATIONSHIP_COLUMN_KEYS = {"left_column", "right_column"}
+_METRIC_KEYS = {"name", "description", "expr", "synonyms"}
+_MODULE_KEYS = {"sql_generation"}
+
+
+def load_semantic_view(text: str) -> dict:
+    loaded = yaml.safe_load(text)
+    if not isinstance(loaded, dict):
+        die("name")
+    return loaded
+
+
+def dump_semantic_view(view: dict) -> str:
+    return yaml.safe_dump(view, sort_keys=False, width=4096, allow_unicode=True)
+
+
+def semantic_view_to_ossie(view: dict) -> dict:
+    require_keys(view, _VIEW_KEYS)
+    for key in ("name", "description", "tables", "relationships", "metrics"):
+        if key not in view:
+            die(key)
+    datasets = [_table_to_dataset(table) for table in _as_list(view["tables"], "tables")]
+    relationships = [
+        _relationship_to_ossie(item) for item in _as_list(view["relationships"], "relationships")
+    ]
+    dataset_names = [dataset["name"] for dataset in datasets]
+    metrics = [
+        _metric_to_ossie(item, dataset_names) for item in _as_list(view["metrics"], "metrics")
+    ]
+    model = {
+        "version": SPEC_VERSION,
+        "name": view["name"],
+        "description": view["description"],
+    }
+    instructions = view.get("module_custom_instructions")
+    if instructions is not None:
+        require_keys(instructions, _MODULE_KEYS)
+        if "sql_generation" not in instructions:
+            die("sql_generation")
+        model["ai_context"] = {"instructions": instructions["sql_generation"]}
+    model["datasets"] = datasets
+    model["relationships"] = relationships
+    model["metrics"] = metrics
+    return model
+
+
+def ossie_to_semantic_view(model: dict) -> dict:
+    if model.get("version") != SPEC_VERSION:
+        die(str(model.get("version")))
+    tables = [_dataset_to_table(dataset) for dataset in model["datasets"]]
+    relationships = [_ossie_relationship_to_view(item) for item in model.get("relationships") or []]
+    dataset_names = [dataset["name"] for dataset in model["datasets"]]
+    metrics = [
+        _ossie_metric_to_view(item, dataset_names) for item in model.get("metrics") or []
+    ]
+    view = {
+        "name": model["name"],
+        "description": model["description"],
+        "tables": tables,
+        "relationships": relationships,
+        "metrics": metrics,
+    }
+    ai_context = model.get("ai_context")
+    if ai_context is not None:
+        require_keys(ai_context, {"instructions"})
+        if "instructions" not in ai_context:
+            die("instructions")
+        view["module_custom_instructions"] = {"sql_generation": ai_context["instructions"]}
+    return view
+
+
+def _table_to_dataset(table: dict) -> dict:
+    require_keys(table, _TABLE_KEYS)
+    for key in ("name", "base_table", "primary_key"):
+        if key not in table:
+            die(key)
+    base_table = require_keys(table["base_table"], _BASE_TABLE_KEYS)
+    for key in ("database", "schema", "table"):
+        if key not in base_table:
+            die(key)
+    primary_key = require_keys(table["primary_key"], _PRIMARY_KEY_KEYS)
+    columns = primary_key.get("columns")
+    if not isinstance(columns, list) or not columns:
+        die("columns")
+    fields = []
+    for column in _as_list(table.get("dimensions", []), "dimensions"):
+        fields.append(_attribute_to_field(column, is_time=False))
+    for column in _as_list(table.get("time_dimensions", []), "time_dimensions"):
+        fields.append(_attribute_to_field(column, is_time=True))
+    for column in _as_list(table.get("facts", []), "facts"):
+        fields.append(_fact_to_field(column))
+    return {
+        "name": table["name"],
+        "source": f"{base_table['database']}.{base_table['schema']}.{base_table['table']}",
+        "primary_key": list(columns),
+        "fields": fields,
+    }
+
+
+def _attribute_to_field(column: dict, *, is_time: bool) -> dict:
+    name, expression, datatype = _column_parts(column)
+    if expression != name:
+        die(expression)
+    expected = dimension_for_datatype(datatype)
+    if expected != {"is_time": is_time}:
+        die(column["data_type"])
+    return {
+        "name": name,
+        "expression": snowflake_expression(expression),
+        "datatype": datatype,
+        "dimension": {"is_time": is_time},
+    }
+
+
+def _fact_to_field(column: dict) -> dict:
+    name, expression, datatype = _column_parts(column)
+    if expression != name:
+        die(expression)
+    if dimension_for_datatype(datatype) is not None:
+        die(column["data_type"])
+    return {
+        "name": name,
+        "expression": snowflake_expression(expression),
+        "datatype": datatype,
+    }
+
+
+def _column_parts(column: dict) -> tuple[str, str, str]:
+    require_keys(column, _COLUMN_KEYS)
+    for key in ("name", "expr", "data_type"):
+        if key not in column:
+            die(key)
+    expression = column["expr"]
+    if not isinstance(expression, str):
+        die("expr")
+    check_expression(expression)
+    data_type = column["data_type"]
+    if data_type not in SNOWFLAKE_TO_OSSIE:
+        die(str(data_type))
+    return column["name"], expression, SNOWFLAKE_TO_OSSIE[data_type]
+
+
+def _relationship_to_ossie(relationship: dict) -> dict:
+    require_keys(relationship, _RELATIONSHIP_KEYS)
+    for key in ("name", "left_table", "right_table", "relationship_columns"):
+        if key not in relationship:
+            die(key)
+    # This file is many-to-one with bookings on the left. Do not infer
+    # cardinality for any other relationship.
+    if (
+        relationship["name"] != RELATIONSHIP_NAME
+        or relationship["left_table"] != MANY_SIDE
+        or relationship["right_table"] != ONE_SIDE
+    ):
+        die(str(relationship["name"]))
+    pairs = _as_list(relationship["relationship_columns"], "relationship_columns")
+    if len(pairs) != 1:
+        die("relationship_columns")
+    pair = require_keys(pairs[0], _RELATIONSHIP_COLUMN_KEYS)
+    for key in ("left_column", "right_column"):
+        if key not in pair:
+            die(key)
+    return {
+        "name": relationship["name"],
+        "from": relationship["left_table"],
+        "to": relationship["right_table"],
+        "from_columns": [pair["left_column"]],
+        "to_columns": [pair["right_column"]],
+    }
+
+
+def _metric_to_ossie(metric: dict, dataset_names: list[str]) -> dict:
+    require_keys(metric, _METRIC_KEYS)
+    for key in ("name", "description", "expr"):
+        if key not in metric:
+            die(key)
+    expression = metric["expr"]
+    if not isinstance(expression, str):
+        die("expr")
+    check_metric_expression(expression, dataset_names)
+    converted = {
+        "name": metric["name"],
+        "description": metric["description"],
+        "expression": snowflake_expression(expression),
+    }
+    if "synonyms" in metric:

Review Comment:
   Nit: import accepts a synonym that contains a newline, or one that duplicates a name. It's written as multi-line bullet content, and `_parse_bullets` then dies on the continuation line in the reverse direction.
   
   Could we reject these on import, or escape them in the format?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: issues-unsubscribe@ossie.apache.org

For queries about this service, please contact Infrastructure at:
users@infra.apache.org