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"