DEV Community

Vlad Hilko
Vlad Hilko

Posted on • Edited on

How to implement Query Object pattern in Ruby on Rails?

In simple terms, Query Object allows you to encapsulate complex database queries.

Why do we need it and what problems can this pattern solve?

Sometimes we have very complex queries that are used directly in the business logic. For example, the following query can be used many times in different controllers and service objects:

def index
  seasons = Season.joins(league: :country).where("countries.name = 'England'")

  render json: seasons
end
Enter fullscreen mode Exit fullscreen mode

What problem does this create for us?

  • It's impossible to test separately from the controller.
  • It's very difficult to stub/mock db request
  • It's not DRY
  • It's not clear, hard to read and understand what's going on.

So what can we do to fix it?

  • We can try to move this logic to the model level.
  • We can try to create a Query Object and move that logic there.

If we decide to choose the first option and use a model, there are 2 ways to implement this:

Class methods

# frozen_string_literal: true

class Season < ApplicationRecord

  def self.by_league(league)
    where(league: league)
  end

  def self.by_status(status)
    where(status: status)
  end

end

Season.by_league(league)
Season.by_status(status)
Enter fullscreen mode Exit fullscreen mode

Scope

class Season < ApplicationRecord

  scope :by_league, -> (league) { where(league: league) }
  scope :by_status, -> (status) { where(status: status) }

end

Season.by_league(league)
Season.by_status(status)
Enter fullscreen mode Exit fullscreen mode

The interface looks good, but we still have some problems. The most important one is the violation of Single responsibility principle. The model becomes too big and fat. We can get more than 100 methods in a year. So it will be very difficult to read, change, and maintain. It would be nice to have the same interface, but move that logic into a separate class.

How can we do that?

The Query Object pattern was created to solve this problem.

Let's start with the simplest Query Object.

# app/units/queries/season.rb

module Queries
  class Season

    class << self
      def by_league(league)
        ::Season.where(league: league)
      end

      def by_status(status)
        ::Season.where(status: status)
      end
    end

  end
end

Queries::Season.by_league(league)
Queries::Season.by_status(status)
Enter fullscreen mode Exit fullscreen mode

Interface looks fine, but there's one important problem - methods are not chainable and we have to repeat ::Season prefix every time. For example, if you want to write something like this Queries::Season.by_league(league).by_status(status), it won't work.

So, how can we fix it?

Rails has an interesting method - extending. This method allows you to add any methods on the model and chain them. For example, the following works fine.

module Scopes
  def by_league(league)
    where(league: league)
  end

  def by_status(status)
    where(status: status)
  end
end

query = Season.all.extending(Scopes)
query.by_league(league).by_status(status)

Enter fullscreen mode Exit fullscreen mode

So now we need to somehow delegate methods from Queries::Season to Season.all.extending(Scopes). How can we do this?
Rails provides a method delegate_missing_to
So we can simply delegate all methods to be called on Queries::Season to Season.all.extending(Scopes).

# frozen_string_literal: true

module Queries
  class Season

    module Scopes
      def by_league(league)
        where(league: league)
      end

      def by_status(status)
        where(status: status)
      end
    end

    class << self
      delegate_missing_to :relation

      def relation
        ::Season.all.extending(Scopes)
      end
    end

  end
end

Queries::Season.by_league(league).by_status(status)
Enter fullscreen mode Exit fullscreen mode

So now all methods are chainable. The last problem we can solve is that the code is NOT DRY, and we don't want to repeat the following every time:

class << self
  delegate_missing_to :relation

  def relation
    ::Season.all.extending(Scopes)
  end
end
Enter fullscreen mode Exit fullscreen mode

Let's create a parent class Query and move the common logic there.

# lib/query.rb

class Query

  class << self

    attr_reader :model

    def set_model(model)
      @model = model
    end

    delegate_missing_to :relation

    private

    def relation
      model.all.extending(self::Scopes)
    end

  end

end
Enter fullscreen mode Exit fullscreen mode

And let's inherit from this class:

# app/units/queries/season.rb

module Queries
  class Season < Query

    set_model ::Season

    module Scopes
      def by_league(league)
        where(league: league)
      end

      def by_status(status)
        where(status: status)
      end
    end

  end
end

Queries::Season.by_league(league).by_status(status)
Enter fullscreen mode Exit fullscreen mode

So the final solution may look as follows:

def index
  seasons = Queries::Season.by_country('England')

  render json: seasons
end
Enter fullscreen mode Exit fullscreen mode

Conclusion:

  • Query Object can be easily tested separately from the controller
  • Query Object can be easily stub/mocked
allow(Queries::Season).to receive(:by_country).with('England').and_return(...)
Enter fullscreen mode Exit fullscreen mode
  • Query Object is DRY and reusable
  • The code is clear, easy to read and understand
  • Query Object is separated from the model and allows us to avoid FAT model

Top comments (2)

Collapse
 
superails profile image
Yaroslav Shmarov

Hey Vlad! I really like your series on patterns (decorators, forms, query objects). This kind of stuff is not commonly seen in simple rails apps, so nice to have you covering it! The query object is a pattern I myself have only recently had the need to work with. Really useful when you are working on really complex logic!

Collapse
 
vladhilko profile image
Vlad Hilko

Hey Yaroslav! Thanks for sharing this feedback with me! It's great to hear that you found my series about patterns helpful 🙂