Design patterns in large Ruby on Rails web applications: constructing a Query Object class that is responsible for elegantly querying a database. Read our blog post to find out how to make a simple and easy to test Query Object implementation within a Rails application.
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. We use this design pattern in large Ruby on Rails web applications, to improve an app’s maintainability and scalability - which makes programmers’ lives a bit easier along the way and allows for faster development times.
Oftentimes, the code responsible for querying the database is placed within scopes in models, or gets mixed in with other logic. It is usually the same code in many places throughout the app, which goes against the principles of DRY programming (Don’t Repeat Yourself!). This leads to a lengthy development process and makes the code more prone to errors - causing a lot of irritation, especially when there is a need to refactor code or make changes to the database itself during any stage of the query.
Example of a poorly designed query function
So what does a poorly designed query function look like and what troubles can it cause? Have a look at the example below - which is a combination of a few actions, including queries to the database - and check for yourself how hard it is to test it without hitting the database with the real records:
def popular_sport_posts
popular_posts = []
if user.posts.where(category: 'sport').any?
sport_posts = user.posts.where(category: 'sport')
return sport_posts if sport_posts.size == 1
if sport_posts.size > 1 && sport_posts.size < 11
sport_posts.each do |post|
popular_posts << post if post.comments.where(published: true).present?
end
end
end
if sport_posts.size > 10
sport_posts.each do |post|
if post.comments.where(published: true).present?
post.comments.each do |comment|
if comment.created_at < 30.days.ago
popular_posts << post
break
end
end
end
end
end
popular_posts
end
A best programming practices solution for the above? We can simply extract our database queries in Rails in the above code to create Policy Objects and Query Objects. This will make each section of code more isolated - and thus far easier to test.
Construction of a Query Object class
There is no significant difference between a standard class and a Query Object class. Still, like any other design pattern, this one also has its own specific set of rules.
In terms of initializing the class, it is always clever to pass the scope. If the scope is not given, then we use the default one. Thanks to this approach, we can always pass a scope and use pre-filtered results, or create more complex queries by composing multiple query objects. Here is a simple example:
module Products
class SportsQuery
def initialize(scope = Post.all)
@scope = scope
end
def products
scope.where(category: 'sport')
end
end
end
Other benefits of this design pattern
When creating a Query Object class, we make our models slimmer and our logic more decoupled. This means we can create more meaningful and faster tests that focus only on that specific part of the code.
Let’s get back again to the `popular_sport_posts` method presented above and refactor it using a Query Object.
class SportsQuery
def initialize(scope = Post.all)
@scope = scope
end
def sport_posts
scope.where(category: 'sport')
end
def sport_posts_count
sport_posts.count
end
def sport_posts_with_published_comments
sport_posts.joins(:comments).where(comments: { published: true })
end
def sport_posts_with_recent_published_comments
sport_posts_with_published_comments.where('DATE(comments.created_at) > ?', 30.days.ago)
end
end
def popular_sport_posts(user, sports_query = SportsQuery.new(user.posts))
sport_posts_count = sports_query.sport_posts_count
if sport_posts_count < 2
sports_query.sport_posts
elsif sport_posts_count < 11
sports_query.sport_posts_with_published_comments
else
sports_query.sport_posts_with_recent_published_comments
end
end
To test this class we are not forced to create real records in the database in order to check behavior. Tests are much faster and the code itself is far more readable. Thanks to the implemented design pattern, we can effortlessly separate database query logic and then just stub the Query Object, testing database communication in an abstracted test class.
See below for yourself:
describe '#popular_sport_posts' do
context 'when sports posts count is less than 2' do
it 'returns sport posts' do
posts = [instance_double(Post)]
sports_query = instance_double(SportsQuery,
sport_posts_count: 1,
sport_posts: posts)
result = described_object.popular_sport_posts(sports_query)
expect(result).to eq(sport_posts)
end
end
context 'when sports posts count is less than 11' do
it 'returns sport posts with published comments' do
posts = [instance_double(Post)]
sports_query = instance_double(SportsQuery,
sport_posts_count: 9,
sport_posts_with_published_comments: posts)
result = described_object.popular_sport_posts(sports_query)
expect(result).to eq(posts)
end
end
context 'when sports posts count is more than 10' do
it 'returns sports posts with recent published comments' do
posts = [instance_double(Post)]
sports_query = instance_double(SportsQuery,
sport_posts_count: 12,
sport_posts_with_recent_published_comments: posts)
result = described_object.popular_sport_posts(sports_query)
expect(subject).to eq(posts)
end
end
end
Query Object and Builder pattern - the perfect pairing
It is worth mentioning that the Query Object pattern works fantastically with the Builder pattern, especially in cases where a scope object is too complicated to just pass it to the initializer. In such an instance it is better to create a base class, which will expose our code for queries.
Usually we keep our Query Objects under `/lib/query_objects/products/sport_query.rb`, where `products` is the name of the database table and the file name, `sport_query.rb`, is a combination of the query suffix and the type of data we are fetching when using this class.
Intrigued by our solution to construct easy-to-test Query Object classes responsible for elegant communication with your database? Want to check out our IT skills and knowledge in practice? Make sure to get in contact with us at iRonin - we can help you with your Ruby on Rails web applications.