visual_models
An interactive ActiveRecord / ActiveModel association graph for Rails apps. Mounts at any path you choose, introspects your models at runtime (no parsing of schema.rb), and renders a D3 force-directed graph that distinguishes every association macro, polymorphic targets, and STI hierarchies.
Inspired by vdb (which renders the database schema), and by class-relationship visualizers such as code-review-graph and graphify.
Development use only. Do not mount this in production without authentication.
Features
- Walks
ApplicationRecord.descendants(configurable base) at runtime — no schema file required. - Optionally pulls in ActiveModel-only classes (form objects, anything with
ActiveModel::Model/ActiveModel::Attributes). - Multi-database aware — every node carries the
database.ymlconnection name. When the graph contains more than one connection, each node displays a small badge in its header. - Detects DB views — models backed by a database VIEW (rather than a table) are rendered with a dashed info-coloured border and a
VIEWtag in the header. - Polymorphic candidate resolution — clicking the virtual
<polymorphic:assoc>node opens a side panel listing every model that declares the inverseas: :assocassociation, with a contextual warning when zero or one candidates are found. - Renders every association macro with its own visual style:
belongs_to— solid arrowhas_one— solid line + single tickhas_many— solid line + crow's foothas_many :through— dashed warning-coloured line, label shows the:throughassociationhas_and_belongs_to_many— dashed line with crow's feet on both ends- polymorphic — dashed accent line ending at a virtual
<polymorphic:assoc>node - STI — dotted line between parent and child
- Each node is a card showing class name, table name, primary key, foreign keys, regular columns, and virtual
attributetypes fromActiveModel::Attributes. - Click any node to highlight its neighbourhood and open a side panel with:
- all outgoing & incoming associations
- STI parents/children
- all columns and virtual attributes
- all validators with their options
- Search, zoom, fit-to-screen, drag-to-rearrange, and show/hide attributes for an uncluttered overview.
- Positions persisted to
localStorageper scope. - Optional HTTP Basic Auth.
- Multiple scopes (regex / proc / explicit list) shown as tabs — handy for separating
coremodels fromAdmin::*. - Zero asset-pipeline dependencies — D3 and Stimulus loaded from CDN.
Screenshots
Association graph — all models

Scoped view — core models with polymorphic node

Side panel — model details with clickable associations

Source modal — Ruby file with syntax highlighting

Installation
Add to your Gemfile, inside the development group:
group :development do
gem 'visual_models'
end
Run:
bundle install
bin/rails visual_models:install # mounts the engine and creates the initializer
Or set it up by hand — see below.
Setup
1. Mount the engine
In config/routes.rb:
Rails.application.routes.draw do
if Rails.env.development?
mount VisualModels::Engine, at: '/dev/models'
end
# rest of your routes…
end
2. Optional initializer
Create config/initializers/visual_models.rb only if you need to change defaults:
# config/initializers/visual_models.rb
return unless Rails.env.development?
VisualModels.configure do |c|
# HTTP Basic Auth. Leave username nil to disable.
c.username = ENV.fetch('VMODEL_USER', nil)
c.password = ENV.fetch('VMODEL_PASS', nil)
# Abstract base whose descendants are walked.
# Defaults to 'ApplicationRecord' (falls back to 'ActiveRecord::Base').
c.base_class = 'ApplicationRecord'
# Pull in ActiveModel-only classes too (form objects etc.).
c.include_active_model = true
# Drop classes by name string or regexp.
c.exclude = [/^Audit::/, 'LegacyImport']
# Tabs. Filter values: nil | Regexp | Proc | Array of names/classes.
c.scopes = {
'all' => nil,
'core' => /^(User|Post|Comment|Tag|PostTag)$/,
'admin' => /^Admin::/
}
# Page title.
c.title = 'Model Graph'
end
3. Visit the graph
http://localhost:3000/dev/models
Configuration reference
| Option | Type | Default | Description |
|---|---|---|---|
username |
`String \ | nil` | nil |
password |
`String \ | nil` | nil |
title |
String |
'Model Graph' |
Browser tab and page-header title. |
base_class |
`nil \ | String \ | Array |
include_active_model |
Boolean |
false |
Also include classes that include ActiveModel::Model or ActiveModel::Attributes. |
exclude |
`Array<String\ | Regexp>` | [] |
scopes |
`Hash<String, nil\ | Regexp\ | Proc\ |
Scope filter shapes
| Filter | Behaviour |
|---|---|
nil |
All discovered classes (after exclude) |
Regexp |
Keep classes whose name matches |
Proc |
Invoked at request time. Must return an array of classes (or class-name strings). |
Array |
Explicit list. Entries may be classes or strings. |
Routing helpers
Inside the engine:
| Helper | Path |
|---|---|
visual_models.root_path |
/dev/models |
visual_models.root_path(scope: 'admin') |
/dev/models?scope=admin |
visual_models.graph_json_path(scope: 'all') |
/dev/models/graph.json?scope=all |
The graph.json endpoint returns the raw payload — useful for piping it into other tools.
How it works
- Eager loading —
Rails.application.eager_load!is called once per request sodescendantsis populated even in development. - Discovery —
VisualModels::ModelsToGraphcollects descendants ofVisualModels.config.base_class(defaultApplicationRecord) and, optionally, every class inObjectSpacethat includesActiveModel::ModelorActiveModel::Attributes. - Filtering — the
excludelist is applied first, then the active scope filter. - Node payload — for each model:
kind(active_record/sti_child/active_model/abstract),table_name, columns (withprimaryandfkflags), virtual attributes fromattribute_types, and the full validator list. - Edges — every
reflect_on_all_associationsis walked. Polymorphicbelongs_tois rendered as a single dashed link to a virtual<polymorphic:assoc>node.has_many :throughkeeps its target but is styled distinctly. STI parent ↔ child edges are emitted separately. - Rendering — a Stimulus controller drives a D3 force-directed simulation. Different markers per macro give each link its own arrow head / crow's foot / single tick. Node positions are saved to
localStoragekeyed by scope.
Visual legend
| Line style | Meaning |
|---|---|
| ─→ solid arrow | belongs_to |
| ─ | solid + tick |
| ─◄ solid + crow's foot | has_many |
| ─ ─ ◄ ◄ dashed crow's feet | has_and_belongs_to_many |
| ─ ─ → dashed warning | has_many :through |
| ─ ─ → dashed accent | polymorphic |
| ··· dotted secondary | STI inheritance |
| Node colour | Meaning |
|---|---|
| Primary blue header | concrete ActiveRecord model |
| Secondary indigo header | STI child |
| Pink accent header | ActiveModel-only class |
| Neutral header, dashed border | abstract class |
| Field colour | Meaning |
|---|---|
| Yellow | primary key |
| Cyan | foreign key |
| Pink | virtual attribute (ActiveModel::Attributes) |
Multi-database support
visual_models reads each model's connection at request time via
Model.connection_db_config.name. If your database.yml defines primary and
analytics, every model's node carries the connection it belongs to.
Models that don't inherit from ApplicationRecord are still picked up. A
typical multi-DB Rails app looks like this:
class AuditRecord < ActiveRecord::Base
self.abstract_class = true
connects_to database: { writing: :audit, reading: :audit }
end
class OperationLog < AuditRecord
belongs_to :user, optional: true
# …
end
OperationLog is not in ApplicationRecord.descendants — it lives under a
separate abstract base. By default visual_models walks ActiveRecord::Base.descendants
which catches ApplicationRecord, AuditRecord, and any other *Record
abstract you've set up via connects_to. The node for OperationLog will
display the audit database badge in its header and the connection in the
side panel — no extra configuration required.
If you'd rather restrict the graph to specific bases, set base_class:
c.base_class = ['ApplicationRecord', 'AuditRecord']
When the rendered graph contains more than one connection, each node header displays a small pill badge showing its database. The connection is also listed in the side panel.
To split the graph by connection, configure scopes:
VisualModels.configure do |c|
c.scopes = {
'all' => nil,
'primary' => ->(*) { ApplicationRecord.descendants.select { |k| db_name(k) == 'primary' } },
'analytics' => ->(*) { AnalyticsRecord.descendants.reject(&:abstract_class?) }
}
end
def db_name(klass)
klass.respond_to?(:connection_db_config) && klass.connection_db_config&.name
rescue StandardError
nil
end
The exposed databases key on the JSON payload (/dev/models/graph.json) is the de-duplicated list of every connection seen across the rendered graph.
Database views
Models backed by a database VIEW rather than a table are auto-detected via
Model.connection.views.include?(Model.table_name) (which all major adapters
support — Postgres, MySQL, SQLite, SQL Server). Detected views render with:
- a dashed, info-coloured border around the card
- a small
VIEWtag in the header subtitle - the
viewpill in the side panel's Type section
Foreign-key columns and association arrows still draw normally — Rails treats view-backed models the same as table-backed models from an introspection point of view, so anything declared in the model body is rendered.
Polymorphic associations
A polymorphic belongs_to is rendered as a dashed accent-coloured edge ending
at a virtual pill node labelled polymorphic :assoc. Click that pill node and
the side panel opens with two sections:
- Used by · belongs_to — every model in the current scope that declares
belongs_to :assoc, polymorphic: true. - Candidate types · as: :assoc — every model in the current scope that
declares the inverse (
has_many :things, as: :assocorhas_one :thing, as: :assoc). These are the concrete classes the polymorphic*_typecolumn may hold.
A contextual note is shown above the lists:
| Candidate count | Tone | Message |
|---|---|---|
| 0 | red | No model in this graph declares as: :assoc. The *_type column may point at something outside the current scope, or the inverse association is missing. |
| 1 | yellow | Only one candidate — consider replacing with a direct belongs_to. |
| ≥ 2 | blue | The actual class is chosen at runtime from the *_type column. Any of the listed models could be on the other end. |
The pill node itself also picks up a yellow or red border in the 0/1-candidate cases, so ambiguous polymorphic associations are visible at a glance without opening the panel.
Security note
This gem reflects on every model in your app — including STI subclasses, validations, and the full attribute list. Never mount it in production without authentication. Wrap the mount in if Rails.env.development? and, ideally, add Basic Auth via the config.