You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@ossie.apache.org by jb...@apache.org on 2026/10/06 09:56:50 UTC

(ossie) branch main updated: Improve Power BI converter round-trip fidelity (#448)

This is an automated email from the ASF dual-hosted git repository.

jbonofre pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ossie.git


The following commit(s) were added to refs/heads/main by this push:
     new 891f0079 Improve Power BI converter round-trip fidelity (#448)
891f0079 is described below

commit 891f007945b5666464a45e2c75c1a0a8be9cd7f7
Author: Markus Cozowicz <ma...@microsoft.com>
AuthorDate: Tue Oct 6 11:56:44 2026 +0200

    Improve Power BI converter round-trip fidelity (#448)
    
    * Improve Microsoft converter round trips
    
    * Address round-trip review feedback
    
    * Address compatibility review feedback
---
 converters/microsoft/README.md                     |   6 +-
 .../microsoft/src/ossie_microsoft/_common.py       |  10 +
 .../src/ossie_microsoft/ossie_to_semantic_model.py |  51 ++--
 .../src/ossie_microsoft/semantic_model_to_ossie.py |  22 +-
 .../tests/test_ossie_to_semantic_model.py          |  64 +++++
 .../tests/test_semantic_model_to_ossie.py          | 281 +++++++++++++++++++++
 6 files changed, 408 insertions(+), 26 deletions(-)

diff --git a/converters/microsoft/README.md b/converters/microsoft/README.md
index 7a89652a..e2b6e64e 100644
--- a/converters/microsoft/README.md
+++ b/converters/microsoft/README.md
@@ -230,8 +230,10 @@ Nothing is discarded silently. The converter follows the two rules in
    expressions, and skipped relationships. This is a deny-list, not an allow-list: a
    TMSL property this converter has never heard of is preserved too, rather than
    silently dropped. The export direction replays it all, so a `model.bim` converted to
-   Apache Ossie and back is the same model. An authored Apache Ossie metric takes
-   precedence over a preserved measure with the same final table and name.
+   Apache Ossie and back is the same model, including explicitly serialized default
+   values such as active relationships and false column key flags, and the original
+   whitespace in DAX expressions. An authored Apache Ossie metric takes precedence over
+   a preserved measure with the same final table and name.
 
 2. **Report.** Anything that genuinely cannot be represented raises a `UserWarning`
    naming the object and the reason. Callers who need a hard guarantee can escalate:
diff --git a/converters/microsoft/src/ossie_microsoft/_common.py b/converters/microsoft/src/ossie_microsoft/_common.py
index ec4a331b..72d0ce67 100644
--- a/converters/microsoft/src/ossie_microsoft/_common.py
+++ b/converters/microsoft/src/ossie_microsoft/_common.py
@@ -59,6 +59,16 @@ DIALECT_DAX = "DAX"
 DIALECT_ANSI = "ANSI_SQL"
 DIALECT_OSSIE_SQL = "OSSIE_SQL_2026"
 
+# Power BI relationship properties whose meaning is tied to a specific endpoint pair.
+# Both converter directions use this set to avoid replaying stale vendor metadata after
+# an Ossie relationship's endpoints are edited.
+RELATIONSHIP_CARDINALITY_METADATA = frozenset(
+    {"fromCardinality", "toCardinality"}
+)
+RELATIONSHIP_ENDPOINT_METADATA = RELATIONSHIP_CARDINALITY_METADATA | frozenset(
+    {"crossFilteringBehavior", "isActive", "relyOnReferentialIntegrity"}
+)
+
 # Bump when the shape of a stashed `data` blob changes.
 STASH_VERSION = 2
 
diff --git a/converters/microsoft/src/ossie_microsoft/ossie_to_semantic_model.py b/converters/microsoft/src/ossie_microsoft/ossie_to_semantic_model.py
index f86bf255..1744d9cc 100644
--- a/converters/microsoft/src/ossie_microsoft/ossie_to_semantic_model.py
+++ b/converters/microsoft/src/ossie_microsoft/ossie_to_semantic_model.py
@@ -54,6 +54,8 @@ from ._common import (
     OSSIE_TO_TMSL_DATATYPE,
     OSSIE_UNSUPPORTED,
     OSSIE_VERSION,
+    RELATIONSHIP_CARDINALITY_METADATA,
+    RELATIONSHIP_ENDPOINT_METADATA,
     TMSL_TO_OSSIE_DATATYPE,
     ConversionError,
     dialect_expressions,
@@ -80,7 +82,6 @@ _STASH_CONTROL_KEYS = frozenset(
 )
 _TABLE_CONTROL_KEYS = frozenset({"excludedColumns"})
 _RELATIONSHIP_CONTROL_KEYS = frozenset({"flipped", "name", "normalizedEndpoints"})
-_RELATIONSHIP_CARDINALITY_KEYS = frozenset({"fromCardinality", "toCardinality"})
 _MEASURE_CONTROL_KEYS = frozenset({"table", "name"})
 _COLUMN_CONTROL_KEYS = frozenset({"dataType", "sourceColumn"})
 
@@ -604,10 +605,11 @@ def _dataset_column_index(dataset):
         aliases = {field["name"]}
         expressions = dialect_expressions(field.get("expression"))
         if expressions:
-            _, expression = _preferred_expression(expressions)
-            candidate = expression.strip('"').strip("`").strip("[]")
-            if IDENTIFIER_RE.match(candidate):
-                aliases.add(candidate)
+            dialect, expression = _preferred_expression(expressions)
+            if dialect != DIALECT_DAX:
+                candidate = expression.strip().strip('"').strip("`").strip("[]")
+                if IDENTIFIER_RE.match(candidate):
+                    aliases.add(candidate)
         for alias in aliases:
             for key in (alias, f"{table}.{alias}"):
                 seen.setdefault(key.casefold(), set()).add(target)
@@ -640,13 +642,15 @@ def _source_column(expressions, name, stash):
         # No expression at all: the field name is the column name by definition.
         return name
 
-    _, expression = _preferred_expression(expressions)
+    dialect, expression = _preferred_expression(expressions)
+    if dialect == DIALECT_DAX:
+        return None
     if stash.get("sourceColumn") == expression:
         # A preserved source column the source query exposes under a name that is not a
         # bare SQL identifier, still unedited. Replay it rather than reparse it.
         return expression
 
-    candidate = expression.strip('"').strip("`").strip("[]")
+    candidate = expression.strip().strip('"').strip("`").strip("[]")
     if IDENTIFIER_RE.match(candidate):
         return candidate
     return None
@@ -655,14 +659,14 @@ def _source_column(expressions, name, stash):
 def _preferred_expression(expressions):
     for dialect in (DIALECT_DAX, DIALECT_ANSI, DIALECT_OSSIE_SQL):
         if dialect in expressions:
-            return dialect, expressions[dialect].strip()
+            return dialect, expressions[dialect]
     dialect = next(
         (name for name in sorted(expressions) if name in sql_to_dax.READABLE_DIALECTS),
         None,
     )
     if dialect is None:
         dialect = sorted(expressions)[0]
-    return dialect, expressions[dialect].strip()
+    return dialect, expressions[dialect]
 
 
 def _map_datatype(datatype, scope):
@@ -862,15 +866,20 @@ def _convert_relationships(relationships, table_columns):
             from_column, to_column = to_column, from_column
 
         tmsl = {
-            "name": stash.get("name", relationship.get("name")),
+            "name": _relationship_name(relationship, stash, endpoints),
             "fromTable": from_table,
             "fromColumn": from_column,
             "toTable": to_table,
             "toColumn": to_column,
         }
+        guarded_metadata = (
+            RELATIONSHIP_ENDPOINT_METADATA
+            if "normalizedEndpoints" in stash
+            else RELATIONSHIP_CARDINALITY_METADATA
+        )
         for key, value in stash.items():
             if key not in _RELATIONSHIP_CONTROL_KEYS and (
-                metadata_is_current or key not in _RELATIONSHIP_CARDINALITY_KEYS
+                metadata_is_current or key not in guarded_metadata
             ):
                 tmsl.setdefault(key, value)
         _apply_ai_context(tmsl, relationship.get("ai_context"))
@@ -878,6 +887,13 @@ def _convert_relationships(relationships, table_columns):
     return converted
 
 
+def _relationship_name(relationship, stash, endpoints):
+    name = relationship.get("name")
+    if name is None or name == _generated_relationship_name(endpoints):
+        return stash.get("name", name)
+    return name
+
+
 def _relationship_metadata_is_current(relationship, stash, endpoints):
     if "normalizedEndpoints" in stash:
         return stash["normalizedEndpoints"] == endpoints
@@ -885,10 +901,11 @@ def _relationship_metadata_is_current(relationship, stash, endpoints):
     # Stashes written before normalizedEndpoints was introduced can still be checked
     # against the generated Ossie relationship name. This preserves their unchanged
     # round trip while avoiding stale metadata after the common endpoint-only edit.
-    generated_name = (
-        f"{endpoints[0]}_{endpoints[1]}_to_{endpoints[2]}_{endpoints[3]}"
-    )
-    return relationship.get("name") == generated_name
+    return relationship.get("name") == _generated_relationship_name(endpoints)
+
+
+def _generated_relationship_name(endpoints):
+    return f"{endpoints[0]}_{endpoints[1]}_to_{endpoints[2]}_{endpoints[3]}"
 
 
 def _endpoints_exist(scope, table_columns, from_table, from_column, to_table, to_column):
@@ -942,7 +959,9 @@ def _tmsl_text(value):
     whenever the value spans multiple lines. Matching that keeps generated files
     diffable against ones written by Power BI itself.
     """
-    return value.splitlines() if "\n" in value else value
+    if "\n" not in value:
+        return value
+    return value.replace("\r\n", "\n").split("\n")
 
 
 def _warn_foreign_extensions(scope, obj):
diff --git a/converters/microsoft/src/ossie_microsoft/semantic_model_to_ossie.py b/converters/microsoft/src/ossie_microsoft/semantic_model_to_ossie.py
index 6e267660..7e8811d1 100644
--- a/converters/microsoft/src/ossie_microsoft/semantic_model_to_ossie.py
+++ b/converters/microsoft/src/ossie_microsoft/semantic_model_to_ossie.py
@@ -42,6 +42,7 @@ from ._common import (
     IDENTIFIER_RE,
     OSSIE_TO_TMSL_DATATYPE,
     OSSIE_VERSION,
+    RELATIONSHIP_ENDPOINT_METADATA,
     TEMPORAL_DATATYPES,
     TMSL_TO_OSSIE_DATATYPE,
     TMSL_UNSUPPORTED_COLUMN,
@@ -94,8 +95,6 @@ _RELATIONSHIP_CONSUMED = frozenset(
         "isActive",
     }
 )
-
-
 def convert_semantic_model_to_ossie(semantic_model: dict | str) -> str:
     """Convert a Power BI semantic model into an Apache Ossie semantic model.
 
@@ -332,7 +331,7 @@ def _convert_column(column, table_scope):
     if column.get("type") == "calculated":
         # A calculated column is DAX. It is carried across as DAX rather than rewritten
         # into SQL, so no expression semantics are invented.
-        expression = make_expression(text(column.get("expression", "")).strip(), DIALECT_DAX)
+        expression = make_expression(text(column.get("expression", "")), DIALECT_DAX)
     else:
         expression = make_expression(column.get("sourceColumn") or name, DIALECT_ANSI)
 
@@ -346,6 +345,9 @@ def _convert_column(column, table_scope):
     ai_context, stash = _split_ai_context(column, _COLUMN_CONSUMED)
     if ai_context is not None:
         field["ai_context"] = ai_context
+    for key in ("isKey", "isUnique"):
+        if column.get(key) is False:
+            stash[key] = False
     if datatype in TEMPORAL_DATATYPES or column.get("dataCategory") == "Time":
         field["dimension"] = {"is_time": True}
 
@@ -420,11 +422,11 @@ def _convert_metrics(tables):
             warn_unsupported(scope, measure, TMSL_UNSUPPORTED_MEASURE, "Apache Ossie", _PRESERVED)
             original_expression = measure.get("expression")
             expression = (
-                text(original_expression).strip()
+                text(original_expression)
                 if original_expression is not None
                 else ""
             )
-            if not expression:
+            if not expression.strip():
                 warn(
                     scope,
                     "measure has no expression; excluded from the Apache Ossie model "
@@ -544,13 +546,17 @@ def _convert_relationships(relationships, exported_names):
         for key in ("fromCardinality", "toCardinality"):
             if key in relationship:
                 stash[key] = relationship[key]
+        if "isActive" in relationship:
+            # Inactive relationships are excluded above. Preserve an explicitly active
+            # value so an otherwise lossless Power BI round trip does not omit it.
+            stash["isActive"] = relationship["isActive"]
         if flipped:
             # Recorded so an export restores the original one-to-many orientation
             # instead of silently rewriting the model shape.
             stash["flipped"] = True
-        if flipped or any(key in relationship for key in ("fromCardinality", "toCardinality")):
-            # Cardinality and orientation only describe these normalized endpoints.
-            # Remember them so later Ossie edits cannot make that metadata stale.
+        if flipped or any(key in relationship for key in RELATIONSHIP_ENDPOINT_METADATA):
+            # These properties describe this specific relationship. Remember its
+            # normalized endpoints so later Ossie edits cannot replay stale metadata.
             stash["normalizedEndpoints"] = [
                 from_table,
                 from_column,
diff --git a/converters/microsoft/tests/test_ossie_to_semantic_model.py b/converters/microsoft/tests/test_ossie_to_semantic_model.py
index f3d6aec6..ad44ecd6 100644
--- a/converters/microsoft/tests/test_ossie_to_semantic_model.py
+++ b/converters/microsoft/tests/test_ossie_to_semantic_model.py
@@ -342,6 +342,26 @@ def test_a_sql_concatenation_becomes_a_table_qualified_calculated_column():
     )
 
 
+def test_a_dax_identifier_is_not_indexed_as_a_physical_column_alias():
+    semantic_model = _minimal()
+    semantic_model["datasets"][0]["fields"] = [
+        {
+            "name": "Calculated",
+            "datatype": "String",
+            "expression": make_expression("source_alias", "DAX"),
+        },
+        {
+            "name": "Label",
+            "datatype": "String",
+            "expression": make_expression("source_alias || ' suffix'", "ANSI_SQL"),
+        },
+    ]
+
+    with pytest.warns(UserWarning, match="source_alias"):
+        column = _column(_table(_convert(semantic_model), "T"), "Label")
+    assert column["expression"] == "BLANK()"
+
+
 def test_an_ossie_sql_concatenation_becomes_a_calculated_column():
     semantic_model = _minimal()
     semantic_model["datasets"][0]["fields"] = [
@@ -1087,6 +1107,50 @@ def test_a_relationship_to_a_missing_column_is_skipped():
     assert "relationships" not in bim["model"]
 
 
+def test_a_legacy_relationship_stash_keeps_non_cardinality_metadata():
+    semantic_model = {
+        "name": "m",
+        "datasets": [
+            {
+                "name": "A",
+                "source": "a",
+                "fields": [{"name": "K", "expression": make_expression("k", "ANSI_SQL")}],
+            },
+            {
+                "name": "B",
+                "source": "b",
+                "fields": [{"name": "K", "expression": make_expression("k", "ANSI_SQL")}],
+            },
+        ],
+        "relationships": [
+            {
+                "name": "customer_sales",
+                "from": "A",
+                "to": "B",
+                "from_columns": ["K"],
+                "to_columns": ["K"],
+            }
+        ],
+    }
+    write_stash(
+        semantic_model["relationships"][0],
+        {
+            "fromCardinality": "many",
+            "toCardinality": "one",
+            "isActive": True,
+            "crossFilteringBehavior": "bothDirections",
+            "relyOnReferentialIntegrity": True,
+        },
+    )
+
+    relationship = _convert(semantic_model)["model"]["relationships"][0]
+    assert "fromCardinality" not in relationship
+    assert "toCardinality" not in relationship
+    assert relationship["isActive"] is True
+    assert relationship["crossFilteringBehavior"] == "bothDirections"
+    assert relationship["relyOnReferentialIntegrity"] is True
+
+
 # --- other vendors ---------------------------------------------------------
 
 
diff --git a/converters/microsoft/tests/test_semantic_model_to_ossie.py b/converters/microsoft/tests/test_semantic_model_to_ossie.py
index a8b522f3..689c504a 100644
--- a/converters/microsoft/tests/test_semantic_model_to_ossie.py
+++ b/converters/microsoft/tests/test_semantic_model_to_ossie.py
@@ -187,6 +187,43 @@ def test_keys_are_mapped(model):
     assert customer["unique_keys"] == [["Email"]]
 
 
+@pytest.mark.parametrize(
+    ("tmsl_property", "ossie_property", "ossie_value"),
+    [
+        ("isKey", "primary_key", ["C"]),
+        ("isUnique", "unique_keys", [["C"]]),
+    ],
+)
+def test_explicit_false_key_flags_round_trip_and_ossie_edits_win(
+    tmsl_property, ossie_property, ossie_value
+):
+    bim = {
+        "name": "key_flags",
+        "model": {
+            "tables": [
+                {
+                    "name": "T",
+                    "columns": [{"name": "C", tmsl_property: False}],
+                }
+            ]
+        },
+    }
+
+    ossie = build_ossie_document(bim)
+    dataset = _dataset(ossie, "T")
+    assert ossie_property not in dataset
+    assert read_stash(_field(dataset, "C"))[tmsl_property] is False
+
+    round_tripped = convert_ossie_to_semantic_model(ossie)
+    column = round_tripped["model"]["tables"][0]["columns"][0]
+    assert column[tmsl_property] is False
+
+    dataset[ossie_property] = ossie_value
+    edited = convert_ossie_to_semantic_model(ossie)
+    column = edited["model"]["tables"][0]["columns"][0]
+    assert column[tmsl_property] is True
+
+
 @pytest.mark.parametrize(
     "dataset_name,expected_source",
     [
@@ -209,6 +246,118 @@ def test_plain_column_uses_source_column_as_sql(model):
     assert amount["description"] == "Extended sales amount"
 
 
+def test_dax_string_literal_remains_a_calculated_column():
+    bim = {
+        "name": "dax_literal",
+        "model": {
+            "tables": [
+                {
+                    "name": "T",
+                    "columns": [
+                        {
+                            "name": "World",
+                            "type": "calculated",
+                            "dataType": "string",
+                            "expression": '"Earth"',
+                        }
+                    ],
+                }
+            ]
+        },
+    }
+
+    ossie = build_ossie_document(bim)
+    field = _field(_dataset(ossie, "T"), "World")
+    assert _expression(field, "DAX") == '"Earth"'
+
+    column = convert_ossie_to_semantic_model(ossie)["model"]["tables"][0]["columns"][0]
+    assert column["type"] == "calculated"
+    assert column["expression"] == '"Earth"'
+
+
+def test_dax_expression_whitespace_survives_a_round_trip():
+    bim = {
+        "name": "dax_whitespace",
+        "model": {
+            "tables": [
+                {
+                    "name": "T",
+                    "columns": [
+                        {
+                            "name": "C",
+                            "type": "calculated",
+                            "dataType": "string",
+                            "expression": '\n"Value"\n',
+                        }
+                    ],
+                    "measures": [{"name": "M", "expression": "\nCOUNTROWS('T')\n"}],
+                }
+            ]
+        },
+    }
+
+    round_tripped = convert_ossie_to_semantic_model(build_ossie_document(bim))
+    table = round_tripped["model"]["tables"][0]
+    assert table["columns"][0]["expression"] == ["", '"Value"', ""]
+    assert table["measures"][0]["expression"] == ["", "COUNTROWS('T')", ""]
+
+
+def test_single_line_dax_whitespace_survives_a_round_trip():
+    bim = {
+        "name": "dax_whitespace",
+        "model": {
+            "tables": [
+                {
+                    "name": "T",
+                    "columns": [
+                        {
+                            "name": "C",
+                            "type": "calculated",
+                            "dataType": "string",
+                            "expression": '  "Value"  ',
+                        }
+                    ],
+                    "measures": [{"name": "M", "expression": "  COUNTROWS('T')  "}],
+                }
+            ]
+        },
+    }
+
+    round_tripped = convert_ossie_to_semantic_model(build_ossie_document(bim))
+    table = round_tripped["model"]["tables"][0]
+    assert table["columns"][0]["expression"] == '  "Value"  '
+    assert table["measures"][0]["expression"] == "  COUNTROWS('T')  "
+
+
+@pytest.mark.parametrize(
+    "separator",
+    ["\r", "\x0b", "\x0c", "\x1c", "\x1d", "\x1e", "\x85", "\u2028", "\u2029"],
+)
+def test_non_newline_dax_separators_remain_scalar(separator):
+    expression = f'"left"{separator}"right"'
+    bim = {
+        "name": "dax_separator",
+        "model": {
+            "tables": [
+                {
+                    "name": "T",
+                    "columns": [
+                        {
+                            "name": "C",
+                            "type": "calculated",
+                            "dataType": "string",
+                            "expression": expression,
+                        }
+                    ],
+                }
+            ]
+        },
+    }
+
+    round_tripped = convert_ossie_to_semantic_model(build_ossie_document(bim))
+    assert round_tripped["model"]["tables"][0]["columns"][0]["expression"] == expression
+
+
 def test_calculated_column_uses_dax_dialect(model):
     field = _field(_dataset(model, "Sales"), "AmountWithTax")
     assert _expression(field, "DAX") == "Sales[Amount] * 1.2"
@@ -255,6 +404,138 @@ def test_active_many_to_one_relationship(model):
     assert rel["to_columns"] == ["CustomerKey"]
 
 
+def test_explicit_active_state_survives_a_round_trip():
+    bim = {
+        "name": "active",
+        "model": {
+            "tables": [
+                {"name": "Sales", "columns": [{"name": "CustomerKey"}]},
+                {"name": "Customer", "columns": [{"name": "CustomerKey"}]},
+            ],
+            "relationships": [
+                {
+                    "name": "active_relationship",
+                    "fromTable": "Sales",
+                    "fromColumn": "CustomerKey",
+                    "toTable": "Customer",
+                    "toColumn": "CustomerKey",
+                    "isActive": True,
+                }
+            ],
+        },
+    }
+
+    ossie = build_ossie_document(bim)
+    relationship = ossie["relationships"][0]
+    stash = read_stash(relationship)
+    assert stash["isActive"] is True
+    assert stash["normalizedEndpoints"] == [
+        "Sales",
+        "CustomerKey",
+        "Customer",
+        "CustomerKey",
+    ]
+
+    round_tripped = convert_ossie_to_semantic_model(ossie)
+    assert (
+        round_tripped["model"]["relationships"][0]
+        == bim["model"]["relationships"][0]
+    )
+
+
+@pytest.mark.parametrize(
+    ("property_name", "property_value"),
+    [
+        ("fromCardinality", "many"),
+        ("toCardinality", "one"),
+        ("crossFilteringBehavior", "oneDirection"),
+        ("crossFilteringBehavior", "bothDirections"),
+        ("relyOnReferentialIntegrity", False),
+    ],
+)
+def test_corpus_backed_relationship_properties_survive_an_exact_round_trip(
+    property_name, property_value
+):
+    source_relationship = {
+        "name": "customer_sales",
+        "fromTable": "Sales",
+        "fromColumn": "CustomerKey",
+        "toTable": "Customer",
+        "toColumn": "CustomerKey",
+        property_name: property_value,
+    }
+    bim = {
+        "name": "relationship_properties",
+        "model": {
+            "tables": [
+                {"name": "Sales", "columns": [{"name": "CustomerKey"}]},
+                {"name": "Customer", "columns": [{"name": "CustomerKey"}]},
+            ],
+            "relationships": [source_relationship],
+        },
+    }
+
+    ossie = build_ossie_document(bim)
+    assert read_stash(ossie["relationships"][0])[property_name] == property_value
+
+    round_tripped = convert_ossie_to_semantic_model(ossie)
+    assert round_tripped["model"]["relationships"] == [source_relationship]
+
+
+def test_ossie_relationship_edits_win_over_stale_power_bi_metadata():
+    source_relationship = {
+        "name": "customer_sales",
+        "fromTable": "Sales",
+        "fromColumn": "CustomerKey",
+        "toTable": "Customer",
+        "toColumn": "CustomerKey",
+        "fromCardinality": "many",
+        "toCardinality": "one",
+        "isActive": True,
+        "crossFilteringBehavior": "bothDirections",
+        "relyOnReferentialIntegrity": True,
+    }
+    bim = {
+        "name": "relationship_precedence",
+        "model": {
+            "tables": [
+                {
+                    "name": "Sales",
+                    "columns": [{"name": "CustomerKey"}, {"name": "AlternateKey"}],
+                },
+                {
+                    "name": "Customer",
+                    "columns": [{"name": "CustomerKey"}, {"name": "AlternateKey"}],
+                },
+            ],
+            "relationships": [source_relationship],
+        },
+    }
+    ossie = build_ossie_document(bim)
+    relationship = ossie["relationships"][0]
+    relationship["name"] = "edited_relationship"
+
+    renamed = convert_ossie_to_semantic_model(ossie)
+    assert renamed["model"]["relationships"] == [
+        {**source_relationship, "name": "edited_relationship"}
+    ]
+
+    relationship["from_columns"] = ["AlternateKey"]
+    relationship["to_columns"] = ["AlternateKey"]
+
+    round_tripped = convert_ossie_to_semantic_model(ossie)
+
+    assert round_tripped["model"]["relationships"] == [
+        {
+            "name": "edited_relationship",
+            "fromTable": "Sales",
+            "fromColumn": "AlternateKey",
+            "toTable": "Customer",
+            "toColumn": "AlternateKey",
+        }
+    ]
+
+
 def test_one_to_many_relationship_is_flipped(model):
     rel = next(r for r in model["relationships"] if r["to"] == "Calendar")
     assert rel["from"] == "Sales"