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
に変更しましたが、処理の流れは掴めました。
それで、作成したクラスがこちらです。
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
}
}
}
}
まとめ
- graphql-rubyはデフォルトで様々なコネクションクラスを持っている
- その他のリソースで検索させたい場合などは
GraphQL::Pagination::Connection
クラスを継承して作ることができる - graphql-rubyでElasticsearchを使いつつ、Relay-Style Cursor Paginationを実現したければ、カスタムコネクションを作る必要がある
- 上記に載せた
ElasticsearchRelationConnection
のコードが、それである。