graphql-rubyでkaminariを利用したページネーション実装について
背景
GraphQL のデフォルトで提供されているページングの方式が Relay スタイル(カーソルベース)であるため、
デフォルトのままでは、オフセットベースのページングを利用したアプリケーションに対応するには少々困ることがあります。
こちらの記事ではpagyを使った実装方法が紹介されており、そちらの記事を参考にkaminariを使ったオフセットベースのページネーションを実現してみようと思います。
といっても、ほぼ記事で紹介されてる内容のpagy
で処理している箇所をkaminari
に置き換えた感じになります。先人に感謝です。
実装後のゴール
Post
というモデルが存在すると仮定し、graphql-ruby
でリゾルバを定義すると以下のような実装になると思います。
connection_type
ではなく、collection_type
を呼び出すことで、kaminari
でページネーションできるようにします。
module Types class PostsResolver < GraphQL::Schema::Resolver type Post.collection_type, null: false def resolve ::Post.all end end end
実装方法
実際にkaminari
をgraphql-ruby
に導入していきましょう。
kaminari の処理を行うクラス
コントローラーでkaminari
を利用する時のようにinitialize
メソッドにてインスタンス化します。
また、Kaminari::paginate_array
を使って、nodes
が配列の場合も考慮しておきます。
併せて、後述するページネーション情報を@page_info
に定義します。
module Types class KaminariExtension attr_reader :page_info, :collection def initialize(nodes, page: nil, limit: nil) @collection = kaminarize(nodes).page(page).per(limit) @page_info = create_page_info end private def kaminarize(nodes) case nodes when ActiveRecord::Relation then nodes when Array then Kaminari.paginate_array(nodes) else raise "#{nodes.class} is not supported." end end def create_page_info { current_page: collection.current_page, is_first_page: collection.first_page?, is_last_page: collection.last_page?, has_next_page: collection.next_page, has_previous_page: collection.prev_page, total_pages: collection.total_pages } end end end
引数とフィールドを定義するクラスを定義
collection_type
を利用した際に引数としてpage
とlimit
が自動でフィールドに定義されるようにします。
resolve
メソッド内では、arguments
からpage
, limit
が渡ってくるので、その値を先ほど定義したTypes::KaminariExtension
クラスに渡します。
module Types class KaminariExtension < GraphQL::Schema::FieldExtension def apply field.argument :page, Integer, required: false, description: 'ページ番号' field.argument :limit, Integer, required: false, description: '1ページあたりの件数' end def resolve(object:, arguments:, **) args = arguments.dup page = args.delete(:page) limit = args.delete(:limit) obj = yield(object, args) ::Types::KaminariExtension.new(obj, page: page, limit: limit) end end end
kaminari
のページネーション情報を GraphQL オブジェクトとして定義
次のページが存在するかなどのページネーション情報をGraphQL オブジェクトとして定義します。
module Types class CollectionPageInfo < ::Types::BaseObject description 'ページネーション情報' field :current_page, Integer, null: false, description: '現在のページ番号' field :is_first_page, Boolean, null: false field :is_last_page, Boolean, null: false field :has_next_page, Integer, null: true, description: '次のページ番号' field :has_previous_page, Integer, null: true, description: '前のページ番号' field :total_pages, Integer, null: false, description: '全ページ数' end end
動的に GraphQL オブジェクトを作成するクラスを定義
GraphQLオブジェクトに対してcollection_type
を利用するとXxxCollection
というオブジェクトを動的に生成するクラスを定義します。
type Post.collection_type
とすることでPostCollection
というGraphQLオブジェクトを動的に生成します。
module Types class Collection < ::Types::BaseObject def self.create(type) Class.new(self) do graphql_name "#{type.graphql_name}Collection" field :collection, [type], null: false field :page_info, ::Types::CollectionPageInfo, null: false end end end end
BaseField の拡張
::Types::KaminariExtension
で定義したフィールドが、自動的に引数に定義されるようにTypes::BaseField
を拡張します。
::Types::KaminariExtension
で定義したpage
およびlimit
が自動で定義されるようになります。
module Types class BaseField < GraphQL::Schema::Field argument_class Types::BaseArgument def initialize(**kwargs, &block) super return unless kwargs[:type].is_a?(Class) # Types::Collectionを継承したクラスでなければ、return return unless kwargs[:type] < ::Types::Collection extension(::Types::KaminariExtension) end end end
BaseObject に collection_type の実装
どのオブジェクトでも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 ||= ::Types::Collection.create(self) end end end
結果
request
query Posts { Posts(page: 1, limit: 3) { collection { __typename id } pageInfo { currentPage isFirstPage isLastPage hasNextPage hasPreviousPage totalPages } } }
response
{ "data": { "Posts": { "collection": [ { "__typename": "Post", "id": "QnVpbGRlci04Mzk=" }, { "__typename": "Post", "id": "QnVpbGRlci04NDA=" }, { "__typename": "Post", "id": "QnVpbGRlci04NDE=" } ], "pageInfo": { "currentPage": 1, "isFirstPage": true, "isLastPage": false, "hasNextPage": 2, "hasPreviousPage": null, "totalPages": 2 } } } }