Railsのransackでキーワード検索を行う方法です。複数の単語を受け取り、複数のカラムを横断し、検索キーワードにマッチするレコードに絞り込むものですが、意外にも一筋縄ではいかなかったので対処法を紹介したいと思います。
調べたり知人に聞いたりして、たどりついた最善の方法を紹介しますが、もっとスマートに実装する方法があるかもしれません。ご指摘などあれば是非お願いします
なお、ここでは複数カラムを横断してキーワード検索を行う方法です。ひとつのカラムに対してキーワード検索を実装するのは、Controllerでq[:keyword]
をsplit(/[[:space:]]/)
するのと、_cont_allを組み合わせ技でできるかと思います。
例にするモデル
今回、例に使うモデルは次のような「仕事」テーブルです。ransackを使って、name
カラムとdescription
カラムをキーワード検索し、マッチしたレコードに絞り込めるようにします。
なお、複数のキーワードが渡ってきた場合は、複数語のAND検索にするようにします。
ビューにキーワード入力欄を設置する
検索フォームを設置するビューを次のようにします:
...
= search_form_for @q do |f|
.form-group
= f.label :keywords, t('helpers.keywords'), class: 'control-label'
= f.text_field :keywords, required: false, value: params.dig(:q, :keywords), class: 'form-control'
= f.button :submit
...
このビューでは、ransackのsearch_form_for
はsimple_form
を使うようにしてあります。キーワードはGETリクエストのq[keywords]
に入るようにしたいのですが、SimpleFormのAPI f.input
を使うと次のようなエラーになってしまいます。
undefined method `keywords' for Ransack::Search
なので、他のf.text_field
を使うようにします。検索中のキーワードがフォームに表示されるように、value: params.dig(:q, :keywords)
を渡すのを忘れないようにします。
モデルにキーワード検索用のscopeを追加する
モデルにキーワード検索用のscopeを追加します。今回の例では、name
とdescription
を検索したいので、この2つのカラムを対象とするscopeを書きます。
class Job < ActiveRecord::Base
...
scope :with_keywords, -> keywords {
if keywords.present?
columns = [:name, :description]
where(keywords.split(/[[:space:]]/).reject(&:empty?).map {|keyword|
columns.map { |a| arel_table[a].matches("%#{keyword}%") }.inject(:or)
}.inject(:and))
end
}
...
end
やっていることは、渡された文字列keywords
を空白で分解して、次のようなSQLを組み立てられるようにしています。
(name LIKE '%キーワード1%' OR description LIKE '%キーワード1%')
AND (name LIKE '%キーワード2%' OR description LIKE '%キーワード2%')
AND (name LIKE '%キーワード3%' OR description LIKE '%キーワード3%')
AND ...
コントローラに検索キーワードを受け取る処理を追加する
最後に、コントローラに検索キーワードを受け取る処理を追加します。
class JobsController < ApplicationController
...
def index
@q = Job.with_keywords(params.dig(:q, :keywords)).ransack(params[:q])
@jobs = @q.result
end
...
end
with_keywords
はnil
を受け取っても問題ないため、params.dig
でキーワードをGETパラメータから引っ張るようにしています。
別解: ransackerを使う方法
ransackを拡張する機能のransackerを使う方法を別解で紹介します。Postgres限定になってしまう方法なので、MySQLや複数のDB対応を念頭においている場合は使えないかもしれません。
まず、モデルにransackerを追加します:
class Job < ActiveRecord::Base
...
ransacker :keywords do |parent|
Arel::Nodes::InfixOperation.new('||', parent.table[:name], parent.table[:description])
end
# MySQLにも対応する場合はCONCAT関数が必要
# ransacker :keywords do |parent|
# Arel::Nodes::NamedFunction.new('CONCAT', [parent.table[:name], parent.table[:description]])
# end
...
end
これは、name || description ILIKE '%キーワード%'
を生成する記述になります。このカラムA || カラムB
がPostgresでは使えるもののMySQLでは使えないので、MySQLにも対応する場合はCONCAT
関数を使う必要が出てきます。
次に、ビューにフォームを作ります:
= search_form_for @q do |f|
= f.input :keywords_cont_all, required: false, label: t('helpers.keywords')
= f.button :submit
別解ではSimpleFormのAPIが使えるようになります。全てのキーワードを含むレコードを抽出したいので、フィールド名は:keywords_cont
ではなく、_cont_all
をつけた:keywords_cont_all
にしておきます。
最後にコントローラです:
class JobsController < ApplicationController
def index
keywords = params.dig(:q, :keywords_cont_all)&.split(/[[:space:]]/)
params[:q][:keywords_cont_all] = keywords if keywords
@q = Job.ransack(params[:q])
@jobs = @q.result
end
end
別解では、キーワードを空白文字で分解する処理がコントローラに乗ってくるので、コントローラが少し複雑になります。