Skip to content
Draft
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
2 changes: 1 addition & 1 deletion converters/honeydew/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ uv run pytest
## Limitations

- **One source dataset per entity**: Honeydew entities can have multiple source dataset files; the converter always generates exactly one, because an Ossie `dataset` block describes a single table or SQL query.
- **Datatype inference**: Ossie fields have no explicit datatype; the converter infers Honeydew datatypes from the `dimension.is_time` flag (`timestamp`) and the presence/absence of the `dimension` key (`string` vs `number`).
- **Datatype inference**: an Ossie field's `datatype` maps to the Honeydew datatype (`String`→`string`, `Integer`→`number`, `Decimal`/`Float`→`float`, `Boolean`→`bool`, `Date`→`date`, `Time`→`time`, `DateTime`/`DateTimeTz`→`timestamp`). Honeydew has no exact-decimal or timezone-aware type, so `Decimal` and `DateTimeTz` are approximated. Fields that omit `datatype` (or declare `Opaque`) fall back to inference from the `dimension.is_time` flag (`timestamp`) and the presence/absence of the `dimension` key (`string` vs `number`). On the return trip the Honeydew datatype becomes the Ossie `dimension` shape, not a `datatype` — so `Ossie → Honeydew → Ossie` does not preserve the declared `datatype`. Ossie `metric.datatype` is not read; Honeydew metrics are always emitted as `number`.
- **Honeydew SQL expressions**: Calculated attributes and metrics use Honeydew's `entity.attribute` reference syntax. These are exported as `ANSI_SQL` dialect expressions in Ossie; they remain valid for round-tripping but may not run on other databases without adaptation.
- **Perspectives and domains**: Not converted (no Ossie equivalent).
- **Connection expressions** (`connection_expr`): Preserved in `HONEYDEW` custom extensions on the Ossie relationship and restored on the return trip.
Expand Down
29 changes: 29 additions & 0 deletions converters/honeydew/src/ossie_honeydew/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@
_LEGACY_OSSIE_METADATA_SECTION = "osi"
_HD_ATTR_KEYS = ("display_name", "hidden", "folder", "format_string", "timegrain")

# Ossie's portable datatype vocabulary (core-spec 'datatypes' enum) mapped onto
# Honeydew's (bool, date, float, number, string, time, timestamp). Honeydew's
# "number" is integral and "float" is approximate, so Decimal — exact base-10,
# but not integral — maps to "float" as the closest available type. Honeydew has
# no timezone-aware type, so DateTimeTz also lands on "timestamp". "Opaque" is
# left out on purpose: it carries no portable meaning, so those fields keep
# falling back to the dimension-shape heuristic.
_OSSIE_TO_HONEYDEW_DATATYPE = {
"String": "string",
"Integer": "number",
"Decimal": "float",
"Float": "float",
"Boolean": "bool",
"Date": "date",
"Time": "time",
"DateTime": "timestamp",
"DateTimeTz": "timestamp",
}


class HoneydewConversionError(Exception):
"""Raised when conversion between Ossie and Honeydew fails."""
Expand Down Expand Up @@ -442,9 +461,19 @@ def _pick_ansi_expression(expression: Any, field_name: str) -> str | None:


def _ossie_field_to_honeydew_datatype(field: dict[str, Any]) -> str:
"""Pick the Honeydew datatype for an Ossie field.

A Honeydew datatype round-tripped through a ``HONEYDEW`` custom extension
wins, then the field's declared ``datatype``. Fields that declare none (or
declare ``Opaque``) fall back to inferring the type from the shape of
``dimension``.
"""
hd_ext = _get_honeydew_extension(field)
if hd_ext.get("datatype"):
return hd_ext["datatype"]
declared = field.get("datatype")
if isinstance(declared, str) and declared in _OSSIE_TO_HONEYDEW_DATATYPE:
return _OSSIE_TO_HONEYDEW_DATATYPE[declared]
dimension = field.get("dimension")
if isinstance(dimension, dict) and dimension.get("is_time"):
return "timestamp"
Expand Down
65 changes: 65 additions & 0 deletions converters/honeydew/tests/test_ossie_honeydew_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,31 @@ def test_parse_ossie_source(source, expected_sql, expected_type):


@pytest.mark.parametrize("field,expected_dt", [
# Declared datatype wins over the dimension-shape heuristic
({"datatype": "String"}, "string"),
({"datatype": "Integer"}, "number"),
({"datatype": "Decimal"}, "float"),
({"datatype": "Float"}, "float"),
({"datatype": "Boolean"}, "bool"),
({"datatype": "Date"}, "date"),
({"datatype": "Time"}, "time"),
({"datatype": "DateTime"}, "timestamp"),
({"datatype": "DateTimeTz"}, "timestamp"),
({"datatype": "Boolean", "dimension": {"is_time": False}}, "bool"),
({"datatype": "Date", "dimension": {"is_time": False}}, "date"),
# is_time is a role flag, not a type: a year grain stays an Integer
({"datatype": "Integer", "dimension": {"is_time": True}}, "number"),
# No usable datatype → fall back to the dimension-shape heuristic
({"datatype": "Opaque", "dimension": {"is_time": True}}, "timestamp"),
({"datatype": "Opaque"}, "number"),
({"datatype": "nonsense"}, "number"),
({"datatype": {"not": "a string"}, "dimension": {"is_time": False}}, "string"),
({"dimension": {"is_time": True}}, "timestamp"),
({"dimension": {"is_time": False}}, "string"),
({}, "number"),
# A round-tripped Honeydew datatype outranks the declared one
({"datatype": "String",
"custom_extensions": [{"vendor_name": "HONEYDEW", "data": '{"datatype": "bool"}'}]}, "bool"),
])
def test_ossie_field_to_honeydew_datatype(field, expected_dt):
assert _ossie_field_to_honeydew_datatype(field) == expected_dt
Expand Down Expand Up @@ -571,6 +593,29 @@ def test_check_safe_path(rel_path, expected):
"key_dataset": "li", "relations": []},
id="composite-pk",
),
# ── declared datatypes reach the dataset attributes ───────────────────────
pytest.param(
{"name": "m", "datasets": [{"name": "orders", "source": "db.s.orders", "fields": [
{"name": "is_rush", "datatype": "Boolean",
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "is_rush"}]}},
{"name": "ordered_on", "datatype": "Date", "dimension": {"is_time": True},
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "ordered_on"}]}},
{"name": "qty", "datatype": "Integer",
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "qty"}]}},
{"name": "note", "datatype": "Opaque", "dimension": {"is_time": False},
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "note"}]}},
]}]},
"schema/orders/datasets/orders.yml",
{"type": "dataset", "entity": "orders", "name": "orders",
"sql": "db.s.orders", "dataset_type": "table",
"attributes": [
{"column": "is_rush", "name": "is_rush", "datatype": "bool"},
{"column": "ordered_on", "name": "ordered_on", "datatype": "date"},
{"column": "qty", "name": "qty", "datatype": "number"},
{"column": "note", "name": "note", "datatype": "string"},
]},
id="declared-datatypes",
),
])
def test_ossie_to_honeydew_file_content(model, path, expected):
files = convert_ossie_to_honeydew(_ossie(model))
Expand Down Expand Up @@ -1386,6 +1431,26 @@ def test_fields_to_honeydew_complex_sql_goes_to_calc():
"datatype": "number", "sql": "price * 0.9"}]


def test_fields_to_honeydew_uses_declared_datatype():
fields = [
{"name": "is_active", "datatype": "Boolean",
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "is_active"}]},
"dimension": {"is_time": False}},
{"name": "shipped_on", "datatype": "Date",
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "shipped_on"}]},
"dimension": {"is_time": True}},
{"name": "disc", "datatype": "Float",
"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "price * 0.9"}]}},
]
dataset_attrs, calc_attrs = _fields_to_honeydew(fields, "orders")
assert dataset_attrs == [
{"column": "is_active", "name": "is_active", "datatype": "bool"},
{"column": "shipped_on", "name": "shipped_on", "datatype": "date"},
]
assert calc_attrs == [{"type": "calculated_attribute", "entity": "orders", "name": "disc",
"datatype": "float", "sql": "price * 0.9"}]


def test_fields_to_honeydew_missing_name_raises():
fields = [{"expression": {"dialects": [{"dialect": "ANSI_SQL", "expression": "col"}]}}]
with pytest.raises(HoneydewConversionError, match="missing 'name'"):
Expand Down
Loading