STI Factory revisited

posted by andy, Wed Oct 01 11:48:00 UTC 2008

Single Table Inheritance (STI) Revisited

I wrote about Single Table Inheritance (STI) in Rails several months ago. STI is a simple design pattern in which multiple subclasses are stored in a single database table and distinguished by a discriminator column. Rails makes this easy to implement by automatically mapping a column named 'type' as the discriminator column (aka, inheritance_column). Of course you can override the name of the discriminator column and I often must do so in practice; most of the applications of STI that I use allow the end-user to select the value for the subclass and trying to render the list of subclasses for the :type column leads to rendering issues.

Allowing the end user to select the subclass has one other problem -- your controller must somehow be aware of the use of STI. Why? Well, ActiveRecord::Base simply does not allow you to assign a value to the inheritance column. It keeps things "type safe" by setting the value of the inheritance column itself. As a result your controller needs to have some sort of STI-awareness built in so that the user can send back a value for the inheritance_column and the controller can use that value for building or creating an instance of that type.

Instant STI Awareness

This need for STI-awareness is prevalent enough that I offered an attempt at creating it in a lib/plugin in the earlier post. The solution focuses on the model, rather than the controller, so that the controller can remain blissfully unaware (some would say decoupled from) the implementation of the model. Unfortunately that earlier code stunk and I've been revisiting it as a part of getting ready for my talk at the South Carolina Ruby Conference.

One of the weak points of the earlier code was the shaky way it tried to work around infinite loops. That was what brought me back to the code. I was looking for a good example of alias_method_chain and realized that it was the solution to the recursion problem.

The basic logic behind the plugin/lib is simple. We need to override the #new method to:

  1. Check for the presence of the inheritance column in the attributes supplied to the new method.
  2. If the inheritance column is found, check to see if the its value is the name of a valid subclass.
  3. If a valid subclass has been requested then call new on the subclass and pass the original args less the inheritance_column.
  4. If a valid subclass was not requested then call new on the superclass.

alias_method_chain

Phrasing the problem in the right way is often the key to unlocking the problem. As steps 3 and 4 above suggest we really want to have a way to hide the inherited #new function, override its functionality, and then invoke that original method. That's what alias_method_chain is all about.

alias_method_chain is an extension of Module added by Rails. The intention of the method is to allow you to wrap an existing method with your new functionality. For example, assume that you have a method called foo that you want to extend by some functionality known as bar. You can think of the original method as "foo without bar" and the new functionality as "foo with bar". For the sake of the other developers with whom you work (think: the world!) you would want to use some aliasing so that others could make use of your fabulous new bar-ology without having to change their code. It would probably look something like this:

alias_method :foo_without_bar, :foo
alias_method :foo, :foo_with_bar

And that is exactly what alias_method_chain does, only with a much simpler syntax:

alias_method_chain :foo, :bar

Placement of alias_method_chain calls is very important. The call must be made after both your new "foo with bar" concept has been added to the class and the original "foo" has been realized. This can get tricky when you are trying to wrap class-level functionality.

Improved STI Factory

Here's a revised version of the STI factory code. The recursion problem is gone thanks to alias_method_chain and, as a side benefit, STI tables get a new subclass_names method that can be used for building select options.

module Koinonia
  module StiFactory
    def self.included(base)
      base.extend Koinonia::StiFactory::ClassMethods
    end
    
    module ClassMethods
      def has_sti_factory
        extend Koinonia::StiFactory::StiClassMethods
        class << self
          alias_method_chain :new, :factory unless method_defined?(:new_without_factor)
        end
      end
    end
    
    module StiClassMethods
      def subclass_names
        subclasses.map(&:name).push(self.name)
      end
 
      def new_with_factory(*args)
        options = args.last.is_a?(Hash) ? args.pop : {}
        
        klass_name = options.delete(self.inheritance_column.to_sym) || self.name
        klass = self.subclass_names.include?(klass_name) ? klass_name.constantize : self
        
        klass.new_without_factory(*args.push(options))
      end
    end
  end
end

Just to explain what's going on a little bit... when Koinonia::StiFactory is included into ARec::Base by init.rb it adds a class-level method to ARec::Base called 'has_sti_factory'. That method is the one that you'd add to an STI class to hook in the factory. When invoked, that method extends the class by adding two methods: new_with_factory that is responsible for checking the supplied attributes and invoking the appropriate new_without_factory, and subclass_names that supplies the names of the known subclasses. With that in place you can now do this:

class Vehicle < ActiveRecord::Base
  self.inheritance_column = 'vehicle_type'
  has_sti_factory
end

class Car < Vehicle; end
class Truck < Vehicle; end
class MonsterTruck < Truck; end

Vehicle.new
=> #<Vehicle id: nil, vehicle_type: nil, ...>

Vehicle.new :vehicle_type => 'Truck'
=> <Truck id: nil, vehicle_type: 'Truck', ...>

Vehicle.new :vehicle_type => 'MonsterTruck'
=> #<MonsterTruck id: nil, vehicle_type: 'MonsterTruck', ...>

Oh, yeah, I've tried to play nice with all the cool kids and wrapped this up as a plugin that you can download from github.

Filed Under: Rails | Tags:

Comments