vdb Docs How It Works

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

  1. Schema parsing Vdb::SchemaToGraph reads the schema file line-by-line using a simple state machine. It extracts create_table blocks, column definitions, add_foreign_key declarations, and add_index constraints.
  2. FK inference Any *_id column whose pluralised root matches an existing table name becomes an implicit link — unless an explicit add_foreign_key already covers it (no duplicates).
  3. 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: true index, the cardinality is upgraded to one → one (single tick on both sides).
  4. 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:

  1. Strips the _id suffix → user_iduser
  2. Pluralises → users
  3. Checks if users exists as a table in the parsed set
  4. 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:

ConcernImplementation
Force simulationD3 forceSimulation with link-distance, charge, and center forces
Node cardsSVG <foreignObject> containing an HTML table per node
Relationship linesCurved SVG <path> elements; source/target offset to card borders
Cardinality markersSVG <marker> defs — arrow (1), crow's-foot (N), single-tick (1-source)
SearchFilters node opacity on input event; dimmed nodes stay in layout
HighlightingClick → lowers opacity of all nodes/links except direct neighbours
Zoom / panD3 zoom() applied to the root SVG group
Position persistencelocalStorage keyed by vdb:{dbName}
Tip D3 v7 and Stimulus v3 are loaded from jsDelivr CDN — they never touch your asset pipeline or node_modules. CSP headers may need to permit the CDN domain; see the Security page for details.

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
vdb v0.1.0 — MIT License — GitHub