フィルターの実装

前回の続き。

処理の流れ

  • フィルター機能を追加した見た目は、以下のようなイメージを目指す。


  • フィルターフォームには、以下のような名前を設定しておく。


  • パラメーターからは以下の情報を取得することができる。(例えば、「ファイルが、200701 を含む」のような条件になる。)
params[:filter_column] フィルター対象の列タイトル
params[:filter_item] 検索キーワード
params[:filter_id] 演算条件を表すid(例:...を含む、...から始まる、等)
  • 3つのパラメーターを取得できたら、最終的にpaginateのオプションを以下のように設定したいのだ。(「ファイルが、200701 を含む」の場合)
@csv_pages, @csvs = paginate :csvs, :per_page => 10, :conditions => "file_name LIKE '%200701%'"


この、3つのパラメータをpaginateのオプションに変換するところに工夫が必要だ。

パラメーターからSQLへの変換テーブルを作成する。

いろいろ悩んだ結果、ifで条件判断するのはやめて、データベース上に変換テーブルを登録してしまった。

  • filter_sqlsテーブルは、「...を含む」とか「...と等しい」などの演算条件params[filter_id]を見て、SQLに変換する方法を知っている。
  • filter_setsテーブルは、filter_sqlを意味のあるグループでまとめている。
  • filter_sqlの「空白である」「空白以外」などは複数のfilter_setに所属する可能性があるので、二つのテーブルは多:多関連になる。
filter_setsテーブル
id name 意味 所有するfilter_sqlsのid
1 free_string 文字列のフリーワード検索 1-5, 18-20
2 free_value 数値の大小比較 6-11, 18-20
3 free_datetime 日付の比較 12-20
4 select プルダウンリスト用 6, 7
5 select_boolean true、false選択用 6のみ
filter_sqlsテーブル
  • item列とoption列の内容は、Ruby式として実行することにした。
  • optionは、BETWEENのように2つの引数が必要だったり、NULLを正しく取り扱うために条件を追加したりと、特例に対応するための項目。
id name not_op operator wild1 item wild2 option sort
1 ...を含む LIKE % % 1000
2 ...から始まる LIKE % 2000
3 ...で終わる LIKE % 3000
4 ...と同じ LIKE 4000
5 ...を含まない NOT LIKE % % 5000
6 ...と等しい = 6000
7 ...と等しくない <> "OR #{column} IS NULL" 7000
8 ...より大きい > 8000
9 ...以上 >= 9000
10 ...より小さい 10000
11 ...以下 <= 11000
12 ...と等しい(日時) BETWEEN start_at_period(item) "AND #{end_at_period(item)}" 12000
13 ...と等しくない(日時) NOT BETWEEN start_at_period(item) "AND #{end_at_period(item)} OR #{column} IS NULL" 13000
14 ...より大きい(日時) > end_at_period(item) 14000
15 ...以上(日時) >= start_at_period(item) 15000
16 ...より小さい(日時) start_at_period(item) 16000
17 ...以下(日時) <= end_at_period(item) 17000
18 空白である LIKE %Q() "OR #{column} IS NULL" 18000
19 空白以外 LIKE "_%" 19000
20 LIKE "%" "OR #{column} IS NULL" 900
:conditionsオプションとの対応をチェック

サンプル

id name not_op operator wild1 item wild2 option
1 ...を含む LIKE % %
18 空白である LIKE %Q() "OR #{column} IS NULL"

以下のように変数を設定すると...

      • id = params[:filter_id]
      • f = FilterSql.find(id)
      • column = params[:filter_column]
      • item = eval(f.item || '') || item || '%'
      • option = eval(f.option || '')

paginateの:conditionsオプションは、次のように表現できる。

:conditions => "#{f.not_op} #{column} #{f.operator} '#{f.wild1}#{item}#{f.wild2}' #{option}"
  • column="file_name", item="200701", id=1(...を含む)で試してみる。
:conditions => "file_name LIKE '%200701%'"
  • column="file_name", item="200701", id=18(空白である)で試してみる。
:conditions => "file_name LIKE '' OR file_name IS NULL"

うまくいった!

マイグレーションの設定
class CreateFilterSqls < ActiveRecord::Migration
  def self.up
    create_table :filter_sqls do |t|
      t.column :name,     :string
      t.column :not_op,   :string
      t.column :operator, :string
      t.column :wild1,    :string
      t.column :item,     :string
      t.column :wild2,    :string
      t.column :option,   :string
      t.column :sort,     :integer
    end
  end

  def self.down
    drop_table :filter_sqls
  end
end
class CreateFilterSets < ActiveRecord::Migration
  def self.up
    create_table :filter_sets do |t|
      t.column :name, :string
    end

    create_table :filter_sets_filter_sqls, :id => false do |t|
      t.column :filter_set_id, :integer
      t.column :filter_sql_id, :integer
    end
  end

  def self.down
    drop_table :filter_sets
    drop_table :filter_sets_filter_sqls
  end
end
多:多関連の設定
class Filtersql < ActiveRecord::Base
  has_and_belongs_to_many :filtersets
end
  • :order => "sort ASC"オプションを設定することで、filter_sqlを、sortフィールドの昇順で取得するようになる。
class Filterset < ActiveRecord::Base
  has_and_belongs_to_many :filtersqls, :order => "sort ASC"
end

コントローラーの設定

app/controllers/csvs_controller.rb
human_compair_sql(column, item, id)というメソッドを定義して、そこでSQLに変換することにした。変換条件はfilter_sqlsテーブルに設定してあるので、とてもシンプルになった!
class CsvsController < ApplicationController
...(途中省略)...
  def human_compair_sql(column, item, id)
    f = Filtersql.find(id)
    option = eval(f.option || '')
    item = eval(f.item || '') || item || '%'
    "#{f.not_op} #{column} #{f.operator} '#{f.wild1}#{item}#{f.wild2}' #{option}"
  end

  def list
    # 配列の設定
    @date_input_sample = '<span id="date_input_sample">(入力例: 2007/4/1 , 4/1 , 6:10)</span>'
    @column_titles = %w{file_name file_comment editable file_size created_at file_updated_at management_section_id user_id}
    
    # :conditionsの設定
    @filter_column = params[:filter_column] || @column_titles[0]
    filter_item = params[:filter_item]
    filter_id = params[:filter_id] || 20
    @conditions = human_compair_sql(@filter_column, filter_item, filter_id)
    
    # :orderの設定
    @next_direction = (params[:sort_direction] == 'asc') ? 'desc' : 'asc'
    @order = "#{params[:sort_field] || 'id'} #{@next_direction}"
    
    @csv_pages, @csvs = paginate :csvs, :per_page => 10, 
                                 :conditions => @conditions, 
                                 :order => @order
    render :action => 'list'
  end
...(途中省略)...

ビューの設定

app/views/csvs/_list_form.rhtml
app/views/csvs/list.rhtmlから呼び出されて、フィルターフォームを描画する。データ形式ごとの違いは、フィールド名の部分テンプレートファイル(_filter_フィールド名.rhtml)で描画する。
<%# [左記条件でフィルタ]ボタンを押すと、id="list_params"の範囲が送信される。(このフォーム全体) %>
<div id="list_params">
<%= form_remote_tag :update => 'list_update', 
                    :submit => 'list_params', 
                    :url => {:action => 'list'} %>

<%# [条件をクリア]をクリックすると、id="no_filter_params"の範囲だけ送信される。 %>
<span id="no_filter_params">
<%# 並び替え条件を維持する。 %>
<%= hidden_field_tag :sort_field ,params[:sort_field] if params[:sort_field] %>
<%= hidden_field_tag :sort_direction ,params[:sort_direction] if params[:sort_direction] %>

<%# フィルタ列の選択 %>
<% options = @column_titles.map{|t| [Csv.human_attribute_name(t), t]} %>
<%= select_tag :filter_column, options_for_select(options, params[:filter_column]) %></span>

<%# フィルタ文字と比較方法を入力 %>
<%# 列タイトルごとに部分テンプレートを用意 _filter_フィールド名.rhtml %>
<%= render :partial => "filter_#{@filter_column}" %>

<%= submit_to_remote 'csv_filter', '左記条件でフィルター', 
                     :update => 'list_update',
                     :submit => 'list_params', 
                     :url => {:action => 'list'} %>
<small>
<%= link_to_remote '条件をクリア', 
                     :update => 'list_update',
                     :submit => 'no_filter_params', 
                     :url => {:action => 'list'} %>
</small>

<%# フィルタ列を選択するselect_tag :filter_columnを監視して、変化したら、条件をクリアして再描画する。 %>
<%= observe_field :filter_column, 
                  :update => "list_update", 
                  :submit => "no_filter_params", 
                  :url => {:action => 'list'} %>
<%= end_form_tag %>
</div>
<!----以下デバッグ用-------------------------------------------------------------->
<!---->
<small><%= params.inspect %></small>
<br />
<small><%= @conditions.inspect %></small>
<!---->

フィールドごとの部分テンプレートは、内容が同じでも全てのフィールド分用意してしまった。以下は、その一部。

app/views/csvs/_filter_file_name.rhtml
ファイル列のフィルタ入力用。
<%= text_field_tag :filter_item, params[:filter_item] %>
<%= select_tag :filter_like_op, options_for_select(@like_op, params[:filter_like_op]) %>
app/views/csvs/_filter_editable.rhtml
編集フラグ列のフィルタ入力用。(「編集可能」「表示のみ」の選択)
<%= select_tag :filter_item, 
      options_for_select([@no_select, ['編集可能', 't'], ['表示のみ', 'f']], params[:filter_item]) %>
<%= hidden_field_tag :filter_compare, '=' %>
app/views/csvs/_filter_user_id.rhtml
担当者列のフィルタ入力用。(担当者をセレクトボックスで選択)
<% options = Csv.find(:all, :include => :user).map do |c| 
               ["#{c.user.lastname} #{c.user.firstname}", c.user_id]
             end.uniq.unshift(@no_select) %>
<%= select_tag :filter_item, options_for_select(options, params[:filter_item].to_i) %>
<%= select_tag :filter_compare, options_for_select(@compare[0, 2], params[:filter_compare]) %>


以上で、日付以外の項目については、ちゃんと機能するようになった。