Rails: Query Object Pattern Implementation

kputra

K Putra

Posted on December 25, 2019

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).

💖 💪 🙅 🚩
kputra
K Putra

Posted on December 25, 2019

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related