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).
Top comments (1)
a nice article