背景
みなさん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という拡張実装もあるようなので、これらを組み合わせることで(未検証ですが)カーソルベースのページネーションに対応できるかもしれません。
参考