graphql-rubyで外部ライブラリを使ってページネーションしたい

# 背景

みなさんGraphQL使ってますか？私は個人的に好きで仕事のプロダクトでももっぱらGraphQLもといRuby on Railsで開発してるので[graphql-ruby](https://github.com/rmosolgo/graphql-ruby)を使っています。GraphQLはどうしてもN+1クエリが問題になりますが、通常の`has_many`な関係においては[goldiloader](https://github.com/salsify/goldiloader)で事足りますし、複雑なものでも[graphql-batch](https://github.com/Shopify/graphql-batch)でバッチング処理を記述することで大抵は回避できるかと思います。

さて、そんなGraphQLですが、多くのブログでも紹介されてるようにページングの方式がRelayスタイル（カーソルベース）であるため、デフォルトのままではオフセットベースでやってきたアプリケーションに導入するには困ることがあると思います。これに対して様々な方法が紹介されてます。

https://qiita.com/nobuo_hirai/items/f9e34d8572a82283538b

https://qiita.com/rikuya11s/items/0a72544ced65fa8ff8fc

ですが、今回の私の場合すでにページネーション用のライブラリとしてプロダクトで[pagy](https://github.com/ddnexus/pagy)を使っていました。これが[kaminari](https://github.com/kaminari/kaminari)でしたら[graphql-kaminari_connection](https://github.com/increments/graphql-kaminari_connection)というGemを導入することで`kaminari`を用いたページネーションが導入できました。しかし`pagy`にはそういったGemがなかったので最初は`graphql-kaminari_connection`を参考にGraphQLでも`pagy`を使ったページネーションを行っていました。このGemは非常にシンプルな故にサクッと模倣できたのですが、当時は自分で`page`や`items`といった`argument`を定義したりリゾルバーでページネーション部分を書かないといけませんでした（今はどうなっているか分かりません）。

そこで今回は少し踏み込んでるので邪道？かもしれませんが、面倒なことは全て外部ライブラリの`pagy`に任せつつなるべく簡潔に`graphql-ruby`にオフセットベースのページネーションを導入する方法を紹介したいと思います。

# 目標

今回は`Test`という（名前からして超適当な）モデルがあると想定します。その際`graphql-ruby`でRelayスタイルのリゾルバを記述する場合、色々書き方はあるかと思いますが、以下のような実装が考えられます。

```ruby:app/graphql/types/tests1_resolver.rb
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`とします）で下記のように実装できることが目標です。

```ruby:app/graphql/types/tests2_resolver.rb
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`をインストールして基本的なセットアップを行います。この辺はすでに多くのブログで紹介されているのでざっくりと書いていきます。

```ruby:Gemfile
gem 'graphql'
gem 'pagy'
```

```bash
$ bundle
$ bin/rails generate graphql:install
```

これで`app/graphql`配下にGraphQLの基本的なファイル群が生成されたと思います。これを基に追加・編集していきたいと思います。

# 実装

では実際に`pagy`を`graphql-ruby`に導入していきたいと思います。

## pagyの処理を行うクラスの定義

まず最初に定義するのは実際に`pagy`の処理を行う部分の実装です。コントローラーで`pagy`を使ったことがある方にとっては特に難しいところはないと思います。

```ruby:app/graphql/types
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`が自動で定義されるようにします。

```ruby:app/graphql/types/pagy_extension.rb
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のオブジェクトを定義します。

```ruby:app/graphql/types/metadata.rb
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`のようなオブジェクトを作成するクラスを定義します。

```ruby:app/graphql/types/collection.rb
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`を実装します。

```ruby:app/graphql/types/base_object.rb
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`を拡張します。

```ruby:app/graphql/types/base_field.rb
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](https://github.com/rmosolgo/graphiql-rails)で行いました。

![Screen Shot 2021-06-23 at 1.23.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/277414/9f1a2040-0890-a0ee-9a99-3b4604b914f8.png)

カーソルベースとオフセットベースの両方を併用したいということはないでしょうがサンプル用の実装なので両方書いておきました。ソースコードはgithubにアップロードしてあるので参考にしたい方はぜひ。

https://github.com/shou8/graphql-ruby-pagy-sample

# まとめ

今回は少し邪道かもしれませんが、あえてページネーションを既存のライブラリで行えるように実装しました。ちなみにオフセットベースのページネーションは公式の[ドキュメント](https://graphql-ruby.org/pro/cursors)にもあるように、その性質上どうしてもデータの削除や追加でズレてしまうことがあるかと思います。しかし管理画面など多くの場合はそれほど大きな問題にはならないかと思いますし、`pagy`にもカーソルベースのページネーションが行えるよう[pagy-cursor](https://github.com/Uysim/pagy-cursor)という拡張実装もあるようなので、これらを組み合わせることで（未検証ですが）カーソルベースのページネーションに対応できるかもしれません。

# 参考

https://gist.github.com/rmosolgo/da1dd95c297d8ed218a319ac83a05d91