Quick and Clear Polymorphic Has Many Through Associations in Rails
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.
Requirements
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:
- An
Item
can be located at aPlace
or aPerson
. - A
Place
orPerson
can have a certain number of the sameItem
, which I decided to track through aquantity
integer. This is what told me I needed ahas_many :through
– I needed a join table to track thequantity
.
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
).
Process
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:
- The
quantity
integer (which made thehas_many :through
necessary). You might have a different attribute (or attributes). It’s whatever you want on your join table. - A reference to the
Items
table. - A reference for the polymorphic relationship. This is the part which,
for me, links to either the
Places
table or thePersons
table. I called my key:itemable
, because aPlace
and aPerson
are both “itemable”, in that they both can have anItem
. 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 modelsBill
,Supplier
, andCustomer
, and bothSupplier
andCustomer
could have aBill
, 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
Conclusion and Bonus Content
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.
Feedback
Questions, comments, or tips for me? See a mistake in this post? Send me an email.