【GraphQL Ruby】ページネーション実装

## はじめに
`GraphQL Ruby` では `connection_type` を使ってカーソルベースのページネーションを容易に実装することができます。また、デフォルトではカーソルベースとなっていますが、少しカスタマイズするだけでオフセットベースのページネーションへの変更も可能です。既にたくさんの記事や知見が公開されているので、それらを参考に自分でも組んでみました。


## カーソルベースのページネーション
「ページ表示時には最初のプロジェクト5件が表示されている → ページ下部までスクロールすると次のプロジェクト5件が読み込まれる」といったUIを想定します。
プロジェクト一覧を取得する`resolver` にて、レスポンスを `connection_type` として定義します。
```diff_ruby:projects_resolver.rb
module Resolvers
  class ProjectsResolver < BaseResolver
    description 'プロジェクト一覧'
    # レスポンスを connection_type で設定
-   type [Types::ProjectType], null: true
+   type Types::ProjectType.connection_type, null: true
    

    def resolve
      Project.all
    end
  end
end
```

https://graphql-ruby.org/pagination/connection_concepts.html

`first` で取得件数を指定、レスポンスに `cursor` を追加します。

```graphql
query Projects {
  projects(first: 5) {
    edges {
      node {
        id
        name
      }
      cursor
    }
  }
}
```

最初の5件を取得した上で、各 `edge` にカーソルが付与されています。5件目の「プロジェクト5」のカーソルは `NQ` ですね。


```json
{
  "data": {
    "projects": {
      "edges": [
        {
          "node": {
            "id": "1",
            "name": "プロジェクト1"
          },
          "cursor": "MQ"
        },
        {
          "node": {
            "id": "2",
            "name": "プロジェクト2"
          },
          "cursor": "Mg"
        },
        {
          "node": {
            "id": "3",
            "name": "プロジェクト3"
          },
          "cursor": "Mw"
        },
        {
          "node": {
            "id": "4",
            "name": "プロジェクト4"
          },
          "cursor": "NA"
        },
        {
          "node": {
            "id": "5",
            "name": "プロジェクト5"
          },
          "cursor": "NQ"
        }
      ]
    }
  }
}
```
先程のGraphQLクエリの条件に `after: "NQ"` を追加します。

```graphql
query Projects {
  projects(first: 5, after: "NQ") {
    edges {
      node {
        id
        name
      }
      cursor
    }
  }
}
```

すると、プロジェクト6〜プロジェクト10の5件を新たに取得することができました。
すなわち、カーソルベースのページネーションは **「最後の要素のカーソルを取得」→ 「次ページの要素問い合わせ時にカーソルを指定」の繰り返し** で実現が可能です。
```json
{
  "data": {
    "projects": {
      "edges": [
        {
          "node": {
            "id": "6",
            "name": "プロジェクト6"
          },
          "cursor": "Ng"
        },
        {
          "node": {
            "id": "7",
            "name": "プロジェクト7"
          },
          "cursor": "Nw"
        },
        {
          "node": {
            "id": "8",
            "name": "プロジェクト8"
          },
          "cursor": "OA"
        },
        {
          "node": {
            "id": "9",
            "name": "プロジェクト9"
          },
          "cursor": "OQ"
        },
        {
          "node": {
            "id": "10",
            "name": "プロジェクト10"
          },
          "cursor": "MTA"
        }
      ]
    }
  }
}
```

## オフセットベースのページネーション
先程の[Connection Concepts](https://graphql-ruby.org/pagination/connection_concepts.html) のページに下記の記述があるように、オフセットベースよりもカーソルベースを `GraphQL Ruby` ではおすすめしているようです。
> Connections have some advantages over offset-based pagination:
> - First-class support for relationship metadata
> - Cursor implementations can support efficient, stable pagination

とはいえ、下記のようなオフセットベースのページネーションでの実装が必要な局面もあるでしょう。

![スクリーンショット 2025-03-25 3.36.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3941310/fd75c457-eb40-453f-99c7-7a4591e17961.png)

その場合も、`cursor` をちょっと変更するだけでページネーションの実現が可能です。下記の記事をもとに、オフセットベースのページネーションに変更してみます。

https://mizumotok.hatenablog.jp/entry/2021/07/27/180556

まずは、`app/lib/custom_encoder` ディレクトリに新たに `offset_encoder.rb` を用意します。`cursor` のエンコードをこれでカスタマイズするわけですが、そのままの数字で良いので引数の `txt` をそのまま返すようにしています。
```ruby:offset_encoder.rb
module CustomEncoder
  module OffsetEncoder
    def self.encode(txt, nonce: false)
      txt
    end
  
    def self.decode(txt, nonce: false)
      txt
    end
  end
end
```
そして、`api_schema.rb` で、先程定義したエンコーダーを使用するように設定します。

```diff_ruby:offset_api_schema.rb
class OffsetApiSchema < GraphQL::Schema
  mutation(Types::MutationType)
  query(Types::QueryType)

  # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html)
  use GraphQL::Dataloader

+ # offsetベースのページネーション
+ cursor_encoder(CustomEncoder::OffsetEncoder)
end
```

先程と同じくプロジェクトを5件、`cursor` が"10"よりも後のもの(3ページ目に表示されるもの)を指定してみます。なお、`cursor` は数値ではなく文字列です。
```graphql
query Projects {
  projects(first: 5, after: "10") {
    edges {
      node {
        id
        name
      }
      cursor
    }
  }
}
```

目論見通り、プロジェクト11〜プロジェクト15を取得できました。
**「(取得したいページNo - 1) * 1ページあたり表示件数」** を`after`に指定してあげることで、オフセットベースでのページネーションを実現することができます。
```json
{
  "data": {
    "projects": {
      "edges": [
        {
          "node": {
            "id": "11",
            "name": "プロジェクト11"
          },
          "cursor": "11"
        },
        {
          "node": {
            "id": "12",
            "name": "プロジェクト12"
          },
          "cursor": "12"
        },
        {
          "node": {
            "id": "13",
            "name": "プロジェクト13"
          },
          "cursor": "13"
        },
        {
          "node": {
            "id": "14",
            "name": "プロジェクト14"
          },
          "cursor": "14"
        },
        {
          "node": {
            "id": "15",
            "name": "プロジェクト15"
          },
          "cursor": "15"
        }
      ]
    }
  }
}
```

## まとめ
- `connection_type` を使用することでページネーションを手早く実装することができる
- `cursor` をカスタマイズすることで、オフセットベースのページネーションとして利用することもできる

## 参考
[GraphQL - Connection Concepts](https://graphql-ruby.org/pagination/connection_concepts.html)
[GraphQL - Cursors](https://graphql-ruby.org/pagination/cursors.html)
[graphql-rubyでページネーション（オフセットベースとカーソルベース）](https://mizumotok.hatenablog.jp/entry/2021/07/27/180556)