Safari Books Online is a digital library providing on-demand subscription access to thousands of learning resources.
A lot of applications start out with a User model of some sort. Over time, as different kinds of users emerge, it might make sense to make a greater distinction between them. Admin and Guest classes are introduced, as subclasses of User. Now, the shared behavior can reside in User, and subtype behavior can be pushed down to subclasses. However, all user data can still reside in the users table—all you need to do is introduce a type column that will hold the name of the class to be instantiated for a given row.
To continue explaining single-table inheritance, let’s turn back to our example of a recurring Timesheet class. We need to know how many billable_hours are outstanding for a given user. The calculation can be implemented in various ways, but in this case we’ve chosen to write a pair of class and instance methods on the Timesheet class:
class Timesheet < ActiveRecord::Base
...
def billable_hours_outstanding
if submitted?
billable_weeks.map(&:total_hours).sum
else
0
end
end
def self.billable_hours_outstanding_for(user)
user.timesheets.map(&:billable_hours_outstanding).sum
end
endI’m not suggesting that this is good code. It works, but it’s inefficient and that if/else condition is a little fishy. Its shortcomings become apparent once requirements emerge about marking a Timesheet as paid. It forces us to modify Timesheet’s billable_hours_outstanding method again:
def billable_hours_outstanding
if submitted? && not paid?
billable_weeks.map(&:total_hours).sum
else
0
end
endThat latest change is a clear violation of the open-closed principle,[6] which urges you to write code that is open for extension, but closed for modification. We know that we violated the principle, because we were forced to change the billable_hours_outstanding method to accommodate the new Timesheet status. Though it may not seem like a large problem in our simple example, consider the amount of conditional code that will end up in the Timesheet class once we start having to implement functionality such as paid_hours and unsubmitted_hours.
[6] http://en.wikipedia.org/wiki/Open/closed_principle has a good summary.
So what’s the answer to this messy question of the constantly changing conditional? Given that you’re reading the section of the book about single-table inheritance, it’s probably no big surprise that we think one good answer is to use object-oriented inheritance. To do so, let’s break our original Timesheet class into four classes.
class Timesheet < ActiveRecord::Base
# non-relevant code ommitted
def self.billable_hours_outstanding_for(user)
user.timesheets.map(&:billable_hours_outstanding).sum
end
end
class DraftTimesheet < Timesheet
def billable_hours_outstanding
0
end
end
class SubmittedTimesheet < Timesheet
def billable_hours_outstanding
billable_weeks.map(&:total_hours).sum
end
endNow when the requirements demand the ability to calculate partially paid timesheets, we need only add some behavior to a PaidTimesheet class. No messy conditional statements in sight!
class PaidTimesheet < Timesheet
def billable_hours_outstanding
billable_weeks.map(&:total_hours).sum - paid_hours
end
endMapping object inheritance effectively to a relational database is not one of those problems with a definitive solution. We’re only going to talk about the one mapping strategy that Rails supports natively, which is single-table inheritance, called STI for short.
In STI, you establish one table in the database to holds all of the records for any object in a given inheritance hierarchy. In Active Record STI, that one table is named after the top parent class of the hierarchy. In the example we’ve been considering, that table would be named timesheets.
Hey, that’s what it was called before, right? Yes, but to enable STI we have to add a type column to contain a string representing the type of the stored object. The following migration would properly set up the database for our example:
class AddTypeToTimesheet < ActiveRecord::Migration
def self.up
add_column :timesheets, :type, :string
end
def self.down
remove_column :timesheets, :type
end
endNo default value is needed. Once the type column is added to an Active Record model, Rails will automatically take care of keeping it populated with the right value. Using the console, we can see this behavior in action:
>> d = DraftTimesheet.create >> d.type => 'DraftTimesheet'
When you try to find an object using the find methods of a base STI class, Rails will automatically instantiate objects using the appropriate subclass. This is especially useful in polymorphic situations, such as the timesheet example we’ve been describing, where we retrieve all the records for a particular user and then call methods that behave differently depending on the object’s class.
>> Timesheet.find(:first) => #<DraftTimesheet:0x2212354...>
Sebastian says ...
The word “type” is a very common column name and you might have plenty of uses for it not related to STI—which is why it’s very likely you’ve experienced an ActiveRecord::SubclassNotFound error. Rails will read the “type” column of your Car class and try to find an “SUV” class that doesn’t exist. The solution is simple: Tell Rails to use another column for STI with the following code:
set_inheritance_column "not_sti"
Note
Rails won’t complain about the missing column; it will simply ignore it. Recently, the error message was reworded with a better explanation, but too many developers skim error messages and then spend an hour trying to figure out what’s wrong with their models. (A lot of people skim sidebar columns too when reading books, but hey, at least I am doubling their chances of learning about this problem.)
Although Rails makes it extremely simple to use single-table inheritance, there are a few caveats that you should keep in mind.
To begin with, you cannot have an attribute on two different subclasses with the same name but a different type. Since Rails uses one table to store all subclasses, these attributes with the same name occupy the same column in the table. Frankly, there’s not much of a reason why that should be a problem unless you’ve made some pretty bad data-modeling decisions.
More importantly, you need to have one column per attribute on any subclass and any attribute that is not shared by all the subclasses must accept nil values. In the recurring example, PaidTimesheet has a paid_hours column that is not used by any of the other subclasses. DraftTimesheet and SubmittedTimesheet will not use the paid_hours column and leave it as null in the database. In order to validate data for columns not shared by all subclasses, you must use Active Record validations and not the database.
Third, it is not a good idea to have subclasses with too many unique attributes. If you do, you will have one database table with many null values in it. Normally, a tree of subclasses with a large number of unique attributes suggests that something is wrong with your application design and that you should refactor. If you have an STI table that is getting out of hand, it is time to reconsider your decision to use inheritance to solve your particular problem. Perhaps your base class is too abstract?
Finally, legacy database constraints may require a different name in the database for the type column. In this case, you can set the new column name using the class method set_inheritance_column in the base class. For the Timesheet example, we could do the following:
class Timesheet < ActiveRecord::Base set_inheritance_column 'object_type' end
Now Rails will automatically populate the object_type column with the object’s type.
It seems pretty common for applications, particularly data-management ones, to have models that are very similar in terms of their data payload, mostly varying in their behavior and associations to each other. If you used object-oriented languages prior to Rails, you’re probably already accustomed to breaking down problem domains into hierarchical structures.
Take for instance, a Rails application that deals with the population of states, counties, cities, and neighborhoods. All of these are places, which might lead you to define an STI class named Place as shown in Listing 9.2. I’ve also included the database schema for clarity:[7]
[7] For autogenerated schema information added to the top of your model classes, try Dave Thomas’s annotate_models plugin at http://svn.pragprog.com/Public/plugins/
# == Schema Information # # Table name: places # # id :integer(11) not null, primary key # region_id :integer(11) # type :string(255) # name :string(255) # description :string(255) # latitude :decimal(20, 1) # longitude :decimal(20, 1) # population :integer(11) # created_at :datetime # updated_at :datetime class Place < ActiveRecord::Base end |
Place is in essence an abstract class. It should not be instantiated, but there is no foolproof way to enforce that in Ruby. (No big deal, this isn’t Java!) Now let’s go ahead and define concrete subclasses of Place:
class State < Place has_many :counties, :foreign_key => 'region_id' end class County < Place belongs_to :state, :foreign_key => 'region _id' has_many :cities, :foreign_key => 'region _id' end class City < Place belongs_to :county, :foreign_key => 'region _id' end
You might be tempted to try adding a cities association to State, knowing that has_many :through works with both belongs_to and has_many target associations. It would make the State class look something like this:
class State < Place has_many :counties, :foreign_key => 'region_id' has_many :cities, :through => :counties end
That would certainly be cool, if it worked. Unfortunately, in this particular case, since there’s only one underlying table that we’re querying, there simply isn’t a way to distinguish among the different kinds of objects in the query:
Mysql::Error: Not unique table/alias: 'places': SELECT places.* FROM places INNER JOIN places ON places.region_id = places.id WHERE ((places.region_id = 187912) AND ((places.type = 'County'))) AND ((places.`type` = 'City' ))
What would we have to do to make it work? Well, the most realistic would be to use specific foreign keys, instead of trying to overload the meaning of region_id for all the subclasses. For starters, the places table would look like the example in Listing 9.3.
# == Schema Information # # Table name: places # # id :integer(11) not null, primary key # state_id :integer(11) # county_id :integer(11) # type :string(255) # name :string(255) # description :string(255) # latitude :decimal(20, 1) # longitude :decimal(20, 1) # population :integer(11) # created_at :datetime # updated_at :datetime |
The subclasses would be simpler without the :foreign_key options on the associations. Plus you could use a regular has_many relationship from State to City, instead of the more complicated has_many :through.
class State < Place has_many :counties has_many :cities end class County < Place belongs_to :state has_many :cities end class City < Place belongs_to :county end
Of course, all those null columns in the places table won’t win you any friends with relational database purists. That’s nothing, though. Just a little bit later in this chapter we’ll take a second, more in-depth look at polymorphic has_many relationships, which will make the purists positively hate you.