diff --git a/converters/dbt/README.md b/converters/dbt/README.md index c39090fb..4b3ebd3e 100644 --- a/converters/dbt/README.md +++ b/converters/dbt/README.md @@ -119,8 +119,9 @@ manifest_json = result.output.model_dump_json(by_alias=True, exclude_none=True, | `NATURAL_ENTITY_DROPPED` | Ossie has no natural-key entity type | | `CUMULATIVE_SEMANTICS_LOSS` | Window/grain semantics cannot be expressed in an Ossie expression string; the base aggregation is preserved | -**Ossie → MSI** reconstructs a best-effort MSI manifest from Ossie's simpler schema. Nothing is dropped, but Ossie carries less structural information than MSI, so the converter makes the following choices: +**Ossie → MSI** reconstructs a best-effort MSI manifest from Ossie's simpler schema. Nothing is dropped for supported inputs, but Ossie carries less structural information than MSI, so the converter makes the following choices: +- Composite primary and unique keys are rejected because MSI entities cannot preserve grouped key semantics - Single aggregations (`SUM(col)`, `COUNT(DISTINCT col)`, etc.) → SIMPLE metric with `metric_aggregation_params` - `(expr_a) / (expr_b)` → RATIO metric with auto-generated sub-metrics - Anything else → SIMPLE metric with the raw expression stored verbatim diff --git a/converters/dbt/src/ossie_dbt/osi_to_msi.py b/converters/dbt/src/ossie_dbt/osi_to_msi.py index c8f155fc..b5132889 100644 --- a/converters/dbt/src/ossie_dbt/osi_to_msi.py +++ b/converters/dbt/src/ossie_dbt/osi_to_msi.py @@ -154,6 +154,17 @@ def _convert_dataset( @staticmethod def _build_key_sets(dataset: OSIDataset, osi_sm: OSISemanticModel) -> _KeySets: """Return a _KeySets with primary, unique, and foreign key column sets for a dataset.""" + for key_type, keys in ( + ("primary key", [dataset.primary_key] if dataset.primary_key else []), + ("unique key", dataset.unique_keys or []), + ): + for key in keys: + if len(key) > 1: + raise ValueError( + f"Dataset {dataset.name!r} has composite {key_type} {key!r}; " + "MetricFlow entities cannot represent composite keys losslessly" + ) + return _KeySets( primary=set(dataset.primary_key or []), unique={col for keys in (dataset.unique_keys or []) for col in keys}, diff --git a/converters/dbt/tests/test_osi_to_msi.py b/converters/dbt/tests/test_osi_to_msi.py index dfed1e42..77761b5e 100644 --- a/converters/dbt/tests/test_osi_to_msi.py +++ b/converters/dbt/tests/test_osi_to_msi.py @@ -125,6 +125,45 @@ def test_unique_key_field_becomes_unique_entity(self) -> None: assert sm.entities[0].name == "email" assert sm.entities[0].type.value == "unique" + @pytest.mark.parametrize( + ("primary_key", "unique_keys", "expected_error"), + [ + ( + ["tenant_id", "order_id"], + None, + "Dataset 'orders' has composite primary key ['tenant_id', 'order_id']; " + "MetricFlow entities cannot represent composite keys losslessly", + ), + ( + None, + [["tenant_id", "external_id"]], + "Dataset 'orders' has composite unique key ['tenant_id', 'external_id']; " + "MetricFlow entities cannot represent composite keys losslessly", + ), + ], + ) + def test_composite_key_is_rejected( + self, + primary_key: list[str] | None, + unique_keys: list[list[str]] | None, + expected_error: str, + ) -> None: + doc = _osi_doc( + datasets=[ + _osi_dataset( + "orders", + fields=[_osi_field("tenant_id"), _osi_field("order_id")], + primary_key=primary_key, + unique_keys=unique_keys, + ) + ] + ) + + with pytest.raises(ValueError) as exc_info: + OSIToMSIConverter().convert(doc) + + assert str(exc_info.value) == expected_error + def test_relationship_from_column_becomes_foreign_entity(self) -> None: doc = _osi_doc( datasets=[