- 9.1 Scopes
- 9.2 Callbacks
- 9.3 Calculation Methods
- 9.4 Single-Table Inheritance (STI)
- 9.5 Abstract Base Model Classes
- 9.6 Polymorphic has_many Relationships
- 9.7 Enums
- 9.8 Foreign-Key Constraints
- 9.9 Modules for Reusing Common Behavior
- 9.10 Modifying Active Record Classes at Runtime
- 9.11 Using Value Objects
- 9.12 Nonpersisted Models
- 9.13 PostgreSQL Enhancements
- 9.14 Conclusion
9.4 Single-Table Inheritance (STI)
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 the 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:
1 class Timesheet < ActiveRecord::Base 2 ... 3 4 def billable_hours_outstanding 5 if submitted? 6 billable_weeks.map(&:total_hours).sum 7 else 8 0 9 end 10 end 11 12 def self.billable_hours_outstanding_for(user) 13 user.timesheets.map(&:billable_hours_outstanding).sum 14 end 15 16 end
I’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:
1 def billable_hours_outstanding 2 if submitted? && not paid? 3 billable_weeks.map(&:total_hours).sum 4 else 5 0 6 end 7 end
That 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.
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.
1 class Timesheet < ActiveRecord::Base 2 # nonrelevant code ommited 3 4 def self.billable_hours_outstanding_for(user) 5 user.timesheets.map(&:billable_hours_outstanding).sum 6 end 7 end 8 9 class DraftTimesheet < Timesheet 10 def billable_hours_outstanding 11 0 12 end 13 end 14 15 class SubmittedTimesheet < Timesheet 16 def billable_hours_outstanding 17 billable_weeks.map(&:total_hours).sum 18 end 19 end
Now 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!
1 class PaidTimesheet < Timesheet 2 def billable_hours_outstanding 3 billable_weeks.map(&:total_hours).sum - paid_hours 4 end 5 end
9.4.1 Mapping Inheritance to the Database
Mapping 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 hold all 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:
1 class AddTypeToTimesheet < ActiveRecord::Migration 2 def change 3 add_column :timesheets, :type, :string 4 end 5 end
No 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 query 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.first => #<DraftTimesheet:0x2212354...>
9.4.2 STI Considerations
Although Rails makes it extremely simple to use single-table inheritance, there are four caveats that you should keep in mind.
First, 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 that should be a problem unless you’ve made some pretty bad data-modeling decisions.
Second and more important, 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 setter method inheritance_column in the base class. For the Timesheet example, we could do the following:
1 class Timesheet < ActiveRecord::Base 2 self.inheritance_column = 'object_type' 3 end
Now Rails will automatically populate the object_type column with the object’s type.
9.4.3 STI and Associations
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
Listing 9.2 The Places Database Schema and the Place Class
1 # == Schema Information 2 # 3 # Table name: places 4 # 5 # id :integer(11) not null, primary key 6 # region_id :integer(11) 7 # type :string(255) 8 # name :string(255) 9 # description :string(255) 10 # latitude :decimal(20, 1) 11 # longitude :decimal(20, 1) 12 # population :integer(11) 13 # created_at :datetime 14 # updated_at :datetime 15 16 class Place < ActiveRecord::Base 17 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:
1 class State < Place 2 has_many :counties, foreign_key: 'region_id' 3 end 4 5 class County < Place 6 belongs_to :state, foreign_key: 'region_id' 7 has_many :cities, foreign_key: 'region_id' 8 end 9 10 class City < Place 11 belongs_to :county, foreign_key: 'region_id' 12 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:
1 class State < Place 2 has_many :counties, foreign_key: 'region_id' 3 has_many :cities, through: :counties 4 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.
Listing 9.3 The Places Database Schema Revised
# == 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.
1 class State < Place 2 has_many :counties 3 has_many :cities 4 end 5 6 class County < Place 7 belongs_to :state 8 has_many :cities 9 end 10 11 class City < Place 12 belongs_to :county 13 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, that will make the purists positively hate you.