JRL About

Polymorphic Associations in Rails 5

July 13, 2017
Updated: November 30, 2021

Twitter Hacker News Reddit LinkedIn
Article Header Image
P

olymorphic Associations help when one table must belongs_to multiple other tables. The Rails docs demonstrate this relationship with an Imageable relationship:

Polymorphic Tables
Polymorphic Imageable tables

Another common example is an Address, without polymorphic behavior it would need to belong to every possible table, and would lead to a lot of wasted DB space and bad code.

1
2
3
4
5
6
class Address < ApplicationRecord
  belongs_to :person
  belongs_to :company
  belongs_to :event
  #MORE and MORE belongs_to
end

Polymorphic associations allow us to have much cleaner code and databases.

1
2
3
class Address < ApplicationRecord
  belongs_to :addressable
end

In this article we'll show you how to set up migrations, models, and forms for a simple polymorphic association - a has_one address.

People have a lot of issues with Polymorphic associations - 1, 2, 3, 4, etc. Many of those questions do not have an accepted answer, despite the seeming abundance of guides on the matter. There are a few gotchas in Rails 5, we will cover them in depth.

So without further ado, let's begin:

1
2
rails g scaffold Events name:string
rails g model Address address:string addressable:references{polymorphic}

Migrations

Open the new db/migrate/***_create_addresses.rb migration and edit the 'references' line to add in an index.

1
t.references :addressable, polymorphic: true, index: true

References is an alias to creating both a foreign key and a type column, made specifically for polymorphic behavior. The foreign key links to the 'parent' table that will create an Adress. It is equivalent to:

1
2
t.integer :addressable_id   #foreign key
t.string  :addressable_type #type

addressable_id references the "parent" association that has_one, has_many, etc of Addresses. For us, this will be an Event id. addressable_type saves the name of the parent, for us it'll be "event".

With that our migration is done! However, before we move on, check out this picture again.

Polymorphic Tables
Polymorphic Imageable tables

A subtle yet extremely important takeaway is that imageable_id, product's id, and employee's id are all integers. There are lots of good reasons to use string ids, and you might want to use one too. If one parent table has a string ID, the polymorphic foreign key (imageable_id) and all other parent tables in that association must also have string ids.

For example, if we changed Employee's ID to a string, we would also need to change Product's ID and Picture's imageable_id. Furthermore, this would prevent us from being able to use references in the Imageable migration (since it aliases an integer). You would need to manually define the two columns:

1
2
t.string :imageable_id, index: true
t.string :imageable_type, index: true

Now our migrations are looking good. Time to migrate!

1
rake db:migrate

Models

The Address model is already OK, and will contain a nice belongs_to :addressable line. The Event model must be edited to create the address association, and to accept nested attributes for the address in its form:

1
2
3
4
class Event < ApplicationRecord
    has_one :address, as: :addressable
    accepts_nested_attributes_for :address
end

Views

I really like simple-forms so let's go ahead and install that before mucking around in Rails forms. Add gem 'simple_form' to the Gemfile and bundle install. Open app/views/events/_form.html.erb and replace the file with a simple_form.

1
2
3
4
5
6
7
8
9
<%= simple_form_for @event do |f| %>
  <%= f.input :name %>

  <%= f.simple_fields_for :address do |address| %>
    <%= render partial: 'shared/address/form', locals: {f: address} %>
  <% end %>

  <%= f.button :submit %>
<% end %>

This file contains a nested form (via simple_fields_for) which is rendered from a partial. Partials allow us to maintain a single point of control over address forms even though they might be used in multiple parent forms.

Let's create the partial. Make a new file and name it app/views/shared/address/_form.html.erb. This file is rather simple:

1
<%= f.input :address %>

The forms are all done!

Let's ensure the address will be displayed when we are inspecting a given Event. Open app/views/events/show.html.erb and add the following display code somewhere in the file.

1
2
3
4
5
6
<!-- other stuff... -->
<p>
  <strong>Address:</strong>
  <%= @event.address.address %>
</p>
<!-- other stuff... -->

Controllers

Finally, let's string everything together in the event controller - app/controllers/events_controller.rb. First we must modify the strong params to accept the nested address fields, so the event_params function must be edited.

1
2
3
4
# other stuff...
def event_params
  params.require(:event).permit(:name, { address_attributes: [:address] } )
end

Finally, an address must be built when the new action is called. This ensures the address's fields will be displayed in our event form.

1
2
3
4
5
# GET /events/new
  def new
    @event = Event.new
    @address = @event.build_address
  end

Seems like we're done right? Not quite.

Troubleshooting

If you run your project (rails s), navigate to /events/new, and attempt to create an event it won't work. On the the terminal running rails, you'll see something like this

1
2
3
4
5
Started POST "/events" for 127.0.0.1 at 2017-07-13 14:52:27 -0400
Processing by EventsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"zo5fvibHtpivjqseJ+AL4ToTTg8fUzSuXxuyvYzdfj6fUZfq7Y0x584Y/fXRSnWffvCLdaJV2J0vkp273GbB6Q==", "event"=>{"name"=>"some name", "address_attributes"=>{"address"=>"some address"}}, "commit"=>"Create Event"}
   (0.1ms)  begin transaction
   (0.0ms)  rollback transaction

Why can't our transaction complete? To find out, let's go back to the event controller and insert a byebug statement after an event save fails

1
2
3
4
5
6
7
8
9
10
11
12
13
def create
  @event = Event.new(event_params)
  respond_to do |format|
    if @event.save
      format.html { redirect_to @event, notice: 'Event was successfully created.' }
      format.json { render :show, status: :created, location: @event }
    else
      byebug #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<< ADD THIS
      format.html { render :new }
      format.json { render json: @event.errors, status: :unprocessable_entity }
    end
  end
end

If an error occurred when saving an event (ie our transaction was rolled back), byebug will halt server execution and open the terminal to that line. Try to save an event again, and notice a prompt appears in the terminal:

1
2
3
4
5
6
7
8
9
10
11
12
[32, 41] in /~/polymorphic-rails/app/controllers/events_controller.rb
   32:       if @event.save
   33:         format.html { redirect_to @event, notice: 'Event was successfully created.' }
   34:         format.json { render :show, status: :created, location: @event }
   35:       else
   36:         byebug
=> 37:         format.html { render :new }
   38:         format.json { render json: @event.errors, status: :unprocessable_entity }
   39:       end
   40:     end
   41:   end
(byebug)

We can use this prompt to find the issue. Typing in @event.errors yields

1
#<ActiveModel::Errors:0x007f54fa167258 @base=#<Event id: nil, name: "some name", created_at: nil, updated_at: nil>, @messages={:"address.addressable"=>["must exist"]}, @details={:"address.addressable"=>[{:error=>:blank}]}>

There's the issue - address.addressable"=>["must exist"]. Address.addressable is the reference to the parent table, an Event. Rails 5 recently made this association required by default, but that doesn't explain why the association is missing.

This seems to be a bug on Rails part. It turns out that if you disable the new validation address.addressable will be filled in, so there's some sort of issue with the ordering of events.

Regardless, let's get it working. You can turn off the Address model's (app/models/address.rb) validation check:

1
2
3
class Address < ApplicationRecord
  belongs_to :addressable, polymorphic: true, optional:true
end

OR you can leave the validation check on and manually assign addressable in event#create (app/controllers/events_controller.rb). I originally went with this second option, but found it caused issues when seeding my database. Event cannot exist without an address, but address could not exist without an event (Addressable). Thus I ended up switching to the first option.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
def create
  @event = Event.new(event_params)
  @event.address.addressable = @event #<<<<<<<<<<< ADD THIS
  respond_to do |format|
  if @event.save #Saves the event. Addressable has something to reference now, but validation does not know this in time, thus it has to be done manually above
      format.html { redirect_to @event, notice: 'Event was successfully created.' }
      format.json { render :show, status: :created, location: @event }
    else
      #dont forget to remove the 'byebug' that was here
      format.html { render :new }
      format.json { render json: @event.errors, status: :unprocessable_entity }
    end
  end
end

Let's check that it works. Insert a byebug into the event controller's show action. Now navigate to /events/new and submit a completed form. Byebug will halt execution as the form redirects to a 'show' page. Ensure that everything is playing together nicely:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Started GET "/events/12" for 127.0.0.1 at 2017-07-13 15:04:40 -0400
Processing by EventsController#show as HTML
  Parameters: {"id"=>"12"}
  Event Load (0.2ms)  SELECT  "events".* FROM "events" WHERE "events"."id" = ? LIMIT ?  [["id", 12], ["LIMIT", 1]]
Return value is: nil

[9, 18] in /~/polymorphic-rails/app/controllers/events_controller.rb
    9:
   10:   # GET /events/1
   11:   # GET /events/1.json
   12:   def show
   13:     byebug
=> 14:   end
   15:
   16:   # GET /events/new
   17:   def new
   18:     @event = Event.new
(byebug) @event
#<Event id: 12, name: "asd", created_at: "2017-07-13 19:02:40", updated_at: "2017-07-13 19:02:40">
(byebug) @event.address
  Address Load (0.3ms)  SELECT  "addresses".* FROM "addresses" WHERE "addresses"."addressable_id" = ? AND "addresses"."addressable_type" = ? LIMIT ?  [["addressable_id", 12], ["addressable_type", "Event"], ["LIMIT", 1]]
#<Address id: 8, address: "asd", addressable_type: "Event", addressable_id: 12, created_at: "2017-07-13 19:02:40", updated_at: "2017-07-13 19:02:40">
(byebug) @event.address.addressable
  Event Load (0.1ms)  SELECT  "events".* FROM "events" WHERE "events"."id" = ? LIMIT ?  [["id", 12], ["LIMIT", 1]]
#<Event id: 12, name: "asd", created_at: "2017-07-13 19:02:40", updated_at: "2017-07-13 19:02:40">
(byebug)

Looks good!

So there you have it. You can check out the code for this project here. It's always frustrating when butting heads with issues in underlying tech, so hopefully this post helps make things easier. Thanks for checking out the guide and good luck!

PS:

If you want your address to geocode and do other fancy stuff check out geocoder.