David Gay

Quick and Clear Polymorphic Has Many Through Associations in Rails

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.

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:

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:

  1. The 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.
  2. A reference to the Items table.
  3. A reference for the polymorphic relationship. This is the part which, for me, links to either the 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

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.


Go back home