【Rails】Finder Object で検索ロジックをすっきりさせる

目次

Finder Object とは

DBへのクエリを担うクラス。
複雑なクエリを発行したい場合に使います。

似たような物として Query object があり、こちらは1つのscopeに対して1つの Query Object を作る実装になります。
昔は多用していたのですが、メンテナンスコストがかかるため現在はほとんど利用していません。

qiita.com

定義

まずは今回の検索に利用するモデルと検索条件を定義します。

モデル

  • 施設
class Facility < ApplicationRecord
  belongs_to :prefecture
  has_many :facility_features, dependent: :destroy
  has_many :features, through: :facility_features
end
class Prefecture < ApplicationRecord
  has_many :facility
end
  • 施設-特徴
class FacilityFeature < ApplicationRecord
  belongs_to :facility
  belongs_to :feature
end
  • 特徴
class Feature< ApplicationRecord
  has_many :facility_features, dependent: :destroy
  has_many :facilities, through: :facility_features
end

検索条件

  • 施設名, 施設名(かな)部分一致
  • 都道府県
  • 特徴

実装例

Finder Object 未使用の場合

まずは Finder Object を使用しない場合の実装を見てみます。

class Facility < ApplicationRecord
  belongs_to :prefecture
  has_many :facility_features, dependent: :destroy
  has_many :features, through: :facility_features

  def self.search(search_params)
    query = all
    query = query.where('name LIKE ? OR name_kana LIKE ?', "%#{search_params[:name]}%", "%#{search_params[:name]}%") if search_params[:name].present?
    query = query.where(prefecture_id: search_params[:prefecture_ids]) if search_params[:prefecture_ids].present?
    query = query.joins(:facility_features).merge(FacilityFeature.where(feature_id: search_params[:feature_ids])) if search_params[:feature_ids].present?
    query
  end
end
class FacilitiesController < ApplicationController
  def index
    @facilities = Facility.search(search_params)
  end

  private

  def search_params
    params.require(:search_form)
          .permit(:name, prefecture_ids: [], feature_ids: [])
  end
end

おそらく、レイヤを追加しないで実装するとこのような実装になります。
これだけだとシンプルですがFacilityが成長しメソッドが増えてくると数百行を超えるクラスになるでしょう。

Finder Object を利用した場合

最初にFinderのベースとなるApplicationFinderを用意します。

class ApplicationFinder
  include ActiveModel::Model
  include ActiveModel::Attributes

  private_class_method :new

  class_attribute :model
  class_attribute :rules, default: []

  class << self
    def model(model)
      self.model = model.all
    end

    def inherited(subclass)
      subclass.rules = []
    end

    def call(*args)
      instance = new(*args)
      yield(instance) if block_given?
      instance.send(:call)
    end

    def rule(method_name, options = {})
      rules.push(method_name: method_name, options: options)
    end
  end

  private

  delegate :arel_table, to: :model

  def call
    rules.each do |rule|
      self.model = run_rule(rule)
    end

    model
  end

  def run_rule(rule)
    if rule[:options].key?(:if)
      if if_condition(rule[:options][:if])
        send(rule[:method_name])
      else
        model
      end
    else
      send(rule[:method_name])
    end
  end

  def if_condition(cond)
    case cond
    when Symbol
      send(cond)
    when Proc
      instance_exec(&cond)
    end
  end
end

複雑なように見えますが利用側はシンプルです。
次にApplicationFinderを継承したFacilityFinderクラスを作りましょう。
説明のためコードにコメントを入れます。

class FacilityFinder < ApplicationFinder
  # 検索対象のモデルを定義
  model Facility

  # 検索に使う属性を定義
  # ActiveModel::Attributes の機能を用いています。
  attribute :name
  attribute :prefecture_ids
  attribute :feature_ids

  # 検索ルールを定義
  # if: オプションで渡した条件がtrueの場合にシンボル名のメソッドを実行します。
  # if: オプションが無い場合はそのまま実行します。
  rule :filter_name, if: -> { name.present? }
  rule :filter_prefecture_ids, if: -> { prefecture_ids.present? }
  rule :filter_feature_ids, if: -> { feature_ids.present? }

  def filter_name
    model.where(arel_table[:name].matches("%#{name}%"))
         .or(model.where(arel_table[:name_kana].matches("%#{name}%")))
  end

  def filter_prefecture_ids
    model.where(prefecture_id: prefecture_ids)
  end

  def filter_feature_ids
    model.joins(:facility_features).merge(FacilityFeature.where(feature_id: feature_ids))
  end
end

Finder Object の導入前と比べて、

  • 検索対象
  • 利用するパラメータ
  • 検索条件

がぱっと見で分かるようになりました。
Controllerは以下のようになります。

class FacilitiesController < ApplicationController
  def index
    @facilities = FacilityFinder.call(search_params)
  end

  private

  def search_params
    params.require(:search_form)
          .permit(:name, prefecture_ids: [], feature_ids: [])
  end
end

最後に

ApplicationFinderはまだまだブラッシュアップできる気がしています。
改善した場合はgistを更新するかもしれません。 https://gist.github.com/furaji/9244205196455172114d96e37b121208