0.0
No release in over 3 years
visual_models mounts a self-contained D3-powered association graph at any path in your Rails app. It introspects ApplicationRecord (and, optionally, ActiveModel-only classes) at runtime, walks every reflection (belongs_to, has_one, has_many, has_many :through, has_and_belongs_to_many, polymorphic), and renders an interactive force-directed graph with search, zoom, drag-to-rearrange, position persistence, and a click-through details panel. Ships with zero asset-pipeline dependencies — D3 and Stimulus are loaded from CDN. Recommended for development environments only.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 7.0, < 9
 Project Readme

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 HTTP Basic Auth username. Set both to enable auth.
password String | nil nil HTTP Basic Auth password.
title String 'Model Graph' Browser tab and page-header title.
base_class nil | String | Array<String> nil Abstract base(s) whose descendants are introspected. nil auto-discovers every AR hierarchy (walks ActiveRecord::Base.descendants — picks up ApplicationRecord, AuditRecord, AnalyticsRecord, …). Pass a string or array to restrict.
include_active_model Boolean false Also include classes that include ActiveModel::Model or ActiveModel::Attributes.
exclude Array<String|Regexp> [] Names/patterns to drop from the graph.
scopes Hash<String, nil|Regexp|Proc|Array> { 'all' => nil } Each entry becomes a tab. The filter is applied on top of the base set.

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.