diff --git a/src/sqlalchemy_erd/html_renderer.py b/src/sqlalchemy_erd/html_renderer.py index 112dac8..ad3f7de 100644 --- a/src/sqlalchemy_erd/html_renderer.py +++ b/src/sqlalchemy_erd/html_renderer.py @@ -235,6 +235,8 @@ def render_html( const e = document.createElementNS(NS, tag); for (const [k,v] of Object.entries(attrs||{{}})) {{ if (k === 'textContent') e.textContent = v; + // SVG ignores the title attribute: the native tooltip needs a child. + else if (k === 'title') el('title', {{ textContent: v }}, e); else e.setAttribute(k, v); }} if (parent) parent.appendChild(e); @@ -353,18 +355,24 @@ def render_html( const sy = HEADER_H + PAD_ + i * FIELD_H; el('line', {{ x1:'10', y1:sy, x2:NODE_W-10, y2:sy, stroke: THEME.separator, 'stroke-width':'1' }}, g); }} - el('text', {{ + const nameAttrs = {{ x:'10', y:fy, 'font-size':'10', 'font-family':"'Courier New', Courier, monospace", fill: field.nameColor, 'font-weight': field.nameWeight, 'dominant-baseline':'middle', textContent: field.name - }}, g); - el('text', {{ + }}; + const kindAttrs = {{ x: NODE_W-8, y:fy, 'font-size':'9', 'font-family':"'Courier New', Courier, monospace", fill: field.kindColor, 'text-anchor':'end', 'dominant-baseline':'middle', opacity:'0.9', textContent: field.kindLabel - }}, g); + }}; + if (field.comment) {{ + nameAttrs.title = field.comment; + kindAttrs.title = field.comment; + }} + el('text', nameAttrs, g); + el('text', kindAttrs, g); }} g.addEventListener('pointerdown', e => {{ diff --git a/src/sqlalchemy_erd/introspect.py b/src/sqlalchemy_erd/introspect.py index 674cb93..943d603 100644 --- a/src/sqlalchemy_erd/introspect.py +++ b/src/sqlalchemy_erd/introspect.py @@ -18,6 +18,7 @@ class ColumnInfo: nullable: bool is_pk: bool is_fk: bool + comment: str | None = None @dataclass @@ -147,6 +148,7 @@ def _build_table( nullable=col.nullable or False, is_pk=is_pk, is_fk=is_fk, + comment=col.comment, )) display_name = class_names.get(table_key, table.name) diff --git a/src/sqlalchemy_erd/serialization.py b/src/sqlalchemy_erd/serialization.py index 5d9d490..1a65c9c 100644 --- a/src/sqlalchemy_erd/serialization.py +++ b/src/sqlalchemy_erd/serialization.py @@ -33,6 +33,7 @@ def build_entities_json(tables: list[TableInfo], theme: Theme) -> str: "nameWeight": name_weight, "kindColor": kind_color, "kindLabel": kind_label, + "comment": col.comment, }) entities.append({ diff --git a/tests/conftest.py b/tests/conftest.py index 03df1c4..d1e5f98 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -307,3 +307,19 @@ def multi_fk_base(): @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/test_html_renderer.py b/tests/test_html_renderer.py index 1e4397d..ab93f96 100644 --- a/tests/test_html_renderer.py +++ b/tests/test_html_renderer.py @@ -95,6 +95,12 @@ def test_per_table_color_override_applied(self, blog_base): entities = json.loads(_build_entities_json(tables, theme)) assert _entity(entities, "users")["headerColor"] == "#ff0000" + def test_column_comment_is_serialized(self, comments_metadata_fixture): + tables, _ = introspect_models(comments_metadata_fixture) + entities = json.loads(_build_entities_json(tables, get_theme("default"))) + email = _field(_entity(entities, "accounts"), "email") + assert email["comment"] == "Primary login email" + # ── _build_relations_json ──────────────────────────────────────────────────── @@ -170,3 +176,12 @@ def test_empty_schema_still_renders(self, empty_base): html = render_html(tables, rels, positions, theme) assert "<!DOCTYPE html>" in html assert _extract_js_object(html, "ENTITIES") == [] + + def test_html_contains_tooltip_binding(self, comments_metadata_fixture): + tables, rels = introspect_models(comments_metadata_fixture) + positions = force_directed_layout(tables, rels) + html = render_html(tables, rels, positions, get_theme("default")) + assert "field.comment" in html + assert "Primary login email" in html + # A title attribute renders no tooltip in SVG; el() must emit a <title> child. + assert "el('title', { textContent: v }, e)" in html diff --git a/tests/test_introspect.py b/tests/test_introspect.py index 5d6c3e2..eaff3a0 100644 --- a/tests/test_introspect.py +++ b/tests/test_introspect.py @@ -373,3 +373,13 @@ def test_nullable_fk_marks_optional_parent(self, cardinality_metadata_fixture): rel = next(r for r in rels if r.to_table == "tasks") assert rel.from_card == "0..1" assert rel.to_card == "N" + + +# -- Column comments ---------------------------------------------------------- + +class TestIntrospectColumnComments: + def test_column_comments_are_preserved(self, comments_metadata_fixture): + tables, _ = introspect_models(comments_metadata_fixture) + accounts = next(t for t in tables if t.name == "accounts") + email = next(c for c in accounts.columns if c.name == "email") + assert email.comment == "Primary login email"