I recently started reading “A Layered Design for Ruby on Rails Applications” by Vladimir Dementyev. I have always encountered some pain points when working with Rails applications when the model and controller layers grow in scope. One specific problem is adding complex querying logic to the model. At first, although it does not look like a dangerous pursuit, adding such a burden on the model starts to crack into the code quality. Thankfully, the book talks about the use of Query Objects and how we can use them to extract complex queries from our models.
I want to also emphasize that query objects is specificlly use for complex queries that might better suited to be extracted into its own class. Not every query needs to be a query object.
For loominex.io, a maintenance management system, I have the following model:
class WorkOrder < ApplicationRecord
belongs_to :workspace
belongs_to :equipment, optional: true
has_many :tasks, dependent: :destroy
has_many_attached :files
scope :search, -> (query) {
where('name ILIKE ?', "%#{query}%")
}
scope :open, -> { where.not(status: 'Completed').where('due_date >= ?', Date.current) }
scope :delayed, -> { where(status: ['Not Started', 'In Progress']).where('due_date < ?', Date.current) }
scope :completed, -> { where(status: 'Completed') }
scope :urgent, -> { where(priority: 'Urgent').where.not(status: 'Completed') }
end
As you can see from the example above, our model is increasing in scope. Increasingly, I’m adding more responsibility to the model layer. Although these queries are not as complex, with time and requirements increasing, the WorkOrder model can get out of hand. This is where the bugs start showing up.
Thankfully, query objects help in extracting some of these queries from the models. It would help us make the WorkOrder model a bit leaner, improve the health of the codebase by removing the querying from the model, and help us integrate a new convention that would make it easier to test the app and decouple the application. Let’s look into it:
class ApplicationQuery
class << self
def query_model
name.sub(/::[^\:]+$/, "").safe_constantize
end
def resolve(...) = new.resolve(...)
end
private attr_reader :relation
def initialize(relation = self.class.query_model.all)
@relation = relation
end
def resolve(...)
relation
end
end
I can now add my query objects under the model directory in the work_order
directory.
├── work_order │ ├── open_status_query.rb │ └── urgent_status_query.rb
ruby Copy code
class WorkOrder
class OpenStatusQuery < ApplicationQuery
def resolve(due_date = Date.current)
relation.where.not(status: 'Completed').where('due_date >= ?', due_date)
end
end
end
class WorkOrder
class UrgentStatusQuery < ApplicationQuery
def resolve
relation.where(priority: 'Urgent').where.not(status: 'Completed')
end
end
end
With this, I can now call:
irb(main):001:0> WorkOrder::UrgentStatusQuery.resolve
WorkOrder Load (1.0ms) SELECT "work_orders".* FROM "work_orders" WHERE "work_orders"."priority" = $1 AND "work_orders"."status" != $2 [["priority", "Urgent"], ["status", "Completed"]]
=>
[#<WorkOrder:0x000000010d536048
id: 13,
equipment_id: nil,
workspace_id: 2,
...
]
irb(main):001:0> WorkOrder::OpenStatusQuery.resolve
WorkOrder Load (3.5ms) SELECT "work_orders".* FROM "work_orders" WHERE "work_orders"."status" != $1 AND (due_date >= '2024-07-03') [["status", "Completed"]]
=>
[#<WorkOrder:0x0000000104cdd2f0
id: 31,
equipment_id: 15,
workspace_id: 6,
name: "sample test",
description: nil,
priority: ni
...
]
We can now conclude that this refactored version makes our codebase healthier, makes it easier to test, removes complex queries from the model layer, and improves our code quality.