DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’» is a community of 963,503 amazing developers

We're a place where coders share, stay up-to-date and grow their careers.

Create account Log in
Renata Marques
Renata Marques

Posted on • Updated on

Design Patterns with Ruby on Rails part 2: Query Object

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 in decomposing your fat ActiveRecord models and keeping your code slim and readable by extracting complex SQL queries or scopes into the separated classes that are easy to reuse and test.

Naming Convention

Usually located in app/queries directory, and of course, can be organized into multiple namespaces.
Typical file name has _query suffix, class name has Query suffix.
e.g.:
Posts::ActivePostsQuery

module Posts
  class ActivePostsQuery
    def self.call
      Post.where(status: 'active', deleted_at: nil)
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

Class methods are 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 PostsQuery
  def initialize(posts = Post.all)
    @posts = posts
  end

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

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

  def deleted
    @posts.with_deleted
  end
end
Enter fullscreen mode Exit fullscreen mode

This way:

query = PostsQuery.new
query.deleted.pending
Enter fullscreen mode Exit fullscreen mode

Query objects and model scopes

I have the following scope defined in Post class:

class Post < ActiveRecord::Base
  scope :active, -> {
    where(section: ['web', 'mobile'], status: 'active', deleted_at: nil)
  }
end
Enter fullscreen mode Exit fullscreen mode

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

The solution may seen obvious:

class Post < ActiveRecord::Base
  def self.active
    ActivePostsQuery.call
  end
end
Enter fullscreen mode Exit fullscreen mode

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 a relation.

class Post < ActiveRecord::Base
  scope :active, ActivePostsQuery
end
Enter fullscreen mode Exit fullscreen mode

Let's design our query object class:

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

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

  def call
    @relation.where(status: 'active', deleted_at: nil)
  end
end
Enter fullscreen mode Exit fullscreen mode

Now the query still available via Post.active scope.

Refactoring

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

class PostsController < ApplicationController
  def index
    @posts = Post.where(active: true, deleted_at: nil)
      .joins(:authors).where(emails: { active: true })
  end
end
Enter fullscreen mode Exit fullscreen mode

When is time to write tests for this action it won't be possible without consulting the database.
It gets easier after extracting this query to a separated class:

class ActivePostsWithAuthorQuery
  attr_reader :relation

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

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

  def call
    active.with_author
  end

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

  def with_author
    relation.joins(:authors).where(emails: { active: true })
  end
end
Enter fullscreen mode Exit fullscreen mode

and the controller can be now updated:

class PostsController < ApplicationController
  def index
    @posts = ActivePostsWithAuthorQuery.call
  end
end
Enter fullscreen mode Exit fullscreen mode

Testing

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

Conclusion

There is always a trade-off and 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 your extendable code.

Top comments (7)

Collapse
 
yourivdlans profile image
Youri van der Lans

Realy liking the simplicity of scope :active, ActivePostsQuery!

Nice acticle :)

Collapse
 
c80609a profile image
scout

How to pass argument to the call method to use the same query class in multiple scopes?

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

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

  def call(value = 'active')
    @relation.where(status: value)
  end
end
Enter fullscreen mode Exit fullscreen mode
class Post
  scope :active, StatusPostsQuery
  scope :inactive, StatusPostsQuery #...?
end
Enter fullscreen mode Exit fullscreen mode
Collapse
 
richorelse profile image
Ritchie Paul Buitre

Shameless plug: I wrote a RubyGem that can do just that. I hope that helps.

Collapse
 
furiabhavesh profile image
Bhavesh Furia

Correction needed:

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

Collapse
 
renatamarques97 profile image
Renata Marques Author

thanks for this, I already corrected it.

Collapse
 
kudapara profile image
Kudakwashe Paradzayi

Nice article. You could also checkout this gem github.com/Selleo/pattern it makes it easier to create and maintain query objects

Collapse
 
stephann profile image
Stephann

Artigo massa. Peguei umas ideias pra implementar aqui na nossa aplicação. Brigadão.

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.