Seven Days of Design Patterns | Part 5 - Query Objects

June 30, 2019

Welcome back folks! I hope you have been enjoying the series on design patterns so far. Today I wanted to carve out some time to talk about query objects. Now you might say something like, “but ActiveRecord already has scopes!”..and you would be 100% correct. The problem is in the ease of use of scopes and their ability to chain. Take the following example:

class IncomeReportController < ApplicationController
  def show
    @transactions = Transaction
                      .completed
                      .past_90_days
                      .not_refunded
                      .joins(:user)
                      .where(user: { status: 'active' })
  end
end

Do you see? It’s almost too easy to take all of these utility scopes and chain them to create some powerful query. While there is nothing inheritantly wrong in chaining scopes, there is a price paid. In cases like this the price is lack of meaning and difficulty in tests. By “lack of meaning” what I’m really trying to say is that it’s unclear why this is the correct query. Let’s explore this a little by pulling out a query object.

module Report
  class IncomeTransactionsQuery
    attr_reader :relation

    def initialize(relation)
      @relation = relation
    end

    def resolve
      relation.completed.not_refunded.joins(:user).where(user: { status: 'active' })
    end
  end
end

I know all we did was extract the query logic, but let’s take a look at the controller now.

class IncomeReportController < ApplicationController
  def show
    all_transactions = query_klass.new(Transaction.past_90_days).resolve
    @transactions = all_transactions.page(params[:page]).per_page(params[:per_page])
  end

  private

  def query_klass
    Report::IncomeTransactionsQuery
  end
end

We can clearly see that a collection is being fetched specifically for our use case of reporting income. That is pretty cool. This also gives us only one place to change if the requirements for this report change. Not sold yet? Let’s add some requirements! After all, we are building real world applications and concepts like reports change often. Here are the new requirements:

  • All existing conditions, except the date range, are solid
  • The report needs to allow filtering by a start and end date
  • The report needs to allow filtering by the status of a customer
  • The report needs to allow filtering by a minimum amount of the transaction
  • The report needs to allow filtering by user_id

Wowza! Leave it up to the finance team to add complexity to our simple little report. But do not fear, our query object is here :)

module Report
  class IncomeTransactionsQuery
    attr_reader :relation
    attr_reader :filters

    def initialize(relation:, filters: {})
      @relation = relation
      @filters = filters
    end

    def resolve
      scope = relation.completed.not_refunded
      return scope.distinct if filters.empty?

      apply_filter(scope, :start_date)
        .yield_self { |scope| apply_filter(scope, :end_date) }
        .yield_self { |scope| apply_filter(scope, :user_status) }
        .yield_self { |scope| apply_filter(scope, :user_id) }
        .yield_self { |scope| apply_filter(scope, :amount) }
        .yield_self { |scope| scope.distinct }
    end

    private

    def apply_filter(scope, filter)
      return scope if filters[filter].nil?
      send("filter_by_#{filter}", scope, filters[filter])
    end

    def filter_by_start_date(scope, value)
      scope.where(created_at: value.to_datetime.beginning_of_day)
    end

    def filter_by_end_date(scope, value)
      scope.where(created_at: value.to_datetime.end_of_day)
    end

    def filter_by_user_status(scope, value)
      scope.joins(:user).where(user: { status: value })
    end

    def filter_by_amount(scope, value)
      scope.where('amount >= ?', value)
    end

    def filter_by_user_id(scope, value)
      scope.joins(:user).where(user: { id: value })
    end
  end
end

Our little query object sure grew up fast! It’s now responsible for more than the original static concept of a query for the income report. It also knows how to filter that query. Even though that’s true, I’d argue it doesn’t violate the Single responsibility principle. The only responsibility our query object has is to return the data needed for an income report. Let’s move onto the controller and see how these changing requirements impacted that code.

class IncomeReportController < ApplicationController
  def show
    all_transactions = query_klass.new(
      relation: Transaction.all,
      filters: filters
    ).resolve

    @transactions = all_transactions.page(params[:page]).per_page(params[:per_page])
  end

  private

  def filters
    params[:filter] ||= {}
    params.require(:filter).permit(
      :start_date,
      :end_date,
      :user_id,
      :user_status,
      :amount
    )
  end

  def query_klass
    Report::IncomeTransactionsQuery
  end
end

Well look at that. Our controller doesn’t change that much after all. The only thing we need to do is accept filter parameters and pass them to the query object. Not to shabby. Before I leave you to try this pattern on your own, I’ll enumerate a few good use-cases I have found for query objects:

  • When there are complex queries or scope chaining
  • When the query needs filtering that is not re-used
  • When the scope/query interacts with more models then itself

And here are some rules I apply when creating this query objects:

  • Accept a scope as the first argument (scope, relation, etc) are all good names
  • Stick to a naming convention (DoSomethingQuery, OtherThingQuery, etc)
  • Use a Base query object to extract away the common interface (attribute readers, initialize, etc)
  • Always return an ActiveRecord scope
  • Define .resolve as a class method that accepts the same arguments as initialize (helps with testing)

There are a lot if little details we kind of glossed over here. Details such as the littly bit of meta-programming in the query object for applying filters, the usage of yield_self, and the lack of data santization on the filters themselves. These are the things I leave to you, the readers, to explore yourselves. What you see here is how I approach these problems in real world RoR applications servicing hundreds to thousands of users on a daily basis. This pattern has been relatively easy to maintain and has stood the test of time on numerous RoR applications as old as eleven years. Don’t just take my word for it though. Try it out in your application(s) and see how it goes.

Next week, instead of moving onto the next design pattern (Presenters) we will be covering how to test the code you saw above. So buckle up and get ready for some test slinging!

As always, thank you for taking the time to read my ramblings. You truly are the best!

Comments

comments powered by Disqus