Several years ago I stumbled upon an intriguing method of structuring complex scopes and extracting them from Rails models. The basic implementation of this pattern goes as follows:
class Users::WithTerminatedManagerQuery
class << self
def call = new.resolve
end
def initialize(relation = User.all)
@relation = relation
end
def resolve = @relation.joins(:manager).where(manager: {terminated: true})
end
Next, you can establish a scope on the model through this class like so:
class User < ApplicationRecord
belongs_to :manager, class_name: 'User', optional: true
scope :with_terminated_manager, Users::WithTerminatedManagerQuery
end
When utilized, it appears this way:
User.where(first_name: 'Ilya').with_terminated_manager
# => SELECT "users".*
FROM "users"
INNER JOIN "users" "manager" ON "manager"."id" = "users"."manager_id"
WHERE "users"."first_name" = 'Ilya'
AND "manager"."terminated" = TRUE
On first glance, this seems to be a nice approach, but if you dive deeper in, specifically at this line:
class Users::WithTerminatedManagerQuery
# ...
def initialize(relation = User.all)
@relation = relation
end
# ...
end
You might think that this scope always operates with User.all
relation. But, as brilliantly outlined in this article by Jeroen Weeink, Rails wraps scopes with the scoping
method, which, under the hood, affects any relations on defined Model in the callable object.
This behaviour can lead to unexpected outcome, especially if the logic in the query object is tweaked like this:
class Users::WithTerminatedManagerQuery
# ...
def resolve(...) = @relation.where(manager: User.where(terminated: true))
end
Executing this code results in a query where the condition first_name is applied to the subquery, which is most likely unintended:
User.with_terminated_manager.where(first_name: 'Ilya')
# SELECT "users".*
# FROM "users"
# WHERE "users"."first_name" = 'Ilya' <---------- first_name condition
# AND "users"."manager_id" IN
# (SELECT "users"."id"
# FROM "users"
# WHERE "users"."first_name" = 'Ilya' <---------- first_name condition
# AND "users"."terminated" = TRUE)
Moreover, if you simply change the order of scopes, you’ll get different results:
User.with_terminated_manager.where(first_name: 'Ilya')
# SELECT "users".*
# FROM "users"
# WHERE "users"."manager_id" IN
# (SELECT "users"."id"
# FROM "users"
# WHERE "users"."terminated" = TRUE)
# AND "users"."first_name" = 'Ilya' <---------- first_name condition
So, how can this be solved?
I can propose few approaches:
- Using
unscoped
within your query objects for all sub-queries:
class Users::WithTerminatedManagerQuery
# ...
def resolve(...) = @relation.where(manager: User.unscoped.where(terminated: true))
end
- Sacrificing the readability of your model, defining your scopes using lambdas and pass current scope implicitly is another solution:
class User < ApplicationRecord
scope :with_terminated_manager, -> { Users::WithTerminatedManagerQuery.new(self).resolve }
end
- Perhaps ActiveRecord’s
ActiveRecord::Scoping::Named.scope
method could be extended to accept class as a second argument, making it work like the lambda from example above.
It’s frustrating to see this pattern widely adopted in Rails, with loads of articles and books making a mention. However, it’s puzzling they don’t bring up these issues I’ve come across. I’m putting this out there, hoping it can help someone. Would love to hear your thoughts.
Links: