Rails 3 + STI: Making Associations Work Properly
I really like the idea of Single Table Inheritance (STI) for all sorts of applications to keep code DRY and make it easier to organize object behavior. The only problem is that Rails 3.0.3 doesn’t fully support STI with association collections.
Let’s say you have a User model that has many badges. The badges will be stored in the badges table but you want to implement each badge in a subclass. All you have to do is make sure there’s a :type field of type string in your badges table and Rails STI support should take care of the rest (well, in theory).
class User < ActiveRecord::Base
has_many :badges
end
class Badge < ActiveRecord::Base
belongs_to :user
def award
raise "Must implement in subclass"
end
end
class Badges::Superhero < Badge
def award
user.status = 'superhero'
end
end
Now you can do cool things like create a new Superhero badge and add it to a user’s badge collection.
user = User.first badge = Badges::Superhero.new user.badges << badge
But for some weird reason, you can’t use the best practice of building a badge directly from the user’s badges collection.
user = User.first badge = user.badges.build(:type => Badges::Superhero) # badge.class == Badge
This is particularly annoying if you’re trying to create new badges from a form where :type is a drop down menu.
The reason the collection build method doesn’t work as expected is because :type is a protected field and ActiveRecord::AssociationReflection doesn’t fully support STI (at least in Rails 3.0.3).
Not to fret, hacks to the rescue!
You have two options to make STI work as expected.
Option 1: Override the Badge.new method to handle :type
class Badge < ActiveRecord::Base
belongs_to :user
self.abstract_class = true
class << self
def new_with_cast(*a, &b)
if (h = a.first).is_a? Hash and (type = h.symbolize_keys[self.class.inheritance_column.to_sym]) and (klass = type.to_s.constantize) != self
raise "Must be a subclass of Badge" unless klass < self # klass should be a descendant of self
return klass.new_without_cast(*a, &b)
end
raise "Badge must be created through a subclass."
new_without_cast(*a, &b)
end
alias_method_chain :new, :cast
end
end
Option 2: Patch AssociationReflection to behave more intelligently
class ActiveRecord::Reflection::AssociationReflection
def build_association(*opts)
col = klass.inheritance_column.to_sym
if (h = opts.first).is_a? Hash and (type = h.symbolize_keys) and type.class == Class
opts.first.to_s.constantize.new(*opts)
elsif klass.abstract_class?
raise "#{klass.to_s} is an abstract class and can not be directly instantiated"
else
klass.new(*opts)
end
end
end
My preference is Option 2 even though it might break in future releases of Rails. I’d rather have Rails behaving as expected than pepper my models code with repetitive hacks.
The above solutions were inspired from a couple of different posts and sources.
I submitted Option 2 as a patch for Rails.
Mar 01, 2011 @ 22:56:32
Your technique is nice for parent.collection.build(:type => Blah) but it breaks parent.collection.create(:type => Blah) and also, i figure you should probably replace the value for ‘type’ with a string representation of that in the options hash so that after the association is built, the type column is a string as we would expect.
I’m not sure how to get around the create issue..
I’ve put similar comments on your lighthouse patch… while STI has been in rails for a while it obviously needs more work..
Apr 09, 2011 @ 03:28:40
Unfortunately none of your options work for me. It simply does not create a class of the desired type. Is there some peice of code you might have missed to add?
Apr 12, 2011 @ 09:55:34
Could option 1 be put into a module and included for every STI superclass? Might be a *little* less hacky.
May 03, 2011 @ 16:58:31
I bow down humbly in the prsneece of such greatness.
Dec 04, 2011 @ 06:40:14
Doesn’t setting self.abstract_class = true disable persistence?
Jan 19, 2012 @ 00:43:18
Joe, do you know if this is still an open issue for Rails?
Thanks so much for documenting this … hard to believe this is not more of an issue for people.
Mar 16, 2012 @ 00:56:31
Hi Kevin. Sorry for the late reply. I’m not sure if it’s stil an issue with Rails 3.2. Let me know if you find out one way or the other. I’ll test it out myself when we’re done launching the public beta of Connect.Me.