Skip to content

Add D2 output format - #64

Open
KTSCode wants to merge 13 commits into
fuelen:mainfrom
KTSCode:feature/d2_format
Open

Add D2 output format#64
KTSCode wants to merge 13 commits into
fuelen:mainfrom
KTSCode:feature/d2_format

Conversation

@KTSCode

@KTSCode KTSCode commented Jul 15, 2026

Copy link
Copy Markdown

Add D2 output format

Adds D2 as a rich output format for mix ecto.gen.erd,
alongside DOT, PlantUML, DBML, QuickDBD, and Mermaid.

D2 is ER-native and renders to SVG without external runtime dependencies, so it
fills the gap between DOT/PlantUML (rich, but need Graphviz/Java) and
Mermaid/DBML (portable, but table-only).

What it renders

Everything DOT and PlantUML can show, plus more:

  • Schemas, embedded schemas, and m2m-derived tables via D2's sql_table shape
  • Crow's-foot cardinality on both ends of a <-> connection, the primary-key side is cf-one-required (exactly one), the foreign-key side cf-many-required (or cf-one for has-one) ER-native, like PlantUML/Mermaid
  • Primary and foreign keys as native constraint: badges, FK marking exceeds DOT, which marks none
  • Clusters as colored D2 containers (set via :map_node + Ecto.ERD.Node.set_cluster/2)
  • Elixir-style types#Enum<[...]>, #Ecto.Embedded<...>, {:array, _} (shares the DOT renderer's type formatter, so :enum_values_limit / :enum_values_order work identically)

Example diagram (source):

Simple blog demo (D2)

Usage

$ mix ecto.gen.erd --output-path=ecto_erd.d2
$ mix ecto.gen.erd --output-path=ecto_erd.d2 && d2 ecto_erd.d2 erd.svg

Design

The guiding principle: mirror DOT (the library's reference rich format), and
deviate toward another format's behavior only where it yields more or better
information.

  • Cardinality crow's-foot on both ends, not DOT's plain edges. D2 is
    ER-native; DOT's plainness is a Graphviz limitation, not a style to copy.
  • Foreign keys marked natively. DOT omits FK marking because its HTML
    tables have no clean affordance; D2's sql_table does, so it should.
  • :columns accepts [] (bare nodes) or the default [:name, :type],
    raising otherwise. Type-only or reordered subsets are impossible on
    sql_table (rows are fixed name/type pairs); name-only bare rows would
    compile in d2 but stay unsupported for parity with Mermaid. With [], edges
    collapse to node level and are deduped per node pair.
  • Collision-safe keys container keys start with cluster_<name> and
    escalate the prefix when that would collide with a global node. Because D2
    folds keys case-insensitively, case-only node or cluster collisions receive
    distinct internal keys while retaining their original displayed names via
    label:.
  • :fontname ignored; D2 has no in-source font-family control (as with
    DBML/QuickDBD/Mermaid).

Notes

  • Example images are SVG, not PNG. D2's PNG export downloads a ~140 MB
    headless Chromium; SVG is native and dependency-free.
  • Arrowheads: d2 v0.7.1 silently ignores source-arrowhead on a ->
    connection but renders both ends on <-> (verified empirically), so edges
    use the bidirectional operator with both ends marked.
  • Equal field names and types: d2 suppresses a sql_table type cell when
    its text equals the row key, such as an :id field of type :id. The
    renderer adds an invisible trailing space to the type text so the value
    remains visible without changing the field key used by edge ports.
  • Escaping: quoted D2 strings escape \, ", ${ (variable
    substitution), and newlines, so hostile schema/source names can't break the
    document or trigger d2 variable interpolation.

Testing

  • Unit tests cover type formatting (enums, embeds, arrays), PK/FK/combined
    constraints, both-end crow's-foot cardinality, clusters, cluster/node key
    collisions (including case-only node and cluster identities), equal
    field-name/type rendering, string escaping, node-pair edge dedup with
    columns: [], the :columns fallback, and embedded-schema edges.
  • Rich and columns: [] collision cases compile cleanly with d2 v0.7.1
    while preserving distinct displayed nodes and containers.
  • Validated end-to-end against a production Phoenix app (51 schemas, 89
    relationships, 16 enums, 11 embeds) all rendered, zero schemas skipped, and
    the output compiled to SVG.

KTSCode added 13 commits June 30, 2026 15:33
Render schemas, embedded schemas, and clusters to D2 (d2lang.com)
via the sql_table shape. Like DOT but using D2's ER-native features
where they add information: crow's-foot cardinality and native
primary/foreign key constraints.

The :columns option accepts only [] or the default; :fontname is
unsupported.
d2 suppresses a sql_table row's value when it equals the row key, so a
field whose rendered type matches its name (an :id field of Ecto type
:id) lost its type cell. Pad the type with a trailing space, invisible
in the rendered SVG. Regenerate the demo SVG, which shipped the bug.
The static cluster_ prefix only moved the container-key collision: a
global node literally named cluster_Accounts still collides, failing
compilation ("sql_table columns cannot have children") or silently
merging bare nodes under columns: []. Prepend cluster_ until no
downcased container key matches a downcased global node id — d2 keys
fold case even when quoted.
@fuelen

fuelen commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Hi Kyle!
Thank you for the contribution! I'll review it later once I take my personal laptop back from the service center 🙂

@fuelen fuelen left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! Please remove the asset, update the README accordingly, and we're good to go.

P.S: don't worry about examples/d2/ being empty. I see that the examples generator is broken for every format at the moment, unrelated to your PR.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please remove this demo file? I understand the point, and nothing is wrong with the image. DOT is the de facto default here and the README carries a single demo. How to present several formats is a separate question I want to think through on its own rather than settle in this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants