How It Works
vdb is a thin Rails engine. Every request to /dev/erd triggers a fresh read of your schema file, infers relationships, and delivers a static HTML page with an embedded D3 graph. No database queries, no Active Record, no caching.
The four-step pipeline
-
Schema parsing
Vdb::SchemaToGraphreads the schema file line-by-line using a simple state machine. It extractscreate_tableblocks, column definitions,add_foreign_keydeclarations, andadd_indexconstraints. -
FK inference Any
*_idcolumn whose pluralised root matches an existing table name becomes an implicit link — unless an explicitadd_foreign_keyalready covers it (no duplicates). -
Cardinality detection Every relationship starts as many → one (crow's-foot on the source side). If the FK column also appears in a single-column
unique: trueindex, the cardinality is upgraded to one → one (single tick on both sides). -
D3 rendering The graph JSON (
{ nodes, links }) is embedded in the page. A Stimulus controller drives D3's force-directed simulation, draws SVG markers (arrows, crow's-foot, single tick), and wires up all interactions.
Schema parsing in depth
Vdb::SchemaToGraph (app/services/vdb/schema_to_graph.rb) is a pure-Ruby line-by-line parser — no eval, no metaprogramming. It handles three sections of db/schema.rb:
Tables and columns
# Input (fragment of db/schema.rb)
create_table "posts", force: :cascade do |t|
t.string "title"
t.text "body"
t.integer "user_id", null: false
t.boolean "featured"
t.datetime "published_at"
t.index ["user_id"], name: "index_posts_on_user_id"
end
# Parsed into
{
id: "posts",
fields: [
{ name: "title", type: "string" },
{ name: "body", type: "text" },
{ name: "user_id", type: "integer" },
{ name: "featured", type: "boolean" },
{ name: "published_at", type: "datetime" }
]
}
Rails-internal columns (created_at, updated_at, id) are preserved so the ERD shows the full real-world shape of each table.
Explicit foreign keys
# Input
add_foreign_key "posts", "users"
# Becomes a link
{ from: "posts", to: "users", fromCard: "N", toCard: "1" }
Implicit FK inference
For every *_id column in any table, the parser:
- Strips the
_idsuffix →user_id→user - Pluralises →
users - Checks if
usersexists as a table in the parsed set - If yes, and no explicit FK already covers this pair, emits an implicit link
# No add_foreign_key in schema, but column exists:
t.integer "reviewer_id"
# Inferred link emitted:
{ from: "reviews", to: "reviewers", fromCard: "N", toCard: "1" }
Cardinality detection
The rules are simple and predictable:
| Condition | fromCard | toCard | Visual |
|---|---|---|---|
| Default (no unique index on FK column) | "N" |
"1" |
Crow's-foot ⎯ arrow |
FK column has single-column unique: true index |
"1" |
"1" |
Single-tick ⎯ arrow |
# 1:1 — reviewers.user_id has a unique index
t.index ["user_id"], name: "index_reviewers_on_user_id", unique: true
# → fromCard: "1", toCard: "1"
# N:1 — posts.user_id has a plain index
t.index ["user_id"], name: "index_posts_on_user_id"
# → fromCard: "N", toCard: "1"
Graph JSON format
The parser produces a plain Ruby hash that is JSON-serialised into the page. The D3 simulation consumes it directly.
{
nodes: [
{
id: "posts",
fields: [
{ name: "title", type: "string" },
{ name: "user_id", type: "integer" }
]
},
# …
],
links: [
{ from: "posts", to: "users", fromCard: "N", toCard: "1" },
{ from: "posts", to: "tags", fromCard: "N", toCard: "1" },
# …
]
}
D3 rendering
The embedded Stimulus controller (erd_controller.js, inlined in the layout) drives the entire visualisation:
| Concern | Implementation |
|---|---|
| Force simulation | D3 forceSimulation with link-distance, charge, and center forces |
| Node cards | SVG <foreignObject> containing an HTML table per node |
| Relationship lines | Curved SVG <path> elements; source/target offset to card borders |
| Cardinality markers | SVG <marker> defs — arrow (1), crow's-foot (N), single-tick (1-source) |
| Search | Filters node opacity on input event; dimmed nodes stay in layout |
| Highlighting | Click → lowers opacity of all nodes/links except direct neighbours |
| Zoom / pan | D3 zoom() applied to the root SVG group |
| Position persistence | localStorage keyed by vdb:{dbName} |
Excluded tables
The parser silently drops tables matching these prefixes/names before building the graph. They would clutter the ERD without adding domain value:
EXCLUDED = %w[
active_storage_attachments
active_storage_blobs
active_storage_variant_records
action_text_rich_texts
ar_internal_metadata
schema_migrations
solid_cache_entries
solid_queue_blocked_executions
solid_queue_claimed_executions
solid_queue_failed_executions
solid_queue_jobs
solid_queue_pauses
solid_queue_processes
solid_queue_ready_executions
solid_queue_recurring_executions
solid_queue_recurring_tasks
solid_queue_semaphores
solid_cable_messages
].freeze