diff --git a/src/sqlalchemy_erd/constants/__init__.py b/src/sqlalchemy_erd/constants/__init__.py
index ef581c3..e1845b4 100644
--- a/src/sqlalchemy_erd/constants/__init__.py
+++ b/src/sqlalchemy_erd/constants/__init__.py
@@ -4,6 +4,7 @@
- ``styling``: SVG card visual styling
- ``routing``: orthogonal edge routing
- ``layered``: the layered layout algorithm
+- ``relationships``: edge kinds and inheritance strategy labels
Import from the relevant submodule, e.g. ``from sqlalchemy_erd.constants.geometry import NODE_W``.
"""
diff --git a/src/sqlalchemy_erd/constants/relationships.py b/src/sqlalchemy_erd/constants/relationships.py
new file mode 100644
index 0000000..b9bad5a
--- /dev/null
+++ b/src/sqlalchemy_erd/constants/relationships.py
@@ -0,0 +1,7 @@
+"""Relationship edge kinds and inheritance strategy labels."""
+
+KIND_FK = "fk"
+KIND_INHERITANCE = "inheritance"
+
+STRATEGY_JOINED = "joined"
+STRATEGY_CONCRETE = "concrete"
diff --git a/src/sqlalchemy_erd/html_renderer.py b/src/sqlalchemy_erd/html_renderer.py
index ad3f7de..edf3315 100644
--- a/src/sqlalchemy_erd/html_renderer.py
+++ b/src/sqlalchemy_erd/html_renderer.py
@@ -260,6 +260,10 @@ def render_html(
el('polygon', {{ points:'0 0, 10 3.5, 0 7', fill: THEME.edgeColor }}, mk);
const mkHi = el('marker', {{ id:'arr-hi', markerWidth:'10', markerHeight:'7', refX:'9', refY:'3.5', orient:'auto' }}, defs);
el('polygon', {{ points:'0 0, 10 3.5, 0 7', fill: THEME.hiColor }}, mkHi);
+ const mkInherit = el('marker', {{ id:'inherit', markerWidth:'12', markerHeight:'10', refX:'11', refY:'5', orient:'auto' }}, defs);
+ el('polygon', {{ points:'1 1, 11 5, 1 9', fill: THEME.bgColor, stroke: THEME.edgeColor, 'stroke-width':'1.4' }}, mkInherit);
+ const mkInheritHi = el('marker', {{ id:'inherit-hi', markerWidth:'12', markerHeight:'10', refX:'11', refY:'5', orient:'auto' }}, defs);
+ el('polygon', {{ points:'1 1, 11 5, 1 9', fill: THEME.bgColor, stroke: THEME.hiColor, 'stroke-width':'1.8' }}, mkInheritHi);
el('rect', {{ width:'100%', height:'100%', fill: THEME.bgColor }}, svg);
el('rect', {{ width:'100%', height:'100%', fill:'url(#erd-dots)' }}, svg);
@@ -294,18 +298,20 @@ def render_html(
}}
const d = makePath(fp, fs, tp, ts);
+ const isInheritance = rel.kind === 'inheritance';
const isNN = rel.fromCard === 'N' && rel.toCard === 'N';
const isCross = Object.keys(SCHEMA_COLORS).length > 0 &&
(fe.schema != null ? fe.schema : '_default') !== (te.schema != null ? te.schema : '_default');
- const g = el('g', {{ 'data-rel-from': rel.from, 'data-rel-to': rel.to }}, svg);
+ const g = el('g', {{ 'data-rel-from': rel.from, 'data-rel-to': rel.to, 'data-rel-kind': rel.kind }}, svg);
el('path', {{ d, fill:'none', stroke:'transparent', 'stroke-width':'18' }}, g);
const edgeAttrs = {{
class: 'erd-edge', d, fill:'none',
stroke: THEME.edgeColor, 'stroke-width': '1.5',
- 'marker-end': 'url(#arr)'
+ 'marker-end': isInheritance ? 'url(#inherit)' : 'url(#arr)'
}};
- if (isNN) edgeAttrs['stroke-dasharray'] = '5 3';
+ if (isInheritance) edgeAttrs['stroke-dasharray'] = '3 4';
+ else if (isNN) edgeAttrs['stroke-dasharray'] = '5 3';
else if (isCross) edgeAttrs['stroke-dasharray'] = '8 4';
el('path', edgeAttrs, g);
@@ -321,6 +327,13 @@ def render_html(
fill: THEME.edgeColor, 'text-anchor':'middle', 'dominant-baseline':'middle',
textContent: rel.toCard
}}, g);
+ if (rel.label) {{
+ el('text', {{
+ class: 'erd-label', x: (fp[0] + tp[0]) / 2, y: (fp[1] + tp[1]) / 2 - 8,
+ 'font-size':'10', 'font-family':'monospace', fill: THEME.edgeColor,
+ 'text-anchor':'middle', 'dominant-baseline':'middle', textContent: rel.label
+ }}, g);
+ }}
}}
// Nodes — tagged with data attributes for updateHighlights
@@ -397,11 +410,13 @@ def render_html(
const from = g.getAttribute('data-rel-from');
const to = g.getAttribute('data-rel-to');
const hi = hoveredId && (from === hoveredId || to === hoveredId);
+ const kind = g.getAttribute('data-rel-kind');
const edge = g.querySelector('.erd-edge');
if (edge) {{
edge.setAttribute('stroke', hi ? THEME.hiColor : THEME.edgeColor);
edge.setAttribute('stroke-width', hi ? '2' : '1.5');
- edge.setAttribute('marker-end', `url(#${{hi ? 'arr-hi' : 'arr'}})`);
+ const marker = kind === 'inheritance' ? (hi ? 'inherit-hi' : 'inherit') : (hi ? 'arr-hi' : 'arr');
+ edge.setAttribute('marker-end', `url(#${{marker}})`);
}}
g.querySelectorAll('.erd-label').forEach(lbl => {{
lbl.setAttribute('fill', hi ? THEME.hiColor : THEME.edgeColor);
diff --git a/src/sqlalchemy_erd/introspect.py b/src/sqlalchemy_erd/introspect.py
index 943d603..d3fd6d1 100644
--- a/src/sqlalchemy_erd/introspect.py
+++ b/src/sqlalchemy_erd/introspect.py
@@ -7,9 +7,13 @@
Interval, JSON, LargeBinary, MetaData, Numeric, SmallInteger, String,
Text, Time, UniqueConstraint, Uuid,
)
-from sqlalchemy.orm import DeclarativeBase
+from sqlalchemy.orm import DeclarativeBase, Mapper
from sqlalchemy.types import TypeDecorator
+from sqlalchemy_erd.constants.relationships import (
+ KIND_FK, KIND_INHERITANCE, STRATEGY_CONCRETE, STRATEGY_JOINED,
+)
+
@dataclass
class ColumnInfo:
@@ -36,6 +40,8 @@ class RelationshipInfo:
from_card: str
to_card: str
fk_column: str
+ kind: str = KIND_FK
+ label: str | None = None
@dataclass(frozen=True)
@@ -113,17 +119,18 @@ def _column_kind(col_info: Any, is_pk: bool, is_fk: bool) -> str:
def _resolve_metadata(
base_or_metadata: type[DeclarativeBase] | MetaData,
-) -> tuple[MetaData, dict[str, str]]:
- """Return the metadata and a table-fullname → mapped-class-name lookup."""
+) -> tuple[MetaData, dict[str, str], list[Mapper]]:
+ """Return metadata plus mapper information when a DeclarativeBase is given."""
if isinstance(base_or_metadata, MetaData):
- return base_or_metadata, {}
+ return base_or_metadata, {}, []
metadata = base_or_metadata.metadata
+ mappers = list(base_or_metadata.registry.mappers)
class_names = {
mapper.local_table.fullname: mapper.class_.__name__
- for mapper in base_or_metadata.registry.mappers
+ for mapper in mappers
}
- return metadata, class_names
+ return metadata, class_names, mappers
def _build_table(
@@ -208,6 +215,55 @@ def _build_relationships(
return relationships
+def _inheritance_strategy(mapper: Mapper) -> str:
+ """Name the strategy of a child mapper that owns its own table."""
+ return STRATEGY_CONCRETE if mapper.concrete else STRATEGY_JOINED
+
+
+def _build_inheritance_relationships(
+ mappers: list[Mapper],
+ kept_names: set[str],
+) -> list[RelationshipInfo]:
+ relationships: list[RelationshipInfo] = []
+ for mapper in sorted(mappers, key=lambda m: m.local_table.fullname):
+ if mapper.inherits is None:
+ continue
+ parent_table = mapper.inherits.local_table
+ child_table = mapper.local_table
+ parent_name = parent_table.fullname
+ child_name = child_table.fullname
+ if parent_name == child_name:
+ continue
+ if parent_name not in kept_names or child_name not in kept_names:
+ continue
+
+ # The join column is the child PK that also references the parent.
+ fk_col = ""
+ pk_names = {col.name for col in child_table.primary_key.columns}
+ for col in child_table.columns:
+ references_parent = any(
+ fk.column.table.fullname == parent_name for fk in col.foreign_keys
+ )
+ if references_parent and col.name in pk_names:
+ fk_col = col.name
+ break
+ if not fk_col:
+ pk_cols = list(child_table.primary_key.columns)
+ fk_col = pk_cols[0].name if pk_cols else ""
+
+ strategy = _inheritance_strategy(mapper)
+ relationships.append(RelationshipInfo(
+ from_table=parent_name,
+ to_table=child_name,
+ from_card="1",
+ to_card="1",
+ fk_column=fk_col,
+ kind=KIND_INHERITANCE,
+ label=strategy,
+ ))
+ return relationships
+
+
def _collapse_association_tables(
filtered_items: list[tuple[str, Any]],
tables: list[TableInfo],
@@ -256,7 +312,7 @@ def introspect_models(
schemas: list[str] | None = None,
filters: Filters | None = None,
) -> tuple[list[TableInfo], list[RelationshipInfo]]:
- metadata, class_names = _resolve_metadata(base_or_metadata)
+ metadata, class_names, mappers = _resolve_metadata(base_or_metadata)
filters = filters or Filters()
include = _compile(filters.include_tables)
exclude = _compile(filters.exclude_tables)
@@ -280,6 +336,17 @@ def introspect_models(
filtered_items, tables, relationships,
)
kept_names = {t.name for t in tables}
+ inheritance_relationships = _build_inheritance_relationships(mappers, kept_names)
+ # Drop only the FK edge the inheritance edge replaces.
+ inheritance_edges = {
+ (rel.from_table, rel.to_table, rel.fk_column)
+ for rel in inheritance_relationships
+ }
+ relationships = [
+ rel for rel in relationships
+ if (rel.from_table, rel.to_table, rel.fk_column) not in inheritance_edges
+ ]
+ relationships.extend(inheritance_relationships)
relationships = [
rel for rel in relationships
if rel.from_table in kept_names and rel.to_table in kept_names
diff --git a/src/sqlalchemy_erd/renderer.py b/src/sqlalchemy_erd/renderer.py
index cfb6a7f..5858cae 100644
--- a/src/sqlalchemy_erd/renderer.py
+++ b/src/sqlalchemy_erd/renderer.py
@@ -1,6 +1,7 @@
from xml.sax.saxutils import escape
from sqlalchemy_erd.constants.geometry import FIELD_H, HEADER_H, NODE_W, PAD
+from sqlalchemy_erd.constants.relationships import KIND_INHERITANCE
from sqlalchemy_erd.constants.styling import (
CARD_RADIUS, FIELD_FONT_SIZE, FIELD_INSET, HEADER_STRIP_H, KIND_FONT_SIZE,
KIND_INSET, TITLE_FONT_SIZE, TITLE_INSET,
@@ -126,6 +127,9 @@ def render_svg(
+
+
+
""")
parts.append(f' ')
@@ -164,9 +168,13 @@ def render_svg(
tpt = _conn_pt(tp, tt, ts, to_idx, node_w)
path_d = orthogonal_path(fpt, fs, tpt, ts)
+ is_inheritance = rel.kind == KIND_INHERITANCE
is_nn = rel.from_card == "N" and rel.to_card == "N"
is_cross = multi_schema and ft.schema != tt.schema
- if is_nn:
+ marker = "inherit" if is_inheritance else "arr"
+ if is_inheritance:
+ dash = ' stroke-dasharray="3 4"'
+ elif is_nn:
dash = ' stroke-dasharray="5 3"'
elif is_cross:
dash = ' stroke-dasharray="8 4"'
@@ -176,11 +184,14 @@ def render_svg(
fl = _label_pos(fpt, fs)
tl = _label_pos(tpt, ts)
- parts.append(f' ')
+ parts.append(
+ f' '
+ )
parts.append(f' ')
parts.append(
f' '
+ f'stroke="{theme.edge_color}" stroke-width="1.5"{dash} marker-end="url(#{marker})" />'
)
parts.append(
f' {rel.to_card}'
)
+ if rel.label:
+ mx = (fpt[0] + tpt[0]) / 2
+ my = (fpt[1] + tpt[1]) / 2 - 8
+ parts.append(
+ f' {escape(rel.label)}'
+ )
parts.append(" ")
for table in tables:
diff --git a/src/sqlalchemy_erd/serialization.py b/src/sqlalchemy_erd/serialization.py
index 1a65c9c..8336e9f 100644
--- a/src/sqlalchemy_erd/serialization.py
+++ b/src/sqlalchemy_erd/serialization.py
@@ -61,6 +61,8 @@ def build_relations_json(
"fromCard": r.from_card,
"toCard": r.to_card,
"fkCol": r.fk_column,
+ "kind": r.kind,
+ "label": r.label,
}
for r in relationships
if r.from_table in table_names and r.to_table in table_names
diff --git a/tests/conftest.py b/tests/conftest.py
index d1e5f98..15b58f5 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,4 +1,7 @@
-"""Shared fixtures for the ERDAlchemy test suite."""
+"""Shared fixtures for the ERDAlchemy test suite.
+
+Per-domain schemas live in ``tests/fixtures`` and are loaded as plugins.
+"""
import pytest
from sqlalchemy import (
@@ -10,6 +13,12 @@
from datetime import datetime, timedelta, time
from decimal import Decimal
+pytest_plugins = [
+ "tests.fixtures.inheritance",
+ "tests.fixtures.cardinality",
+ "tests.fixtures.comments",
+]
+
# ── Single-table schema ──────────────────────────────────────────────────────
@@ -273,53 +282,3 @@ class Task(MultiFkBase):
@pytest.fixture
def multi_fk_base():
return MultiFkBase
-
-
-# -- Cardinality schema -------------------------------------------------------
-
-cardinality_metadata = MetaData()
-
-Table(
- "users", cardinality_metadata,
- Column("id", Integer, primary_key=True),
- Column("name", String(100), nullable=False),
-)
-
-Table(
- "profiles", cardinality_metadata,
- Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
- Column("bio", Text),
-)
-
-Table(
- "avatars", cardinality_metadata,
- Column("id", Integer, primary_key=True),
- Column("user_id", Integer, ForeignKey("users.id"), unique=True, nullable=False),
-)
-
-Table(
- "tasks", cardinality_metadata,
- Column("id", Integer, primary_key=True),
- Column("assignee_id", Integer, ForeignKey("users.id"), nullable=True),
-)
-
-
-@pytest.fixture
-def cardinality_metadata_fixture():
- return cardinality_metadata
-
-
-# -- Column comments schema ---------------------------------------------------
-
-comments_metadata = MetaData()
-
-Table(
- "accounts", comments_metadata,
- Column("id", Integer, primary_key=True),
- Column("email", String(200), comment="Primary login email"),
-)
-
-
-@pytest.fixture
-def comments_metadata_fixture():
- return comments_metadata
diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/fixtures/cardinality.py b/tests/fixtures/cardinality.py
new file mode 100644
index 0000000..570874e
--- /dev/null
+++ b/tests/fixtures/cardinality.py
@@ -0,0 +1,38 @@
+"""Fixtures for relationship cardinality schemas."""
+
+import pytest
+from sqlalchemy import Column, ForeignKey, Integer, MetaData, String, Table, Text
+
+
+# -- Cardinality schema -------------------------------------------------------
+
+cardinality_metadata = MetaData()
+
+Table(
+ "users", cardinality_metadata,
+ Column("id", Integer, primary_key=True),
+ Column("name", String(100), nullable=False),
+)
+
+Table(
+ "profiles", cardinality_metadata,
+ Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
+ Column("bio", Text),
+)
+
+Table(
+ "avatars", cardinality_metadata,
+ Column("id", Integer, primary_key=True),
+ Column("user_id", Integer, ForeignKey("users.id"), unique=True, nullable=False),
+)
+
+Table(
+ "tasks", cardinality_metadata,
+ Column("id", Integer, primary_key=True),
+ Column("assignee_id", Integer, ForeignKey("users.id"), nullable=True),
+)
+
+
+@pytest.fixture
+def cardinality_metadata_fixture():
+ return cardinality_metadata
diff --git a/tests/fixtures/comments.py b/tests/fixtures/comments.py
new file mode 100644
index 0000000..966409d
--- /dev/null
+++ b/tests/fixtures/comments.py
@@ -0,0 +1,17 @@
+"""Fixtures for schemas carrying column comments."""
+
+import pytest
+from sqlalchemy import Column, Integer, MetaData, String, Table
+
+comments_metadata = MetaData()
+
+Table(
+ "accounts", comments_metadata,
+ Column("id", Integer, primary_key=True),
+ Column("email", String(200), comment="Primary login email"),
+)
+
+
+@pytest.fixture
+def comments_metadata_fixture():
+ return comments_metadata
diff --git a/tests/fixtures/inheritance.py b/tests/fixtures/inheritance.py
new file mode 100644
index 0000000..7fa912d
--- /dev/null
+++ b/tests/fixtures/inheritance.py
@@ -0,0 +1,66 @@
+"""Fixtures for SQLAlchemy inheritance schemas."""
+
+import pytest
+from sqlalchemy import ForeignKey, String
+from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
+
+
+# -- Joined-table inheritance schema -----------------------------------------
+
+class InheritanceBase(DeclarativeBase):
+ pass
+
+
+class Employee(InheritanceBase):
+ __tablename__ = "employees"
+ id: Mapped[int] = mapped_column(primary_key=True)
+ kind: Mapped[str] = mapped_column(String(50))
+ name: Mapped[str] = mapped_column(String(100))
+ __mapper_args__ = {
+ "polymorphic_on": kind,
+ "polymorphic_identity": "employee",
+ }
+
+
+class Manager(Employee):
+ __tablename__ = "managers"
+ id: Mapped[int] = mapped_column(ForeignKey("employees.id"), primary_key=True)
+ department: Mapped[str] = mapped_column(String(100))
+ __mapper_args__ = {"polymorphic_identity": "manager"}
+
+
+@pytest.fixture
+def inheritance_base():
+ return InheritanceBase
+
+
+# -- Inheritance plus an extra FK to the same parent --------------------------
+
+class InheritanceExtraFkBase(DeclarativeBase):
+ pass
+
+
+class Staff(InheritanceExtraFkBase):
+ __tablename__ = "staff"
+ id: Mapped[int] = mapped_column(primary_key=True)
+ kind: Mapped[str] = mapped_column(String(50))
+ __mapper_args__ = {
+ "polymorphic_on": kind,
+ "polymorphic_identity": "staff",
+ }
+
+
+class Lead(Staff):
+ __tablename__ = "leads"
+ id: Mapped[int] = mapped_column(ForeignKey("staff.id"), primary_key=True)
+ mentor_id: Mapped[int] = mapped_column(ForeignKey("staff.id"))
+ __mapper_args__ = {
+ "polymorphic_identity": "lead",
+ "inherit_condition": id == Staff.id,
+ }
+
+
+@pytest.fixture
+def inheritance_extra_fk_base():
+ return InheritanceExtraFkBase
+
diff --git a/tests/test_html_renderer.py b/tests/test_html_renderer.py
index ab93f96..b835622 100644
--- a/tests/test_html_renderer.py
+++ b/tests/test_html_renderer.py
@@ -115,7 +115,7 @@ def test_relation_has_expected_keys(self, blog_base):
tables, rels = introspect_models(blog_base)
positions = force_directed_layout(tables, rels)
parsed = json.loads(_build_relations_json(rels, tables, positions))
- assert set(parsed[0]) == {"from", "to", "fromCard", "toCard", "fkCol"}
+ assert set(parsed[0]) == {"from", "to", "fromCard", "toCard", "fkCol", "kind", "label"}
def test_via_relation_keeps_via_prefix(self, m2m_base):
tables, rels = introspect_models(m2m_base)
@@ -131,6 +131,13 @@ def test_relation_to_unknown_table_is_filtered_out(self, blog_base):
parsed = json.loads(_build_relations_json(rels, kept, positions))
assert all(r["from"] != "comments" and r["to"] != "comments" for r in parsed)
+ def test_inheritance_relation_serializes_kind_and_label(self, inheritance_base):
+ tables, rels = introspect_models(inheritance_base)
+ positions = force_directed_layout(tables, rels)
+ parsed = json.loads(_build_relations_json(rels, tables, positions))
+ rel = next(r for r in parsed if r["kind"] == "inheritance")
+ assert rel["label"] == "joined"
+
# ── render_html ──────────────────────────────────────────────────────────────
diff --git a/tests/test_introspect.py b/tests/test_introspect.py
index eaff3a0..f0ff2e3 100644
--- a/tests/test_introspect.py
+++ b/tests/test_introspect.py
@@ -353,6 +353,33 @@ def test_schema_attribute_set(self, multi_schema_metadata_fixture):
assert schemas == {"auth", "billing"}
+# -- SQLAlchemy inheritance ---------------------------------------------------
+
+class TestIntrospectInheritance:
+ def test_joined_inheritance_edge_is_distinct(self, inheritance_base):
+ _, rels = introspect_models(inheritance_base)
+ inheritance = [r for r in rels if r.kind == "inheritance"]
+ assert len(inheritance) == 1
+ rel = inheritance[0]
+ assert rel.from_table == "employees"
+ assert rel.to_table == "managers"
+ assert rel.from_card == "1"
+ assert rel.to_card == "1"
+ assert rel.label == "joined"
+
+ def test_joined_inheritance_does_not_duplicate_fk_edge(self, inheritance_base):
+ _, rels = introspect_models(inheritance_base)
+ pairs = [(r.from_table, r.to_table) for r in rels]
+ assert pairs.count(("employees", "managers")) == 1
+
+ def test_extra_fk_to_parent_survives_inheritance_edge(
+ self, inheritance_extra_fk_base,
+ ):
+ _, rels = introspect_models(inheritance_extra_fk_base)
+ edges = {(r.kind, r.fk_column) for r in rels if r.to_table == "leads"}
+ assert edges == {("inheritance", "id"), ("fk", "mentor_id")}
+
+
# -- Relationship cardinality -------------------------------------------------
class TestRelationshipCardinality:
diff --git a/tests/test_renderer.py b/tests/test_renderer.py
index a85240e..9c2f486 100644
--- a/tests/test_renderer.py
+++ b/tests/test_renderer.py
@@ -163,3 +163,20 @@ def test_custom_table_colors(self, blog_base):
theme = get_theme("default", table_colors={"users": "#ff0000"})
svg = render_svg(tables, rels, positions, theme)
assert "#ff0000" in svg
+
+
+# -- Inheritance rendering ----------------------------------------------------
+
+class TestInheritanceRendering:
+ def test_inheritance_edge_has_distinct_kind_and_marker(self, inheritance_base):
+ tables, rels = introspect_models(inheritance_base)
+ positions = force_directed_layout(tables, rels)
+ svg = render_svg(tables, rels, positions, get_theme("default"))
+ root = ET.fromstring(svg)
+ rel_groups = [g for g in root.findall("svg:g", NS) if g.get("data-kind") == "inheritance"]
+ assert len(rel_groups) == 1
+ edge = rel_groups[0].find("svg:path[@class='erd-edge']", NS)
+ assert edge.get("marker-end") == "url(#inherit)"
+ assert edge.get("stroke-dasharray") == "3 4"
+ text = " ".join(t.text or "" for t in root.iter("{http://www.w3.org/2000/svg}text"))
+ assert "joined" in text