Design pattern: Query objects

  • Avoid "fat model"
  • Easy to test
  • Extenable
  • Single response sibility
# app/models/booking.rb
belongs_to :beneficiary
scope :by_price, -> (direction) { order(price: direction) }
scope :by_booking_date, -> (direction) { order(booking_date: direction) }

Without scope:

# app/queries/booking_query.rb
class BookingQuery
  class << self
    SORT_OPTIONS = %w[by_price by_booking_date by_beneficiary_savings]

    def call(params, scope = Booking.all)
      scope = scope.where(conditions)
      scope = by_status(scope, params[:status])
      scope = by_active_beneficiary(scope, params[:active_beneficiary])
      scope = sort_by(scope, params[:sort], params[:direction])
      scope
    end

    # Make the class extendable
    def conditions; end

    def by_active_beneficiary(scope, value)
      return scope if value.blank?
      scope.where(beneficiaries: { active: value })
    end

    def sort_by(scope, sort, direction)
      sort = sort.presence_in(SORT_OPTIONS) || :by_booking_date
      direction = direction == :desc ? :desc : :asc
      scope.public_send(sort, direction)
    end
  end
end

With scope

# app/queries/scopeable.rb
module Scopeable
  def scope(name, body)
    define_method name do |*args, **kwargs|
      relation = instance_exec(*args, **kwargs, &body)
      relation || self
    end
  end
end
# app/queries/booking_query.rb
class BookingQuery
  module Scopes
    extend Scopeable

    # Some scopes that only use in this Query Object
    scope :by_status, -> (status) { status && where(status: status) }

    def by_beneficiary_savings(direction)
      includes(:beneficiary).order("beneficiaries.personal_savings #{direction}")
    end
  end

  class << self
    SORT_OPTIONS = %w[by_price by_booking_date by_beneficiary_savings]

    def call(params, scope = Booking.all)
      sort = sort.presence_in(SORT_OPTIONS) || :by_booking_date
      direction = direction == :desc ? :desc : :asc

      scope.extending(Scopes)
           .where(conditions)
           .by_status(params[:status])
           .by_beneficiary_savings(direction)
           .public_send(sort, direction)
    end

    # Make the class extendable
    def conditions; end
  end
end

Extend

# app/queries/high_price_booking_query.rb
class HighPriceBookingQuery < BookingQuery
  class << self
    def conditions
      'price > 1000000'
    end
  end
end

Usage

# Controller
filters = {status: :confirmed, active_beneficiary: true }
HighPriceBookingQuery.call(filters.merge(sort_params))

def sort_params
  # { sort: :by_price, direction: :desc }
  params.slice(:sort, :direction)
end