Single table inheritance (STI) is a way to emulate object-oriented inheritance in a relational database1 by storing multiple object types in one table, distinguishable by a discriminator column such as type
. Mixing levels of abstraction may make join
operations easier, but it also makes other queries more complicated. In Rails 3.2, a query optimization was introduced that had some unintended consequences for STI.
A scope represents a narrowing of a database query, and a named scope is syntactic sugar for defining a class method at runtime.
class Shirt < ActiveRecord::Base
scope :in_style, -> { where('purchase_date >= ?', Time.now - 2.months)}
end
class PoloShirt < Shirt
scope :red, -> { where(color: 'red') }
scope :blue, -> { where(color: 'blue') }
end
class SweatShirt < Shirt
scope :logo, -> { where(logo: 'dragon' )}
end
PoloShirt.red.in_style
The scope :in_style
is converted into a class method behind the scenes at runtime, and is defined on the singleton class where the scope was named, not on the caller. While this detail has no consequences for objects outside of an inheritance scheme, it means that when PoloShirt invokes the :in_style
scope, the class method is declared on Shirt, not PoloShirt.
Here is where the history lesson begins. Scopes have evolved in Rails, and while they remain syntactic sugar for definition class methods, the details, method signature, and sql translation have differed dramatically.
In Rails 3.0, the scope method accepts a name, scope_options, and optional block. Scopes are directly translated into class methods behind the scenes, and the consequences of chaining scopes are the same as chaining queries, just nicer looking.
PoloShirt.red.in_style.to_sql
=> SELECT * from shirts WHERE color = 'red' where type = 'PoloShirt' where purchase_date >= 1417102745 where type IN ('PoloShirt', 'Sweatshirt')
Notice the implicit where
clause in STI. The :red
scope, declared on PoloShirt has where type = 'PoloShirt'
, and the :in_style
scope, declared on Shirt, has where type IN ('PoloShirt', 'Sweatshirt')
. The second where
clause will have no impact on the query results, because they are already scoped to ‘PoloShirt’. It is this behaviour that evolves over time.
The first jump in scope
evolution is in Rails 3.2, where the scope_options can include lambdas. Passing a lambda is a big advantage in that it allows the scope to be re-evaluated each time it is called. Unfortunately the implementation also remixes the query parameters of all chained scopes before evaluation:
In nested scopings, all previous parameters are overwritten by the innermost rule, with the exception of
where
,includes
, andjoins
operations in Relation, which are merged.
PoloShirt.red.in_style.to_sql
=> SELECT * from shirts where color = 'red' where purchase_date >= 1417102745 where type IN ('PoloShirt', 'Sweatshirt')
The first where type =
clause is merged with the last where type IN
clause. The results of this query will no longer be scoped to PoloShirts, but will return all red.in_style
Shirts of any type. Bad news for STI.
The good news is that this behaviour is fixed in Rails 4.0. The latest evolution of scopes no longer allow you to pass a non-callable object (like a hash), and all scopes are merged using AND.
PoloShirt.red.in_style.to_sql
=> SELECT * from shirts where color = 'red' where purchase_date >= 1417102745 where type = 'PoloShirt' AND type IN ('PoloShirt', 'Sweatshirt')
Our red.in_style
query is once again scoped to the right level of the inheritance heirarchy. Named scopes are chainable and lazy-evaluated, making them a powerful query-building tool that can be difficult to troubleshoot — especially when they are mixed with single-table inheritance.