DEV Community

K Putra
K Putra

Posted on • Edited on

Rails: Query Object Pattern Implementation

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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) 
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
johnnyting profile image
JohnnyTing

a nice article