← All posts

Using ActiveRecord Reflection in Rails Generators

October 10, 2023 rails

This extends my previous post on customizing Rails generators for views and controllers. The built-in scaffold generator only knows about the attributes you pass on the command line. It doesn’t know that Post belongs to User, or that Order has many LineItems. With ActiveRecord reflection, your custom generators can detect associations at generation time and produce views and controllers that account for them.

The Idea

ActiveRecord models already describe their associations. Post.reflect_on_all_associations returns an array of reflection objects with everything you need — association name, type, foreign key, class name. A custom generator can load the model, read its reflections, and use that information in templates.

A Generator That Reads Associations

lib/generators/smart_scaffold/smart_scaffold_generator.rb:

class SmartScaffoldGenerator < Rails::Generators::NamedBase
    source_root File.expand_path("templates", __dir__)

    def generate_views
        load_reflections
        template "index.html.erb.tt",
            "app/views/#{plural_file_name}/index.html.erb"
        template "show.html.erb.tt",
            "app/views/#{plural_file_name}/show.html.erb"
        template "_form.html.erb.tt",
            "app/views/#{plural_file_name}/_form.html.erb"
    end

    private

    def load_reflections
        require "#{Rails.root}/app/models/#{file_name}"
        @model_class = class_name.constantize
        @belongs_to = @model_class.reflect_on_all_associations(:belongs_to)
        @has_many = @model_class.reflect_on_all_associations(:has_many)
        @has_one = @model_class.reflect_on_all_associations(:has_one)
        @columns = @model_class.columns.reject { |c|
            %w[id created_at updated_at].include?(c.name)
        }
    end

    def foreign_keys
        @belongs_to.map(&:foreign_key)
    end

    def display_columns
        @columns.reject { |c| foreign_keys.include?(c.name) }
    end
end

load_reflections loads the actual model class and queries its associations. The templates can then iterate over @belongs_to, @has_many, and the column list to produce smarter output.

lib/generators/smart_scaffold/templates/index.html.erb.tt:

<h1><%= plural_table_name.titleize %></h1>

<table>
  <thead>
    <tr>
    <% @belongs_to.each do |assoc| -%>
      <th><%= assoc.name.to_s.titleize %></th>
    <% end -%>
    <% display_columns.each do |column| -%>
      <th><%= column.name.titleize %></th>
    <% end -%>
      <th></th>
    </tr>
  </thead>
  <tbody>
    <%% @<%= plural_table_name %>.each do |<%= singular_table_name %>| %>
    <tr>
      <% @belongs_to.each do |assoc| -%>
      <td><%%= link_to <%= singular_table_name %>.<%= assoc.name %>,
            <%= assoc.name %>_path(<%= singular_table_name %>.<%= assoc.name %>) %></td>
      <% end -%>
      <% display_columns.each do |column| -%>
      <td><%%= <%= singular_table_name %>.<%= column.name %> %></td>
      <% end -%>
      <td><%%= link_to "Show", <%= singular_table_name %> %></td>
    </tr>
    <%% end %>
  </tbody>
</table>

Instead of showing a raw user_id column, the index links to the associated user. Foreign key columns are excluded from the regular column list since they’re covered by the association link.

Form Template with Select Inputs for Associations

lib/generators/smart_scaffold/templates/_form.html.erb.tt:

<%%= form_with(model: @<%= singular_table_name %>) do |form| %>
  <%% if @<%= singular_table_name %>.errors.any? %>
  <div class="alert alert-danger">
    <%% @<%= singular_table_name %>.errors.full_messages.each do |message| %>
    <p><%%= message %></p>
    <%% end %>
  </div>
  <%% end %>

<% @belongs_to.each do |assoc| -%>
  <div class="mb-3">
    <%%= form.label :<%= assoc.foreign_key %>, "<%= assoc.name.to_s.titleize %>" %>
    <%%= form.collection_select :<%= assoc.foreign_key %>,
          <%= assoc.klass.name %>.all, :id, :name,
          { prompt: "Select <%= assoc.name.to_s.titleize %>" },
          { class: "form-control" } %>
  </div>
<% end -%>
<% display_columns.each do |column| -%>
<% case column.type -%>
<% when :text -%>
  <div class="mb-3">
    <%%= form.label :<%= column.name %> %>
    <%%= form.text_area :<%= column.name %>, class: "form-control" %>
  </div>
<% when :boolean -%>
  <div class="mb-3 form-check">
    <%%= form.check_box :<%= column.name %>, class: "form-check-input" %>
    <%%= form.label :<%= column.name %>, class: "form-check-label" %>
  </div>
<% when :date -%>
  <div class="mb-3">
    <%%= form.label :<%= column.name %> %>
    <%%= form.date_field :<%= column.name %>, class: "form-control" %>
  </div>
<% when :datetime -%>
  <div class="mb-3">
    <%%= form.label :<%= column.name %> %>
    <%%= form.datetime_local_field :<%= column.name %>, class: "form-control" %>
  </div>
<% when :integer, :float, :decimal -%>
  <div class="mb-3">
    <%%= form.label :<%= column.name %> %>
    <%%= form.number_field :<%= column.name %>, class: "form-control" %>
  </div>
<% else -%>
  <div class="mb-3">
    <%%= form.label :<%= column.name %> %>
    <%%= form.text_field :<%= column.name %>, class: "form-control" %>
  </div>
<% end -%>
<% end -%>

  <%%= form.submit class: "btn btn-primary" %>
<%% end %>

Belongs-to associations become collection_select dropdowns automatically. Column types map to appropriate field types — text gets a text_area, boolean gets a check_box, date gets a date_field, and so on.

Show Template with Has-Many Tables

lib/generators/smart_scaffold/templates/show.html.erb.tt:

<h1><%%= @<%= singular_table_name %>.<%= display_columns.first&.name || 'id' %> %></h1>

<dl>
<% @belongs_to.each do |assoc| -%>
  <dt><%= assoc.name.to_s.titleize %></dt>
  <dd><%%= link_to @<%= singular_table_name %>.<%= assoc.name %>,
        <%= assoc.name %>_path(@<%= singular_table_name %>.<%= assoc.name %>) %></dd>
<% end -%>
<% display_columns.each do |column| -%>
  <dt><%= column.name.titleize %></dt>
  <dd><%%= @<%= singular_table_name %>.<%= column.name %> %></dd>
<% end -%>
</dl>

<% @has_many.each do |assoc| -%>
<h2><%= assoc.name.to_s.titleize %></h2>
<%% if @<%= singular_table_name %>.<%= assoc.name %>.any? %>
<table>
  <thead>
    <tr>
      <%% <%= assoc.klass.name %>.columns.each do |col| %>
        <%% next if %w[id created_at updated_at].include?(col.name) %>
        <%% next if col.name.end_with?("_id") %>
        <th><%%= col.name.titleize %></th>
      <%% end %>
    </tr>
  </thead>
  <tbody>
    <%% @<%= singular_table_name %>.<%= assoc.name %>.each do |record| %>
    <tr>
      <%% <%= assoc.klass.name %>.columns.each do |col| %>
        <%% next if %w[id created_at updated_at].include?(col.name) %>
        <%% next if col.name.end_with?("_id") %>
        <td><%%= record.send(col.name) %></td>
      <%% end %>
    </tr>
    <%% end %>
  </tbody>
</table>
<%% end %>

<% end -%>

<%%= link_to "Edit", edit_<%= singular_table_name %>_path(@<%= singular_table_name %>) %> |
<%%= link_to "Back", <%= plural_table_name %>_path %>

The show page lists belongs-to associations as links and renders a table for each has-many association. The has-many tables iterate the associated model’s columns at runtime so they stay current even if you add columns later.

Using Route Helpers Dynamically

When associations are polymorphic or nested, you can’t hardcode path helpers. Use polymorphic_path in your templates instead:

<td><%%= link_to <%= singular_table_name %>.<%= assoc.name %>,
      polymorphic_path(<%= singular_table_name %>.<%= assoc.name %>) %></td>

For nested routes, if your generator knows about the parent:

<%%= link_to "New <%= singular_table_name.titleize %>",
      new_<%= parent_name %>_<%= singular_table_name %>_path(@<%= parent_name %>) %>

You can detect nested routes in the generator by checking if the model has a required belongs-to and the routes file defines a nested resource. That’s more involved, but for a generator you use daily it’s worth the upfront cost.

Tips

  • The model must exist before you run the generator. Reflection only works on classes that have been loaded and have a backing table. Run migrations first, then generate views.
  • assoc.klass gives you the associated model class. Use it to call .columns, .reflect_on_all_associations, or anything else you need.
  • assoc.foreign_key returns the foreign key column name (user_id). Use this to filter it out of the regular column list.
  • collection_select assumes a :name method. If your models use a different display attribute, add a to_label method to your models and use that, or make the attribute configurable in your generator with a class method convention.
  • Handle missing associations gracefully. Not every model has belongs-to or has-many. The templates above just skip those sections when the arrays are empty.
Let’s work together! Tell Me About Your Project