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
19 changes: 13 additions & 6 deletions converters/wisdom/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,13 @@ 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, 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`)
Expand All @@ -86,9 +90,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
Expand Down
23 changes: 15 additions & 8 deletions converters/wisdom/src/ossie_wisdom/ossie_to_wisdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down Expand Up @@ -348,11 +351,15 @@ 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"
if relationship.ai_context in _NOTE_RELATIONSHIP_TYPES:
relationship_type = _NOTE_RELATIONSHIP_TYPES[relationship.ai_context]
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(
Expand All @@ -368,10 +375,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:
Expand Down
15 changes: 10 additions & 5 deletions converters/wisdom/src/ossie_wisdom/wisdom_to_ossie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)
# 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
Expand All @@ -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}"
Expand Down
81 changes: 80 additions & 1 deletion converters/wisdom/tests/test_ossie_to_wisdom.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Expand Down Expand Up @@ -197,6 +218,64 @@ def test_one_to_one_note_restores_relationship_type():
assert edges[0]["properties"]["relationshipType"] == "ONE_TO_ONE"


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

Expand Down
Loading