olymorphic Associations help when one table must belongs_to
multiple other tables.
The Rails docs demonstrate this relationship with an Imageable relationship:
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.
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.