Choreo ERD: Comprehensive Walkthrough
# For Hex publication/readers:
Mix.install([
{:choreo, "~> 0.14.1"},
# {:choreo, path: Path.expand("../..", __DIR__), force: true},
{:kino_vizjs, "~> 0.9.0"}
])
Introduction
Choreo.ERD is a database schema modeler that supports Entity-Relationship Diagrams (ERDs). It allows database engineers and system architects to dynamically build out database tables, model exact relationships (with standard cardinalities), automatically validate constraints, and run advanced topological analysis on schemas.
Everything renders flawlessly to DOT (Graphviz) for styled HTML record tables or Mermaid.js native erDiagram representation.
Choreo provides two complementary syntax styles:
- Programmatic Pipe API (
Choreo.ERD) — A stable, pipe-first interface ideal for dynamic builders and integrations (e.g., extracting schemas from Ecto). - Lab DSL (
Choreo.Lab.DSL.ERD) — A concise, Livebook-friendly syntax for sketching schemas and exploring data models.
The introductory example below uses the explicit pipe-first syntax. All subsequent examples throughout this guide demonstrate the Lab DSL.
alias Choreo.ERD
alias Choreo.ERD.Analysis
import Choreo.Lab.DSL.ERD
# Initialize a standard e-commerce database schema using pipe syntax
schema =
ERD.new()
|> ERD.add_table(:users, columns: [
%{name: :id, type: :integer, key: :pk},
%{name: :email, type: :varchar, comment: "unique index"}
])
|> ERD.add_table(:orders, columns: [
%{name: :id, type: :integer, key: :pk},
%{name: :user_id, type: :integer, key: :fk},
%{name: :total_amount, type: :numeric}
])
|> ERD.add_table(:order_items, columns: [
%{name: :id, type: :integer, key: :pk},
%{name: :order_id, type: :integer, key: :fk},
%{name: :product_id, type: :integer, key: :fk},
%{name: :quantity, type: :integer}
])
|> ERD.add_table(:products, columns: [
%{name: :id, type: :integer, key: :pk},
%{name: :sku, type: :varchar, comment: "stock keeping unit"},
%{name: :price, type: :numeric}
])
# Draw foreign key relationships with multiplicities
|> ERD.add_relationship(:users, :orders, cardinality: :one_to_many, label: "places")
|> ERD.add_relationship(:orders, :order_items, cardinality: :exactly_one_to_many, label: "contains")
|> ERD.add_relationship(:products, :order_items, cardinality: :zero_or_one_to_many, label: "ordered_in")
Kino.Layout.tabs(
Siren: Choreo.Lab.Siren.new(ERD.to_mermaid(schema)),
Graphviz: Kino.VizJS.render(ERD.to_dot(schema)),
Sketch: Choreo.Lab.Sketch.new(ERD.to_mermaid(schema))
)
Schema Construction & Strict Validation
Choreo.ERD enforces strict compile-time and runtime validation using NimbleOptions to guarantee the model's structural integrity.
Column Validation in the DSL
In the DSL, tables are declared with table("name") do ... end (or entity), and columns are declared with dedicated helper verbs:
pk :name, :type/primary_key :name, :type— Primary Key.fk :name, :type/foreign_key :name, :type— Foreign Key.field :name, :type/column :name, :type— Regular column, accepting optional:keyand:comment.
Each column is validated against a schema:
:name— (Required) Atom or string column name.:type— (Required) Data type representation (e.g.:integer,:varchar,:numeric).:key— (Optional) Key constraint, either:pk,:fk, ornil.:comment— (Optional) Explanatory comment displayed in tooltips/captions.
# This will fail at runtime because the column key option is invalid
try do
erd do
table("broken_table") do
field :id, :integer, key: :invalid_key
end
end
rescue
e -> e
end
Relationship Multiplicities
Relationships support standard crow's foot multiplicities via typed DSL constructors or the :cardinality option:
| Cardinality Identifier | DSL Helper / Alias | DOT Arrow Style | Mermaid Syntax Shape |
|---|---|---|---|
:one_to_one |
one_to_one, has_one |
teetee / teetee |
||--|| |
:one_to_many |
one_to_many, has_many |
teetee / crowodot |
||--o{ |
:zero_or_one_to_many |
zero_or_one_to_many, maybe_has_many |
odot / crowodot |
|o--o{ |
:exactly_one_to_many |
exactly_one_to_many, has_at_least_one |
teetee / crowtee |
||--|{ |
:many_to_many |
many_to_many, has_and_belongs_to_many |
crowodot / crowodot |
}o--o{ |
# Example of a one-to-one profile link using the DSL
profile_schema =
erd do
users =
table("users") do
pk :id, :integer
end
profiles =
table("profiles") do
pk :id, :integer
fk :user_id, :integer
end
has_one users ~> profiles, "has_one"
end
Kino.Layout.tabs(
Siren: Choreo.Lab.Siren.new(ERD.to_mermaid(profile_schema)),
Graphviz: Kino.VizJS.render(ERD.to_dot(profile_schema)),
Sketch: Choreo.Lab.Sketch.new(ERD.to_mermaid(profile_schema))
)
Strict Column & Datatype Verification
To ensure that your relationships point to valid columns and do not introduce mismatching datatypes, Choreo.ERD supports column-level mapping checks via :from_column (or from:) and :to_column (or to:).
Column matching is opt-in: passing only
:from_columnor only:to_columnskips the check entirely. Both must be provided to trigger validation.
You can also toggle the global strict_column_matching: true option in the DSL:
# This will fail because strict_column_matching is active but we did not provide column mappings
try do
erd strict_column_matching: true do
users =
table("users") do
pk :id, :integer
end
posts =
table("posts") do
pk :id, :integer
fk :user_id, :integer
end
one_to_many users ~> posts
end
rescue
e -> e
end
# This will fail because the datatypes of the joined columns mismatch (integer vs varchar)
try do
erd do
users =
table("users") do
pk :id, :integer
end
posts =
table("posts") do
pk :id, :integer
fk :user_uuid, :varchar
end
one_to_many users ~> posts, from: :id, to: :user_uuid
end
rescue
e -> e
end
# This succeeds perfectly because columns exist and datatypes match
strict_schema =
erd strict_column_matching: true do
users =
table("users") do
pk :id, :integer
end
posts =
table("posts") do
pk :id, :integer
fk :user_id, :integer
end
one_to_many users ~> posts, from: :id, to: :user_id
end
Themed Visualizations
All built-in themes are optimized specifically for HTML-like database record labels, adjusting header fills, fonts, card borders, and relationship paths dynamically:
tabs = [
{"Default", Kino.VizJS.render(ERD.to_dot(schema))},
{"Ocean", Kino.VizJS.render(ERD.to_dot(schema, theme: :ocean))},
{"Forest", Kino.VizJS.render(ERD.to_dot(schema, theme: :forest))},
{"Dark", Kino.VizJS.render(ERD.to_dot(schema, theme: :dark))}
]
Kino.Layout.tabs(tabs)
Note on Mermaid theming: The native Mermaid
erDiagramsyntax has limited styling support.ERD.to_mermaid/2accepts a:themeoption for API consistency but currently ignores it.
Topological Analysis Suite
The Choreo.ERD.Analysis suite runs graph-theoretic queries across the database schema, identifying layout hotspots, joining issues, and structural coupling metrics.
1. Optimal Join Paths
To join two distant tables, the shortest_join_path/3 BFS treats the database relationships as an undirected graph, computing the exact sequence of intermediate joins:
# Find the exact join sequence between users and products
Analysis.shortest_join_path(schema, :users, :products)
2. Circular Foreign Key Cycles
Circular relationships complicate database migrations, row insertions, and cascade teardowns. Analysis.cycles/1 detects all circular foreign key references:
# Introducing a circular relation loop using the DSL: User -> Order -> Item -> User
circular_schema =
erd do
users =
table("users") do
pk :id, :integer
end
orders =
table("orders") do
pk :id, :integer
fk :user_id, :integer
end
items =
table("order_items") do
pk :id, :integer
fk :order_id, :integer
end
one_to_many users ~> orders, "places"
one_to_many orders ~> items, "contains"
one_to_many items ~> users, "assigned_to"
end
# Detect the cycle loop
Analysis.cycles(circular_schema)
3. Orphan Tables
Orphan tables are structurally isolated from the database relationships, highlighting neglected assets or incomplete models:
# An ERD with an isolated logging audit table defined using the DSL
isolated_schema =
erd do
users =
table("users") do
pk :id, :integer
end
orders =
table("orders") do
pk :id, :integer
fk :user_id, :integer
end
table("audit_logs") do
pk :id, :integer
field :event, :varchar
end
one_to_many users ~> orders, "places"
end
# Identify orphans
Analysis.orphans(isolated_schema)
4. Structural Table Degrees (Coupling Metrics)
Determines table centrality. Tables with high outgoing/incoming degrees represent vital database hubs, whereas low-degree tables are leaf attributes:
# Calculate degrees for each table
Analysis.table_degrees(schema)
5. Normalization Score
Analysis.normalization_score(schema)
The default
:one_to_onepenalty is 0 because legitimate 1-1 splits (e.g., PII isolation) are common. Passweights: [one_to_one: 5]to opt-in to penalizing them.
Cheat Sheet
Lab DSL Syntax
| Syntax | Description |
|---|---|
erd do ... end |
Define an ERD diagram |
erd strict_column_matching: true do ... end |
Define an ERD with strict column matching |
users = table("users") do ... end |
Declare a table with columns |
pk :id, :integer / primary_key :id, :integer |
Primary key column |
fk :user_id, :integer / foreign_key :user_id, :integer |
Foreign key column |
field :name, :type, comment: "..." |
Regular column with optional comment |
has_many users ~> posts, "writes" |
Relationship with label |
one_to_many users ~> posts, from: :id, to: :user_id |
Relationship with column mappings |
| `users ~> posts | > has_many("writes") |
Programmatic Pipe API & Analysis
| Task / Feature | Command |
|---|---|
| Create ERD | ERD.new/1 |
| Add Database Table | ERD.add_table/3 (Opts: :label, :columns) |
| Add Relationship | ERD.add_relationship/4 (Opts: :label, :cardinality, :from_column, :to_column) |
| Render Styled DOT Graphviz | ERD.to_dot/2 (Opts: :theme, :direction, :highlighted_nodes) |
| Render Native Mermaid | ERD.to_mermaid/2 |
| Discover Join Sequence | Analysis.shortest_join_path/3 |
| Detect FK Circular Cycles | Analysis.cycles/1 |
| Find Disconnected Tables | Analysis.orphans/1 |
| Compute Coupling Centrality | Analysis.table_degrees/1 |
| Find Affected Tables | Analysis.affected_by/2 |
| Find Table Dependencies | Analysis.depends_on/2 |
| Find Redundant Relationships | Analysis.transitive_reduction/1 |
| Find Longest Cascade | Analysis.longest_dependency_chain/1 |
| Validate Schema | Analysis.validate/1 |
| Score Normalization | Analysis.normalization_score/2 |