Scopes and Single Table Inheritance
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.
1 2 3 4 5 6 7 8 9 10 11 12 13 14
: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.
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
joinsoperations in Relation, which are merged.
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.
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.