Skip to content
Merged
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
6 changes: 4 additions & 2 deletions .github/workflows/converter-microsoft-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,15 @@ on:
branches: [ "main" ]
paths:
- 'converters/microsoft/**'
- 'core-spec/ossie-schema.json'
- 'core-spec/**'
- 'examples/**'
- '.github/workflows/converter-microsoft-ci.yml'
pull_request:
branches: [ "main" ]
paths:
- 'converters/microsoft/**'
- 'core-spec/ossie-schema.json'
- 'core-spec/**'
- 'examples/**'
- '.github/workflows/converter-microsoft-ci.yml'

jobs:
Expand Down
9 changes: 8 additions & 1 deletion converters/microsoft/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ semantic model. Power BI output is available as either a TMSL `model.bim` mappin
TMDL document. The conversion is offline and requires no Power BI or Fabric
connection.

Each Ossie JSON/YAML document contains one model, with `name`, `datasets`,
`relationships`, and `metrics` at the root alongside `version`. Legacy
`semantic_model` wrappers must be unwrapped before conversion; split documents
containing multiple models into separate files. Because the Ossie schema requires at
least one dataset, importing a Power BI model fails explicitly when every table is
Comment thread
eisber marked this conversation as resolved.
malformed or excluded from the vendor-neutral model.

## Installation

```bash
Expand Down Expand Up @@ -171,7 +178,7 @@ logging.getLogger("ossie_microsoft").addHandler(logging.StreamHandler())

| Power BI (TMSL) | Apache Ossie |
|-----------------|--------------|
| `name` / `model.description` | `semantic_model.name` / `.description` |
| `name` / `model.description` | `name` / `description` |
| `model.tables[]` | `datasets[]` |
| table partition source (`entity`, `m`, `query`, `calculated`) | `dataset.source` |
| `table.columns[]` | `dataset.fields[]` |
Expand Down
36 changes: 19 additions & 17 deletions converters/microsoft/src/ossie_microsoft/ossie_to_semantic_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,32 +155,37 @@ def convert_ossie_to_semantic_model(
if not isinstance(document, dict):
raise TypeError("input must be Apache Ossie YAML text or a parsed document")

if "semantic_model" in document:
raise ValueError(
"Legacy 'semantic_model' wrappers are not supported; "
"place model properties at the document root"
)
if "dialects" in document or "vendors" in document:
raise ValueError("Root dialects and vendors are not supported by the Ossie spec")
if not document.get("name"):
raise ValueError("document requires 'name' and 'datasets' at the root")
datasets = document.get("datasets")
if (
not isinstance(datasets, list)
or not datasets
or any(not isinstance(dataset, dict) or not dataset.get("name") for dataset in datasets)
):
raise ValueError("document 'datasets' must be a non-empty list of named objects")

version = document.get("version")
if version and version != OSSIE_VERSION:
warn(
"document",
f"document targets Apache Ossie spec {version}, this converter targets "
f"{OSSIE_VERSION}; conversion may be incomplete",
)

models = document.get("semantic_model")
if not isinstance(models, list) or not models or not isinstance(models[0], dict):
raise ValueError("document is missing a 'semantic_model' entry")
if len(models) > 1:
warn(
"document",
f"a model.bim holds a single model; converting the first of {len(models)} "
"and skipping the rest",
)
semantic_model = models[0]
semantic_model = document

stash = read_stash(semantic_model)
_warn_foreign_extensions("model", semantic_model)
warn_unsupported("model", semantic_model, OSSIE_UNSUPPORTED, "Power BI", _DROPPED)

tables, table_columns, generated_partitions = _convert_datasets(
semantic_model.get("datasets") or []
)
tables, table_columns, generated_partitions = _convert_datasets(datasets)
_apply_measures(tables, semantic_model.get("metrics") or [])
_restore_excluded_measures(tables, stash.get("excludedMeasures") or [])

Expand Down Expand Up @@ -730,9 +735,6 @@ def _apply_measures(tables, metrics):

table = by_name.get(stash.get("table"))
if table is None:
if not tables:
warn(scope, "the model has no table to hold the measure; skipped")
continue
table = tables[0]
warn(
scope,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ def build_ossie_document(bim_file):
t for t in model.get("tables") or [] if isinstance(t, dict) and t.get("name")
]
tables = [t for t in all_tables if _is_exported_table(t)]
if not tables:
raise ValueError(
"model.bim has no tables that can be exported as Apache Ossie datasets"
)
excluded_tables = [t for t in all_tables if not _is_exported_table(t)]
exported_names = {t["name"] for t in tables}

Expand Down Expand Up @@ -179,7 +183,7 @@ def build_ossie_document(bim_file):
excluded_measures,
)

return {"version": OSSIE_VERSION, "semantic_model": [semantic_model]}
return {"version": OSSIE_VERSION, **semantic_model}
Comment thread
eisber marked this conversation as resolved.


# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion converters/microsoft/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,4 @@ def osi(bim):

@pytest.fixture(scope="module")
def model(osi):
return osi["semantic_model"][0]
return {key: value for key, value in osi.items() if key != "version"}
70 changes: 42 additions & 28 deletions converters/microsoft/tests/test_edge_cases.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import warnings

import pytest
import yaml

from ossie_microsoft import convert_ossie_to_semantic_model
from ossie_microsoft._common import (
Expand Down Expand Up @@ -59,7 +60,7 @@ def _model(**overrides):
],
}
semantic_model.update(overrides)
return {"version": OSSIE_VERSION, "semantic_model": [semantic_model]}
return {"version": OSSIE_VERSION, **semantic_model}


def _convert(document):
Expand Down Expand Up @@ -121,15 +122,16 @@ def test_a_malformed_extension_entry_is_ignored():

def test_a_non_dict_table_is_skipped():
bim = {"name": "m", "model": {"tables": ["nonsense", {"name": None}]}}
assert build_ossie_document(bim)["semantic_model"][0].get("datasets") == []
with pytest.raises(ValueError, match="no tables that can be exported"):
build_ossie_document(bim)


def test_a_non_dict_column_is_skipped():
bim = {
"name": "m",
"model": {"tables": [{"name": "T", "columns": ["nonsense", {"noName": 1}]}]},
}
model = build_ossie_document(bim)["semantic_model"][0]
model = build_ossie_document(bim)
assert model["datasets"][0].get("fields") is None


Expand All @@ -138,23 +140,30 @@ def test_a_non_dict_measure_is_skipped():
"name": "m",
"model": {"tables": [{"name": "T", "measures": ["nonsense", {"noName": 1}]}]},
}
model = build_ossie_document(bim)["semantic_model"][0]
model = build_ossie_document(bim)
assert model.get("metrics") is None


def test_a_non_dict_relationship_is_skipped():
bim = {"name": "m", "model": {"tables": [], "relationships": ["nonsense"]}}
assert build_ossie_document(bim)["semantic_model"][0].get("relationships") is None
bim = {
"name": "m",
"model": {"tables": [{"name": "T"}], "relationships": ["nonsense"]},
}
assert build_ossie_document(bim).get("relationships") is None


def test_a_non_dict_dataset_is_skipped():
bim = _convert(_model(datasets=["nonsense", {"noName": 1}]))
assert bim["model"]["tables"] == []
@pytest.mark.parametrize(
"datasets",
[None, {}, [], "dataset", ["nonsense"], [{"name": "T"}, "nonsense"], [{}]],
)
def test_datasets_must_be_a_non_empty_list_of_named_objects(datasets):
with pytest.raises(ValueError, match="non-empty list of named objects"):
_convert(_model(datasets=datasets))


def test_a_non_dict_field_is_skipped():
document = _model()
document["semantic_model"][0]["datasets"][0]["fields"] = ["nonsense", {"noName": 1}]
document["datasets"][0]["fields"] = ["nonsense", {"noName": 1}]
bim = _convert(document)
assert bim["model"]["tables"][0]["columns"] == []

Expand All @@ -172,36 +181,26 @@ def test_a_non_dict_metric_is_skipped():
def test_a_composite_unique_key_is_reported():
"""TMSL marks uniqueness per column; a composite constraint has no equivalent."""
document = _model()
document["semantic_model"][0]["datasets"][0]["unique_keys"] = [["C", "D"]]
document["datasets"][0]["unique_keys"] = [["C", "D"]]
with pytest.warns(UserWarning, match="composite unique constraint"):
bim = _convert(document)
assert "isUnique" not in bim["model"]["tables"][0]["columns"][0]


def test_a_malformed_unique_key_is_ignored():
document = _model()
document["semantic_model"][0]["datasets"][0]["unique_keys"] = ["not a list"]
document["datasets"][0]["unique_keys"] = ["not a list"]
_convert(document)


def test_an_unrecognized_datatype_is_reported_and_left_unspecified():
document = _model()
document["semantic_model"][0]["datasets"][0]["fields"][0]["datatype"] = "Fictional"
document["datasets"][0]["fields"][0]["datatype"] = "Fictional"
with pytest.warns(UserWarning, match="unrecognized Apache Ossie data type"):
bim = _convert(document)
assert "dataType" not in bim["model"]["tables"][0]["columns"][0]


def test_a_measure_with_no_table_to_live_on_is_reported():
"""A Power BI measure must belong to a table; with no tables there is nowhere."""
document = _model(datasets=[], metrics=[
{"name": "M", "expression": make_expression("SUM(x)", "DAX")}
])
with pytest.warns(UserWarning, match="no table to hold the measure"):
bim = _convert(document)
assert bim["model"]["tables"] == []


def test_a_relationship_to_a_missing_table_is_reported():
document = _model(relationships=[
{"name": "r", "from": "Nope", "from_columns": ["C"], "to": "T", "to_columns": ["C"]}
Expand Down Expand Up @@ -229,7 +228,7 @@ def test_a_duplicate_measure_name_is_qualified_by_its_table():
},
}
with pytest.warns(UserWarning, match="duplicate measure name"):
model = build_ossie_document(bim)["semantic_model"][0]
model = build_ossie_document(bim)
assert [m["name"] for m in model["metrics"]] == ["Total", "B.Total"]


Expand Down Expand Up @@ -261,7 +260,7 @@ def test_a_relationship_missing_an_endpoint_is_reported_and_preserved():
},
}
with pytest.warns(UserWarning, match="missing an endpoint"):
model = build_ossie_document(bim)["semantic_model"][0]
model = build_ossie_document(bim)
# Preserved, so a round trip back to Power BI does not delete it.
assert read_stash(model)["excludedRelationships"][0]["name"] == "broken"

Expand All @@ -287,7 +286,7 @@ def test_a_duplicate_relationship_name_is_reported_and_preserved():
},
}
with pytest.warns(UserWarning, match="duplicate relationship"):
model = build_ossie_document(bim)["semantic_model"][0]
model = build_ossie_document(bim)
assert len(model["relationships"]) == 1
assert read_stash(model)["excludedRelationships"][0]["name"] == "dup"

Expand All @@ -301,9 +300,24 @@ def test_the_cli_writes_to_stdout_without_an_output_path(tmp_path, capsys):
from ossie_microsoft.cli import main

src = tmp_path / "m.bim"
src.write_text(json.dumps({"name": "m", "model": {"tables": []}}), encoding="utf-8")
src.write_text(
json.dumps({"name": "m", "model": {"tables": [{"name": "T"}]}}),
encoding="utf-8",
)
assert main(["import", "-i", str(src)]) == 0
assert "semantic_model" in capsys.readouterr().out
assert yaml.safe_load(capsys.readouterr().out)["name"] == "m"


def test_the_cli_rejects_a_model_without_an_exportable_dataset(tmp_path, capsys):
from ossie_microsoft.cli import main

src = tmp_path / "m.bim"
out = tmp_path / "m.yaml"
src.write_text(json.dumps({"name": "m", "model": {"tables": []}}), encoding="utf-8")

assert main(["import", "-i", str(src), "-o", str(out)]) == 1
assert "no tables that can be exported" in capsys.readouterr().err
assert not out.exists()


def test_the_cli_reports_a_bad_file_without_a_traceback(tmp_path, capsys):
Expand Down
Loading
Loading