diff --git a/converters/honeydew/README.md b/converters/honeydew/README.md index 4419862b..a4a4bbed 100644 --- a/converters/honeydew/README.md +++ b/converters/honeydew/README.md @@ -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. diff --git a/converters/honeydew/src/ossie_honeydew/converter.py b/converters/honeydew/src/ossie_honeydew/converter.py index 33793e74..6946ada7 100644 --- a/converters/honeydew/src/ossie_honeydew/converter.py +++ b/converters/honeydew/src/ossie_honeydew/converter.py @@ -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.""" @@ -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" diff --git a/converters/honeydew/tests/test_ossie_honeydew_converter.py b/converters/honeydew/tests/test_ossie_honeydew_converter.py index d83523b4..d9e4410f 100644 --- a/converters/honeydew/tests/test_ossie_honeydew_converter.py +++ b/converters/honeydew/tests/test_ossie_honeydew_converter.py @@ -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 @@ -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)) @@ -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'"):