この記事はQiitaにも書いていますが、一応ブログにも同様のものを載せておきます。
graphql-rubyはリソースの参照元のデフォルトがActiveRecordなので、それをElasticsearchに変更したかったのですが、ライブラリがなかったので自分で実装しました。
環境について
実装方法について
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
に変更しましたが、処理の流れは掴めました。
それで、作成したクラスがこちらです。
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
これを使えるようにします。先ほど作ったコネクションを使えるように登録します。
class MySchema < GraphQL::Schema connections.add(Elasticsearch::Model::Response::Response, Connections::ElasticsearchRelationConnection) # 省略 end
そして、これを使ったスキーマを定義します。UserモデルのElasticsearchのスキーマ定義は省略します…。
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 } } } }