Notes on Single Table Inheritance (STI) in Rails 3.0

I’m currently working on a project at Panoptic Development 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
 => # 
> c = AlphaKase.new
 => #

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.

.

14 thoughts on “0

  1. This article helped a lot. One thing that I noticed was that Rails seems to be getting even lazier in development mode and the config/initializers won't load. My solution was to put the preload_sti_models.rb code in a before_filter in application_controller.rb.

  2. In railes 3.2.9 you can't instantiate an instance of of BetaKase or AlphaKase by simply going AlphaKase.new because the table name is wrong. Had to add this to option #3

    child.class_eval do
    set_table_name :kases
    end

  3. Ok great. But I still have a question. I'm trying to do a auto catalog, and I'll have companies and these companies will be autoparts(with the categories => mechanical parts, tires,Exhaust) or autoservices (with another categories). My first idea of approach was making it with categories and subcategories. But after thinking and searching I decided to use company (parent class) and two children class (services and parts). I build it using your approach but it turned out to be difficult to handle with the navigation, for example: the home page of my website should show 2 main links parts and services. what should I use? a link_to? How?

  4. This has been really helpful. but i'm still getting a problem and am really stuck. Any help would be much appreciated. I can create new model objects no problem. but when i try to edit they do not update. in fact i've noticed the SQL UPDATE is never called. If i create and edit from the console it updates fine.

  5. Other than my question this is the most comprehensive post I have seen on this topic which ties STI together. Keep up the great work

  6. Chris

    How does one set this up if you don't want to use the base_url of kases. Say you are using STI to model people and you have Employees < People, Suppliers < People and Patients < People with People being the class inheriting from ActiveRecord::Base. So I don't want to be able to access People in the browser (url), but only want Employees or Suppliers or Patients. Is this possible?

    1. Sorry for the very late reply. I think in that case you would want to setup your routes like I detail in the "Bonus" section of my post, but without the initial `resources :people`. I haven't tested this so YMMV. Let me know if it works, or otherwise how you ended up solving your problem.

  7. Just wanted to let you know that I think this was an excellent writeup of STI. I learned a lot from it. Consider writing up a page on the Rails wiki?

    Secondly, I ended up having a problem with preload_sti_models.rb on Rails 3.1.3. It ended up being this bug.
    https://github.com/rails/rails/issues/3364

    The solution is listed in my comment there; you may want to incorporate that into your next revision of this post.

    1. Thanks for the tip re: Rails 3.1.3. I hadn't thought about adding it to the Rails Wiki. To be honest, I think it might be too verbose of a topic for that forum, and given my relative lack of experience with this I'm not sure my post should be the authoritative source on how to do it. But if it works for you, I'm glad I could help!

    2. Chris, great writeup. As with many others, you helped me solve a problem I was grappling with. As far as not being "the authoritative source on how to do it", all I can say is that that's the great thing about a Wiki: someone who writes (especially someone who writes well about a subject is the most authoritative source. Until someone who can improve on the existing answer comes along, that is.

Comments are closed.