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

実装方法

実際にkaminarigraphql-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を利用した際に引数としてpagelimitが自動でフィールドに定義されるようにします。 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
      }
    }
  }
}