RoR: Additional Attributes with STI
If you've decided to use ActiveRecord's Single-Table Inheritance (STI) and you want it to be easy for subclasses to have arbitrary additional attributes, you might be tempted to create an Attribute model and do something like this (there are optimizations to be made, but I'm not bothering):
class StiSuperclass < ActiveRecord::Base has_many :attributes def method_missing(name, *args) name = name.to_s setter = /=$/ === name expected_args = 0 if setter name = name[0...-1] expected_args = 1 end unless args.size == expected_args err = "wrong number of arguments (#{args.size} for #{expected_args})" raise ArgumentError.new(err) end attribute = attributes.find { |a| a.name == name } if setter if attribute attribute.value = args[0] else attribute = Attribute.new(:name => name, :value => args[0]) attributes << attribute args[0] else attribute ? attribute.value : nil end end end class Attribute < ActiveRecord::Base belongs_to :sti_superclass serialize :value end
While that can work, it's inefficient both in terms of database usage and execution speed. Since these attributes don't have any real meaning in the database to begin with, it's much simpler and easier to put them in a column of the base table. Consider this alternate design:
class StiSuperclass < ActiveRecord::Base serialize :attributes def method_missing(name, *args) setter = /=$/ === name.to_s expected_args = 0 if setter name = name.to_s[0...-1].to_sym expected_args = 1 else name = name.to_sym end unless args.size == expected_args err = "wrong number of arguments (#{args.size} for #{expected_args})" raise ArgumentError.new(err) end if setter attributes[name] = args[0] else attributes[name] end end private def attributes self[:attributes] ||= {} end def attributes=(hash) attributes.replace(hash) end end
We now have no need for an additional table and lots of joins. Better yet, we can make our additional attributes explicit in the subclasses and do away with method_missing with the following:
class StiSuperclass < ActiveRecord::Base serialize :attributes def self.additional_attribute(*names) names.each { |name| name = name.to_sym define_method(name) { attributes[name] } define_method("#{name}=") { |value| attributes[name] = value } } end private def attributes self[:attributes] ||= {} end def attributes=(hash) attributes.replace(hash) end end class StiSubclass < StiSuperclass additional_attribute :foo, :bar end
For those of you who are skeptical about the usefulness of arbitrary additional fields, consider it in the context of my current project. I am developing a framework for writing multiplayer card game backends. The Game model cannot (and should not attempt to) take into account the attributes any possible card game will need. Instead it provides a convenient hook for explicitly defining the additional attributes a particular backend will need.
Enjoy!
Labels: Metaprogramming, Rails, Ruby
3 Comments:
At 7/02/2006 03:31:00 PM, Anonymous said…
If you are absolutely sure that the properties are not needed for any
(Db-)lookup I agree that the serialze-approach is way better(in performance
and simplicity). But if you need to lookup any of the properties(through a sql query to get the parent objects) then serialize(yaml) is not ideal.
Also, the "additional_attribute" meta programming solution is applicable for the "has_many :attributes" approach too, no?
At 7/02/2006 06:16:00 PM, Anonymous said…
If you use ferret for search the you can define a custom .to_doc method on your model. This gives hints to ferret as to how it treats searches on serialized attr's. So you can have your serial(ization) and search it too ;)
At 7/03/2006 08:20:00 AM, Gregory said…
First off, remember that I am talking about arbitrary attributes. If you have attributes that you actually know about ahead of time, your best bet is probably to have a side table for each concrete subclass and a has_one relationship. You can then perform trickery to delegate attribute get/set to the other model.
There are several problems with a table of arbitrary attributes, not least of which is data typing. Just what type is your value for your key-value pairs? Is it a number, a string, a date, something more complex? You'll notice that in the first implementation there I am still using a serialized column for the value.
Post a Comment
<< Home