Ruby, iOS, and Other Development

A place to share useful code snippets, ideas, and techniques

All code in posted articles shall be considered public domain unless otherwise noted.
Comments remain the property of their authors.

2006-07-02

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: , ,

3 Comments:

  • At 7/02/2006 03:31:00 PM, Anonymous 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 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, Blogger 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