Skip to content

Conversation

Else00
Copy link

@Else00 Else00 commented Jul 2, 2025

Summary

This PR introduces a new output format to erdantic, allowing users to generate class diagrams using the D2 declarative diagramming language.

Motivation

While erdantic's default Graphviz output is excellent, D2 offers powerful, modern layout engines (like ELK and Dagre) that can be particularly effective for organizing large, complex data models automatically. This provides users with an alternative rendering backend that excels at creating clean, readable diagrams from complex schemas.

As a text-based format, D2 diagrams are also highly portable and easy to version control alongside source code.

For more information on D2's class diagrams, see the official tour: https://d2lang.com/tour/uml-classes

Implementation

  • A new to_d2() method has been added to the EntityRelationshipDiagram class.
  • A corresponding --d2 / -D CLI flag has been added to generate D2 output to stdout.
  • The implementation maps erdantic's model and relationship metadata to D2's UML class shape syntax, including field visibility based on Python naming conventions (_ for protected, __ for private).

Usage and Example Output

The commands below generate a diagram.d2 file from the pydantic models and then render it into an SVG:

# Generate D2 output for the pydantic example models
erdantic --d2 erdantic.examples.pydantic.Party > diagram.d2

# Render the diagram to SVG using the D2 CLI
d2 diagram.d2 diagram.svg

Here is the resulting diagram.svg:

diagram


Layout Engine Comparison on a Complex Diagram

To showcase the power of D2's layout engines, here are renderings of a more complex diagram using dagre, elk, and tala.

1. Dagre (Default Layout)
d2 -l dagre complex.d2 d2_dagre.svg

d2_dagre


2. ELK Layout
d2 -l elk complex.d2 d2_elk.svg

d2_elk


3. TALA Layout
d2 -l tala complex.d2 d2_tala.svg

d2_tala

@jayqi
Copy link
Member

jayqi commented Jul 3, 2025

Hi @Else00,

Thanks for the very polished PR! This functionality looks great, and I'm definitely interested in getting it into erdantic. Just as a heads up—I'm pretty busy with work and travel over the next two weeks. Since adding support for a new output format and extending the API is a big deal, I want to be thoughtful and make sure the details of the proposed implementation make sense. D2 is also new to me, so I'll want to learn at least a little more about it. That's just all to say that I may be slow to review, but that doesn't mean I'm abandoning this or don't want to merge. If you don't get a review from me by July 16, please ping me here.

@Else00
Copy link
Author

Else00 commented Jul 17, 2025

Any news?

@jayqi
Copy link
Member

jayqi commented Jul 17, 2025

Hi @Else00, thanks for your patience. I started reviewing this last night and am still working on it.

erdantic/d2.py Outdated
Comment on lines 6 to 18
def _get_d2_cardinality(edge: Edge) -> str:
"""Maps erdantic's cardinality and modality to D2 cardinality strings."""
is_many = edge.target_cardinality == Cardinality.MANY
is_nullable = edge.target_modality == Modality.ZERO

if is_many and is_nullable:
return '"*"' # Zero or more
elif is_many and not is_nullable:
return '"1..*"' # One or more
elif not is_many and is_nullable:
return '"0..1"' # Zero or one
else: # not is_many and not is_nullable
return '"1"' # Exactly one
Copy link
Member

Choose a reason for hiding this comment

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

This doesn't match the existing cardinality and modality behavior. Please see this documentation.

This should handle all of the cases, including Cardinality.UNSPECIFIED and Modality.UNSPECIFIED.

Additionally, it looks like D2's arrowheads support crow's foot notation. For consistency, we should use those instead of just the triangle head.

I see that it only supports the four cases, so we can map Cardinality.MANY, Modality.UNSPECIFIED to the cf-many case for now.

erdantic/d2.py Outdated
Comment on lines 21 to 25
def _sanitize_name(name: str) -> str:
"""Sanitizes a name for D2 syntax, quoting if it contains special characters."""
if any(c in name for c in " -.,;()[]{}<>"):
return f'"{name}"'
return name
Copy link
Member

Choose a reason for hiding this comment

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

Is this function necessary? Why not just always quote the name?

It seems like this logic isn't sufficient anyways. The D2 docs says that if your name collides with a reserved keyword, you'd need to quote it anyways, and we're not checking for any reserved keywords here.

@jayqi
Copy link
Member

jayqi commented Jul 20, 2025

Please note that I pushed some commits that you need to pull.

Copy link

codecov bot commented Jul 20, 2025

Codecov Report

❌ Patch coverage is 96.96970% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 98.3%. Comparing base (34916ff) to head (b0c95a3).

Files with missing lines Patch % Lines
erdantic/d2.py 96.1% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff           @@
##            main    #152     +/-   ##
=======================================
- Coverage   98.4%   98.3%   -0.2%     
=======================================
  Files         21      22      +1     
  Lines        884     950     +66     
=======================================
+ Hits         870     934     +64     
- Misses        14      16      +2     
Files with missing lines Coverage Δ
erdantic/cli.py 98.9% <100.0%> (+0.1%) ⬆️
erdantic/core.py 97.7% <100.0%> (+<0.1%) ⬆️
erdantic/d2.py 96.1% <96.1%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@Else00
Copy link
Author

Else00 commented Aug 11, 2025

Apologies for the late follow-up.

Implemented the requested changes:

  • Removed the -D short alias; kept only --d2.
  • Switched to D2 crow’s-foot arrowheads (cf-one, cf-one-required, cf-many, cf-many-required), handling UNSPECIFIED combinations as discussed.
  • Always quote identifiers for D2 (shape keys and connection labels). Do not quote field names in class bodies to match snapshots.
  • Quote type values only when needed (special chars), now including the | for PEP 604 unions.
  • Kept the lazy import of the D2 renderer inside to_d2() and documented it to avoid circular imports.
  • Updated tests and D2 asset snapshots accordingly. Local test suite: 120 passed.

Happy to tweak mapping defaults or quoting strategy if you prefer something different.

…ng; CLI --d2; tests & assets; document lazy import
Copy link

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR adds D2 class diagram support to erdantic, enabling users to generate diagrams using the D2 declarative diagramming language as an alternative to the default Graphviz output.

  • Adds to_d2() method to EntityRelationshipDiagram class for generating D2 format output
  • Implements CLI flag --d2 for outputting D2 format to console
  • Maps erdantic's model and relationship metadata to D2's UML class shape syntax with proper field visibility

Reviewed Changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
erdantic/d2.py New module implementing D2 rendering with helper functions for quoting, visibility, and cardinality mapping
erdantic/core.py Adds to_d2() method to EntityRelationshipDiagram class
erdantic/cli.py Adds --d2 CLI flag with callback to make output optional and mutual exclusivity with --dot
tests/test_d2.py Comprehensive unit tests for D2 rendering functionality
tests/test_cli.py CLI tests for the new --d2 flag
tests/test_against_assets.py Integration tests comparing D2 output against static asset files
tests/assets/*.d2 Static D2 output files for different model frameworks

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

jayqi and others added 2 commits August 17, 2025 23:30
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
@jayqi jayqi self-requested a review August 18, 2025 04:14
@Else00
Copy link
Author

Else00 commented Aug 23, 2025

@jayqi any news? I need to do something else?

@Else00
Copy link
Author

Else00 commented Aug 29, 2025

@jayqi I need to do something?

@jayqi
Copy link
Member

jayqi commented Aug 29, 2025

Hi @Else00, sorry for the delay. I will take a look at this PR over the weekend.

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