Excerpt
This article is also available in: Portuguese
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.
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:
```plain text
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:
```plain text
class ServiceOffering < ApplicationRecord
def self.by_state(state)
where(state: state)
end
def self.by_education_level(education_
This article is also available in: Portuguese
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.
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:
```plain text
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:
```plain text
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:
```plain text
ServiceOffering
.by_state("CA")
.by_education_level("Kindergarten")
```
What if our filters are optional? Let’s make by_state optional:
```plain text
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:
```plain text
# 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:
```plain text
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.
1. 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;
2. 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;
3. 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.
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.
```plain text
# 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:
```plain text
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:
```plain text
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.
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:
```plain text
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.
There are many ways to structure a query object, for example:
- Injecting a scope in the initializer to provide more flexibility to the caller:
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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:
```plain text
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.
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!
If you enjoyed this post, you might also like:
