ActiveRecord STI + abstract_class? have a gotcha

Auteur: 
François Beausoleil

To try and ease my pain, I am refactoring a Rails 2.3.5 application using Hobo from multiple tables to a single table. I’m talking about models which are essentially the same: companies, people and employees. I went from this:

1 class Company < ActiveRecord::Base
2 has_many :employees
3 has_many :people, :through => :employees
4 end
5
6 class Person < ActiveRecord::Base
7 has_many :employees
8 has_many :companies, :through => :employees
9 end
10
11 class Employee < ActiveRecord::Base
12 belongs_to :company
13 belongs_to :person
14 end

To an STI-enabled class hierarchy:

1 class Addressee < ActiveRecord::Base
2 self.abstract_class = true
3 end
4
5 class Company < Addressee
6 has_many :employees
7 has_many :people, :through => :employees
8 end
9
10 class Person < Addressee
11 has_many :employees
12 has_many :companies, :through => :employees
13 end
14
15 class Employee < Addressee
16 belongs_to :company
17 belongs_to :person
18 end

Can you spot the mistake? Here’s where it breaks down:

1 $ script/console
2 > PersonAddressee.count
3 SQL (9.9ms) SELECT count(*) AS count_all FROM "addressees"
4 => 10231

When the classes are defined with abstract_class? set to true, calling Company#count executes the following:

  1. ActiveRecord Company#superclass, which answers Addressee
  2. ActiveRecord calls Addressee#abstract_class?, which answers true
  3. Because Company#base_class == Company#superclass, no STI is involved, and ActiveRecord queries the addressees table with no STI hints (type column).

When removing the abstract class declaration, here’s how it works:

  1. ActiveRecord Company#superclass, which answers Addressee
  2. ActiveRecord calls Addressee#abstract_class?, which answers false
  3. ActiveRecord Addressee#superclass, which answers ActiveRecord::Base
  4. Because Company#base_class != Company#superclass, STI is involved and ActiveRecord adds the type hint to the addressees table query.

The documentation is quite clear though:

abstract_class?()

Returns whether this class is a base AR class. If A is a base class and B descends from A, then B.base_class will return B.

The documentation on #base_class is much better though:

base_class()

Returns the base AR subclass that this class descends from. If A extends AR::Base, A.base_class will return A. If B descends from A through some arbitrarily deep hierarchy, B.base_class will return A.

And current HEAD documentation is the best of them all:

base_class()

Returns the base AR subclass that this class descends from. If A extends AR::Base, A.base_class will return A. If B descends from A through some arbitrarily deep hierarchy, B.base_class will return A.

If B < A and C < B and if A is an abstract_class then both B.base_class and C.base_class would return B as the answer since A is an abstract_class.

Luckily, I found this before users got a chance to report bugs. And no, I would never have thought to add a test for this: I’m not in the habit of testing base ActiveRecord functionality.