Rails: Query Object Pattern Implementation
Tags: ruby, rails, designpattern, cleancode
Published: 2019-12-25 03:50:38

*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](https://www.ironin.it/blog/design-patterns-in-large-rails-applications-query-objects.html)

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](#chapter-1)
[2. Using scope in model](#chapter-2)
[3. Add metaprogramming](#chapter-3)
[4. Endgame - skinny controller](#chapter-4)
[5. The hidden cost](#chapter-5)
[6. Final thoughts](#chapter-6)
<a name="chapter-1"></a>
## 1. Beginning
Let say we have these kind of controllers:
```ruby
# 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.
<a name="chapter-2"></a>
## 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](https://guides.rubyonrails.org/active_record_querying.html#scopes).

Let's add scope in our models, and we will update our controllers too!
```ruby
# 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.
<a name="chapter-3"></a>
## 3. Add metaprogramming
If you don't know about Ruby Metaprogramming, [you can start with my article about Ruby Metaprogramming](https://dev.to/kputra/ruby-metaprogramming-part-1-1gjb). If you don't want to read the full article, just read chapter #3.

Now, let's upgrade our controllers using `send()`
```ruby
# 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!
<a name="chapter-4"></a>
## 4. Endgame - skinny controller
Let's make a module to store our code.
```ruby
# 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.
```ruby
# 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
```
<a name="chapter-5"></a>
## 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:
```ruby
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`.
```ruby
# 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
```
<a name="chapter-6"></a>
## 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](https://www.justinweiss.com/articles/search-and-filter-rails-models-without-bloating-your-controller/) + [it's gist](https://gist.github.com/justinweiss/9065666) for my future use (in case the link is broken).