Rails / Elasticsearch

この記事はRuby on Rails Advent Calendar 2016の17日目です。

RailsでElasticsearchといえばelasticsearch-railsですね。

今回はelasticsearch-railsとkaminariを合わせて使う時に押さえておいた方が良いことをまとめてみました。

ただ、基本的にはドキュメントにも書いてあることなのでちゃんとドキュメントを読んでおいた方が良いです。

検索結果をArelオブジェクトとして扱うと順番が変わる

まず前準備として、これはドキュメントにも書いてあることなのですが

records = User.search(sort: {id: :desc}).records
records.class # => Elasticsearch::Model::Response::Records
records.ids # => ["3", "2", "1"]

という結果を取得した時に

arel = records.includes(:user_category)
arel.class # => Tag::ActiveRecord_Relation
arel.map(&:id) # => [1, 2, 3]

と、Arelオブジェクトとして評価してしまうと順番が維持されません。

ドキュメントにはto_aを使うようにと書いてあるのでそうしましょう。

User.search(sort: {id: :desc}).records.includes(:user_category).to_a.map(&:id) # => [3, 2, 1]

elasticsearch-railsとkaminariと合わせて使う

こちらもドキュメントに書いてあるのですが、elasticsearch-railsはkaminariwill_paginateと合わせて使うことができます。

例えばこんな感じです

records = User.search(sort: {id: :desc}).page(1).per(10).records
records.current_page # => 1
records.limit_value  # => 10
records.total_count  # => 3

ここまでは特に問題ありません。

検索結果をArelオブジェクトとして扱う&kaminariと合わせて使う

検索結果をArelオブジェクトとして扱いたくて、さらにページングもやりたいという時は問題が起きます。

普通に書くと

records = User.search(sort: {id: :desc}).page(1).per(10).records.includes(:user_category).to_a

となりますが、to_aで返ってくるのはArrayオブジェクトなので、ページング情報が失われてしまいます。

この場合は少し面倒ですが、こんな感じにする必要があります。

records = User.search(sort: {id: :desc}).page(1).per(10).records
records.class # => Elasticsearch::Model::Response::Records
kaminari_options = {
  limit: records.limit_value,
  offset: records.offset_value,
  total_count: records.total_count
}
arel = records.includes(:user_category)
paginatable_array = Kaminari.paginate_array(arel.to_a, kaminari_options)
paginatable_array.class # => Kaminari::PaginatableArray
paginatable_array.map(&:id) # => [3, 2, 1]

ただ、これをそのままメソッド化してしまうとArelオブジェクトのメソッドチェーンを自由にできないので、こんな感じにしておくと良いと思います。

class BaseSearcher
  def initialize(params)
    @params = params
  end

  def search(options = {}, &block)
    page = options[:page] || 1
    per = options[:per] || 10
    model.search(@params).page(page).per(per).records
    kaminari_options = {
      limit: records.limit_value,
      offset: records.offset_value,
      total_count: records.total_count
    }
    records = yield(records) if block_given?
    Kaminari.paginate_array(arel.to_a, kaminari_options)
  end
end

module Users
  class Searcher < BaseSearcher
    def model
      User
    end
  end
end

使い方はこんな感じですね

records = Users::Searcher.new(sort: {id: :desc}).search(page: 1, per: 10) { |scope| scope.includes(:user_category) }
records.map(&:id)    # => [3, 2, 1]
records.current_page # => 1
records.limit_value  # => 10
records.total_count  # => 3

まとめ

ドキュメントはちゃんと読みましょう。