背景
みなさんGraphQL使ってますか?私は個人的に好きで仕事のプロダクトでももっぱらGraphQLもといRuby on Railsで開発してるのでgraphql-rubyを使っています。GraphQLはどうしてもN+1クエリが問題になりますが、通常のhas_manyな関係においてはgoldiloaderで事足りますし、複雑なものでもgraphql-batchでバッチング処理を記述することで大抵は回避できるかと思います。
さて、そんなGraphQLですが、多くのブログでも紹介されてるようにページングの方式がRelayスタイル(カーソルベース)であるため、デフォルトのままではオフセットベースでやってきたアプリケーションに導入するには困ることがあると思います。これに対して様々な方法が紹介されてます。
ですが、今回の私の場合すでにページネーション用のライブラリとしてプロダクトでpagyを使っていました。これがkaminariでしたらgraphql-kaminari_connectionというGemを導入することでkaminariを用いたページネーションが導入できました。しかしpagyにはそういったGemがなかったので最初はgraphql-kaminari_connectionを参考にGraphQLでもpagyを使ったページネーションを行っていました。このGemは非常にシンプルな故にサクッと模倣できたのですが、当時は自分でpageやitemsといったargumentを定義したりリゾルバーでページネーション部分を書かないといけませんでした(今はどうなっているか分かりません)。
そこで今回は少し踏み込んでるので邪道?かもしれませんが、面倒なことは全て外部ライブラリのpagyに任せつつなるべく簡潔にgraphql-rubyにオフセットベースのページネーションを導入する方法を紹介したいと思います。
目標
今回はTestという(名前からして超適当な)モデルがあると想定します。その際graphql-rubyでRelayスタイルのリゾルバを記述する場合、色々書き方はあるかと思いますが、以下のような実装が考えられます。
module Types
class Tests1Resolver < GraphQL::Schema::Resolver
type Test.connection_type, null: false
def resolve
::Test.all
end
end
end
今回は上記の内容とほぼ同様の記述でpagyでのページネーションができるように、具体的にはTypes::Test.connection_typeと同様の記述(今回はcollection_typeとします)で下記のように実装できることが目標です。
module Types
class Tests2Resolver < GraphQL::Schema::Resolver
type Test.collection_type, null: false
def resolve
::Test.all
end
end
end
環境
- ruby 3.0.1
- rails 6.1.3.2
- graphql-ruby 1.12.12
- pagy 4.9.0
準備
まずは何はともあれgraphql-rubyとpagyをインストールして基本的なセットアップを行います。この辺はすでに多くのブログで紹介されているのでざっくりと書いていきます。
gem 'graphql'
gem 'pagy'
$ bundle
$ bin/rails generate graphql:install
これでapp/graphql配下にGraphQLの基本的なファイル群が生成されたと思います。これを基に追加・編集していきたいと思います。
実装
では実際にpagyをgraphql-rubyに導入していきたいと思います。
pagyの処理を行うクラスの定義
まず最初に定義するのは実際にpagyの処理を行う部分の実装です。コントローラーでpagyを使ったことがある方にとっては特に難しいところはないと思います。
module Types
class Pagy
include ::Pagy::Backend
attr_reader :metadata, :collection
def initialize nodes, page: nil, items: nil
@metadata, @collection = paginate nodes, page: page, items: items
end
private
def paginate nodes, page: nil, items: nil
case nodes
when ActiveRecord::Relation then pagy nodes, page: page, items: items
when Array then pagy_array nodes, page: page, items: items
end
end
def params
{}
end
end
end
コントローラーに導入する場合と同様にpagyの各メソッドを使うためにPagy::Backendをインクルードしてます。またpagyがActiveRecordだけでなくArrayに対してもページネーション機能を提供してくれているのでcase文にてActiveRecord::RelationかArrayを判定して各々に対して異なるメソッドを呼んでいます。
最後に奇妙なparamsですが、これはコントローラーの場合pagyがparamsにあるpageやitemsを読み込むために呼ばれるのですが、今回はGraphQLの引数から提供される予定なので空のハッシュを置いて対処してます。
自動的に引数とフィールドを定義するクラスの定義
graphql-rubyのRelayではTest.connection_typeのようにコネクションタイプを用いることで、特にfirstとかafterを記述しなくても自動的に引数が定義されるかと思います。それと同様に今回はpagyで使うフィールドとしてpageとitemsが自動で定義されるようにします。
module Types
class PagyExtension < GraphQL::Schema::FieldExtension
def apply
field.argument :page, Integer, required: false
field.argument :items, Integer, required: false
end
def resolve object:, arguments:, **_rest
args = arguments.dup
page = args.delete :page
items = args.delete :items
obj = yield object, args
Pagy.new obj, page: page, items: items
end
end
end
resolveでは定義した引数がargumentsとして渡されるので、pageとitemsを取り出しつつ、各リゾルバーにそれ以外の引数を渡し、その結果を先程定義したTypes::Pagyに渡しています。
pagyのメタデータをGraphQLオブジェクトとして定義
graphql-rubyではデフォルトのRelayを用いるとpageInfoとしてhasNextPageなどのページ情報が取得できるかと思います。それと同様にpagyメソッドから返り値として得られるメタデータを格納するためのGraphQLのオブジェクトを定義します。
module Types
class Metadata < BaseObject
field :count, Integer, null: false
field :page, Integer, null: false
field :items, Integer, null: false
field :pages, Integer, null: false
field :last, Integer, null: false
field :offset, Integer, null: false
field :from, Integer, null: false
field :to, Integer, null: false
field :prev, Integer, null: true
field :next, Integer, null: true, resolver_method: :object_next
delegate :next, to: :object, prefix: true
end
end
pagyでは返り値としてメタデータを返してくれますが、その中にnextというインスタンス変数があります。それをそのまま呼び出してしまいたいのですが、それだと既存のRubyにあるnextと見分けがつかなくなるため以下のような警告と対処方法をログに出してくれます。
Metadata's `field :next` conflicts with a built-in method, use `resolver_method:` to pick a different resolver method for this field (for example, `resolver_method: :resolve_next` and `def resolve_next`). Or use `method_conflict_warning: false` to suppress this warning.
なので、ここではresolver_methodを用いて呼び出すメソッド名を変更し、そのメソッド名をそのままobjectに対して呼べるようにdelegateを使っています。
動的にGraphQLオブジェクトをラップしたGraphQLオブジェクトを生成するクラスの定義
タイトルがとってもややこしいですが、目標でも書いたようにgraphql-rubyではGraphQLオブジェクトに対してcollection_typeを用いることでTestConnectionというオブジェクトが動的に生成されます。これと同様のことができるようにTestCollectionのようなオブジェクトを作成するクラスを定義します。
module Types
class Collection < BaseObject
def self.create type
Class.new self do
graphql_name "#{type.graphql_name}Collection"
field :collection, [type], null: false
field :metadata, Metadata, null: false
end
end
end
end
self.createでは動的にTypes::Collectionを継承したオブジェクト(のクラス)を生成しています。またその生成されるクラスではTypes::Pagyにてpagyメソッドの返り値であるcollectionとmetadataをフィールドとして定義しています。ここでTypes::BaseObjectではなくselfつまりTypes::Collectionを継承させてるのは後々判定に用いるためです。
ベースオブジェクトにcollection_typeの実装
どのオブジェクトでも使えるようにベースオブジェクトにクラスメソッドとしてcollection_typeを実装します。
module Types
class BaseObject < GraphQL::Schema::Object
edge_type_class Types::BaseEdge
connection_type_class Types::BaseConnection
field_class Types::BaseField
def self.collection_type
@collection_type ||= Collection.create self
end
end
end
Types::Collection.createにself(自身のクラス)を渡していますが、そもそもcollection_typeメソッドはTypes::BaseObjectを継承したクラス、例えば目標のようにTypes::Testなどで呼ばれます。つまり、このselfにはTypes::Testなどのオブジェクトが入ってくることが分かります。こうしてTypes::Testを含んだ新たなクラスを動的に生成することが可能となります。
ベースフィールドの拡張
最後に上記で作成したTypes::PagyExtensionを用いて自動的に引数が定義されるようにTypes::BaseFieldを拡張します。
module Types
class BaseField < GraphQL::Schema::Field
argument_class Types::BaseArgument
def initialize **kwargs, &block
super
return unless kwargs[:type].is_a? Class
return unless kwargs[:type] < Collection
extension PagyExtension
end
end
end
ここではkwargs[:type]がTypes::Collectionであるかどうかを判定しています。これによって、必要な場合(つまりTypes::Collectionを継承したクラス)にのみTypes::PagyExtensionで定義したpageおよびitemsが自動で定義されるようになります。
これで実装は完了です。
テスト
テストは適当なデータを作成してgraphiql-railsで行いました。
カーソルベースとオフセットベースの両方を併用したいということはないでしょうがサンプル用の実装なので両方書いておきました。ソースコードはgithubにアップロードしてあるので参考にしたい方はぜひ。
まとめ
今回は少し邪道かもしれませんが、あえてページネーションを既存のライブラリで行えるように実装しました。ちなみにオフセットベースのページネーションは公式のドキュメントにもあるように、その性質上どうしてもデータの削除や追加でズレてしまうことがあるかと思います。しかし管理画面など多くの場合はそれほど大きな問題にはならないかと思いますし、pagyにもカーソルベースのページネーションが行えるようpagy-cursorという拡張実装もあるようなので、これらを組み合わせることで(未検証ですが)カーソルベースのページネーションに対応できるかもしれません。
参考
