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
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
This way:
query = PostsQuery.new
query.deleted.pending
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
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
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
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
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
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
and the controller can be now updated:
class PostsController < ApplicationController
def index
@posts = ActivePostsWithAuthorQuery.call
end
end
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 (8)
Realy liking the simplicity of
scope :active, ActivePostsQuery
!Nice acticle :)
How to pass argument to the
call
method to use the same query class in multiple scopes?Shameless plug: I wrote a RubyGem that can do just that. I hope that helps.
Hi, Renata. Awesome arcticle!
I have a question about first example using chaining methods:
query = PostsQuery.new
query.deleted.pending
I tried to replicate this and I notice that it's not working because deleted method will return an ActiveRecord::Relation class, resulting in an
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.
Nice article. You could also checkout this gem github.com/Selleo/pattern it makes it easier to create and maintain query objects
Artigo massa. Peguei umas ideias pra implementar aqui na nossa aplicação. Brigadão.