polymorphic association helpers

notice

introduction

Josh Susser writes about how has_many :through does not support polymorphic associations even in edge Rails. He offers a number of workarounds.

Needing a lot of associations like this in a project, I wrote a module that wraps his second solution inside a helper function, just like the Rails built-in association helpers. It makes keeping track of which field acts as which name in which table a hundred times easier.

You also get snazzy .push() and .delete() methods on the polymorphic collection, too—no CRUD involved.

update

Chris Wanstrath turned my module into a plugin and also made the code a little more maintainable. Now I just need somebody to write in with comprehensive unit tests…

Download from here.

Because of the plugin, I removed the cut-and-paste module code from below. But I still recommend you look at the plugin source in order to get a better grasp on what exactly is getting defined behind the scenes.

usage

# this is the (typed) class that accepts the (untyped) polymorphic objects as children
class Petfood < ActiveRecord::Base
  has_many_polymorphs :eaters, :from => [:dogs, :cats, :birds]
end

# this is the join table
class EatersPetfood < ActiveRecord::Base
  belongs_to :petfood
  belongs_to :eater, :polymorphic => true
end

# this is an example of one of the child classes
class Dog < ActiveRecord::Base
  # nothing
end

You can use the :through key if your join table is not named in the standard (eaters_petfoods) way. Other ActiveRecord options should work, but may require minor modifications.

I eliminated the explicit association in the child classes entirely. It’s cooler and drier, like Antarctica. Also, it should warn you now if you try to create loops in your graph of polymorphic association relationships. Loops will not work without explicitly renaming the parent class in the association. These associations are all single-directional.

But now you can do:

example
petfood = Petfood.find(1)
petfood.eaters.map(&:class) # => [Dog, Cat, Cat, Bird]

petfood.eaters.push(Cat.create)
petfood.reload
petfood.cats.size # => 3
petfood.eaters.size # => 5

petfood.eaters.delete(petfood.dogs[0])
petfood.reload
petfood.dogs.size # => 0
petfood.eaters.size # => 4

# works both ways
petfood.eaters[0].petfoods.include?(petfood) # => true

If you want to avoid name conflicts on the secondary collection methods, you can pass in :rename_individual_collections => true and the polymorphic name will be prepended to the methods (e.g., petfood.eaters_cats instead of just petfood.cats).

Extra bonus: self-referential associations work. If you need to access the join table association for some reason from a class as a child of itself, though, you need to use join_table_name_as_child instead of just join_table_name; otherwise you get them going the opposite way.

I am proud. Let me know if you find bugs. It will print out a lot of debugging information on initialization if in development mode.

3 responses

  1. This is lovely, thanks! I was dreading writing all these relationships out, this makes it much cleaner.

  2. I’m Lovin’ It!

    What about STI classes?

    I have DepartmentGoal and CompanyGoal inherited from Goal

    Tried:

    has_many_polymorphs :advancings,
      :through => :advancings,
      :from => [:value_drivers, :goals,
                :strategic_priorities]
    

    Which works for value drivers and strategic priorities, but not goals. .goals doesn’t work because everything is either a DepartmentGoal or a CompanyGoal and department_goals or company_goals doesn’t work because there is no association by that name.

  3. I’ve never, ever used STI. It shouldn’t be hard to support, but will probably require some additions to the API. If you can’t figure out the plugin source and add it yourself (and I would be happy to add your changes to the plugin distribution from then onward), I can look at it later this week.

Follow

Get every new post delivered to your Inbox.

Join 469 other followers