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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,8 +449,8 @@ and a conformance kit that validates a bespoke mapping in CI.
### Import and export, from the Model tab

The Model tab's toolbar has **Export** and **Import** menus. Export downloads the model
in any interchange format; import parses SQL DDL, a Mermaid diagram, or a JSON Schema and
stages it as a proposal you review before it lands.
in any interchange format; import parses SQL DDL, a Mermaid diagram, a JSON Schema, or an
**erwin XML export** and stages it as a proposal you review before it lands.

![The Export menu open in the Model-tab toolbar: a SQL dialect selector and download items for SQL DDL, Mermaid erDiagram, DBML, CSV, an ODCS data contract, and Neo4j Cypher](docs/assets/export-menu.png)

Expand All @@ -459,6 +459,24 @@ stages it as a proposal you review before it lands.
An imported model is visible on the canvas immediately as a staged preview — nothing
touches disk or git until you submit the proposal.

### Import from erwin

Nobody adopts a modeling tool greenfield, so `mdl import erwin` reads a real erwin Data
Modeler XML export and turns it into a committable Modelith model — entities, primary and
foreign keys, relationships (with their verb phrases), subtype/supertype categories,
subject areas, and domains, keeping the physical names alongside the logical ones:

```bash
mdl import erwin your-model.xml -o model # writes a fresh model
mdl import erwin your-model.xml -o model --apply # or merge into an existing one
```

It parses the export by streaming, so 7-10MB files import without a memory spike, and it
degrades gracefully: anything Modelith doesn't model (views, ER-diagram layout, physical
transforms) is skipped with a note rather than a failure. In VS Code it's on the palette
(**Modelith: Import Erwin XML**), a button in the Reverse Review panel, and a right-click
on any `.xml` file.

## Reverse engineering a real warehouse

`mdl reverse` lifts a warehouse into a logical model from any of three sources — a
Expand Down
57 changes: 53 additions & 4 deletions canvas/src/Inspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ export function Inspector({
readOnly={readOnly}
onCommit={(v) => exec("rename_entity", { id: entity.id, name: v })}
/>
{(entity.physical_name || !readOnly) && (
<div className="physical-name-row" title="Physical table name (erwin Physical_Name)">
<span className="physical-label">physical</span>
<EditableText
value={entity.physical_name ?? ""}
className="physical-name-input"
placeholder={readOnly ? "—" : "physical table name…"}
readOnly={readOnly}
onCommit={(v) => exec("rename_entity", { id: entity.id, physical_name: v })}
/>
</div>
)}
<div>
<SubjectAreaPicker entity={entity} doc={doc} readOnly={readOnly} exec={exec} />
<PatternPicker entity={entity} readOnly={readOnly} exec={exec} />
Expand Down Expand Up @@ -260,10 +272,33 @@ export function Inspector({
const other = r.from.entity === entity.id ? r.to.entity : r.from.entity;
return (
<div key={r.id} className="rel-row">
<button className="rel-link" onClick={() => onFocusEntity(other)}>
<span className="rel-card">{cardinalityGlyph(r, entity.id)}</span>
{entityName(other)}
</button>
<div className="rel-main">
<button className="rel-link" onClick={() => onFocusEntity(other)}>
<span className="rel-card">{cardinalityGlyph(r, entity.id)}</span>
{entityName(other)}
</button>
{(r.verb_phrase || r.inverse_verb_phrase || !readOnly) && (
<div className="verb-phrases" title="erwin verb phrases (parent → child, and inverse)">
<EditableText
value={r.verb_phrase ?? ""}
className="verb-input"
placeholder={readOnly ? "" : "verb phrase…"}
readOnly={readOnly}
onCommit={(v) => exec("update_relationship", { id: r.id, verb_phrase: v })}
/>
<span className="verb-sep">/</span>
<EditableText
value={r.inverse_verb_phrase ?? ""}
className="verb-input"
placeholder={readOnly ? "" : "inverse…"}
readOnly={readOnly}
onCommit={(v) =>
exec("update_relationship", { id: r.id, inverse_verb_phrase: v })
}
/>
</div>
)}
</div>
{!readOnly && (
<button
className="mini-btn danger"
Expand Down Expand Up @@ -510,6 +545,11 @@ function AttrRow({
<tr>
<td className={a.role === "business_key" ? "pk" : ""}>
{a.name}
{a.physical_name && a.physical_name !== a.name && (
<span className="physical-col" title="Physical column name">
{a.physical_name}
</span>
)}
{a.enum_values && a.enum_values.length > 0 && (
<span className="enum-badge" title={`Allowed: ${a.enum_values.join(", ")}`}>
enum
Expand All @@ -527,6 +567,15 @@ function AttrRow({
<tr>
<td className={a.role === "business_key" ? "pk" : ""}>
<EditableText value={a.name} readOnly={false} onCommit={(v) => upd({ name: v })} />
{(a.physical_name || false) && (
<EditableText
value={a.physical_name ?? ""}
className="physical-col-input"
placeholder="physical…"
readOnly={false}
onCommit={(v) => upd({ physical_name: v })}
/>
)}
{a.enum_values && a.enum_values.length > 0 && (
<span className="enum-badge" title={`Allowed: ${a.enum_values.join(", ")}`}>
enum
Expand Down
3 changes: 2 additions & 1 deletion canvas/src/RelationshipEdge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ export const RelationshipEdge = memo(function RelationshipEdge({
<EndGlyph x={targetX} y={targetY} side={targetPosition} many={tgtMany} optional={false} />
{(selected || composite) && rel && (
<text className="rel-label" x={(sourceX + targetX) / 2} y={(sourceY + targetY) / 2 - 8}>
{selected ? rel.name : ""}
{/* erwin verb phrase reads "<parent> <verb> <child>"; fall back to the name */}
{selected ? rel.verb_phrase || rel.name : ""}
{composite ? `${selected ? " " : ""}(${data!.memberCount})` : ""}
</text>
)}
Expand Down
3 changes: 2 additions & 1 deletion canvas/src/sme/ImportExportMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ const IMPORT_FORMATS = [
{ id: "sql", label: "SQL DDL", hint: "CREATE TABLE … PRIMARY KEY / FOREIGN KEY" },
{ id: "mermaid", label: "Mermaid erDiagram", hint: "structural: entities, attributes, relationships" },
{ id: "json-schema", label: "JSON Schema", hint: "each object definition becomes an entity" },
{ id: "erwin", label: "erwin XML", hint: "entities, keys, relationships, subject areas, subtypes" },
];

function ImportPanel({
Expand Down Expand Up @@ -230,7 +231,7 @@ function ImportPanel({
<input
type="file"
className="ie-file"
accept=".sql,.mmd,.md,.json,.txt"
accept=".sql,.mmd,.md,.json,.xml,.txt"
onChange={(e) => onFile(e.target.files?.[0] ?? null)}
/>
{format === "mermaid" && (
Expand Down
13 changes: 11 additions & 2 deletions canvas/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -286,8 +286,17 @@ body { font-family: var(--font); background: var(--bg); color: var(--text); }
.h3-actions { float: right; display: inline-flex; gap: 6px; }
.empty-hint { color: var(--text-dim); font-size: 12px; font-style: italic; }
.add-attr { display: flex; gap: 6px; margin-top: 8px; }
.rel-row { display: flex; gap: 6px; align-items: center; }
.rel-row .rel-link { flex: 1; }
.rel-row { display: flex; gap: 6px; align-items: flex-start; }
.rel-row .rel-main { flex: 1; }
.verb-phrases { display: flex; align-items: center; gap: 4px; margin-top: 2px; padding-left: 4px; }
.verb-phrases .verb-input { font-size: 11px; font-style: italic; color: var(--text-dim); min-width: 40px; }
.verb-phrases .verb-sep { color: var(--text-dim); font-size: 11px; }
/* physical (erwin) names, shown secondary to the logical name */
.physical-name-row { display: flex; align-items: center; gap: 6px; margin-top: 2px; }
.physical-name-row .physical-label { font-size: 9px; text-transform: uppercase; color: var(--text-dim); letter-spacing: .04em; }
.physical-name-input { font-size: 12px; color: var(--text-dim); }
.physical-col { display: block; font-size: 10px; color: var(--text-dim); font-family: var(--mono, monospace); }
.physical-col-input { display: block; font-size: 10px; color: var(--text-dim); }
.tool-btn.primary { color: var(--accent); border-color: rgba(224,166,58,0.4); font-weight: 600; }
.tool-btn.badged::after { content: "●"; color: var(--warn); margin-left: 4px; font-size: 8px; vertical-align: super; }
.divider { width: 1px; background: var(--border); margin: 0 4px; align-self: stretch; }
Expand Down
4 changes: 4 additions & 0 deletions canvas/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export interface Conceptual {
export interface AttributeRow {
id: string;
name: string;
physical_name?: string | null; // erwin physical column name, when it differs
domain: string | null;
role: "business_key" | "surrogate_key" | "attribute" | "measure";
definition?: string | null;
Expand All @@ -77,6 +78,7 @@ export interface KeyGroupRow {
export interface Entity {
id: string;
name: string;
physical_name?: string | null; // erwin physical table name, when it differs
definition?: string | null;
pattern: string | null;
conceptual: Conceptual | null;
Expand Down Expand Up @@ -105,6 +107,8 @@ export interface Relationship {
cardinality: "one_to_one" | "one_to_many" | "many_to_one" | "many_to_many";
identifying: boolean;
optionality: "mandatory" | "optional";
verb_phrase?: string | null; // erwin: "<parent> <verb_phrase> <child>"
inverse_verb_phrase?: string | null;
}

export interface PhysicalTable {
Expand Down
52 changes: 46 additions & 6 deletions packages/cli/src/mdl_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2727,16 +2727,56 @@ def import_osi_cmd(

@import_app.command("erwin")
def import_erwin_cmd(
file: Path = typer.Argument(..., help="erwin XML export"),
file: Path = typer.Argument(..., help="erwin XML export (.xml)"),
out: Path = typer.Option(Path("model"), "--out", "-o"),
name: str = typer.Option(None, "--name"),
apply: bool = typer.Option(
False,
"--apply",
help="Merge the erwin model INTO the existing model at --out via the editing "
"engine, instead of writing a fresh model. Use when adding erwin objects to a "
"model you already have.",
),
force: bool = typer.Option(
False, "--force", help="Overwrite --out in place instead of diverting to a sibling."
),
) -> None:
"""Import an erwin XML export into the IR (spec §6.4)."""
model = import_erwin(file.read_text(encoding="utf-8"), project_name=name)
write_reversed(model, out)
"""Import a real erwin XML export into a Modelith model (spec §6.4).

Default: write a fresh model at --out (like `mdl reverse` — an erwin export is a whole
model). If --out already holds a model it diverts to a `-reversed-v<N>` sibling unless
--force. With --apply, merge the erwin objects into the existing model at --out through
the same validated mutation engine manual edits use."""
# streaming reader handles 7-10MB files; pass the PATH so it reads the file directly.
result = import_erwin(file, project_name=name)
for w in result.warnings:
typer.secho(f" note: {w}", fg=typer.colors.YELLOW)

if apply:
from mdl_emit_erd.imports.model import model_to_commands

from mdl_core.commands import CommandError, apply_command

applied = 0
for c in model_to_commands(result.model):
try:
apply_command(out, c["op"], c["payload"])
applied += 1
except (CommandError, FileNotFoundError) as e:
typer.secho(f" skipped {c['op']}: {e}", fg=typer.colors.YELLOW)
typer.secho(
f"merged {len(result.model.logical_entities)} entities from erwin into {out} "
f"({applied} change(s) applied)",
fg=typer.colors.GREEN,
)
return

if not force:
out = _nonclobbering_out(out)
write_reversed(result.model, out)
typer.secho(
f"imported {len(model.logical_entities)} entities, "
f"{len(model.relationships)} relationships from erwin to {out}",
f"imported {len(result.model.logical_entities)} entities, "
f"{len(result.model.relationships)} relationships from erwin to {out}",
fg=typer.colors.GREEN,
)

Expand Down
63 changes: 63 additions & 0 deletions packages/cli/tests/test_import_erwin_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""CLI: `mdl import erwin` — write-a-model default + --apply into an existing model."""

from __future__ import annotations

import sys
from pathlib import Path

from typer.testing import CliRunner

from mdl_cli.main import app

runner = CliRunner()

# the real-shaped erwin fixture (reuse the reader's test fixture)
_FIXTURE = (
Path(__file__).resolve().parents[2] / "reverse" / "tests" / "test_erwin.py"
)
sys.path.insert(0, str(_FIXTURE.parent))


def _erwin_xml() -> str:
text = _FIXTURE.read_text()
return text.split('_ERWIN = """')[1].split('"""')[0]


def test_import_erwin_writes_a_model(tmp_path):
xml = tmp_path / "trading.xml"
xml.write_text(_erwin_xml())
out = tmp_path / "model"
r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)])
assert r.exit_code == 0, r.output
assert (out / "logical" / "entities" / "counterparty.yaml").exists()
# warnings surface as notes
assert "note:" in r.output
# and the written model validates
v = runner.invoke(app, ["validate", "-m", str(out)])
assert v.exit_code == 0, v.output


def test_import_erwin_apply_into_existing(tmp_path):
xml = tmp_path / "trading.xml"
xml.write_text(_erwin_xml())
existing = tmp_path / "existing"
existing.mkdir()
(existing / "mdl-project.yaml").write_text("name: existing\ndbt_target: duckdb\n")
r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(existing), "--apply"])
assert r.exit_code == 0, r.output
assert "merged" in r.output
assert (existing / "logical" / "entities" / "trade.yaml").exists()
v = runner.invoke(app, ["validate", "-m", str(existing)])
assert v.exit_code == 0, v.output


def test_import_erwin_diverts_on_existing_model(tmp_path):
# a second write into a dir that already holds a model diverts to a sibling
xml = tmp_path / "trading.xml"
xml.write_text(_erwin_xml())
out = tmp_path / "model"
assert runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)]).exit_code == 0
r = runner.invoke(app, ["import", "erwin", str(xml), "-o", str(out)])
assert r.exit_code == 0, r.output
# the original is untouched; a -reversed-v1 sibling was written
assert (tmp_path / "model-reversed-v1" / "mdl-project.yaml").exists()
31 changes: 26 additions & 5 deletions packages/core/src/mdl_core/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,15 +176,22 @@ def _create_entity(repo: ModelRepo, p: dict) -> str:


def _rename_entity(repo: ModelRepo, p: dict) -> None:
_require(p, "id", "name")
_require(p, "id")
le = repo.model.logical_entities.get(p["id"])
if le is None:
raise CommandError(f"no logical entity {p['id']}")
new_name = _slug(p["name"])
rel, node = _node_for(repo, le.id)
node["name"] = new_name
# keep the file name in step with the entity name (repo hygiene)
repo.rename_file(rel, f"logical/entities/{new_name}.yaml")
if p.get("name"):
new_name = _slug(p["name"])
node["name"] = new_name
# keep the file name in step with the entity name (repo hygiene)
repo.rename_file(rel, f"logical/entities/{new_name}.yaml")
# physical table name (erwin Physical_Name); empty clears the key
if "physical_name" in p:
if p["physical_name"]:
node["physical_name"] = p["physical_name"]
else:
node.pop("physical_name", None)


def _delete_entity(repo: ModelRepo, p: dict) -> None:
Expand Down Expand Up @@ -361,6 +368,12 @@ def _update_attribute(repo: ModelRepo, p: dict) -> None:
attr[k] = _slug(p[k]) if k == "name" else p[k]
if "nullable" in p and p["nullable"] is not None:
attr["nullable"] = bool(p["nullable"])
# physical column name (erwin Physical_Name); empty clears the key
if "physical_name" in p:
if p["physical_name"]:
attr["physical_name"] = p["physical_name"]
else:
attr.pop("physical_name", None)
return
raise CommandError(f"no attribute {p['attribute_id']}")

Expand Down Expand Up @@ -482,6 +495,14 @@ def _update_relationship(repo: ModelRepo, p: dict) -> None:
node["definition"] = p["definition"]
else:
node.pop("definition", None)
# Verb phrases (erwin's readable role names): "<parent> <verb_phrase> <child>" and the
# inverse. Set from import, editable in the Inspector; empty clears the key.
for key in ("verb_phrase", "inverse_verb_phrase"):
if key in p:
if p[key]:
node[key] = p[key]
else:
node.pop(key, None)


# --- ontology alignment -----------------------------------------------------------
Expand Down
Loading
Loading