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 times. 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:
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:
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
.
class TransactionsQuery
end
As a next step, we need to create an initializer where a default call path for our object will be created
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.
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.
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:
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.
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:
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:
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.
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.