Query Objects in Rails Technology
Query objects are not a particularly new idea, they’ve been around a while and in many languages.
Simply put, a query object is just a class used to encapsulate a piece of query logic.
Traditionally, in Rails, query logic typically lives in the relevant ActiveRecord
model class as a scope or in some cases as a separate service class that performs a query and returns something depending on the need (I say something as service objects tend to be quite general by nature).
Building a Query object from an existing scope
Say we have an existing scope, which looks as follows:
Lets say we want to move this to a query object. First step, lets create a place to put our query objects.
I’d recommend a new folder under the app
called queries
so its nice and explicit whats in there.
The above scope refactored as a query object might look a little bit like as follows:
This kind of works. I mean its encapsulated our query logic, but its not very flexible as we can’t mix it with other queries like we would typically do when chaining scopes.
We could make this a little bit more flexible by giving the query object some state so that it can be passed a relation object to work on (with a default so its optional).
We’ll still keep the main invoking method name as call
which is a de facto practice in Ruby.
It might look something like as follows:
Now, it’s a little bit more flexible because we can pass in an existing collection and it will work off of that.
For example, something like this UserPromotedPostsQuery.new(User.country('Ireland')).call
.
In other words, Users with promoted posts from Ireland.
It would probably make sense to namespace this query also under the User
namespace as you’d imagine our queries
directory would get quite unmanageable as we add more to it.
In that case, the query object would be referenced via User::PromotedPostsQuery
and live in app/queries/users/promoted_posts_query.rb
.
As we create more queries, you can imagine that theres going to be some copy and pasting each time, as the initializer is going to be nearly the same for each one. The only aspect that will change is the subject, which can be implied by the name space in any case.
So, how would a BaseQuery
class look that all query objects could inherit from.
It might look something like this..
Now, our initializer defaults to the module parent, so once we properly namespace our queries under the ActiveRecord
subject class, this will automatically work and we can override this if desired.
We simply need to implement the call
method. The call
in our base class is simply a template.
So, now our User::PromotedPostsQuery
would look as follows:
Nice and to the point but still flexible. We can still do stuff like User::PromotedPostsQuery.new(User.country('Ireland')).call
.
We’ve also made a tiny refactoring to use merge
so the Post.promoted
logic lives under the Post
where it should.
Wiring our query object up to our model
If we wanted to chain our query objects together so we can build up useful queries, you can imagine it would get a little bit long winded. For example
User::IsActive.new(User::PromotedPostsQuery.new(User.country('Ireland')).call).call
i.e. Active users with promoted posts from Ireland.
Ugh… I guess we dont have to do it all in one line, but there is a nicer way to hook these query objects to our model class.
When we look at the ActiveRecord
definition of a named scope
, we see something interesting.
# File activerecord/lib/active_record/scoping/named.rb, line 170
def scope(name, body, &block)
unless body.respond_to?(:call)
raise ArgumentError, "The scope body needs to be callable."
end
# ...
end
This tells us that we can actually pass a body that responds to call
and follows the rules of what scopes do (i.e. always return collections) and it’ll all work.
So, conveniently, we can actually do the following…
So, now we can do User.active.with_promoted_posts.country('Ireland')
.
This is much more readable now.
We can make one small adjustment to our BaseQuery
class so we don’t need to always initialize it (as in Ruby, even classes are objects).
We can add the following:
class << self
delegate :call, to: :new
end
By doing this, it means wiring it up to our model is a little cleaner
scope :with_promoted_posts, PromotedPostsQuery
Its also possible to write query objects that take some variables via the call method.
For example, we might have a query object that fetches users that have made comments of a particular status.
i.e. User::CommentStatusQuery.call('pending')
In other words, Users that have pending comments. For scenarios like this we have 2 options when wiring them up to the model,
Option 1, as above…
scope :with_comment_status, CommentStatusQuery
The downside to this approach is that its not obvious that a parameter is required. In this case if we wanted to make it more explicit, we could re-writing it as follows:
scope :with_comment_status, ->(status) { CommentStatusQuery.call(status) }
Both approaches work and I guess it depends on preferences which way is preferred.
Merging with the filterable concern
In my previous post, I ran over an approach to filtering on an index using a Filterable
concern.
Conveniently, we can merge our query objects with our filterable concern as follows.
filter_scope :with_comment_status, CommentStatusQuery
Now, with_comment_status
is a filtering param we can automatically use in our controller index.
So, we’ll now be able to go to www.site.com/users?with_comment_status=pending
and it will invoke our query object.
The complete BaseQuery with examples
So, heres what it all finally looks like…
For a working example of this in action, you can take a look at the following repo: https://github.com/johnmfarrell1/rails_index_filtering