Select dropdown for polymorphic associations

When building a CRUD-oriented web application with Ruby on Rails, most things are pretty straightforward. Your tables, models, controllers, and views all naturally align, and you can lean on the Rails scaffolds. One gap, however, is dealing with polymorphic associations in your forms. Let’s explore how global IDs can provide us with a simple solution.


For this blog post, let’s consider building an app that has Posts that have polymorphic content, where a post’s content can be either an Article or a Video.

We can scaffold such a resource with the Rails CLI:

bin/rails generate scaffold Post title:string! content:belongs_to{polymorphic}

This command will create a migration file like this:

class CreatePosts < ActiveRecord::Migration[8.0]
def change
create_table :posts do |t|
t.string :title, null: false
t.belongs_to :content, polymorphic: true, null: false
 
t.timestamps
end
end
end

And a model file like this:

class Post < ApplicationRecord
belongs_to :content, polymorphic: true
end

These both look great, and are how you should build polymorphic associations. The issue arises when we view the scaffolded _form partial:

<%= form_with(model: post) do |form| %>
<% if post.errors.any? %>
<div style="color: red">
<h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>
 
<ul>
<% post.errors.each do |error| %>
<li><%= error.full_message %></li>
<% end %>
</ul>
</div>
<% end %>
 
<div>
<%= form.label :title, style: "display: block" %>
<%= form.text_field :title %>
</div>
 
<div>
<%= form.label :content_id, style: "display: block" %>
<%= form.text_field :content_id %>
</div>
 
<div>
<%= form.submit %>
</div>
<% end %>

This form is not production-ready. We should never ask users to enter table IDs into forms. Were this a simple belongs_to association, I would always reach first for a form.collection_select and replace this text_field with something like:

<div>
<%= form.label :content_id, style: "display: block" %>
<%= form.collection_select(:content_id, Content.all, :id, :public_name, prompt: true) %>
</div>

With a polymorphic association like we have, however, this won’t work because we don’t have a single Content model. Yes, we could potentially reach for delegated types instead of a polymorphic association, but sometimes a straight polymorphic association is what is best for the database schema. So, how can we build a simple yet elegant form experience for a polymorphic association?

I won’t bury the lede; my answer is to reach for global IDs. Let me explain why. Simple means no (or very few) moving parts. I don’t want two dependent <select>s, where the user first selects the type and then the second select only shows the subset of options that are of that type; that requires Javascript and that shouldn’t be a requirement for a simple default. Elegant means easy to use and straightforward to build. Having one form field and a diff of no more than 10 lines of code is a good rule of thumb for elegance in this scenario. A solution built on top of global IDs allow me to build a simple and elegant solution.

Let’s start with the form field. Instead of two dependent <select>s, let’s build a single <select> with grouped options. This allows the user to clearly see that this is a polymorphic association as well as which type each option is. Here is the example of a grouped select from the MDN docs on optgroup:

Our list of options are grouped with non-selectable headers. Rails has a companion form helper for building such <select>s with the grouped_collection_select helper, which requires passing a single collection that can be nested via getter methods. The example in the docs are continents that have many countries each of which has many cities, so you can do form.grouped_collection_select(:country_id, @continents, :countries, :name, :id, :name). With our polymorphic association, we can’t easy get a single collection of all possible content values, so instead of using grouped_collection_select, we can drop down and use grouped_options_for_select helper instead with our class form.select

<div>
<%= form.label :content_gid, style: "display: block" %>
<%= form.select(:content_gid,
grouped_options_for_select(
[
[ 'Articles',
Article
.order(:title)
.map { |it| [it.title, it.to_gid.to_s] } ],
[ 'Videos',
Video
.order(:title)
.map { |it| [it.title, it.to_gid.to_s] } ]
]
)) %>
</div>

Here we build up our grouped options and pass that as the choices to the form.select helper. Our grouped options is a basic array of arrays, where each top-level array is a group. A group has first a string heading and then an inner array of choice tuples. This is where we turn to global IDs. A choice tuple takes a label and then a value. The label is what is shown to users and the value is what is sent back to the server. Our values need to encode both the id and the type of this particular choice for the post’s content. And this is precisely what global IDs provide us. As the globalid docs state:

A Global ID is an app wide URI that uniquely identifies a model instance:

gid://YourApp/Some::Model/id

This is helpful when you need a single identifier to reference different classes of objects.

By encoding both the class name and the ID, global IDs provide all of the information we need to set our polymorphic association. All we need is a new accessor on our model to get and set our association via global IDs:

class Post < ApplicationRecord
belongs_to :content, polymorphic: true
 
def content_gid
content&.to_gid
end
 
def content_gid=(gid)
self.content = GlobalID::Locator.locate gid
end
end

Easy enough! In addition to our Post#content_id accessor we add a Post#content_gid accessor whose getter returns the associated content’s global ID and whose setter takes a global ID and uses it to set the full content association.

So, instead of using a flat collection of choices in a <select> for a standard belongs_to association via the content_id field, when I am working with a polymorphic association I reach for a grouped collection of choices via the content_gid field.

If you want to ensure that every ActiveRecord model with a polymorphic belongs_to association has this *_gid accessor, you can add the following initializer to your Rails app:

# config/initializers/polymorphic_belongs_to_gid.rb
ActiveSupport.on_load(:active_record) do
module_parent.const_get('Associations::Builder::BelongsTo').class_eval do
def self.define_accessors(model, reflection)
super
 
return unless reflection.polymorphic?
 
mixin = model.generated_association_methods
name = reflection.name
 
mixin.class_eval <<-CODE, __FILE__, __LINE__ + 1
def #{name}_gid
public_send(:#{name})&.to_gid
end
 
def #{name}_gid=(global_id)
value = GlobalID::Locator.locate global_id
association(:#{name}).writer(value)
end
CODE
end
end
end

This patches Active Record’s association builder to additionally define the *_gid accessors for polymorphic belongs_to associations. This way, you can always reach for *_gid accessors when working with polymorphic associations.


Update (11-12-2024)

A number of people chimed in to point out the using signed global IDs would prevent the potential for client-side tampering. This is a great point and a worthy tweak. The code doesn’t get notably more complicated, but the functionality does get notably more secure. So, let’s update our setup to use signed global IDs instead of plaintext global IDs.

First, our methods (whether manually added or generated via an initializer) should read and write signed global IDs:

class Post < ApplicationRecord
belongs_to :content, polymorphic: true
 
def content_gid
content&.to_sgid(for: :polymorphic_association, expires_in: nil)
end
 
def content_gid=(gid)
self.content = GlobalID::Locator.locate_signed(gid, for: :polymorphic_association)
end
end

Here we change .to_gid to .to_sgid (an alias for .to_signed_global_id) and pass both an expiration and a purpose. We don’t want this signed global ID to expire, so we pass expires_in: nil (default is that they expire in 1 month). The purpose is a string that helps ensure that the signed global ID is only used for this one intended purpose. This is a security feature that helps prevent signed global IDs from being used in unintended ways. Next, we change GlobalID::Locator.locate to GlobalID::Locator.locate_signed and pass the same purpose.

Our form now needs to use the signed global ID as the value in the <option>s. And this is a great opportunity to clean up our form code a bit. The current version is a bit verbose:

<div>
<%= form.label :content_gid, style: "display: block" %>
<%= form.select(:content_gid,
grouped_options_for_select(
[
[ 'Articles',
Article
.order(:title)
.map { |it| [it.title, it.to_gid.to_s] } ],
[ 'Videos',
Video
.order(:title)
.map { |it| [it.title, it.to_gid.to_s] } ]
]
)) %>
</div>

And, it doesn’t enforce that the different possible content models need to adhere to a shared interface. So, let’s create a model concern that we can include into all “contentable” models to define and implement the needed interface:

# app/models/concerns/contentable.rb
module Contentable
extend ActiveSupport::Concern
 
class_methods do
def to_options
ordered.map { |it| [it.public_name, it.to_sgid(for: :polymorphic_association, expires_in: nil)] }
end
end
 
included do
scope :ordered, -> { order(**public_order) }
end
 
def public_name
raise NotImplementedError
end
 
def public_order
raise NotImplementedError
end
end

Now, we could use this Contentable concern in our Image and Video models. For example, like this:

class Article < ApplicationRecord
include Contentable
 
def public_name = title
def public_order = { title: :asc }
end

By defining and implementing the interface that the models that can be used in the polymorphic content association need to adhere to, we can now simplify our form code:

<div>
<%= form.label :content_gid, style: "display: block" %>
<%= form.select(:content_gid,
grouped_options_for_select(
[
[ 'Articles',
Article.to_options ],
[ 'Videos',
Video.to_options ]
]
)) %>
</div>

All together, these are solid improvements that make our implementation both more secure and more maintainable.

Thanks for the great feedback and for making this code even better!