diff --git a/converters/wisdom/README.md b/converters/wisdom/README.md index 4b40a0ce..5cb4667b 100644 --- a/converters/wisdom/README.md +++ b/converters/wisdom/README.md @@ -67,9 +67,14 @@ Ossie encodes cardinality by direction (`from` = many side, `to` = one side), so | Wisdom `relationshipType` | Ossie | |---------------------------|-------| | `MANY_TO_ONE` | `from` = left, `to` = right | -| `ONE_TO_MANY` | `from` = right, `to` = left | -| `ONE_TO_ONE` | `from` = left, plus an `ai_context` note (direction is arbitrary) | -| `MANY_TO_MANY` | `from` = left, plus an `ai_context` note and a `CARDINALITY_LOSS` warning | +| `ONE_TO_MANY` | `from` = right, `to` = left, plus the note `one-to-many relationship` | +| `ONE_TO_ONE` | `from` = left, plus the note `one-to-one relationship` (direction is arbitrary) | +| `MANY_TO_MANY` | `from` = left, plus the note `many-to-many relationship; cardinality is not representable in Ossie` and a `CARDINALITY_LOSS` warning | + +The notes are written to the relationship's `ai_context` so the export direction can +restore the original type. They are matched exactly (ignoring leading and trailing +whitespace), so editing a note changes the +exported cardinality (see below). Compound join conditions that are an `AND` of equality conditions are flattened into positional `from_columns`/`to_columns` arrays; any other compound condition (e.g. `OR`) @@ -86,9 +91,12 @@ are stable: - A field whose expression is just its own (possibly quoted) name becomes a column; anything else becomes a formula. `dimension.is_time` becomes a `TIMESTAMP` data type (wisdom re-derives exact types from the warehouse). -- Relationships become `MANY_TO_ONE` edges (Ossie's `from` is the many side); the - `ai_context` notes written by `wisdom-to-ossie` restore `ONE_TO_ONE`/`MANY_TO_MANY`, and - composite keys become compound `AND` join conditions. +- Relationships become `MANY_TO_ONE` edges (Ossie's `from` is the many side). A relationship + whose `ai_context` is exactly one of the notes written by `wisdom-to-ossie` gets its type + back: `ONE_TO_ONE`, `MANY_TO_MANY`, or `ONE_TO_MANY` with the two sides swapped back. Any + other `ai_context` on a relationship, including a hand-edited note, is dropped with an + `AI_CONTEXT_DROPPED` warning and the edge stays `MANY_TO_ONE`. Composite keys become + compound `AND` join conditions. - Metrics attach to the first dataset their expression references (a `METRIC_TABLE_UNRESOLVED` warning falls back to the first dataset). - Connections are per-dialect placeholders (`et-connection-snowflake`, ...) expected to be diff --git a/converters/wisdom/src/ossie_wisdom/ossie_to_wisdom.py b/converters/wisdom/src/ossie_wisdom/ossie_to_wisdom.py index b7be440e..00332139 100644 --- a/converters/wisdom/src/ossie_wisdom/ossie_to_wisdom.py +++ b/converters/wisdom/src/ossie_wisdom/ossie_to_wisdom.py @@ -21,7 +21,7 @@ can be fed to ``importDomain``. Inverse of :mod:`ossie_wisdom.wisdom_to_ossie`: model ``ai_context`` splits back into system instructions and knowledge items, fields split back into columns and formulas, relationship direction is read as -many-to-one (with the ai_context notes restoring one-to-one/many-to-many), and +many-to-one (with the ai_context notes restoring the other cardinalities), and metrics attach to the table their expression references. IDs are derived deterministically from element names, and connections are @@ -42,6 +42,9 @@ OssieSemanticModel, ) from ossie_wisdom.converter_issues import ConverterIssue, ConverterIssueType, ConverterResult +from ossie_wisdom.wisdom_to_ossie import RELATIONSHIP_TYPE_NOTES + +_NOTE_RELATIONSHIP_TYPES = {note: relationship_type for relationship_type, note in RELATIONSHIP_TYPE_NOTES.items()} _WISDOM_DIALECT = { OssieDialect.SNOWFLAKE: "snowflake", @@ -348,11 +351,16 @@ def _convert_relationships( ) continue relationship_type = "MANY_TO_ONE" + left_columns, right_columns = relationship.from_columns, relationship.to_columns if isinstance(relationship.ai_context, str): - if relationship.ai_context.startswith("one-to-one"): - relationship_type = "ONE_TO_ONE" - elif relationship.ai_context.startswith("many-to-many"): - relationship_type = "MANY_TO_MANY" + marked_type = _NOTE_RELATIONSHIP_TYPES.get(relationship.ai_context.strip()) + if marked_type is not None: + relationship_type = marked_type + if relationship_type == "ONE_TO_MANY": + # The forward path swapped sides to put the many side in `from`; swap back. + # left_ref, right_ref and the join conditions below must be built after this. + left, right = right, left + left_columns, right_columns = right_columns, left_columns else: issues.append( ConverterIssue( @@ -368,10 +376,10 @@ def _convert_relationships( right_ref = {"uuid": zsheet_refs[right]["uuid"], "name": right} conditions = [ { - "leftColumn": {"name": from_column, "zsheetRef": left_ref}, - "rightColumn": {"name": to_column, "zsheetRef": right_ref}, + "leftColumn": {"name": left_column, "zsheetRef": left_ref}, + "rightColumn": {"name": right_column, "zsheetRef": right_ref}, } - for from_column, to_column in zip(relationship.from_columns, relationship.to_columns) + for left_column, right_column in zip(left_columns, right_columns) ] properties: dict = {"relationshipType": relationship_type} if len(conditions) == 1: diff --git a/converters/wisdom/src/ossie_wisdom/wisdom_to_ossie.py b/converters/wisdom/src/ossie_wisdom/wisdom_to_ossie.py index 67cf677e..2630c2aa 100644 --- a/converters/wisdom/src/ossie_wisdom/wisdom_to_ossie.py +++ b/converters/wisdom/src/ossie_wisdom/wisdom_to_ossie.py @@ -52,6 +52,14 @@ _SIMPLE_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +# Relationship `ai_context` notes for the Wisdom types Ossie direction can't express. +# ossie_to_wisdom matches these exactly, so a free-text note is never read as a marker. +RELATIONSHIP_TYPE_NOTES = { + "ONE_TO_MANY": "one-to-many relationship", + "ONE_TO_ONE": "one-to-one relationship", + "MANY_TO_MANY": "many-to-many relationship; cardinality is not representable in Ossie", +} + class WisdomToOssieConverter: """Converts a wisdom domain export dict into an Ossie Document.""" @@ -271,7 +279,7 @@ def _convert_relationships( continue relationship_type = properties.get("relationshipType", "") - ai_context: Optional[str] = None + ai_context = RELATIONSHIP_TYPE_NOTES.get(relationship_type) if isinstance(relationship_type, str) else None # Ossie encodes cardinality by direction: `from` is the many side, `to` the one side. if relationship_type == "ONE_TO_MANY": from_dataset, to_dataset = right, left @@ -281,10 +289,7 @@ def _convert_relationships( from_dataset, to_dataset = left, right from_columns = [pair[0] for pair in column_pairs] to_columns = [pair[1] for pair in column_pairs] - if relationship_type == "ONE_TO_ONE": - ai_context = "one-to-one relationship" - elif relationship_type == "MANY_TO_MANY": - ai_context = "many-to-many relationship; cardinality is not representable in Ossie" + if relationship_type == "MANY_TO_MANY": issues.append( ConverterIssue( issue_type=ConverterIssueType.CARDINALITY_LOSS, element_name=f"{left} <-> {right}" diff --git a/converters/wisdom/tests/test_ossie_to_wisdom.py b/converters/wisdom/tests/test_ossie_to_wisdom.py index f89f5413..512ea828 100644 --- a/converters/wisdom/tests/test_ossie_to_wisdom.py +++ b/converters/wisdom/tests/test_ossie_to_wisdom.py @@ -118,12 +118,33 @@ def test_metrics_attach_to_referenced_tables(export): def test_relationship_types_restored(export): edges = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] types = [edge["properties"]["relationshipType"] for edge in edges] - assert types == ["MANY_TO_ONE", "MANY_TO_ONE", "MANY_TO_MANY", "MANY_TO_ONE"] + assert types == ["MANY_TO_ONE", "ONE_TO_MANY", "MANY_TO_MANY", "MANY_TO_ONE"] compound = edges[3]["properties"]["compoundJoinCondition"]["nestedCondition"] assert compound["logicalOperator"] == "AND" assert len(compound["conditions"]) == 2 +def _without_uuids(value): + if isinstance(value, dict): + return {key: _without_uuids(item) for key, item in value.items() if key != "uuid"} + if isinstance(value, list): + return [_without_uuids(item) for item in value] + return value + + +def test_relationships_round_trip_from_wisdom(export): + original = json.loads(FIXTURE.read_text())["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] + # The OR-joined edge is not representable in Ossie and is dropped (with an issue) on the way in. + representable = [ + edge + for edge in original + if edge["properties"].get("compoundJoinCondition", {}).get("nestedCondition", {}).get("logicalOperator") != "OR" + ] + edges = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"] + # ZSheet uuids are regenerated from names, so compare everything else. + assert _without_uuids(edges) == _without_uuids(representable) + + def test_connections_are_per_dialect(export): dialects = {connection["dialect"] for connection in export["connections"]} assert dialects == {"snowflake", "ansi"} @@ -197,6 +218,89 @@ def test_one_to_one_note_restores_relationship_type(): assert edges[0]["properties"]["relationshipType"] == "ONE_TO_ONE" +def test_relationship_note_with_surrounding_whitespace_still_matches(): + # A YAML block scalar (`ai_context: |`) loads with a trailing newline. + document = OssieDocument( + name="m", + datasets=[ + OssieDataset(name="a", source="db.s.a"), + OssieDataset(name="b", source="db.s.b"), + ], + relationships=[ + OssieRelationship( + name="a_to_b", + from_dataset="a", + to="b", + from_columns=["id"], + to_columns=["id"], + ai_context="one-to-one relationship\n", + ) + ], + ) + result = OssieToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00") + edge = result.output["domain"]["zsheet_json"]["relationshipGraph"]["relationships"][0] + assert edge["properties"]["relationshipType"] == "ONE_TO_ONE" + assert _issues_of(result, ConverterIssueType.AI_CONTEXT_DROPPED) == [] + + +def test_free_text_relationship_note_is_not_read_as_a_type_marker(): + document = OssieDocument( + name="m", + datasets=[ + OssieDataset(name="orders", source="db.s.orders"), + OssieDataset(name="customers", source="db.s.customers"), + ], + relationships=[ + OssieRelationship( + name="orders_to_customers", + from_dataset="orders", + to="customers", + from_columns=["customer_id"], + to_columns=["id"], + ai_context="one-to-many, orders to customers", + ) + ], + ) + result = OssieToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00") + edge = result.output["domain"]["zsheet_json"]["relationshipGraph"]["relationships"][0] + assert edge["properties"]["relationshipType"] == "MANY_TO_ONE" + assert edge["leftDataSource"]["zsheet"]["name"] == "orders" + assert [issue.element_name for issue in _issues_of(result, ConverterIssueType.AI_CONTEXT_DROPPED)] == [ + "orders_to_customers" + ] + + +def test_one_to_many_note_restores_relationship_type_and_direction(): + document = OssieDocument( + name="m", + datasets=[ + OssieDataset(name="orders", source="db.s.orders"), + OssieDataset(name="customers", source="db.s.customers"), + ], + relationships=[ + OssieRelationship( + name="orders_to_customers", + from_dataset="orders", + to="customers", + from_columns=["customer_fk"], + to_columns=["id"], + ai_context="one-to-many relationship", + ) + ], + ) + export = OssieToWisdomConverter().convert(document, exported_at="2026-07-10T00:00:00+00:00").output + edge = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"][0] + assert edge["properties"]["relationshipType"] == "ONE_TO_MANY" + assert edge["leftDataSource"]["zsheet"]["name"] == "customers" + assert edge["rightDataSource"]["zsheet"]["name"] == "orders" + condition = edge["properties"]["joinCondition"] + assert (condition["leftColumn"]["name"], condition["leftColumn"]["zsheetRef"]["name"]) == ("id", "customers") + assert (condition["rightColumn"]["name"], condition["rightColumn"]["zsheetRef"]["name"]) == ( + "customer_fk", + "orders", + ) + + def test_unresolved_metric_attaches_to_first_dataset(): from ossie import OssieMetric diff --git a/converters/wisdom/tests/test_wisdom_to_ossie.py b/converters/wisdom/tests/test_wisdom_to_ossie.py index e08c4d9f..b1d9e090 100644 --- a/converters/wisdom/tests/test_wisdom_to_ossie.py +++ b/converters/wisdom/tests/test_wisdom_to_ossie.py @@ -118,6 +118,16 @@ def test_relationship_directions(model): assert (flipped.from_dataset, flipped.to) == ("orders", "customers") +def test_non_string_relationship_type_is_kept_as_many_to_one(): + export = json.loads(FIXTURE.read_text()) + edge = export["domain"]["zsheet_json"]["relationshipGraph"]["relationships"][0] + edge["properties"]["relationshipType"] = {"unexpected": "shape"} + model = WisdomToOssieConverter().convert(export).output + relationship = model.relationships[0] + assert (relationship.from_dataset, relationship.to) == ("orders", "customers") + assert relationship.ai_context is None + + def test_many_to_many_is_kept_with_cardinality_loss(result, model): relationships = {relationship.name: relationship for relationship in model.relationships} many_to_many = relationships["orders_to_tags"]