by April 20, 2015

The problem

You have a model which uses single table inheritance, and you need to add an attribute which exists for some sub-classes, but not others.

Firstly, it's worth noting that using polymorphic associations may be a better fit for your use case, but if you're sticking with STI, read on. For the remainder of the post, we'll implement Martin Fowlers's example class hierarchy.

A Rails app has been created to demonstrate the approaches below, which can be found here. For our starting point we've already added the Player model, which uses STI to define two sub-classes: Cricketer and Footballer.

The 'extra column' solution

We'd like to add batting average, but only for the Cricketer model. Normally in this situation, we're stuck adding the column to the players table:

add_column :players, :batting_average, :float

This has a few disadvantages, though. For example, both the Player and Footballer models will now respond to the batting_average accessor methods, even though they shouldn't:

1
2
3
4
2.1.2 :001 > Player.first
 => #<Footballer id: 1, name: "Alice", created_at: "2015-04-18
2.1.2 :002 > Player.first.batting_average
 => nil

Also, whenever we have a non-cricketer, they'll always have a null batting_average:

1
2
3
4
5
6
sti_store_development=# SELECT name, type, batting_average FROM players;
 name  |    type    | batting_average
-------+------------+-----------------
 Alice | Footballer |
 Bob   | Cricketer  |            61.2
(2 rows)

This isn't so bad for one column, but following on with the example and adding bowler model it's easy to see how this becomes compounded as we add additional sub-class specific attributes:

1
2
3
4
5
6
7
  sti_store_development=# SELECT name, type, batting_average, bowling_average FROM players;
   name  | type       | batting_average | bowling_average
  -------+------------+-----------------+-----------------
   Alice | Footballer |                 |
   Bob   | Cricketer  |            61.2 |
   Carol | Bowler     |            12.3 |            80.3
   (3 rows)

An implementation of this this solution may be found here.

The 'STI store' solution

For our new approach we will use the lesser known store_accessor method from ActiveRecord::Store, let's see how we can use it to add a batting_average only to the Cricketer model:

Firstly, instead of adding a separate column for each sub-class specific attribute, we'll add a single column to players called sti_store, give it type json:

add_column :players, :sti_store, :json

Now we can use the store_accessor to generate accessor methods for batting_average. Crucially, we'll specify this on the Cricketer sub-class:

1
2
3
4
class Cricketer < Player
  store_accessor :sti_store, :batting_average
  
end

By doing this we have a solution to the first issue given above: The Cricketer model responds to the batting_average accessor, but Footballer and Player do not.

At this point it'd be nice to add a validation in the sub-class for our new attribute, and it works with store_accessor as expected:

1
2
3
4
5
class Cricketer < Player
  store_accessor :sti_store, :batting_average
  validates :batting_average, numericality: { less_than_or_equal_to: 100 }
  
end

Following the example, let's add the Bowler model, as a sub-class of Cricketer, again we just specify its bowling_average attribute with store_accessor:

1
2
3
4
class Bowler < Cricketer
  store_accessor :sti_store, :bowling_average
  
end

Note that the Bowler model inherits its parent's batting_average attribute, as shown here.

Looking at this in psql we now see the single sti_store column, showing only the appropriate attributes for each type:

1
2
3
4
5
6
7
sti_store_development=# SELECT name, type, sti_store FROM players;
 name  |    type    |                    sti_store
-------+------------+-------------------------------------------------
 Alice | Footballer |
 Bob   | Cricketer  | {"batting_average":61.2}
 Carol | Bowler     | {"batting_average":12.3,"bowling_average":80.3}
(3 rows)

Much better.

Errata:

Is PostgreSQL's JSON type required?

Not necessarily, we use store_accessor directly, but it should be possible to use store instead (see the ActiveRecord::Store documentation).

Dave Tapley

Dave Tapley

Programmer

Need help with your project?

We specialize in Ruby on Rails and JavaScript projects. Code audits, maintenance and feature development on existing apps, or new application development. We've got you covered.

Get in touch!