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

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の記事で紹介されていました。

https://qiita.com/nobuo_hirai/items/f9e34d8572a82283538b

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

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

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

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

https://graphql-ruby.org/pagination/custom_connections

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

https://github.com/rmosolgo/graphql-ruby/tree/master/lib/graphql/pagination

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

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

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

```ruby: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
```

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

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

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

```ruby: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で検索させることができるようになりました。

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

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

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

```json
{
  "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`のコードが、それである。