Flexible Ruby on Rails Reader Objects
Rails and ActiveRecord provide a simple interface for retrieving information from a database. With a few characters, I can retrieve all of my users with User.all. This simplicity is great, but it breaks down when you start doing more advanced queries.
By Robert Ross on 5/1/2019
Rails and ActiveRecord are great at providing a simple interface for retrieving information from a database. With a few simple characters, I can retrieve all of my users with User.all
. While this simplicity is great, it breaks down when you want to start doing more advanced queries such as paginated results, filtered records, etc. Typically you start to see Rails models get a swath of scope definitions to implement this, but over time this just becomes incredibly difficult to maintain. This also gets hairy when you want to use the same reader object for different types of requests (API vs UI for example).
Your Typical Approach
Let’s assume we have a model called User
with 4 attributes:
Name (string)
Email (string)
Role (owner, admin, member)
Invited At (datetime)
Now we want to select all users invited in the last week that were set to “admin” and paginate it. Typically we’d accomplish this by doing something like:
def index
@users = User.where(role: 'admin').where('invited_at >= ?', 7.days.ago).per_page(15).page(params[:page])
end
Not exactly easy to look at. We have given our controller a ton of knowledge of how exactly to retrieve these users. We could add a scope
to our model but that becomes oddly specific. We could parameterize it, but then when if we only want recently invited without the role field included? It gets messy very quickly.
A Simple Reader
With this in mind, we can solve this by introducing a new type of class: A Reader object. I like creating my reader objects in the app/readers
directory. This design is more ideal to me because it follows the CQRS pattern by separating Reads and Writes.
# app/readers/user_reader.rb
class UserReader
def initialize(params = {})
@params = params
end
private
attr_reader :params
end
In this example, we’re adding a class that accepts params
and assigns it to an instance variable on initialization. The reason we do this is because we can accept filtering params and pagination easily in our controller later.
Next, what I prefer doing is making our reader comply with the Enumerable module in Ruby. Let’s implement our each
method to accomplish this:
# app/readers/user_reader.rb
class UserReader
include Enumerable
def initialize(params = {})
@params = params
end
def each(&block)
User.all.each(&block)
end
private
attr_reader :params
end
What we’re doing here is slowly building our reader object. At this point we can actually swap out our controller’s code:
# app/controllers/users_controller.rb
def index
@users = UserReader.new(params)
end
Because our class implements Enumerable, our views don’t need to change at all!
Adding Pagination
I’m a big fan of the pagy gem. It’s relatively new and I find it to be more flexible than the common will_paginate
or kaminari
gems.
To add it, we can actually include the Backend
module of Pagy. We need to change how our each
method behaves though, because Pagy doesn't add any methods to ActiveRecord::Base
(which I'm a big fan of). Let's take a look at our new class:
class UserReader
include Enumerable
include Pagy::Backend
def initialize(params = {})
@params = params
end
def each(&block)
paginate!
@list.each(&block)
end
def pagination
paginate!
@pagination
end
private
# Check if our instance variables have been set and if not
# run our query and paginate it.
def paginate!
return if defined?(@pagination) && defined?(@list)
@pagination, @list = pagy(User.all)
end
attr_reader :params
end
Let’s break this down:
First we include the
Pagy::Backend
module. This works incredibly well because by default the module uses theparams
method in the class it's included in for pagination values like page and per\_page which we've defined with an attr\_reader.We modify our `each` method and put a call to a private method named
paginate!
. This method is responsible for assigning our pagination data and list array to instance variables.Our
each
method then iterates over the list and calls the providedblock
for each item.Our
pagination
method must exist so Pagy can work in the frontend. (More on this towards the end).
Next, let’s add a simple filter that can filter on role types. The Open-Closed principle is a great pattern for features as such filters by using an array of filterable keys and adding the methods as necessary.
class UserReader
include Enumerable
include Pagy::Backend
FILTERABLE_PARAMS = %i( role )
def initialize(params = {})
@params = params
end
def each(&block)
paginate!
@list.each(&block)
end
def pagination
paginate!
@pagination
end
private
# Iterate through all of our filterable params
# and append to the active record query as necessary
def relation
FILTERABLE_PARAMS.inject(User.all) do |relation, key|
if params.has_key?(key) && params[key].present?
send("filter_#{key}", relation, params[key])
else
relation
end
end
end
def filter_role(relation, role)
relation.where(role: role)
end
# Check if our instance variables have been set and if not
# run our query and paginate it.
def paginate!
return if defined?(@pagination) && defined?(@list)
@pagination, @list = pagy(relation)
end
attr_reader :params
end
Breakdown:
We’ve added a
relation
method that gives us a strung togetherActiveRecord::Relation
object. It iterates through all filterable keys as defined by ourFILTERABLE_PARAMS
constant. This is important because it prevents users from passing arbitrary query params and calling methods in our code.We’ve modified our
paginate!
method to call therelation
method instead ofUser.all
. This assigns our list object to use the filtered query now.By adding the
filter_role
method and accepting a relation and role name to filter on, we can quickly add filtering methods (just like a scope) to our reader as long as it exists in theFILTERABLE_PARAMS
constant.
Now, for the moment of truth! If we have a local Rails application running, we can head to http://localhost:3000/users?role=admin and see if our filters and pagination is working!
Pagy::Frontend
This is all swell, but we still need to display pagination links to our users in Pagy. In our app/helpers/application_helper.rb
we need to include the frontend module:
module ApplicationHelper
include Pagy::Frontend
end
And in our app/views/users/index.html.erb
:
<%== pagy_nav(@users.pagination) %>
Closing
I’ve found this Pattern to be extremely helpful in building FireHydrant. Since the UI and API both list objects, it’s nice to be able to call the same Object for this and mutate the params
before passing it into the initializer. This gives the same interface for all places I need to fetch records. Not only that, but these objects are extremely easy to test in isolation with your testing framework of choice.
As always, the final result of this blog post can be found on GitHub here: https://github.com/firehydrant-io/blog-flexible-readers.
Hope you enjoyed it!
You just got paged. Now what?
FireHydrant helps every team master incident response with straightforward processes that build trust and make communication easy.
See FireHydrant in action
See how our end-to-end incident management platform can help your team respond to incidents faster and more effectively.
Get a demo