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.

Robert Rossprofile image

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:

  1. First we include the Pagy::Backend module. This works incredibly well because by default the module uses the params method in the class it's included in for pagination values like page and per\_page which we've defined with an attr\_reader.

  2. 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.

  3. Our each method then iterates over the list and calls the provided block for each item.

  4. 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:

  1. We’ve added a relation method that gives us a strung together ActiveRecord::Relation object. It iterates through all filterable keys as defined by our FILTERABLE_PARAMS constant. This is important because it prevents users from passing arbitrary query params and calling methods in our code.

  2. We’ve modified our paginate! method to call the relation method instead of User.all. This assigns our list object to use the filtered query now.

  3. 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 the FILTERABLE_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.

Learn How

See FireHydrant in action

See how service catalog, incident management, and incident communications come together in a live demo.

Get a demo