Polymorphic associations are common in Rails apps. Often, when building a user interface to attach a generic record like a comment or picture to its polymorphic parent, we would use Rails’ nested attributes feature and a nested form. However, in some cases the generic record is, conceptually speaking, the primary record on the page, and so it is the outermost form and can’t be nested.
Imagine the following situation. We’re building an app where users can review books, movies, and plays. On the page where users type their review, we need an input to select the subject of the review–the book, movie, or play that the review is about. This is obviously a contrived example, so try to suspend your judgment about the modeling of the concepts on the back-end and the user flow on the front-end.
/app/models/review.rb
class Review < ApplicationRecord
belongs_to :subject, polymorphic: true
end
/app/models/book.rb
class Book < ApplicationRecord
has_many :reviews, as: :subject
end
/app/models/movie.rb
class Movie < ApplicationRecord
has_many :reviews, as: :subject
end
/app/models/play.rb
class Play < ApplicationRecord
has_many :reviews, as: :subject
end
/app/views/reviews/_form.html.erb
<%= form_with(model: review, local: true) do |f| %>
<%= f.label :title %>
<%= f.input :title %>
<%= f.label :body %>
<%= f.textarea :body %>
<%= f.label :subject %>
<%= # TODO implement subject selector %>
<% end %>
Although Rails provides a lot of useful helper methods to create form inputs, it doesn’t seem to have a solution that is readily usable for our TODO. We need a form input (or set of inputs) that allows the user to enter two pieces of information–the parent type and the parent ID. Because the parent can be one of multiple models, there are multiple tables in play, and we cannot assume the parent type from the ID. Both pieces of information are necessary.
Let’s look at our core technologies – HTML, CSS, JavaScript, and Ruby – for potential solutions.
HTML is the absolute core of our front-end, and it would be awesome if there was some native HTML solution to our problem. While HTML does allow for interaction between elements – think <select>
with nested <option>
s, <label>
s and their associated inputs, and <datalist>
– none of them amount to a complete solution for us. The <optgroup>
is almost what we need. It provides a grouping mechanism for <select>
that would allow us to group records by their type. Unfortunately, regardless of the type of record selected, as a single input it could only send a single value, e.g. the ID. But let’s keep it in mind.
CSS provides some features that might be useful, in particular its ability to hide elements based on the state of an adjacent or parent element. While this could help in the UI, it can only be part of a greater solution. Generally speaking, CSS rules cannot be written against user input. One counterexample is the :checked
pseudo-class selector, which allows us to show or hide lists of record IDs depending on a selected type. However, when the form is submitted, all record ID inputs will be sent to the server, regardless of whether or not they are visible to the user.
As often happens in our work, when we fail to think of other solutions, we grab JavaScript from our toolbox and get to work. Maybe we can leverage a customizable open-source select box? Actually, this feature is so simple that we can probably write a safe solution using browser APIs directly without pulling in any dependencies. But are we confident that we can do it in a way that won’t disrupt accessibility? For now, let’s hold off with using JavaScript, and we can return to it if needed.
We have established that we cannot quickly and easily make the front-end send the two parameters we need. This implies that the front-end needs to send both the ID and the type in a single parameter. We can accomplish this with some custom methods that serialize/deserialize a record to/from a String.
/app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
def to_s
“#{class}:#{id}”
end
end
/app/models/review.rb
class Review < ApplicationRecord
belongs_to :subject, polymorphic: true
def subject_string
subject&.to_s
end
def subject_string=(string)
type, id = string.split(‘:’)
self.subject = type.constantize.find(id)
end
end
By using an assignment method (subject_string=
), we can make the decoding happen automatically during “mass assignment” in the controller, provided that subject_string
is permitted for mass assignment in the controller. In the view layer, we need to update the form to send this parameter.
/app/views/reviews/_form.html.erb
<%= form_with(model: review, local: true) do |f| %>
<%= f.label :title %>
<%= f.input :title %>
<%= f.label :body %>
<%= f.textarea :body %>
<%= f.label :subject %>
<%= f.grouped_collection_select
:subject_string,
[Book, Movie, Play],
:all,
:model_name,
:to_s,
:title
%>
<% end %>
Our added line with grouped_collection_select
will render an HTML <select>
containing each model’s records inside an <optgroup>
.
This seems good enough! Here we could write some tests and call this feature done, but doing so would fail to leverage a handy feature that is built right into Rails – GlobalID. GlobalID was introduced in Rails 4.2 alongside the unveiling of Active Job, seemingly to solve a problem quite similar to the hypothetical one before us. Prior to 4.2, when passing records to background jobs, one had to specify the type and ID separately and then perform a lookup inside the job itself. Beginning with 4.2, Active Job does this work itself by serializing and deserializing the record using GlobalID.
By using GlobalID, we can reduce the lines of code in our app and thus the number of tests we need to write. First, we can remove our redefinition of ApplicationRecord#to_s
, because the GlobalID methods we need are already mixed into Active Record. Second, we can rewrite our custom serialization/deserialization methods.[1]
/app/models/review.rb
class Review < ApplicationRecord
belongs_to :subject, polymorphic: true
def subject_sgid
self.subject&.to_signed_global_id
end
def subject_sgid=(sgid)
self.subject = GlobalID::Locator.locate_signed(sgid)
end
end
Lastly, we should update our review form and controller to use the new subject_sgid
methods instead of subject_string
.
That’s it! We’ve solved our problem using Rails’ own form helpers and Active Record methods – no HTML, CSS, or JavaScript hacks or dependencies required. What’s more, we have done it in a way that is nearly guaranteed to be accessible and that requires introducing the minimum number of additional tests to the codebase.
- Note that we use GlobalID’s methods that create signed identifiers, rather than its plain methods (
to_global_id
andGlobalID::Locator.locate
). A regular global ID looks like this and is quite readable:gid://app/Movie/1
. A signed global ID is an encoded version that GlobalID can verify:BAh7CEkiCGdpZAY6BkVUSSIsZ2lkOi8...
. Because we expect a signed value inReview#subject_sgid=
, the user cannot send a modified value that GlobalID will accept, as it would in the case of changinggid://app/Movie/1
togid://app/User/1
. Therefore the only records the user can set as a review’s subject are ones that we allow when we include them in the review form. Without this security precaution, a malicious user could attach unexpected records (e.g. a user) that could later be modified or destroyed as part of some other action in the system. As an added benefit, the subject’s underlying class and ID (likely the database table’s primary key) are not revealed in the HTML.
Loved the article? Hated it? Didn’t even read it?
We’d love to hear from you.
Nice post! One update is the `to_s` method needs to use `self.class` (vs just `class`) like so:
“`
def to_s
“#{self.class}:#{id}”
end
“`
Second update is the ‘to_s’ parameter in the grouped_collection_select needs to be replaced by ‘to_signed_global_id’. Works with Rails 7.
This is great, but what if instead of show everything you want to filter by an id of some kind.
e.g.
Is there any way to get something like this to work? Or with polymorphic relationships is this not possible?
*
f.grouped_collection_select
:subject_string,
[Book, Movie, Play],
:written_by_author(author_id),
:model_name,
:to_s,
:title
Nice post!
I also want to ask: Is there a way to set default value rather than using select?