2020-05-31 • Updated 2020-07-19
This weekend I spent too long trying to get the Internet to tell me how to
set up a polymorphic has_many :through
relationship in Rails. The official
docs don't go into this topic. It took me a bit to put the pieces together, so
I'm sharing here for posterity.
This post uses Ruby 2.7.1, Rails 6.0.3.1, and PostgreSQL 12.2, but it should work fine with other databases, and I have no reason to expect that the solution described herein will become invalid any time soon.
I'll briefly describe my situation, so you can adapt it to your own. In my scenario, I began with three models that I had to associate:
Item
can be located at a Place
or a Person
.Place
or Person
can have a certain number of the same Item
, which
I decided to track through a quantity
integer. This is what told me I
needed a has_many :through
– I needed a join table to track the quantity
.You can easily substitute your own tables. Instead of Item
, Place
, and
Person
, you could use Bill
, Customer
, and Supplier
(if a Bill
could
be owned by a Customer
or Supplier
).
You don't need to perform any migrations on your starting three tables. In my
case, I had created Item
, Place
, and Person
before realizing I needed
a polymorphic has_many :through
, and I could leave them as they were.
I needed to create the join table. I decided to call the model ItemStack
,
which I generated like so:
bin/rails g model ItemStack quantity:integer item:references itemable:references
I opened the generated migration and changed it to look like this:
class CreateItemStacks < ActiveRecord::Migration[6.0]
def change
create_table :item_stacks do |t|
t.integer :quantity
t.references :item, null: false, foreign_key: true
t.references :itemable, null: false, polymorphic: true
t.timestamps
end
end
end
So there's three things in there. In order:
quantity
integer (which made the has_many :through
necessary). You
might have a different attribute (or attributes). It's whatever you want
on your join table.Items
table.Places
table or the Persons
table.
I called my key :itemable
, because a Place
and a Person
are both
"itemable", in that they both can have an Item
. Not a real word, I know.
I could have used :holdable
or :graspable
or :haveable
or something,
but I felt that :itemable
was functionally clear. Again, you can call
this whatever you want. If you had the models Bill
, Supplier
, and
Customer
, and both Supplier
and Customer
could have a Bill
, you
would probably use :billable
.Run the migration and Rails will take care of setting up the rows you need for your polymorphic association:
bin/rails db:migrate
Along with the migration, you have to update your models. Here's what mine needed:
class Item < ApplicationRecord
has_many :item_stacks
has_many :persons, through: :item_stacks, source: :itemable, source_type: "Person"
has_many :places, through: :item_stacks, source: :itemable, source_type: "Place"
end
class Place < ApplicationRecord
has_many :item_stacks, as: :itemable
has_many :items, through: :item_stacks
end
class Person < ApplicationRecord
has_many :item_stacks, as: :itemable
has_many :items, through: :item_stacks
end
class ItemStack < ApplicationRecord
belongs_to :item
belongs_to :itemable, polymorphic: true
end
Pretty straightforward, except for the source
and source_type
stuff.
You absolutely need to specify those things, even though one might think
Active Record would have enough information to get along without them. The
source_type
is the name of the related model class. That string is actually
used in the database to identify which table the polymorphic foreign key is
for.
To better understand what I mean, check out an example record for an
ItemStack
:
#<ItemStack id: 10, quantity: 11, item_id: 2, itemable_type: "Place", itemable_id: 2, created_at: "2020-05-31 18:18:53", updated_at: "2020-05-31 18:18:53">
... and the schema that was generated by my migration (comments added by me):
create_table "item_stacks", force: :cascade do |t|
t.integer "quantity"
t.bigint "item_id", null: false
t.string "itemable_type", null: false # <- Needed for polymorphic
t.bigint "itemable_id", null: false # <- Needed for polymorphic
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["item_id"], name: "index_item_stacks_on_item_id"
t.index ["itemable_type", "itemable_id"], name: "index_item_stacks_on_itemable_type_and_itemable_id"
end
That's all there is to it. Now I can do stuff like:
# Find ItemStack for Item ID 2 at Place ID 2
ItemStack.find_by(item_id: 2, itemable_id: 2, itemable_type: "Place")
# Find `some_item` at `some_place`
ItemStack.find_by(item: some_item, itemable: some_place)
And maybe I want to add some validation to my ItemStack
:
# In app/models/item_stack.rb
validates :quantity, numericality: { greater_than_or_equal_to: 0, only_integer: true }
validates :itemable_id, presence: true
validates :itemable_type, presence: true
And perhaps I'd like any ItemStack
on a Person
or Place
to be
destroyed if the Person
or Place
is destroyed, so I do this:
# In app/models/person.rb or app/models/place.rb
# I add `dependent: :destroy` to this `has_many` line I wrote earlier:
has_many :item_stacks, as: :itemable, dependent: :destroy
Once I got the hang of this stuff, it was pretty clear how things worked.
Of course, this is still my first go at polymorphic has_many :through
, and
I'll update this post if I discover I've made any mistakes. But this setup
is working pretty well for me so far.
Questions, comments, or tips for me? See a mistake in this post? Send me an email.