#370 Ransack
- Download:
- source codeProject Files in Zip (59.2 KB)
- mp4Full Size H.264 Video (29 MB)
- m4vSmaller H.264 Video (12.7 MB)
- webmFull Size VP8 Video (15 MB)
- ogvFull Size Theora Video (30.7 MB)
下のサンプルアプリケーションには商品リストが表示されています。これらの商品をユーザがどのフィールドからでも検索できるようにしようと思います。この機能をゼロから作ることもできますが、今回はRansackというgemを使います。このgemは、エピソード251で紹介したMetaSearchの後継で、複雑な検索フォームを簡単に作成することが可能になります。
Ransackをインストールするには、gemfileにgemを追加してbundle
コマンドを実行します。
gem 'ransack'
Ransackをインストールしたら、検索機能を付加したいアクション内で利用できます。今回はProductsController
のindex
アクションに作成します。Product.search
を呼び出してq
パラメータ(ここにはユーザから渡された検索パラメータが含まれます)を渡してsearchオブジェクトを作成します。見つかった商品を取得するために、このオブジェクトに対してresult
を呼び出します。
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result end end
次にフォームを作成します。Ransackはsearch_form_for
というフォームビルダーを持っています。これを使ってページの先頭に検索フォームを追加します。このメソッドはブロックをとり、それに対してフォームビルダーを渡します。ブロックで、検索の対象にするフィールドを定義しますが、これらのフィールドに対してつける名前が重要になります。フォームのテキストフィールドにname_cont
という名前をつけたのは、Ransackがこのテキストフィールドに入力された値を名前に含んだ商品を検索するという意味です。
<%= search_form_for @search do |f| %> <div class="field"> <%= f.label :name_cont, "Name contains" %> <%= f.text_field :name_cont %> </div> <div class="actions"><%= f.submit "Search" %></div> <% end %>
ページをリロードすると検索フォームが現れるので、フィールドに値を入力してフォームを送信すると、商品にフィルタがかかります。
これはかなり強力で、ビューにさらにフィールドを追加することで、簡単にさらに機能を加えることができるということを意味しています。その場合に、ほかの場所に別のロジックが必要になるということもありません。例えば価格でフィルターをかけるには、下のようにさらに2つフィールドを追加します。
<%= search_form_for @search do |f| %> <div class="field"> <%= f.label :name_cont, "Name contains" %> <%= f.text_field :name_cont %> </div> <div class="field"> <%= f.label :price_gteq, "Price between" %> <%= f.text_field :price_gteq %> <%= f.label :price_lteq, "and" %> <%= f.text_field :price_lteq %> </div> <div class="actions"><%= f.submit "Search" %></div> <% end %>
これで、ある価格の範囲の商品を検索することができます。
wikiの基本的な検索のセクションを見ると、検索をどう行なうかをカスタマイズするために渡すことができる述語(predicate)のリストと、それぞれで実行されるSQLが示されています。またRansackを使うと、ソート用のリンクを簡単につけることができるので、表のヘッダ列を検索結果を並び替えるためのリンクにします。ビューの中でsort_link
というヘルパーメソッドを使って、表のヘッダのセルの静的な文字列を、商品をソートするためのリンクに置き換えます。
<tr> <th><%= sort_link(@search, :name, "Product Name") %></th> <th><%= sort_link(@search, :released_on, "Release Date") %></th> <th><%= sort_link(@search, :price, "Price") %></th> </tr>
ページをリロードすると、結果の表を各フィールドでソートするためのリンクができました。
動的な検索フォームを作る
次にRansackのさらに高度な機能を紹介します。検索フォームビルダーには、目に見える部分以外にもさらに機能があります。ユーザが検索で使用するカラムと述語を自由に設定したり、完全に動的な検索フォームを作ることもできます。ページに具体的な検索フィールドを持つ代わりに、より動的なものを作ります。フォームビルダーでcondition_fields
メソッドを呼び出し、これがそれぞれの検索条件に対して別のフォームビルダーを渡します。そして各条件のattribute_fieldをループして、それぞれにattribute_select
を表示します。次に述語と値のためのフィールドを表示します。
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <div class="field"> <%= c.attribute_fields do |a| %> <%= a.attribute_select %> <% end %> <%= c.predicate_select %> <%= c.value_fields do |v| %> <%= v.text_field :value %> <% end %> </div> <% end %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
このコードは冗長に見えるかも知れませんが、この機能のためには必要なものです。うまく動くようにするために、コントローラを少し修正します。検索にデフォルトでは条件がないので、新規に空の条件を追加します。
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result @search.build_condition end end
ページをリロードすると、フィールドが3つあり、対象フィールド、述語、条件を指定します。フォームを送信すると、商品リストにフィルターがかけられ、別のフィールドのセットができるので別の検索条件を追加できます。
この方法のいい点は、ここに関連を追加することも可能だというところです。attribute_select
はオプションとして関連を持つこともでき、一つの商品は一つのCategory
に属するので、これを追加することにします。
<%= a.attribute_select associations: [:category] %>
ページをリロードすると、商品カテゴリのフィールドでも検索できるようになります。
動的に条件を追加・削除する
JavaScriptで動的に条件を追加したり削除したりできれば便利でしょう。この機能を実装するのは少し複雑ですが、ネストされたフォームと同じようなパターンで動作するので、エピソード196で行なったのと似た方法で実装します。まず条件フィールドを部分テンプレートに移動させます。
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c%> <% end %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
新しい部分テンプレートに作成したフォームフィールドの変数の名前を変更し、検索条件を削除するためのリンクを追加します。
<div class="field"> <%= f.attribute_fields do |a| %> <%= a.attribute_select associations: [:category] %> <% end %> <%= f.predicate_select %> <%= f.value_fields do |v| %> <%= v.text_field :value %> <% end %> <%= link_to "remove", '#', class: "remove_fields" %> </div>
ApplicationHelper
に、エピソード196で作成したのと似たような動きをするlink_to_add_fields
メソッドを作成します。
module ApplicationHelper def link_to_add_fields(name, f, type) new_object = f.object.send "build_#{type}" id = "new_#{type}" fields = f.send("#{type}_fields", new_object, child_index: id) do |builder| render(type.to_s + "_fields", f: builder) end link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")}) end end
indexテンプレートでこのメソッドを呼び出し、条件を作成するためのリンクを作ります。
<%= search_form_for @search do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c%> <% end %> <p><%= link_to_add_fields "Add Conditions", f, :condition %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
最後にJavaScriptでこれを動かします。
jQuery -> $('form').on 'click', '.remove_fields', (event) -> $(this).closest('.field').remove() event.preventDefault() $('form').on 'click', '.add_fields', (event) -> time = new Date().getTime() regexp = new RegExp($(this).data('id'), 'g') $(this).before($(this).data('fields').replace(regexp, time)) event.preventDefault()
これでフィールドを動的に追加や削除をできるようになります。ページをリロードすると、検索条件を追加・削除するためのリンクが表示されます。
これでうまくいきますが、もしユーザが条件を追加しすぎた場合はGETリクエストで送信できる上限に達してしまいます。この問題を回避する一つの方法は、代わりにPOSTを使う方法で、以下でその実装を行ないます。まずroutesファイルでproducts resourceにブロックを追加して、search
routeを加えます。これがPOSTリクエストを受け付け、index
アクションを呼び出します。
Store::Application.routes.draw do resources :products do collection { post :search, to: 'products#index' } end root to: 'products#index' end
このアクションを実行するよう検索フォームを変更します。
<%= search_form_for @search, url: search_products_path, method: :post do |f| %> <%= f.condition_fields do |c| %> <%= render "condition_fields", f: c %> <% end %> <p><%= link_to_add_fields "Add Conditions", f, :condition %> <div class="actions"><%= f.submit "Search" %></div> <% end %>
これで商品に対して検索を行なうと、データはURLパラメータを使わずにPOSTリクエストで送信されます。しかしまだソート用リンクに問題があります。今のままではsearch
routeにGETリクエストを送るので機能しません。リンクでソートを処理するのではなく、この機能をフォームに移動し、Ransackの機能を利用して、別のフィールドでソートするためのselectメソッドを生成するようにします。
<div class="field"> Sort: <%= f.sort_fields do |s| %> <%= s.sort_select %> <% end %> </div>
コントローラにはデフォルトではソート順が設定されてないので、条件の設定時に行ったのと同じように作成することができます。
class ProductsController < ApplicationController def index @search = Product.search(params[:q]) @products = @search.result @search.build_condition @search.build_sort if @search.sorts.empty? end end
ページにソート用のフィールドができたので、対象フィールドと降順・昇順を選択できます。
検索条件を引き継がなくてはいけないリンクを持つ場合がありますが、ページネーションのリンクのようにフォーム経由では行えないようなものもあります。この問題を解決するための方法がいくつかあります。一つはリンクを POSTリクエストとして送信するためにJavaScriptを使用する方法、もう一つはエピソード111で行ったように検索パラメータをデータベースに保存する方法です。
Ransackについての今回のエピソードは以上です。複雑な検索フォームの例として、Ransackデモアプリケーションを見てみましょう。ここには、より完全な高度な検索機能が含まれていて、複数のソート用フィールドを指定したり、条件グループを追加することができます。