You may have heard of query objects before: their main purpose is to encapsulate a database query as a reusable, potentially composable, and parameterizable unit. When is it best to reach for them and how can we structure them? Let’s dive into this topic.
Reusing query filters
In Rails, it’s very easy to create database queries. Provided that we’ve defined an Active Record model, we can easily construct an ad-hoc database query:
ServiceOffering
.where(state: "CA")
.joins(:vendor)
.where(vendors: {education_level: "Kindergarten"})
If we spread queries like that throughout our application but need to change how the “education level” filter works, there will be many touchpoints to update. The simplest fix for that is to represent our filters with class methods to make them reusable:
class ServiceOffering < ApplicationRecord
def self.by_state(state)
where(state: state)
end
def self.by_education_level(education_level)
joins(:vendor)
.where(vendors: {education_level: education_level})
end
# ...
end
And call our query like this:
ServiceOffering
.by_state("CA")
.by_education_level("Kindergarten")
Handling optional filters
What if our filters are optional? Let’s make by_state
optional:
def self.by_state(state)
where(state: state) if state.present?
end
Unfortunately, that breaks our filter chain. In the example below,
if the value of params[:state]
is blank, we get an error:
# undefined method `by_education_level' for nil:NilClass
ServiceOffering
.by_state(params[:state])
.by_education_level(params[:education_level])
And the solution for that is Active Record scopes, which are lenient
to nil
return values as to preserve the chainability of our scopes
when they return nil
:
class ServiceOffering < ApplicationRecord
scope :by_state, ->(state) { where(state: state) if state.present? }
scope :by_education_level, ->(education_level) do
if education_level.present?
joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
# ...
end
A case for query objects: domain-specific queries
Our situation has certainly improved from the first code snippet, but the latest version of our query still has a few smells.
It’s a chainable query, so it’s very likely that our filters will always be used together; we want to make sure our filters are tested in the same combinations they will actually be used in production, but no proper encapsulation exists;
Our filters are optional; the logic to skip a filter is very specific and may not make sense in the general context of our
ServiceOffering
model. If we reuse a scope like that, we may inadvertently introduce a bug in our application if we’re not counting with the possibility of blank filters;We are joining with other tables, which feels outside of our model’s responsibility; whenever our query spans more than one table or reaches a certain complexity threshold, it’s a sign we could represent it with a query object.
The main problem with the default Rails mindset is that whenever we need a new filter, the easiest way around is to add a new method to our model. Over time, our model tends to get littered with inexpressive filter bits and its body doesn’t form a coherent whole.
Sometimes, a subset of filters will only be used (or reused) in a particular subdomain, so it makes sense to group them together as a coherent unit with a single purpose and leave our models alone. And it gets even better if we name our queries after something that makes sense within our domain.
Building a query object
If the service offerings belong to the marketplace context of our
application, we could create a MarketplaceItems
class to represent
it. Today, MarketplaceItems
returns ServiceOffering
objects, but
tomorrow it may return something else – so abstracting our operation
as a proper domain entity is surely beneficial.
# For simplicity's sake, we are not applying a namespace to this class
class MarketplaceItems
def self.call(filters)
scope = ServiceOffering.all
if filters[:state].present?
scope = scope.where(state: filters[:state])
end
if filters[:education_level].present?
scope = scope
.joins(:vendor)
.where(vendors: {education_level: filters[:education_level]})
end
scope
end
end
Calling this query object is very simple:
MarketplaceItems.call(state: "CA", education_level: "Kindergarten")
Our query object works, but is not very scalable. Repeatedly
reassigning a local variable under an if
condition is tiring and
muddles our code, especially when more than a few filters are
involved. Would private methods improve our semantics? Let’s see:
class MarketplaceItems
class << self
def call(filters)
scope = ServiceOffering.all
scope = by_state(scope, filters[:state])
scope = by_education_level(scope, filters[:education_level])
scope
end
private
def by_state(scope, state)
return scope if state.blank?
scope.where(state: state)
end
def by_education_level(scope, education_level)
return scope if education_level.blank?
scope
.joins(:vendor)
.where(vendors: {education_level: filters[:education_level]})
end
end
end
Not by much. We still need to pass the scope
around and keep track
of a local variable. That’s relatively manageable and not entirely
bad, but there’s a better way around it.
A better query object
Luckily, Ruby is a dynamic language and contrary to what some people
believe, extending an object at runtime is not expensive. Rails
provides us with the extending
method to extend an
ActiveRecord::Relation
at runtime, which we can use to our
advantage. Let’s refactor our query object with that trick in mind:
class MarketplaceItems
module Scopes
def by_state(state)
return self if state.blank?
where(state: state)
end
def by_education_level(education_level)
return self if education_level.blank?
joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
def self.call(filters)
ServiceOffering
.extending(Scopes)
.by_state(filters[:state])
.by_education_level(filters[:education_level])
end
end
Notice how much cleaner our query object feels! The call
method is
only concerned with building our query and filter chaining, and our
scopes are neatly separated in a module. We still need to return
self
in our scopes on blank parameters, but that does not nullify
the merits of the approach we were able to come up so far.
That approach really shines when dealing with a considerable number of filters.
Ways to structure a query object
There are many ways to structure a query object, for example:
- Injecting a scope in the initializer to provide more flexibility to the caller:
class MarketplaceItems
def self.call(scope, filters)
new(scope).call(filters)
end
def initialize(scope = ServiceOffering.all)
@scope = scope
end
def call(filters = {})
# ...
end
end
# Scope marketplace items to a particular vendor
MarketplaceItems.call(vendor.service_offerings, filters)
- Making it return raw data rather than an Active Record scope, which is useful when dealing with performance-sensitive queries:
class MarketplaceItems
COLUMNS = [:title]
# An alternative is to have a second method to return
# raw data, in addition to a main method that returns
# an ActiveRecord::Relation
def self.call(filters)
ServiceOffering
.extending(Scopes)
.by_state(filters[:state])
.by_education_level(filters[:education_level])
.pluck(*COLUMNS)
.map { |row| COLUMNS.zip(row).to_h }
end
end
- Using raw SQL instead of the Active Record query builder.
And many others! What’s important is that each option is used with a purpose that serves the app without overengineering, unless a hard convention exists on your project.
Bonus: Scopes with Rails-like behavior
If we’re bothered with returning self
when a filter is blank, we can
easily solve that problem with a Scopeable
module:
module Scopeable
def scope(name, body)
define_method name do |*args, **kwargs|
relation = instance_exec(*args, **kwargs, &body)
relation || self
end
end
end
Now we can extend our Scopes
module with Scopeable
and shorten our
scopes a little bit:
module Scopes
extend Scopeable
scope :by_state, ->(state) { state && where(state: state) }
scope :by_education_level, ->(education_level) do
education_level && joins(:vendor)
.where(vendors: {education_level: education_level})
end
end
I’m particularly not a fan of that approach (and of Active Record
scopes in general) because it may hinder the discoverability of our
code, so I’m generally biased towards normal Ruby methods. I’d even
prefer to explicitly mark the methods I want to behave as scopes with
a scope
annotation, as follows:
module Scopes
extend Scopeable
def by_state(state)
state.present? && where(state: state)
end
scope :by_state # Decorate by_state to make it behave like a scope
# ...
end
But that is left as an exercise to the reader.
Wrapup
I’m a fan of domain-driven design and single purpose objects, so I usually prefer to keep my models clean of specific cruft that does not pertain to the general model entity.
Active Record models with query methods are definitely bearable when dealing with generic and non-chainable filters, otherwise a well-named query object is a great alternative to consider!