Optimizing code with Query Objects

It’s quite probable that at work you have encountered with overloaded models and a huge number of calls in the controllers for quite a lot of time. Basing on the knowledge in the Rails environment, in this article, I am going to propose a simple solution to this problem.

A very important aspect of the rails application is to minimize the number of redundant dependencies, which is why the entire Rails environment has recently been promoting the service object approach and the use of the PORO (Pure Old Ruby Object) method. A description of how to use such a solution you will find here. In this article, we will solve the concept step by step and adapt it to the problem.

Problem

In a hypothetical application, we are dealing with a complicated transaction system. Our model, representing each transaction, has a set of scopes, that help you to get data. It is a great job facilitation as it can be found in one place. However, this doesn’t last for long. With the development of the application, the project is becoming more and more complicated. Scopes no longer have simple ‘where’ references, we lack data and start to load relationships. After a while, it reminds a complicated system of mirrors. And, what’s worse, we do not know how to do a multi-line lambda!

Below, you will find an already expanded application model. The payment system transactions are stored in. As you can see in the example below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Transaction < ActiveRecord::Base
  belongs_to :account
  has_one :withdrawal_item

  scope(:for_publishers, lambda do
    select("transactions.*")
      .joins(:account).where("accounts.owner_type = 'Publisher'")
      .joins("JOIN publishers ON owner_id = publishers.id")
  end)

  scope :visible, -> { where(visible: true) }

  scope(:active, lambda do
    joins(<<-SQL)
      LEFT OUTER JOIN source ON transactions.source_id = source.id
      AND source.accepted_at IS NOT NULL
    SQL
  end)
end

The model is one thing, but as the scale of our project increases, the controllers also start to swell. Let’s look at the example below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class TransactionsController < ApplicationController
  def index
    @transactions = Transaction.for_publishers
                                   .active
                                   .visible
                                   .joins("LEFT JOIN withdrawal_items ON withdrawal_items.transaction_id = transactions.id")
                                   .joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR \
                                    (withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
                                   .order(:created_at)
                                   .page(params[:page])
                                   .per(params[:page])
    @transactions = apply_filters(@transactions)
  end
end

Here we can see many lines of chained methods alongside with additional joins that we do not want to perform in many places, only in this particular one. The attached data is later used by the apply_filters method, which adds the appropriate data filtering, based on the GET parameters. Of course, we can transfer some of these references to scope, but isn’t this the problem that we are actually trying to resolve?

Solution

Since we already know about a problem we have, we must solve this. Based on the reference in the introduction, we will use the PORO approach here. In this exact case, this approach is called the query object, which is a development of the service objects concept.

Let’s create a new directory named “services”, located in the apps directory of our project. There we will create a class named `TransactionsQuery`.

1
2
class TransactionsQuery
end

As a next step, we need to create an initializer where a default call path for our object will be created

1
2
3
4
5
class TransactionsQuery
  def initialize(scope = Transaction.all)
    @scope = scope
  end
end

Thanks to this, we will be able to transfer the relationship from the active record to our facility. Now we can transfer all our scopes to the class, which are needed only in the presented controller.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class TransactionsQuery
  def initialize(scope = Transaction.all)
    @scope = scope
  end

  private

  def active(scope)
    scope.joins(<<-SQL)
      LEFT OUTER JOIN source ON transactions.source_id = source.id
      AND source.accepted_at IS NOT NULL
    SQL
  end

  def visible(scope)
    scope.where(visible: true)
  end

  def for_publishers(scope)
    scope.select("transactions.*")
         .joins(:account)
         .where("accounts.owner_type = 'Publisher'")
         .joins("JOIN publishers ON owner_id = publishers.id")
  end
end

We still miss the most important part, ie collecting data into one string and making the interface public. The method where we will stick everything together will be named a “call”.

What is really important is that we will use the @scope instance variable there, where the scope of our call is located.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
class TransactionsQuery
  ...
  def call
    visible(@scope)
        .then(&method(:active))
        .then(&method(:for_publishers))
        .order(:created_at)
  end

  private
  ...
end

The entire class presents itself as the following:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class TransactionsQuery
  def initialize(scope = Transaction.all)
    @scope = scope
  end

  def call
    visible(@scope)
        .then(&method(:active))
        .then(&method(:for_publishers))
        .order(:created_at)
  end

  private

  def active(scope)
    scope.joins(<<-SQL)
      LEFT OUTER JOIN source ON transactions.source_id = source.id
      AND source.accepted_at IS NOT NULL
    SQL
  end

  def visible(scope)
    scope.where(visible: true)
  end

  def for_publishers(scope)
    scope.select("transactions.*")
         .joins(:account)
         .where("accounts.owner_type = 'Publisher'")
         .joins("JOIN publishers ON owner_id = publishers.id")
  end
end

After our cleaning-up, the model looks definitely lighter. There we focus only on the data validation and relationships between other models.

1
2
3
4
class Transaction < ActiveRecord::Base
  belongs_to :account
  has_one :withdrawal_item
end

The controller has already implemented our solution; we have moved all additional queries to a separate class. However, the calls, we did not have in the model, remain an unresolved issue. After some changes, our index action looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class TransactionsController < ApplicationController
  def index
    @transactions = TransactionsQuery.new
                                     .call
                                     .joins("LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id")
                                     .joins("LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR \
                                      (withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')")
                                     .order(:created_at)
                                     .page(params[:page])
                                     .per(params[:page])
    @transactions = apply_filters(@transactions)
  end
end

Solution

In the case of implementing good practices and conventions, a good idea may be to replace all similar occurrences of a given problem. Therefore, we will move the SQL query from the index action to the separate query object. We will call this a `TransactionsFilterableQuery` class. The style, which we prepare the class in, will be similar to that one presented in `TransactionsQuery`. As part of the code changes, a more intuitive record of large SQL queries will be smuggled, using multiline character strings called heredoc. The solution available you will find below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class TransactionsFilterableQuery
  def initialize(scope = Transaction.all)
    @scope = scope
  end

  def call
    withdrawal(@scope).then(&method(:withdrawal_items))
  end

  private

  def withdrawal(scope)
    scope.joins(<<-SQL)
      LEFT JOIN withdrawals ON withdrawals.id = withdrawal_items.withdrawal_id OR
      (withdrawals.id = source.resource_id AND source.resource_type = 'Withdrawal')
    SQL
  end

  def withdrawal_items(scope)
    scope.joins(<<-SQL)
      LEFT JOIN withdrawal_items ON withdrawal_items.accounting_event_id = transactions.id
    SQL
  end
end

In case of changes in the controller, we reduce the mass of lines by adding the query object. It is important that we separate everything except the part responsible for pagination.

1
2
3
4
5
6
7
8
9
class TransactionsController < ApplicationController
  def index
    @transactions = TransactionsQuery.new.call.then do |scope|
      TransactionsFilterableQuery.new(scope).call
    end.page(params[:page]).per(params[:page])

    @transactions = apply_filters(@transactions)
  end
end

Summary

Query object changes a lot in the approach to writing SQL queries. In ActiveRecord, it is very easy to place all business and database logic in the model since everything is in one place. This will work quite well for smaller applications. As the complexity of the project increases, we set the logic to other places. The same query object allows you to group member query queries into a specific problem.

Thanks to this, we have an easy possibility of the code’s later inheritance and because of duck typing, you can also use these solutions in other models. The disadvantage of this solution is a larger amount of code and fragmentation of responsibility. However, whether we want to take up such a challenge or not, depends on us and how badly we are disturbed by fat models.

Next

Let's start a project

Estimate project