【Rails】Finder Object で検索ロジックをすっきりさせる
目次
Finder Object とは
DBへのクエリを担うクラス。
複雑なクエリを発行したい場合に使います。
似たような物として Query object があり、こちらは1つのscopeに対して1つの Query Object を作る実装になります。
昔は多用していたのですが、メンテナンスコストがかかるため現在はほとんど利用していません。
定義
まずは今回の検索に利用するモデルと検索条件を定義します。
モデル
- 施設
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