Using ActiveRecord Reflection in Rails Generators
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.
Index Template with Parent Links
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.klassgives you the associated model class. Use it to call.columns,.reflect_on_all_associations, or anything else you need.assoc.foreign_keyreturns the foreign key column name (user_id). Use this to filter it out of the regular column list.collection_selectassumes a:namemethod. If your models use a different display attribute, add ato_labelmethod 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.