visual_models

Gem Version License

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.yml connection 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 VIEW tag in the header.
  • Polymorphic candidate resolution — clicking the virtual <polymorphic:assoc> node opens a side panel listing every model that declares the inverse as: :assoc association, with a contextual warning when zero or one candidates are found.
  • Renders every association macro with its own visual style:
    • belongs_to — solid arrow
    • has_one — solid line + single tick
    • has_many — solid line + crow's foot
    • has_many :through — dashed warning-coloured line, label shows the :through association
    • has_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 attribute types from ActiveModel::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 localStorage per scope.
  • Optional HTTP Basic Auth.
  • Multiple scopes (regex / proc / explicit list) shown as tabs — handy for separating core models from Admin::*.
  • Zero asset-pipeline dependencies — D3 and Stimulus loaded from CDN.

Screenshots

Association graph — all models

Graph overview showing all models connected with typed association lines

Scoped view — core models with polymorphic node

Core scope tab selected showing models and the polymorphic :imageable node

Side panel — model details with clickable associations

Admin model panel open showing STI parent, associations, columns, and validations

Source modal — Ruby file with syntax highlighting

Source modal showing Admin model source code with line numbers and Ruby 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

  1. Eager loadingRails.application.eager_load! is called once per request so descendants is populated even in development.
  2. DiscoveryVisualModels::ModelsToGraph collects descendants of VisualModels.config.base_class (default ApplicationRecord) and, optionally, every class in ObjectSpace that includes ActiveModel::Model or ActiveModel::Attributes.
  3. Filtering — the exclude list is applied first, then the active scope filter.
  4. Node payload — for each model: kind (active_record / sti_child / active_model / abstract), table_name, columns (with primary and fk flags), virtual attributes from attribute_types, and the full validator list.
  5. Edges — every reflect_on_all_associations is walked. Polymorphic belongs_to is rendered as a single dashed link to a virtual <polymorphic:assoc> node. has_many :through keeps its target but is styled distinctly. STI parent ↔ child edges are emitted separately.
  6. 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 localStorage keyed 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 VIEW tag in the header subtitle
  • the view pill 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: :assoc or has_one :thing, as: :assoc). These are the concrete classes the polymorphic *_type column 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.