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
.delete() methods on the polymorphic collection, too—no CRUD involved.
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.
# 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:
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) petfood.reload petfood.dogs.size # => 0 petfood.eaters.size # => 4 # works both ways petfood.eaters.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
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.