I’m currently working on a project that calls for splitting up a single model into two similar models with slightly differing functionality. I was already familiar with Alex Reisner’s excellent article on when to use Single Table Inheritance versus the other alternatives that one might use, and after re-reading the article with a colleague it was determined that STI was probably our best bet. However, neither of us could remember seeing any recent articles on STI in Rails, specifically with Rails 3, and it’s been my experience that if people aren’t talking about a Rails feature it’s probably because it’s been recently deprecated or replaced.

Not wanting to back ourselves in a corner before we were sure it would work, I decided to spike a dummy Rails app and see what problems we would run into. There are plenty of questions on StackOverflow (1, 2, etc.), et. al., with various suggestions for working through issues related to STI, but there was no one concise guide that detailed the benefits and drawbacks of each approach. I decided to document my findings. This is my first real-life attempt at implementing an STI pattern, so please leave a comment if you feel that I omitted something or if you know of another way to approach one of these issues.

TL;DR (skip to the final setup)

Last Update: 03 Feb 12. See list of changes.

Initial Setup

  • Starting with an empty rails 3.0.11 app
  • $ rails g scaffold Kase name:string type:string and $ rake db:migrate
  • Added two empty subclass definitions to kase.rb

Our Kase model looks like this:

# app/models/kase.rb
class Kase < ActiveRecord::Base; end
class AlphaKase < Kase; end
class BetaKase < Kase; end

Findings

Console Problem

Subclasses are not available until after a parent object has been loaded, at least in lazy environments such as development, i.e. environments where config.cache_classes = false

Example:

> c = AlphaKase.new
NameError: uninitialized constant AlphaKase
> k = Kase.new
 => #<Kase>
> c = AlphaKase.new
 => #<AlphaKase>

We definitely want our subclasses available as soon as possible, so we can fix this in lazy-loading environments using one of two ways:

  1. by setting up an initializer that preloads kase.rb, thus loading our subclasses as well
  2. by splitting the subclasses out into their own files, i.e. alpha_class.rb, beta_class.rb

We’ll go with the first option for now since it seems the simplest:

# config/initializers/preload_sti_models.rb
if Rails.env.development?
  # Make sure we preload the parent and children classes in development
  require_dependency File.join("app","models","kase.rb")
end

Note: Don’t do this. Keep reading to find out why.

Index and Create Problem

Requests to the parent controller work fine with an empty database, but a number of drawbacks become immediately clear once we start adding data:

  1. Because the type attribute is protected from mass assignment by default, we can’t create a subclassed item by specifying the type in a form field.
  2. If we create a subclass via the console and then reload the index page we get an error like undefined method `alpha_kase_path'

Fixing Type Attribute Issue

We can fix the 1st issue by updating our #new and #create actions to look for a type element in the params hash and set it explicitly as the type attribute on the new class (after mass-assigning the rest of the params) This is a little ugly, but it can at least be DRY’d up using a private method:

# app/controllers/kase_controller.rb
class KasesController < ApplicationController
  def new
    # replaces `@kase = Kase.new`
    setup_sti_model
    # ...
  end
  def create
    # replaces `@kase = Kase.new(params[:kase])`
    setup_sti_model
    # ...
  end
private
  def setup_sti_model
    # This lets us set the "type" attribute from forms and querystrings
    model = nil
    if !params[:kase].blank? and !params[:kase][:type].blank?
      model = params[:kase].delete(:type).constantize.to_s
    end
    @kase = Kase.new(params[:kase])
    @kase.type = model
  end
end

Unfortunately we will now get an error like uninitialized constant BetaKase when we submit the form. To fix this we need to separate our subclasses out into separate files.

# app/models/kase.rb
class Kase < ActiveRecord::Base; end
# app/models/alpha_kase.rb
class AlphaKase < Kase; end
# app/models/beta_kase.rb
class BetaKase < Kase; end

At this point we could get rid of the preload_sti_models.rb initializer we created above as we no longer need it to preload the subclass models.

Note: You may not want to delete it. Keep reading to find out why.

Fixing the undefined method Issue

We can fix the 2nd issue in one of three ways:

  1. Overriding the class method of each subclass to return the parent class
  2. Setting up individual routes for each subclass
  3. Defining a self.inherited method on our parent class that sets the model_name of each child to that of the parent.
Option 1

The first option looks to be pretty easy:

# app/models/alpha_kase.rb
class AlphaKase < Kase
  def class
    Kase
  end
end
# app/models/beta_kase.rb
class BetaKase < Kase
  def class
    Kase
  end
end

The problem with this approach is that calls to instantiate a new child object, like AlphaKase.new, now return a parent Kase class object with a type of nil, and you have to explicitly set the type attribute to the subclass name before saving the new object. This is not ideal and would probably require too many other cascading work arounds, so we’ll avoid this solution.

Option 2

The problem that is immediately apparent with option 2 is that it means your route file grows quickly with every STI model you create and managing that can be cumbersome. This could be alleviated to some degree by using some meta programming to programmatically setup the child routes by looping over the Kase.descendants array like so:

# config/routes.rb
StiTest::Application.routes.draw do
  resources :kases
  Kase.descendants.each do |klass|
    k = klass.model_name.pluralize.underscore.to_sym
    resources k, :controller => 'kases'
  end
end

This presents another problem though: the Kase.descendants array will only be properly populated once all of the subclasses have been loaded, and in lazy environments we know that this doesn’t happen without an initializer and we just deleted ours. We can add the initializer back again to solve this problem but now we need to tell it to load each subclass file. In other words we are faced with maintaining a list of subclasses in either the initializer or the routes file so we really haven’t gained anything. And while this may seem like a “six in one hand, half a dozen in the other” type of problem, at least with an initializer we get the added benefit of having Kase.descendants prefilled too which may come in handy for other meta programming tricks. So we could create the initializer again and specify each subclass:

# config/initializers/preload_sti_models.rb
if Rails.env.development?
  # Make sure we preload the parent and children classes in development
  %w[kase alpha_kase beta_kase].each do |c|
    require_dependency File.join("app","models","#{c}.rb")
  end
end

This effectively solves our problem, and I didn’t see any immediate drawbacks. Let’s go over option 3 anyway and see what that nets.

Option 3

It turns out that option three is the easiest approach and requires the least bit of programming as we can leave the routes file as-is and it doesn’t require us to setup the initializer (though we still might want it just to have the Kase.descendants array setup.)

# app/models/kase.rb
class Kase < ActiveRecord::Base
  def self.inherited(child)
    child.instance_eval do
      alias :original_model_name :model_name
      def model_name
        Kase.model_name
      end
    end
    super
  end
end

If there is a downside of this approach it’s that we don’t get the subclass-specific routes, e. g. new_alpha_kase, as we would with option 2, but I can’t think of any reason why we would need them if this solves the problem anyway. Note that I am aliasing the original model_name method so we can still access it via some_kase.class.original_model_name should we ever need it.

So, let’s go with option #3, plus add the initializer for the reason previously mentioned.

Wrap up

At this point we can reliably create subclasses in the console without first initiating a Kase object. And we can create new subclasses via the scaffolding forms and display them in a list. If we try to create a subclass with an invalid type (i.e. “FooKase”) then we get an error like uninitialized constant FooKase, which is perfectly OK, though we could prevent this by adding a validator to our parent model (while taking advantage of the Kase.descendants array!):

# app/models/kase.rb
validate do |kase|
  kase.errors[:type] << "must be a valid subclass of Kase" unless Kase.descendants.map{|klass| klass.name}.include?(kase.type)
end

And that’s it! We now have a working STI pattern.

Final Setup

Below is the final setup for our STI test application. You can get the entire application source from GitHub

# app/controllers/kase_controller.rb
class KasesController < ApplicationController
  def new
    setup_sti_model
    # ...
  end
  def create
    setup_sti_model
    # ...
  end
private
  def setup_sti_model
    # This lets us set the "type" attribute from forms and querystrings
    model = nil
    if !params[:kase].blank? and !params[:kase][:type].blank?
      model = params[:kase].delete(:type).constantize.to_s
    end
    @kase = Kase.new(params[:kase])
    @kase.type = model
  end
end
# app/models/kase.rb
class Kase < ActiveRecord::Base
  validate do |kase|
    kase.errors[:type] << "must be a valid subclass of Kase" unless Kase.descendants.map{|klass| klass.name}.include?(kase.type)
  end
  # Make sure our STI children are routed through the parent routes
  def self.inherited(child)
    child.instance_eval do
      alias :original_model_name :model_name
      def model_name
        Kase.model_name
      end
    end
    super
  end
end
# app/models/alpha_kase.rb
class AlphaKase < Kase; end
# app/models/beta_kase.rb
class BetaKase < Kase; end
# config/initializers/preload_sti_models.rb
if Rails.env.development?
  # Pre-loaded our STI subclasses in development so Kase.descendants is properly populated
  %w[kase alpha_kase beta_kase].each do |c|
    require_dependency File.join("app","models","#{c}.rb")
  end
end

Bonus

If for some reason you decide to solve the routing problem by adding the additional routes rather than overriding the model_name of the subclasses, you will end up with additional routes like this:

    alpha_kases GET    /alpha_kases(.:format)          {:action=>"index", :controller=>"kases"}
                POST   /alpha_kases(.:format)          {:action=>"create", :controller=>"kases"}
 new_alpha_kase GET    /alpha_kases/new(.:format)      {:action=>"new", :controller=>"kases"}
edit_alpha_kase GET    /alpha_kases/:id/edit(.:format) {:action=>"edit", :controller=>"kases"}
     alpha_kase GET    /alpha_kases/:id(.:format)      {:action=>"show", :controller=>"kases"}
                PUT    /alpha_kases/:id(.:format)      {:action=>"update", :controller=>"kases"}
                DELETE /alpha_kases/:id(.:format)      {:action=>"destroy", :controller=>"kases"}
     beta_kases GET    /beta_kases(.:format)           {:action=>"index", :controller=>"kases"}
                POST   /beta_kases(.:format)           {:action=>"create", :controller=>"kases"}
  new_beta_kase GET    /beta_kases/new(.:format)       {:action=>"new", :controller=>"kases"}
 edit_beta_kase GET    /beta_kases/:id/edit(.:format)  {:action=>"edit", :controller=>"kases"}
      beta_kase GET    /beta_kases/:id(.:format)       {:action=>"show", :controller=>"kases"}
                PUT    /beta_kases/:id(.:format)       {:action=>"update", :controller=>"kases"}
                DELETE /beta_kases/:id(.:format)       {:action=>"destroy", :controller=>"kases"}

Now however, if we go to one of the #new action routes our @kase variable will hold a generic Kase object, not the subclassed object we might expect. We can hack our way through this by updating our dynamic route generator to add a param value that will set the proper type automagically via our KaseController#setup_sti_model method:

# config/routes.rb
StiTest::Application.routes.draw do
  # This assumes that you've setup the initializer that preloads the
  # kase.rb file in lazy environments
  resources :kases
  Kase.descendants.each do |klass|
    k = klass.model_name.pluralize.underscore.to_sym
    resources k, :controller => 'kases', :kase => {:type => klass.model_name}
  end
end

This is more of a convenience than a necessity as it just pre-fills the type field, but I thought it was worth mentioning since I didn’t see it mentioned in any of the other STI posts I read while researching this.

Change List

This document has been updated several times and will be continuously updated as often as necessary to keep it up to date.

  • 02 Feb 12: Cleaned up the article a bit and added.
  • 03 Feb 12: subclasses method is deprecated as of Rails 3.0, so replace it with descendents. Also aliasing the child class model_name as original_model_name so we don’t lose it completely when it is overwritten in the instance_eval block.