has_many_polymorphs for dummies
Published over 7 years ago

Has_many_polymorphs is a rockin’ Rails plugin. But sometimes it’s like:

You hear about the plugin, and instead of “this is fuckin’ sweet!”, you might be like “pfff whatever”. But that kind of thinking is just abetting the enemy. Be prepared. The need will arise.

So… I’m going to prepare you to use “has_many_polymorphs”. I’ll take a top-down approach for this tutorial (my first tutorial…bitches!):

Use case

Consider the following example.

We have a Person. A Person can own several types of items: Dvds, Books, Cars, Ferraris in all colors. Ferraris are not Cars; they clearly deserve their own model.

Maybe:

class Person  < ActiveRecord::Base
  has_many :items
end

class Person  < ActiveRecord::Base
has_many :items
end

class Book < ActiveRecord::Base # Dvd, Car, etc.
belongs_to :person
end

Hey great! If only it would work. What class is :items supposed to use? No one knows.

The table jungle

Our next instinct might be to create a join model and use a has_many :through association.

Our models would look like:

class Person < ActiveRecord::Base
        has_many :dvd_ownerships  
        has_many :car_ownerships
        has_many :dvds, :through => :dvd_ownerships
        has_many :cars, :through => :car_ownerships
end

class Person < ActiveRecord::Base
has_many :dvd_ownerships
has_many :car_ownerships
has_many :dvds, :through => :dvd_ownerships
has_many :cars, :through => :car_ownerships
end

class DvdOwnership < ActiveRecord::Base
belongs_to :person
belongs_to :dvd
end

class CarOwnership < ActiveRecord::Base
belongs_to :person
belongs_to :car
end

class Dvd < ActiveRecord::Base
has_many :dvd_ownerships
has_many :people, :through => :dvd_ownerships
end

class Car < ActiveRecord::Base
has_many :car_ownerships
has_many :people, :through => :car_ownerships
end

Well, this is weak. We need a separate, yet identical join table for every item type. This would make our database a table jungle. Let’s be a bit smarter and use just one join table.

Rails way; broken way

Rails has a sneaky feature called Polymorphic Associations which could be very
useful in situations like this. In a few words, polymorphic associations are unclassed and can be connected to any model.

In order to use polymorphic associations, our models should apparently look like below. Please note this code will not work. Dammit.

class Person < ActiveRecord::Base
  has_many :ownerships, :as => :ownable
        has_many :dvds, :through => :ownerships
        has_many :cars, :through => :ownerships
end

class Person < ActiveRecord::Base
has_many :ownerships, :as => :ownable
has_many :dvds, :through => :ownerships
has_many :cars, :through => :ownerships
end

class Ownership < ActiveRecord::Base
belongs_to :person
belongs_to :ownable, :polymorphic => true
end

class Dvd < ActiveRecord::Base
has_many :ownerships, :as => :ownable
has_many :people, :through => :ownerships
end

class Car < ActiveRecord::Base
has_many :ownerships, :as => :ownable
has_many :people, :through => :ownerships
end

What’s wrong? In the Person model, we have has_many :dvds, :through => :ownerships association defined.
ActiveRecord will then try to find the :dvds association in the :source model (Ownership). But ActiveRecord provides no way to specify that an association has a several different sources when viewed through a has_many :through.

Well maybe you could do some ActiveRecord internals hacking, or use a bunch of SQL conditions, and somehow make it work. Maybe. Definitely no fun either way.

has_many_polymorphs to the rescue

So let’s call has_many_polymorphs ! It’s an emergency!

script/plugin install svn://rubyforge.org/var/svn/fauna/has_many_polymorphs/trunk

It’s arrived… but can it solve our problem?

class Person < ActiveRecord::Base
        has_many_polymorphs :ownables, :from => [:dvds, :cars, :books], :through => :ownerships
end

class Person < ActiveRecord::Base
has_many_polymorphs :ownables, :from => [:dvds, :cars, :books], :through => :ownerships
end

class Ownership < ActiveRecord::Base
belongs_to :person
belongs_to :ownable, :polymorphic => true
end

class Dvd < ActiveRecord::Base
end

class Car < ActiveRecord::Base
end

class Book < ActiveRecord::Base
end

“Excuse me! WTF just happened?!?!”

3 lines of model code, instead of a shrubbery of SQL. Sweet.

what just happened

has_many_polymorphs is has_many :through for polymorphic associations.

There’s a lot of magic here. For explanation, we’ll use following terminology mapping :

  • Parent model → Person
  • Join model → Ownership
  • Child models → Dvd, Car (These are the models you specify in the :from key of has_many_polymorphs )

has_many_polymorphs sets up a shitload of associations for you just from that one method call:

  • a magical polymorphic has_many :through association in the parent model that includes all the children. E.g. Person#ownables. (This is actually its own association type, but it’s just like a has_many :through.)
  • a has_many association for the join model in the parent model. E.g has_many :ownerships in the Person model. This is a normal has_many association using the parent_id as a foreign key in the join. (Remember how we said belongs_to :person in the Ownership model.)
  • a polymorphic has_many association for the join model in all child models. E.g has_many :ownerships, :as => :ownable in Dvd, Car models.
  • a bunch of has_many :through associations for all children supplied in :from in parent. E.g has_many :dvds and has_many :cars in Person model
  • a bunch of has_many :through associations in all children supplied in :from for parent. E.g. has_many :people in Dvd and Car models.

The last bits are tricky. Even though you have defined a has_many_polymorphs associations in parent model ( Person ), it dynamically injects associations into the child models ( Dvd, Car ) as well.

If you turn on a has_many_polymorphs debugging option ( ENV[‘HMP_DEBUG’] to true), it’ll show you the generated associations:

class Person< ActiveRecord::Base
  has_many :ownerships, :dependent => :destroy, :foreign_key => "person_id", :class_name => "Ownership"
  has_many :dvds, :source => :ownable, :through => :ownerships, :source_type => "Dvd", :class_name => "Dvd"
  has_many :cars, :source => :ownable, :through => :ownerships, :source_type => "Car", :class_name => "Car"
end

class Dvd < ActiveRecord::Base
  has_many :ownerships, :dependent => :destroy, :as => :ownable
  has_many :people, :source => :person, :foreign_key => "person_id", :through => :ownerships, :class_name => "Person"
end 

class Car < ActiveRecord::Base
  has_many :ownerships, :dependent => :destroy, :as => :ownable
  has_many :people, :source => :person, :foreign_key => "person_id", :through => :ownerships, :class_name => "Person"
end

However, this is just to give you a rough idea. has_many_polymorphs extends some of the associations to add more functionality and make them work even harder for you.

Hey let’s use it already

Now you can do things with the parent object like:

# Buy a new car!
>> p = Person.find(:first)
>> p.cars << Car.create(:name => 'Ferrari')  
>> p.cars.count
=> 1
>> p.dvds << Dvd.create(:name => "Hello world")
>> p.dvds.count
=> 1
>> p.ownables.count
=> 2

# Buy a new car!
>> p = Person.find(:first)
>> p.cars << Car.create(:name => Ferrari)
>> p.cars.count
=> 1
>> p.dvds << Dvd.create(:name => "Hello world")
>> p.dvds.count
=> 1
>> p.ownables.count
=> 2

And the same for the child object:

>> d = Dvd.find(:first)
>> d.people.count  
=> 1
>> d.people << Person.create(:name => "Neo")
>> d.people.count
=> 2

Further reading