suin.io

Rails:ransackでキーワード検索を実装する方法

suin2016年9月4日

Railsのransackでキーワード検索を行う方法です。複数の単語を受け取り、複数のカラムを横断し、検索キーワードにマッチするレコードに絞り込むものですが、意外にも一筋縄ではいかなかったので対処法を紹介したいと思います。

調べたり知人に聞いたりして、たどりついた最善の方法を紹介しますが、もっとスマートに実装する方法があるかもしれません。ご指摘などあれば是非お願いします:bow:

なお、ここでは複数カラムを横断してキーワード検索を行う方法です。ひとつのカラムに対してキーワード検索を実装するのは、Controllerでq[:keyword]split(/[[:space:]]/)するのと、_cont_allを組み合わせ技でできるかと思います。

例にするモデル

今回、例に使うモデルは次のような「仕事」テーブルです。ransackを使って、nameカラムとdescriptionカラムをキーワード検索し、マッチしたレコードに絞り込めるようにします。

なお、複数のキーワードが渡ってきた場合は、複数語のAND検索にするようにします。

ビューにキーワード入力欄を設置する

検索フォームを設置するビューを次のようにします:

app/views/jobs/index.html.slim
...
= 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_forsimple_formを使うようにしてあります。キーワードはGETリクエストのq[keywords]に入るようにしたいのですが、SimpleFormのAPI f.inputを使うと次のようなエラーになってしまいます。

undefined method `keywords' for Ransack::Search

なので、他のf.text_fieldを使うようにします。検索中のキーワードがフォームに表示されるように、value: params.dig(:q, :keywords)を渡すのを忘れないようにします。

モデルにキーワード検索用のscopeを追加する

モデルにキーワード検索用のscopeを追加します。今回の例では、namedescriptionを検索したいので、この2つのカラムを対象とするscopeを書きます。

app/models/job.rb
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 ...

コントローラに検索キーワードを受け取る処理を追加する

最後に、コントローラに検索キーワードを受け取る処理を追加します。

app/controllers/jobs_controller.rb
class JobsController < ApplicationController
  ...
  def index
    @q = Job.with_keywords(params.dig(:q, :keywords)).ransack(params[:q])
    @jobs = @q.result
  end
  ...
end

with_keywordsnilを受け取っても問題ないため、params.digでキーワードをGETパラメータから引っ張るようにしています。

別解: ransackerを使う方法

ransackを拡張する機能のransackerを使う方法を別解で紹介します。Postgres限定になってしまう方法なので、MySQLや複数のDB対応を念頭においている場合は使えないかもしれません。

まず、モデルにransackerを追加します:

app/models/job.rb
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関数を使う必要が出てきます。

次に、ビューにフォームを作ります:

app/views/jobs/index.html.slim
= 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にしておきます。

最後にコントローラです:

app/controllers/jobs_controller.rb
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

別解では、キーワードを空白文字で分解する処理がコントローラに乗ってくるので、コントローラが少し複雑になります。

RELATED POSTS