Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@
**/htmlcov/
**/dist/
**/target/
*.py[cod]
9 changes: 8 additions & 1 deletion converters/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,11 +112,17 @@ Datasets represent logical tables (fact or dimension tables). They contain field

Fields represent row-level attributes. They can be simple column references or computed expressions.

> **Note:** `datatype` (on `Field` and `Metric`) declares a field's logical data type; `dimension.is_time` is an independent temporal-role marker.
> A field may carry both, either, or neither. Use `datatype` for data-type questions (casting, serialization); use `is_time` for role questions
> (classifying time dimensions). When `is_time` is unset it defaults to `true` if `datatype` is one of `Date`, `Time`, `DateTime`, `DateTimeTz`,
> and `false` otherwise. Explicit `is_time` always wins.

| Ossie Field | Description | Converter Consideration |
|-----------|-------------|------------------------|
| `name` | Field identifier | Map to column/attribute name |
| `expression.dialects` | Multi-dialect SQL expressions | Select the dialect matching the target vendor; fall back to `ANSI_SQL` |
| `dimension.is_time` | Whether the field is a time dimension | Map to vendor-specific time dimension markers |
| `datatype` | Logical data type of the field (one of `String`, `Integer`, `Decimal`, `Float`, `Boolean`, `Date`, `Time`, `DateTime`, `DateTimeTz`, `Opaque`). | Converters SHOULD consult `datatype` for the field's logical data type. `Decimal` is exact base-10 with unspecified precision and scale; `Float` is approximate. Prefer the temporal members (`Date`, `Time`, `DateTime`, `DateTimeTz`) to classify time dimensions. Omit `datatype` when unknown; use `Opaque` + `custom_extensions` for a known type outside the portable vocabulary. |
| `dimension.is_time` | Temporal-role marker. When `true`, the field should be treated as a time dimension regardless of its `datatype` (e.g. an `Integer` year grain, a `String` month name, or a `Date` column). When unset, defaults to `true` for temporal `datatype`s (`Date`, `Time`, `DateTime`, `DateTimeTz`) and `false` otherwise. | Map to vendor-specific time dimension markers. Converters SHOULD classify as a time dimension when `is_time` resolves to `true` (either explicit or defaulted from a temporal `datatype`). An explicit `is_time: false` suppresses the time-dimension classification even on temporal-typed columns. |
| `label` | Categorization label | Map if vendor supports field labels/tags |
| `description` | Human-readable description | Most vendors support field descriptions |
| `ai_context` | Synonyms and business context | Map if vendor supports semantic annotations |
Expand Down Expand Up @@ -163,6 +169,7 @@ Metrics are aggregate measures defined at the semantic model level. They can spa
|-----------|-------------|------------------------|
| `name` | Metric identifier | Map to vendor's measure/KPI name |
| `expression.dialects` | Multi-dialect aggregate expressions | Select the appropriate dialect; fall back to `ANSI_SQL` |
| `datatype` | Logical data type of the metric result (one of `String`, `Integer`, `Decimal`, `Float`, `Boolean`, `Date`, `Time`, `DateTime`, `DateTimeTz`, `Opaque`). | Converters SHOULD consult `datatype` to declare the result type of the aggregation. Numeric measures should distinguish exact `Decimal` or `Integer` results from approximate `Float` results. Omit `datatype` when unknown; use `Opaque` + `custom_extensions` for a known type outside the portable vocabulary. |
| `description` | What the metric measures | Most vendors support descriptions |
| `ai_context` | Synonyms and business context | Map if vendor supports semantic annotations |

Expand Down
4 changes: 2 additions & 2 deletions converters/dbt/src/ossie_dbt/osi_to_msi.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ def _classify_field(
1. primary_key → PRIMARY entity
2. unique_keys → UNIQUE entity
3. foreign key (from relationship) → FOREIGN entity
4. dimension.is_time → TIME dimension (granularity defaults to DAY)
4. effective time-dimension role → TIME dimension (granularity defaults to DAY)
5. fallback → CATEGORICAL dimension

Aggregation info lives on metrics (`metric_aggregation_params`), not on
Expand Down Expand Up @@ -227,7 +227,7 @@ def _classify_field(
)
)
return
if field.dimension is not None and field.dimension.is_time:
if field.is_time_dimension():
# Ossie carries no granularity metadata; default to DAY.
dimensions.append(
PydanticDimension(
Expand Down
24 changes: 24 additions & 0 deletions converters/dbt/tests/test_osi_to_msi.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import pytest
from syrupy.assertion import SnapshotAssertion

from ossie import OSIDataType, OSIDimension
from ossie_dbt.msi_to_osi import MSIToOSIConverter
from ossie_dbt.osi_to_msi import OSIToMSIConverter
from metricflow_semantic_interfaces.type_enums import (
Expand Down Expand Up @@ -156,6 +157,29 @@ def test_field_becomes_dimension_by_is_time(
assert sm.dimensions[0].name == field_name
assert sm.dimensions[0].type == expected_type

@pytest.mark.parametrize(
("dimension", "datatype", "expected_type"),
[
(OSIDimension(), OSIDataType.DATE, DimensionType.TIME),
(OSIDimension(is_time=False), OSIDataType.DATE_TIME_TZ, DimensionType.CATEGORICAL),
(None, OSIDataType.DATE, DimensionType.CATEGORICAL),
],
)
def test_field_becomes_dimension_by_effective_time_role(
self,
dimension: OSIDimension | None,
datatype: OSIDataType,
expected_type: DimensionType,
) -> None:
field = _osi_field("occurred_at").model_copy(
update={"dimension": dimension, "datatype": datatype}
)
doc = _osi_doc(datasets=[_osi_dataset("events", fields=[field])])

sm = OSIToMSIConverter().convert(doc).output.semantic_models[0]

assert sm.dimensions[0].type == expected_type

def test_field_referenced_in_metric_stays_as_dimension(self) -> None:
"""Fields referenced in metric expressions are no longer promoted to measures."""
doc = _osi_doc(
Expand Down
28 changes: 28 additions & 0 deletions converters/gooddata/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,42 @@ uv run pytest
| Dataset | Dataset |
| Attribute (+ Labels) | Field with `dimension` metadata |
| Fact | Field without `dimension` metadata |
| Source column data type | Field `datatype` |
| Reference (FK) | Relationship |
| Date Instance | Dataset with `GOODDATA` custom_extension (`date_dimension: true`) |
| MAQL expression | Dialect entry (`dialect: MAQL`) |

### Data Types

| GoodData source type | Ossie `DataType` |
|---|---|
| `STRING` | `String` |
| `INT` | `Integer` |
| `NUMERIC` | `Decimal` |
| `BOOLEAN` | `Boolean` |
| `DATE` | `Date` |
| `TIMESTAMP` | `DateTime` |
| `TIMESTAMP_TZ` | `DateTimeTz` |

Only an explicitly present GoodData `sourceColumnDataType` becomes an Ossie
`datatype`. If GoodData omits the source type, the Ossie datatype remains
unspecified rather than being inferred from whether the field is an attribute or fact.

Unknown GoodData source types become `Opaque` and their exact value is retained in a
`GOODDATA` custom extension for lossless round trips. Regular GoodData attributes remain
explicitly non-time dimensions even when their source column has a temporal data type;
GoodData date instances continue to carry the time-dimension role.

## Limitations

- **Metrics are not converted.** GoodData metrics use MAQL, a context-aware metric language
where dimensionality and filters are applied at report time. The current Ossie metric model
is SQL-expression-based and cannot represent this paradigm.
- AggregatedFacts are not yet supported.
- Workspace data filters are not mapped.
- GoodData has only one `NUMERIC` source type, so exporting Ossie `Float` loses the
distinction between approximate and exact numeric values.
- GoodData has no time-only source type. Ossie `Time` fields retain the existing
attribute or fact default and emit a warning when exported.
- Ossie `Opaque` fields require an exact GoodData source type in their `GOODDATA`
extension for lossless export; otherwise the role default is used with a warning.
124 changes: 124 additions & 0 deletions converters/gooddata/src/ossie_gooddata/datatype_mapping.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# 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.

"""Data type mapping shared by the GoodData converters."""

from __future__ import annotations

import warnings

GOODDATA_TO_OSSIE_DATATYPES = {
"STRING": "String",
"INT": "Integer",
"NUMERIC": "Decimal",
"BOOLEAN": "Boolean",
"DATE": "Date",
"TIMESTAMP": "DateTime",
"TIMESTAMP_TZ": "DateTimeTz",
}

OSSIE_TO_GOODDATA_DATATYPES = {value: key for key, value in GOODDATA_TO_OSSIE_DATATYPES.items()}

def gooddata_to_ossie_datatype(source_type: str | None) -> str | None:
"""Map a GoodData source column type to an Ossie DataType value."""
if source_type is None or not source_type.strip():
return None
return GOODDATA_TO_OSSIE_DATATYPES.get(source_type.strip().upper(), "Opaque")


def ossie_to_gooddata_datatype(
datatype: str | None,
*,
default: str,
field_name: str,
extension_type: str | None = None,
) -> str:
"""Choose a GoodData source type, preserving exact extension data first."""
mapped_type = OSSIE_TO_GOODDATA_DATATYPES.get(datatype) if datatype is not None else None

if extension_type is not None and extension_type.strip():
normalized_extension_type = extension_type.strip().upper()
if datatype is None or datatype == "Opaque":
return normalized_extension_type
if mapped_type is not None and normalized_extension_type != mapped_type:
warnings.warn(
f"Field '{field_name}' has Ossie datatype '{datatype}', which maps to "
f"GoodData '{mapped_type}', but its GOODDATA extension specifies "
f"'{normalized_extension_type}'. Preserving the extension value.",
stacklevel=2,
)
elif datatype == "Float":
if normalized_extension_type == "NUMERIC":
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Float'; GoodData only has "
"NUMERIC, so the exact/approximate distinction will be lost.",
stacklevel=2,
)
else:
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Float', which maps lossily to "
f"GoodData 'NUMERIC', but its GOODDATA extension specifies "
f"'{normalized_extension_type}'. Preserving the extension value.",
stacklevel=2,
)
elif datatype == "Time":
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Time', which has no native "
f"GoodData source column type. Preserving the extension value "
f"'{normalized_extension_type}'.",
stacklevel=2,
)
elif mapped_type is None:
warnings.warn(
f"Field '{field_name}' has unrecognized Ossie datatype '{datatype}'. "
f"Preserving the GOODDATA extension value '{normalized_extension_type}'.",
stacklevel=2,
)
return normalized_extension_type

if datatype is None:
return default
if mapped_type is not None:
return mapped_type
if datatype == "Float":
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Float'; GoodData only has "
"NUMERIC, so the exact/approximate distinction will be lost.",
stacklevel=2,
)
return "NUMERIC"
if datatype == "Time":
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Time', which has no native "
f"GoodData source column type. Using the role default '{default}'.",
stacklevel=2,
)
return default
if datatype == "Opaque":
warnings.warn(
f"Field '{field_name}' has Ossie datatype 'Opaque' without an exact "
f"GoodData source type in its GOODDATA extension. Using the role default '{default}'.",
stacklevel=2,
)
return default

warnings.warn(
f"Field '{field_name}' has unrecognized Ossie datatype '{datatype}'. "
f"Using the GoodData role default '{default}'.",
stacklevel=2,
)
return default
16 changes: 16 additions & 0 deletions converters/gooddata/src/ossie_gooddata/gooddata_to_osi.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import json
from typing import Any

from ossie_gooddata.datatype_mapping import gooddata_to_ossie_datatype
from ossie_gooddata.models import (
GdAttribute,
GdDataset,
Expand Down Expand Up @@ -162,6 +163,7 @@ def _convert_date_instance(di: GdDateInstance) -> dict[str, Any]:

def _convert_attribute(attr: GdAttribute, dataset_id: str) -> dict[str, Any]:
"""Convert a GoodData attribute to an Ossie field."""
datatype = gooddata_to_ossie_datatype(attr.source_column_data_type)
osi_field: dict[str, Any] = {
"name": attr.source_column,
"expression": {
Expand All @@ -172,13 +174,17 @@ def _convert_attribute(attr: GdAttribute, dataset_id: str) -> dict[str, Any]:
},
"dimension": {"is_time": False},
}
if datatype is not None:
osi_field["datatype"] = datatype
if attr.description:
osi_field["description"] = attr.description
if attr.title != attr.source_column:
osi_field.setdefault("ai_context", {})["synonyms"] = [attr.title]

# Store GoodData-specific attribute metadata in custom_extensions
ext = _build_attribute_extension(attr)
if datatype == "Opaque":
ext["source_column_data_type"] = attr.source_column_data_type
if ext:
osi_field["custom_extensions"] = [{"vendor_name": "GOODDATA", "data": json.dumps(ext)}]

Expand All @@ -187,6 +193,7 @@ def _convert_attribute(attr: GdAttribute, dataset_id: str) -> dict[str, Any]:

def _convert_fact(fact: GdFact, dataset_id: str) -> dict[str, Any]:
"""Convert a GoodData fact to an Ossie field."""
datatype = gooddata_to_ossie_datatype(fact.source_column_data_type)
osi_field: dict[str, Any] = {
"name": fact.source_column,
"expression": {
Expand All @@ -196,10 +203,19 @@ def _convert_fact(fact: GdFact, dataset_id: str) -> dict[str, Any]:
],
},
}
if datatype is not None:
osi_field["datatype"] = datatype
if fact.description:
osi_field["description"] = fact.description
if fact.title != fact.source_column:
osi_field.setdefault("ai_context", {})["synonyms"] = [fact.title]
if datatype == "Opaque":
osi_field["custom_extensions"] = [
{
"vendor_name": "GOODDATA",
"data": json.dumps({"source_column_data_type": fact.source_column_data_type}),
}
]

return osi_field

Expand Down
12 changes: 6 additions & 6 deletions converters/gooddata/src/ossie_gooddata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ class GdAttribute:
title: str
source_column: str
description: str = ""
source_column_data_type: str = "STRING"
source_column_data_type: str | None = None
sort_column: str | None = None
sort_direction: str | None = None
labels: list[GdLabel] = field(default_factory=list)
Expand All @@ -80,7 +80,7 @@ class GdFact:
title: str
source_column: str
description: str = ""
source_column_data_type: str = "NUMERIC"
source_column_data_type: str | None = None
tags: list[str] = field(default_factory=list)


Expand Down Expand Up @@ -192,7 +192,7 @@ def gd_model_from_dict(data: dict[str, Any]) -> GdDeclarativeModel:
title=attr.get("title", ""),
source_column=attr.get("sourceColumn", ""),
description=attr.get("description", ""),
source_column_data_type=attr.get("sourceColumnDataType", "STRING"),
source_column_data_type=attr.get("sourceColumnDataType"),
sort_column=attr.get("sortColumn"),
sort_direction=attr.get("sortDirection"),
labels=labels,
Expand All @@ -206,7 +206,7 @@ def gd_model_from_dict(data: dict[str, Any]) -> GdDeclarativeModel:
title=f.get("title", ""),
source_column=f.get("sourceColumn", ""),
description=f.get("description", ""),
source_column_data_type=f.get("sourceColumnDataType", "NUMERIC"),
source_column_data_type=f.get("sourceColumnDataType"),
tags=f.get("tags", []),
)
for f in ds.get("facts", [])
Expand Down Expand Up @@ -366,7 +366,7 @@ def _attr_to_dict(a: GdAttribute) -> dict[str, Any]:
}
if a.description:
d["description"] = a.description
if a.source_column_data_type != "STRING":
if a.source_column_data_type is not None and a.source_column_data_type != "STRING":
d["sourceColumnDataType"] = a.source_column_data_type
if a.sort_column:
d["sortColumn"] = a.sort_column
Expand All @@ -385,7 +385,7 @@ def _fact_to_dict(f: GdFact) -> dict[str, Any]:
}
if f.description:
d["description"] = f.description
if f.source_column_data_type != "NUMERIC":
if f.source_column_data_type is not None and f.source_column_data_type != "NUMERIC":
d["sourceColumnDataType"] = f.source_column_data_type
if f.tags:
d["tags"] = f.tags
Expand Down
Loading