Rails: Query Object Pattern Implementation
K Putra
Posted on December 25, 2019
If you continue to read this article, I assume that you know Ruby, OOP in Ruby, RoR, Active Record, and a little bit Ruby Metaprogramming.
What is Query Object Pattern?
Query Objects are classes specifically responsible for handling complex SQL queries, usually with data aggregation and filtering methods, that can be applied to your database.
source: this article
In this article, we won't create separate class, but we create a module that can be reusable for all models.
Let's start our journey! (I use Rails API-only as example, but this article can be implemented in normal Rails as well)
Table of Contents:
1. Beginning
2. Using scope in model
3. Add metaprogramming
4. Endgame - skinny controller
5. The hidden cost
6. Final thoughts
1. Beginning
Let say we have these kind of controllers:
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.where(nil)
users = users.where(role: params[:role]) if params[:role]
users = users.where(status: params[:status]) if params[:status]
users = users.where(public_id: params[:public]) if params[:public]
render json: users, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.where(nil)
companies = companies.where("name like ?", "#{params[:name]}%") if params[:name]
companies = companies.where(tax_id: params[:tax]) if params[:tax]
render json: companies, status: 200
end
end
Not so good, eh. Let's use scope
in our models.
2. Using scope in model
Now we implement scope in User
model and Company
model. What is scope? And how we use it? You can start with this official guide of rails.
Let's add scope in our models, and we will update our controllers too!
# app/models/user.rb
class User < ApplicationRecord
scope :role, -> (role) { where(role: role) }
scope :status, -> (status) { where(status: status) }
scope :public, -> (public_id) { where(public_id: public_id) }
end
# app/models/company.rb
class Company < ApplicationRecord
scope :name, -> (name) { where("name like ?", "#{name}%") }
scope :tax, -> (tax_id) { where(tax_id: tax_id) }
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.where(nil)
users = users.role(params[:role]) if params[:role]
users = users.status(params[:status]) if params[:status]
users = users.public(params[:public]) if params[:public]
render json: users, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.where(nil)
companies = companies.name(params[:name]) if params[:name]
companies = companies.tax(params[:tax]) if params[:tax]
render json: companies, status: 200
end
end
Well, our controllers is a bit more beautiful. But, they aren't satisfying enough.
3. Add metaprogramming
If you don't know about Ruby Metaprogramming, you can start with my article about Ruby Metaprogramming. If you don't want to read the full article, just read chapter #3.
Now, let's upgrade our controllers using send()
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.where(nil)
params.slice(:role, :status, :public).each do |key, value|
users = users.send(key, value) if value
end
render json: users, status: 200
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.where(nil)
params.slice(:name, :tax).each do |key, value|
companies = companies.send(key, value) if value
end
render json: companies, status: 200
end
end
Okay, but we should not satisfied enough. We can refactor our controllers!
4. Endgame - skinny controller
Let's make a module to store our code.
# app/models/concerns/filterable.rb
module Filterable
extend ActiveSupport::Concern
module ClassMethods
def filter(filtering_params)
results = self.where(nil)
filtering_params.each do |key, value|
results = results.send(key, value) if value
end
results
end
end
end
Now, we have to update our models and upgrade our controllers.
# app/models/user.rb
class User < ApplicationRecord
include Filterable
...
end
# app/models/company.rb
class Company < ApplicationRecord
include Filterable
...
end
# app/controllers/users_controller.rb
class UsersController < ApplicationController
def index
users = User.filter(filtering_(params))
render json: users, status: 200
end
private
def filtering_(params)
params.slice(:role, :status, :public)
end
end
# app/controllers/companies_controller.rb
class CompaniesController < ApplicationController
def index
companies = Company.filter(filtering_(params))
render json: companies, status: 200
end
private
def filtering_(params)
params.slice(:name, :tax)
end
end
5. The hidden cost
a. Remember to whitelist your params! In my example, I whitelist my params using filtering_(params)
method in each controller. If you don't whitelist your params, imagine this:
params = { destroy: 1 }
User.filter(params)
b. In Ruby 2.6.0, method filter is added on enumerables that takes a block. You can check the code below. But I strongly suggest to change the method name, from filter
to filter_by
.
# This will throw error, because ruby use filter for enumerables.
def index
@companies = Company.includes(:agency).order(Company.sortable(params[:sort]))
@companies = @companies.filter(params.slice(:ferret, :geo, :status))
end
# This won't throw error, because we call filter for class Company
def index
@companies = Company.filter(params.slice(:ferret, :geo, :status))
@companies = @companies.includes(:agency).order(Company.sortable(params[:sort]))
end
6. Final thoughts
Is this really necessary? May be it is not if the parameters given is not much, or you have only 1 or 2 models. But, what if the parameters is more than 4 per models, and you have 10 models?
Btw, this code is not my work. I only rewrite this article + it's gist for my future use (in case the link is broken).
Posted on December 25, 2019
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.