1.1. 検索フォームの実装¶
1.1.1. 課題: 商品検索画面の実装¶
今回は業務システムにはつきものの検索画面実装について、以下販売管理システムのサンプルアプリケーションを例に考えてみます。
実装したい画面は下図の通りです。
システムはproducts(商品)テーブルを持ちます。 productモデルは以下の通りです。
# == Schema Information
#
# Table name: products
#
# id :integer not null, primary key
# code :string(10) not null # 商品コード
# name :string(50) not null # 商品名
# name_kana :string(50) default(""), not null # 商品名カナ
# price :integer not null # 商品価格
# purchase_cost :integer not null # 仕入原価
# availability :boolean not null # 販売可能フラグ
# created_at :datetime
# updated_at :datetime
#
class Product < ActiveRecord::Base
end
商品データのサンプルは、下図の通りです。
システムの要件は以下の通りです。
- 以下条件で商品を検索できること
- 商品コードは部分一致検索できること
- 商品名は部分一致検索できること
- 商品名カナは部分一致検索できること
- 以下条件で商品価格が検索可能であること
- 指定した価格以上の商品が検索できること
- 指定した価格以下の商品が検索できること
- 以下条件で仕入原価が検索可能であること
- 指定した原価以上の商品が検索できること
- 指定した原価以下の商品が検索できること
- 販売可能な商品のみ絞り込み検索ができること
1.1.2. 検索フォームオブジェクトを利用する¶
商品検索画面の検索ロジックをどこに置くのがよいでしょうか。
Controller内で複雑なロジックの実装を行ってはいけません。
# [Bad Example] Controller内で複雑なロジックを実装してはいけない
class ProductsController < ApplicationController
def search
t = Product.arel_table
code = params[:code]
name = params[:name]
@products = Product.all
@products = @products.where(t[:code].matches("%#{code}%")) if code.present?
@products = @products.where(t[:name].matches("%#{name}%")) if name.present?
# 以下略
end
end
上記は俗に言うFatControllerの原因ともなる実装であり、見通しが悪くテストしにくいコードです。
検索ロジックをProductモデル内に実装するのはどうでしょうか。
悪くない方法ですが、なんでもかんでもProductモデルに詰め込んでしまうと、プロジェクトの規模が大きくなってきた場合に見通しの悪いコードとなってしまいます。
FatModel問題を回避するためにも、検索専用のフォームオブジェクトを作成することをお勧めします。
# app/models/search/base.rb
class Search::Base
include ActiveModel::Model
include ActiveModel::Validations::Callbacks
def contains(arel_attribute, value)
arel_attribute.matches("%#{escape_like(value)}%")
end
def escape_like(string)
string.gsub(/[\\%_]/) { |m| "\\#{m}" }
end
def value_to_boolean(value)
ActiveRecord::ConnectionAdapters::Column.value_to_boolean(value)
end
end
# app/models/search/product.rb
class Search::Product < Search::Base
ATTRIBUTES = %i(
code
name name_kana
price_from price_to
purchase_cost_from purchase_cost_to
availability
)
attr_accessor(*ATTRIBUTES)
def matches
t = ::Product.arel_table
results = ::Product.all
results = results.where(contains(t[:code], code)) if code.present?
results = results.where(contains(t[:name], name)) if name.present?
results = results.where(contains(t[:name_kana], name_kana)) if name_kana.present?
results = results.where(t[:price].gteq(price_from)) if price_from.present?
results = results.where(t[:price].lteq(price_to)) if price_to.present?
if purchase_cost_from.present?
results = results.where(t[:purchase_cost].gteq(purchase_cost_from))
end
if purchase_cost_to.present?
results = results.where(t[:purchase_cost].lteq(purchase_cost_to))
end
results = results.where(availability: true) if value_to_boolean(availability)
results
end
end
検索ロジックは検索フォームオブジェクトに実装し、 contollerに置くロジックはシンプルなものにとどめておきます。
# app/controller/products_controller.rb
class ProductsController < ApplicationController
# 検索画面を初めて開いた段階では、検索結果を表示せず検索フォームのみ表示
def index
@product = Search::Product.new
end
def search
@product = Search::Product.new(search_params)
@products = @product
.matches
.order(availability: :desc, code: :asc)
.decorate
end
private
# 検索フォームから受け取ったパラメータ
def search_params
params
.require(:search_product)
.permit(Search::Product::ATTRIBUTES)
end
end
Viewも以下シンプルなものとなります。
# app/views/products/index.html.erb の一部を抜粋
<%= form_for(@product, url: search_products_path, html: { method: :get, class: 'form-horizontal', role: 'form' }) do |f| %>
<%= f.text_field :code, class: 'form-control' %>
<%= f.text_field :name, class: 'form-control' %>
# 他は略
<% end %>
# app/views/products/search.html.erb の一部を抜粋
<table class="table table-list">
<thead>
<tr>
<th>販売状況</th>
<th>商品コード</th>
<th>商品名</th>
<th>販売価格</th>
<th>仕入原価</th>
</tr>
</thead>
<% @products.each do |product| %>
<tbody>
<tr>
<td><%= product.sales_condition %> </td>
<td><%= product.code %></td>
<td><%= product.name %></td>
<td><%= product.display_price %></td>
<td><%= product.display_purchase_cost %></td>
</tr>
</tbody>
<% end %>
</table>
フォームオブジェクトを作成することで、 商品検索画面のロジックを一つのクラスに分離することができました。 このように処理を分割することで、見通しのよいコードが書けるようになります。
1.1.3. Ransackを利用する¶
検索画面実装にあたり、生のSQLを書かないと実装できない程の複雑なロジックを要求される場合は、 検索フォームオブジェクトを利用する のように、検索専用のフォームオブジェクトを作成し、 その中にロジックを実装することをお勧めします。
複雑な検索を要求されない場合、わざわざ検索専用のフォームオブジェクトを作成するのは面倒です。 Ransack(https://github.com/activerecord-hackery/ransack)を利用すれば、手軽に検索画面を実装可能です。
Ransackを導入すると、ActiveRecordモデルにsearchとresult関数が追加されます。 search関数では、以下のような記法でデータ検索が可能となります。
# app/models/product.rb
# == Schema Information
#
# Table name: products
#
# id :integer not null, primary key
# code :string(10) not null # 商品コード
# name :string(50) not null # 商品名
# name_kana :string(50) default(""), not null # 商品名カナ
# price :integer not null # 商品価格
# purchase_cost :integer not null # 仕入原価
# availability :boolean not null # 販売可能フラグ
# created_at :datetime
# updated_at :datetime
#
class Product < ActiveRecord::Base
end
# 以下rails consoleにて実行
# name like で検索 (cont は contains の略)
irb(main):002:0> Product.search(name_cont: 'ABC').result
Product Load (6.5ms) SELECT `products`.* FROM `products` WHERE (`products`.`name` LIKE '%ABC%')
=> #<ActiveRecord::Relation []>
# name equal で検索
irb(main):003:0> Product.search(name_eq: 'ABC').result
Product Load (6.9ms) SELECT `products`.* FROM `products` WHERE `products`.`name` = 'ABC'
# price grater than で検索
=> #<ActiveRecord::Relation []>
irb(main):004:0> Product.search(price_gt: 100).result
Product Load (1.0ms) SELECT `products`.* FROM `products` WHERE (`products`.`price` > 100)
記法の詳細については、RansackのWebサイトをご参照下さい。
検索画面のController/Viewは、Ransackでは以下のように実装します。
# app/controller/products_controller.rb
class ProductsController < ApplicationController
def index
@q = Product.search
end
def search
@q = Product.search(search_params)
@products = @q
.result
.order(availability: :desc, code: :asc)
.decorate
end
private
def search_params
search_conditions = %i(
code_cont name_cont name_kana_cont availability_true
price_gteq price_lteq purchase_cost_gteq purchase_cost_lteq
)
params.require(:q).permit(search_conditions)
end
end
# app/views/products/search.html.erb (細かいタグは省略)
<%= search_form_for(@q, url: search_products_path, html: { method: :get, class: 'form-horizontal', role: 'form' }) do |f| %>
<%= f.text_field :code_cont, class: 'form-control' %>
<%= f.text_field :name_cont, class: 'form-control' %>
<%= f.text_field :name_kana_cont, class: 'form-control' %>
<div class="checkbox">
<label>
<%= f.check_box :availability_true %>
販売中のみ
</label>
</div>
<%= f.text_field :price_gteq, class: 'form-control' %>
<%= f.text_field :price_lteq, class: 'form-control' %>
<%= f.text_field :purchase_cost_gteq, class: 'form-control' %>
<%= f.text_field :purchase_cost_lteq, class: 'form-control' %>
<% end %>
フォームをPostすると、以下のようなパラメータをコントローラ内で受け取ることができます。
6: def search
=> 7: binding.pry
8: @q = Product.search(search_params)
9: @products = @q
10: .result
11: .order(availability: :desc, code: :asc)
12: .decorate
13: end
[1] pry(#<ProductsController>)> params
=> {"utf8"=>"✓",
"q"=>{"code_cont"=>"RA", "name_cont"=>"アルミ", "name_kana_cont"=>"アルミ", "availability_true"=>"0", "price_gteq"=>"100", "price_lteq"=>"", "purchase_cost_gteq"=>"", "purchase_cost_lteq"=>""},
"action"=>"search",
"controller"=>"products"}
パラメータqの中身を直接Ransackのsearch関数に渡すことで、検索が可能となります。
Ransackを利用する場合は、必ずStrongParameterを利用するようにしましょう。 以下のような実装はするべきではありません。
# app/controller/products_controller.rb
# [Bad Example] StrongParameterを利用していない実装は避けるべき
class ProductsController < ApplicationController
def index
@q = Product.search
end
def search
@q = Product.search(params[:q])
@products = @q
.result
.order(availability: :desc, code: :asc)
.decorate
end
end
paramの値を直接search関数に渡してはいけません。
パラメータq の値は、ユーザが自由に書き換え可能です。 そのため、検索画面に存在しない項目もパラメータの渡し方次第で検索可能となってしまいます。
思わぬ脆弱性問題に繋がる可能性もあるため、Ransack利用時は必ずStrongParameterを利用するようにしましょう。
1.1.4. サンプルアプリケーション¶
今回実装したサンプルアプリケーションは、以下ページで取得可能です。