Railsでフォームオブジェクトを使った検索を簡単に実装する方法

RailsでFat ControllerになったらForm Classを作れ,という記事はあちこちで見るのですが,今一つ参考になるような実装があまり見つからなかったので記事にしてみました.
Rails 3.2.13,Ruby 2.0.0系で動作確認済みです.

Fat Controller問題

Railsで特に何も考えずに検索機能を作っていると,検索ロジックでcontrollerが膨らんできてしまうと思います(Fat Controller問題).

例えば,検索フォームの内容として最初は名前だけで検索するとのことで,

HogeController < ApplicationController
  def index
    @hoges = Hoge.where("name LIKE ?", "%#{params[:name]}%")
  end
end

と書いていたのが,そのうちメールアドレスや住所でも絞り込みしたくなり,

HogeController < ApplicationController
  def index
    @hoges = Hoge.where("name LIKE ?", "%#{params[:name]}%")
    @hoges = @hoge.where("email LIKE ?", "%#{params[:email]}%")
    @hoges = @hoge.where("address LIKE ?", "%#{params[:address]}%")
  end
end

と,どんどん内容が増えていきます.ちなみに,当該パラメータが空の場合に絞り込みたくなければ,

HogeController < ApplicationController
  def index
    @hoges = Hoge.scoped
    @hoges = @hoge.where("name LIKE ?", "%#{params[:name]}%") if params[:name].present?
    @hoges = @hoge.where("email LIKE ?", "%#{params[:email]}%") if params[:email].present?
    @hoges = @hoge.where("address LIKE ?", "%#{params[:address]}%") if params[:address].present?
  end
end

と,scopedと後置ifを使って書くと少しはマシに見えますが,検索フィールド数が増えたり,パラメータを前処理してからwhereに渡したりしたくなってくるとどんどんcontrollerが太っていきます.
また,この方式の場合,検索結果ページにも検索フォームを表示したい,といったときには検索パラメータをViewに渡す必要があり,その辺りも含めて実装していくと,さらに太ります.

フォームクラスを作る

こんな時,検索フォーム自体をクラスとして実装するフォームクラスを検討せよ,というのが良く言われる話です.
フォームクラス自体はWebアプリケーションフレームワークでは割合一般的で,PHPでも古くはPEAR::HTML_Quickformから最近ではSymfony 1.x系のsfFormなど,フォームを1クラスとして実装する概念は一般的です.

RailsではCode Climate Blog7 Patterns to Refactor Fat ActiveRecord Modelsでフォームクラスに関する言及がありますし,RailsCastsでもForm Objectに関する配信があります(これはPro向け配信なので,有償登録しないと観られません).
Code Climate Blogの例では,solnic/virtusというgemを利用するやり方が書いてあり,実際に上記の例で実装してみると,以下の様になると思います.

Form Class

# app/models/hoge_form.rb
class HogeForm
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attribute :name, String
  attribute :email, String
  attribute :address, String

  # validationが必要ならここにvalidatesを書ける
  #  validates :name, presence: true

  def search
    scoped = Hoge.scoped
    scoped = Hoge.where("name LIKE ?", "%#{name}%") if name.present?
    scoped = Hoge.where("email LIKE ?", "%#{email}%") if email.present?
    scoped = Hoge.where("address LIKE ?", "%#{address}%") if address.present?
    scoped
  end
end

Controller

# app/controllers/hoge_controller.rb
HogeController < ApplicationController
  def index
    @hoge_form = HogeForm.new params[:hoge_form]
    @hoges = @hoge_form.search
  end
end

View

<!-- app/views/hoges/index.html -->
<h1>Hoge search</h1>
<div class="search_form">
  <%= form_for @hoge_form, url: hoges_path, method: :get do |f| %>
    <%= f.text_field :name %>
    <%= f.text_field :email %>
    <%= f.text_field :address %>  
    <%= f.submit %>
  <% end %>
</div>
<div class="result">
  <table>
    <tr>
      <th>名前</th>
      <th>メールアドレス</th>
      <th>住所</th>
    </tr>
    <% @hoges.each do |hoge| %>
      <tr>
        <td><%= hoge.name %></td>
        <td><%= hoge.email %></td>
        <td><%= hoge.address %></td>
      </tr>
    <% end %>
  </table>
</div>

Controllerにあった検索ロジックがフォームクラスに移ったのが分かると思います.
また,もしパラメータの前処理がしたければ,フォームクラス側のvalidatorなどで実装すれば良いわけですね.

フォームクラスを一般化する

ここまでに上げたフォームクラスの実装方法により,Controllerに実装されていったロジックがフォームクラスに移り,見通しが良くなりました.
しかし,こういった特定Modelに対する検索機能は非常によく行う処理のため,いちいちScaffoldごとに書いているとしんどいです.

そこで,一般的な検索に使うフォームクラスを作成するのに便利なクラスを作ってみました.内部でActiveAttrを使っているので,利用する場合はGemfileにactive_attrを追加してbundle installして使って下さい.

# 検索フォームのための汎用モデル
class SearchForm
  include ActiveAttr::Model

  class_attribute :_search_model
  class_attribute :_like_attributes
  class_attribute :_equal_attributes
  class_attribute :_join_tables

  self._like_attributes  = []
  self._equal_attributes = []
  self._join_tables   = []

  class << self
    private
    def inherited(child)
      child._search_model = child.name.gsub('SearchForm', '').constantize
    end

    def define_attribute(*attrs)
      attrs.each do |attr|
        if attr.respond_to?(:each)
          attr.each do |attr2|
            __send__(:attribute, attr2) unless attributes.include?(attr2)
          end
        else
          __send__(:attribute, attr) unless attributes.include?(attr)
        end
      end
    end

    def search_model(attr)
      self._search_model = attr
    end

    def like_attributes(*attrs)
      define_attribute attrs
      if attrs.respond_to?(:each)
        attrs.each do |attr|
          self._like_attributes += [attr]
        end
      else
        self._like_attributes += [attrs[0]]
      end
    end

    def equal_attributes(*attrs)
      define_attribute attrs
      if attrs.respond_to?(:each)
        attrs.each do |attr|
          self._equal_attributes += [attr]
        end
      else
        self._equal_attributes += [attrs[0]]
      end
    end

    def join_tables(*attrs)
      define_attribute attrs
      if attrs.respond_to?(:each)
        attrs.each do |attr|
          self._join_tables += [attr]
        end
      else
        self._join_tables += [attrs[0]]
      end
    end
  end

  def search
    scoped = _search_model.scoped
    _like_attributes.each do |attr|
      scoped = scoped.where _search_model.arel_table[attr].matches("%#{send(attr)}%") if send(attr).present?
    end
    _equal_attributes.each do |attr|
      scoped = scoped.where _search_model.arel_table[attr].eq(send(attr)) if send(attr).present?
    end

    _join_tables.each do |model|
      scoped = scoped.includes model
    end
    scoped
  end
end

このSearchFormを使うと,先ほど書いた例は以下の様に書けます.

Form Class

class HogeSearchForm < SearchForm
  like_attributes :name, :email, :address
end

Controller

# app/controllers/hoge_controller.rb
HogeController < ApplicationController
  def index
    @hoge_search_form = HogeSearchForm.new params[:hoge_search_form]
    @hoges = @hoge_search_form.search
  end
end

View

<!-- app/views/hoges/index.html -->
<h1>Hoge search</h1>
<div class="search_form">
  <%= form_for @hoge_search_form, url: hoges_path, method: :get do |f| %>
    <%= f.text_field :name %>
    <%= f.text_field :email %>
    <%= f.text_field :address %>  
    <%= f.submit %>
  <% end %>
</div>
<div class="result">
  <table>
    <tr>
      <th>名前</th>
      <th>メールアドレス</th>
      <th>住所</th>
    </tr>
    <% @hoges.each do |hoge| %>
      <tr>
        <td><%= hoge.name %></td>
        <td><%= hoge.email %></td>
        <td><%= hoge.address %></td>
      </tr>
    <% end %>
  </table>
</div>

フォームオブジェクト(HogeSearchForm)の実装がDSLを使って非常に単純に書けるようになりました.この状態だとLIKEとEQUAL検索しかできませんが,SearchFormを拡張していくことで他の検索条件にも耐えられるようになるかと思います.
まだまだブラッシュアップしたい所はたくさんありますが,とりあえず動くので公開しておきます.もっと良いものになったらまたアップデートして,また記事にしようと思います.

ではでは,いつも通りご利用は自己責任でお願いします.

Ruby on RailsによるWEBシステム開発、Android/iPhoneアプリ開発、電子書籍配信のことならお任せください この記事を書いた人と働こう! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

morimorihoge

高校卒業後,学生をやりながらずっとWebアプリ開発に携わってきました.2010くらいまではPHP/Symfonyプログラマでしたが,それ以降のWeb開発はRailsほぼ一本に宗旨替えしました.開発とは別にサーバ構築・運用も10年以上やってきているので,要件定義から設計・実装・環境構築・運用まで一通り何でもこなせます.開発以外では季節により大学でWebサービス開発やプログラミング関連の非常勤講師もしており,技術の啓蒙・教育にも積極的に関わっています.最近はPM的な仕事が増えていますが,現役開発者としていつでも動ける程度にはコードもサーバも弄る日々を送っています.AWS 認定ソリューションアーキテクト – アソシエイトレベル取りました

morimorihogeの書いた記事

週刊Railsウォッチ

インフラ

BigBinary記事より

ActiveSupport探訪シリーズ