LoginSignup
0

More than 1 year has passed since last update.

GraphQLの検索先をActiveRecordからElasticsearchに変更する

Last updated at Posted at 2021-06-17

graphql-rubyはリソースの参照元のデフォルトがActiveRecordなので、それをElasticsearchに変更したかったのですが、ライブラリがなかったので自分で実装しました。

環境について

  • Ruby 2.7.3
  • Rails 6.0.3.4
  • Gem
    • graphql 1.12.12
    • elasticsearch-model 7.1.1

実装方法について

GraphQLのページネーションは Relay-Style Cursor Pagination が主流でしょう。その他、kaminariなどを使ったページネーションの実装もQiitaの記事で紹介されていました。

こちらの記事にあるように、社内で使うだけとかであれば、kaminariのページネーションを使うのもありだと思うのですが、最終的に公開を目指しているAPIなので、素直にRelay-Style Cursor Paginationを使えるようにしました。

カスタムコネクションを作る

graphql-rubyは、デフォルトで様々なリソース用のコネクションクラスを準備しています。ActiveRecordだけでなく、Sequel、MongoDB、配列などをサポートしています。しかし、Elasticsearchはありません。そのため、独自にカスタムコネクションを作らなければなりません。

カスタムコネクションの作り方のサンプルは、公式のカスタムコネクションのページにざっくりとした作り方が書いてあります。

当初は、これを見ながら実装しようかと思ったのですが、やはりざっくりとしか書いてないので、なかなかわかりませんでした。そこで、graphql-rubyのpaginationsディレクトリのソースコードを読みながら進めることにしました。

ActiveRecordRelationConnectionというクラスがあるのですが、それはRelationConnectionクラスを継承していたので、当初はRelationConnectionを継承して進めようとしましたが、よくわからなかったので、Connectionクラスを継承元とし、RelationConnectionで定義されているメソッドを全て再実装していきました。

結果的には、RelationConnectionで実装されているメソッドのままでよいものが大多数だったので、継承元をRelationConnectionに変更しましたが、処理の流れは掴めました。

それで、作成したクラスがこちらです。

app/graphql/connections/elasticsearch_relation_connection.rb
module Connections
  class ElasticsearchRelationConnection < GraphQL::Pagination::RelationConnection

    def nodes
      @nodes ||= limited_nodes.records.to_a
    end
    # Rubocopにload_nodesメソッドが不要と言われた
    # しかし、継承元のRelationConnectionで呼ばれているのでnodesメソッドのエイリアスにしておく
    # また、元々private methodだったので変更しておく
    alias_method :load_nodes, :nodes
    private :load_nodes

    # GraphQL::Pagination::RelationConnectionの実装を改修
    # `@paged_node_offset`にオフセットが入っているので、2重で足さないようにした。
    def cursor_for(item)
      load_nodes
      # index in nodes + existing offset + 1 (because it's offset, not index)
      # offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0) + (relation_offset(items) || 0)
      offset = @nodes.index(item) + 1 + (@paged_nodes_offset || 0)
      encode(offset.to_s)
    end

    private

      # @param [Elasticsearch::Model::Response::Response]
      # @param [Integer] size LimitSize
      # @return [Boolean] sizeよりも残りが大きければtrueを返す
      def relation_larger_than(relation, size)
        initial_offset = relation_offset(relation)
        relation_count(relation) > initial_offset + size
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] オフセットの値
      def relation_offset(relation)
        relation.search.definition.fetch(:from, 0)
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer, nil] 取得数
      def relation_limit(relation)
        relation.search.definition[:size]
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [Integer] 総ヒット数
      def relation_count(relation)
        relation.results.total
      end

      # @param [Elasticsearch::Model::Response::Response]
      # @return [ActiveRecord::Relation]
      def null_relation(relation)
        relation.records.none
      end

      def limited_nodes
        super()
      rescue ArgumentError => _e
        # カーソルの先頭より前の要素を取得しようとするとArgumentErrorになったため、
        # 例外を補足して空のActiveRecord::Relationを返すようにした
        ApplicationRecord.none
      end
  end
end

これを使えるようにします。先ほど作ったコネクションを使えるように登録します。

app/graphql/my_schema.rb
class MySchema < GraphQL::Schema
  connections.add(Elasticsearch::Model::Response::Response, Connections::ElasticsearchRelationConnection)
  # 省略
end

そして、これを使ったスキーマを定義します。UserモデルのElasticsearchのスキーマ定義は省略します…。

app/graphql/types/query_type.rb
module Types
  class QueryType < Types::BaseObject
    field :users, Objects::User.connection_type, null: false do
      argument :keyword, String, required: false # 検索キーワード
    end

    # **argsにすることで、graphqlのページング条件などを一手に引き受けさせる
    def users(keyword: nil, **args)
      query = Elasticsearch::DSL::Search::Search.new
      query.query do
        bool do
          if keyword.present?
            must do
              simple_query_string do
                query keyword
                fields ['keyword_search_field']
                default_operator :and
              end
            end
          end
        end
      end

      es_response = User.__elasticsearch__.search(query.to_hash)
      # 先ほど作ったコネクションで返す
      Connections::ElasticsearchRelationConnection.new(
        es_response,
        first: args[:first],
        last: args[:last],
        before: args[:before],
        after: args[:after],
      )
    end
  end
end

これで、Elasticsearchに対してGraphQLで検索させることができるようになりました。

実際にクエリを書いてみます。

query {
  users(keyword: "山田", first: 3) {
    edges {
      cursor
      node {
        id
        name
      }
    }
    pageInfo {
      startCursor
      endCursor
      hasNextPage
      hasPreviousPage
    }
  }
}

先ほどのクエリを実行した結果です(公開用にデータは適当に修正しています)。
pageInfoにカーソルの値や、前後のページの有無が返っています。

{
  "data": {
    "users": {
      "edges": [
        {
          "cursor": "MQ",
          "node": {
            "id": "34",
            "name": "山田 孝夫"
          }
        },
        {
          "cursor": "Mg",
          "node": {
            "id": "76",
            "name": "山田 孝之"
          }
        },
        {
          "cursor": "Mw",
          "node": {
            "id": "55",
            "name": "山田 太郎"
          }
        }
      ],
      "pageInfo": {
        "startCursor": "MQ",
        "endCursor": "Mw",
        "hasNextPage": true,
        "hasPreviousPage": false
      }
    }
  }
}

まとめ

  • graphql-rubyはデフォルトで様々なコネクションクラスを持っている
  • その他のリソースで検索させたい場合などはGraphQL::Pagination::Connectionクラスを継承して作ることができる
  • graphql-rubyでElasticsearchを使いつつ、Relay-Style Cursor Paginationを実現したければ、カスタムコネクションを作る必要がある
  • 上記に載せたElasticsearchRelationConnectionのコードが、それである。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0