Pitfalls of using QueryObject pattern with scopes

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:

  1. Using unscoped within your query objects for all sub-queries:
class Users::WithTerminatedManagerQuery
  # ...
  def resolve(...) = @relation.where(manager: User.unscoped.where(terminated: true))
end
  1. 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
  1. 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:

Another solution came into my mind, you can define class method on your QueryObject that will return lambda and it can be used for AR scopes:

class Users::WithTerminatedManagerQuery
  class << self
    def ar_scope
      query_klass = self
      -> { query_klass.new(self).resolve }
    end
  end
end

class User < ApplicationRecord
  scope :with_terminated_manager, Users::WithTerminatedManagerQuery.ar_scope
end