TechTutorial

Flexible Ruby on Rails Reader Objects

By May 1, 2019 July 30th, 2019 No Comments

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 overtime 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 quick.

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 being 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!

You just got paged. Now What? Get started with FireHydrant.

Bobby Tables

Bobby Tables

My name is Robert but people call me Bobby, Bobby Tables. I'm a long time software tinkerer and love building tools for other engineers and writing about it!