loading...

Design Patterns with Ruby on Rails part 2: Query Object

renatamarques97 profile image Renata Marques Updated on ・3 min read

This post is the second part of a series of posts about design patterns with Ruby on Rails.
See other parts here:
Part 1

Query Object

The query object is a pattern that helps to make Rails models slim by extracting
complex SQL queries or scopes into the separated classes that are easy to reuse and test.

Naming

Usually located in app/queries directory, and of course, can be organized into multiple namespaces if needed.
Typical file name has _query suffix, class name has Query suffix and meaningful name that tells us what is the purpose of the class.
e.g.:
Users::ListActiveUsersQuery

module Users
  class ListActiveUsersQuery
    def self.call
      User.where(status: 'active', deleted_at: nil).where.not(email: nil)
    end
  end
end

Why use a class method instead of an instance? because it is more practical and easier to stub.

There is a way to divide this query into pieces and become even more reusable? Yes, chaining methods.

class UsersQuery
  def initialize(users = User.all)
    @users = users
  end

  def active
    @users.where(active: true, pending: false)
  end

  def pending
    @users.where(pending: true, active: false)
  end

  def deleted
    @users.with_deleted
  end
end

This way:

query = UsersQuery.new
query.deleted.pending

Query objects and model scopes

If you have the following scope defined:

class User < ActiveRecord::Base
  scope :active, -> {
    where(role: ['moderator', 'guest'], status: 'active', deleted_at: nil)
  }
end

and you want to extract this query and keep this behavior with User.active, how can you do this?

The solution may seen obvious:

class User < ActiveRecord::Base
  def self.active
    ListActiveUsersQuery.call
  end
end

but it adds more code and it is no longer visible in the scope definition.

Using a scope with query objects

Don't forget that your query object has to return relation.

class User < ActiveRecord::Base
  scope :active, ListActiveUsersQuery
end

Let's design our query object class:

class ListActiveUsersQuery
  class << self
    delegate :call, to: :new
  end

  def initialize(relation = User)
    @relation = relation
  end

  def call
    @relation.where.not(email: nil).where(status: 'active', deleted_at: nil)
  end
end

Now the query still available via User.active scope.

Refactoring

Let's take a look at how to use query objects in practice when refactoring larger queries.

class UsersController < ApplicationController
  def index
    @users = User.where(active: true, deleted_at: nil)
      .joins(:emails).where(emails: { active: true })
  end
end

if you want to write tests for this action it won't be possible without hitting the database.
It gets easier after extracting this query to a separated class:

class ListActiveUsersWithEmailQuery
  attr_reader :relation

  def self.call(relation = User)
    new(relation).call
  end

  def new(relation = User)
    @relation = relation
  end

  def call
    active.with_email
  end

  def active
    relation.where(active: true, deleted_at: nil)
  end

  def with_email
    relation.joins(:emails).where(emails: { active: true })
  end
end

and the controller can be now updated:

class UsersController < ApplicationController
  def index
    @users = ListActiveUsersWithEmailQuery.call
  end
end

Testing

The query's not a controller concern anymore, it simplifies your test, now you can simply call allow(ListActiveUsersWithEmailQuery).to receive(:call).and_return(...) in your spec.

Conclusion

At this point, you are familiarized with query object pattern and you should be able to start using in your Rails
application.
It's important to remember that moving to a separated class is not always a good idea, it adds a complexity layer
to your code and has a maintainability cost, use this pattern with caution, and enjoy more readable and extendable code.

References

[book] Rails Patterns by Pawel Dabrowski

Posted on Jun 22 by:

renatamarques97 profile

Renata Marques

@renatamarques97

Computer Scientist | Back-End Developer at Codeminer42 | Open Source Enthusiast

Discussion

markdown guide
 

Correction needed:

Replace 'Policy' with 'Query' in "Typical file name has _query suffix, class name has Policy suffix"

 

thanks for this, I already corrected it.