@mizumotokのブログ

テクノロジー、投資、書評、映画、筋トレなどについて

graphql-rubyでページネーション(オフセットベースとカーソルベース)

GraphQLであるオブジェクトの配列をとってくるときにページネーションをしたいというユースケースはよくあります。GraphQLではページネーションのベストプラクティスとしてカーソルベースを取り上げています。この記事ではカーソルベースのページネーションの仕組みとgraphql-rubyでのカーソルベース、オフセットベース両方に対応したページネーションの実装方法について解説します。 f:id:mizumotok:20210727175637j:plain

GraphQLのページネーション

ページネーションのパターン

GraphQLでは要件にあわせてページネーションを実装して構わないのですが、3パターンが考えられます。

  • オフセットベース:friends(first:2 offset:2)のように指定して、次の2件を取得
  • IDベース:friends(first:2 after:$friendId)のように指定して、取得済みの最後のfriendの次の2件を取得
  • カーソルベース:friends(first:2 after:$friendCursor)のように指定して、取得済みの最後のfriendのカーソルから次の2点を取得

GraphQLではカーソルベースをベストプラクティスとしてあげています。カーソルの実装は特に決まっておらず、実際にはIDやオフセットでもよいのですが、その場合はBase64エンコードすることが推奨されています。 graphql.org

カーソルベースページネーションを実現するConnection Type

FacebookによるGraphQL実装であるRelayでは、カーソルベースページネーションを実現するために、Connectionsパターンを仕様として策定しています。 relay.dev

Connection Typeを使用したGraphQLの例("opaqueCursor"のカーソルの次の10件を取得)

{
  user {
    id
    name
    friends(first: 10, after: "opaqueCursor") {
      edges {
        cursor
        node {
          id
          name
        }
      }
      pageInfo {
        hasNextPage
      }
    }
  }
}

上記の例ではfriendsConnection Typeになります。
Connection TypeはフィールドとしてedgespageInfoを持ちます。
edgesEdge Typeの配列で、Edge Typeはフィールドとしてcurosrnodeを持ちます。
nodeはどんな型でもいいのですが、欲しい情報の実体(ここではfriend)になります。配列は不可です。
pageInfoPageInfoという型で、フィールトドしてhasPreviousPagehasNextPagestartCursorendCursorを持ちます。フィールド名から意味はわかると思います。

graphql-rubyでのページネーション

カーソルベース

graphql-rubyではConnection Typeが実装されていますので、あっという間にカーソルベースのページネーションが実現できます。

field :items, Types::ItemType.connection_type, null: false

def items
  obejct.items # Types::ItemTypeのコレクションを返す実装をする
end

このようにすでに作成済みのTypes::ItemType.connection_typeをつけるだけでConnection Typeになります。firstlastafterbeforeという引数は自動的に作られます。

クライアントからは以下のようなクエリーを送れば、ページネーションに必要な情報を取得することができます。

query MyQuery {
  items(first:10, after: 'MTA') {
    edges {
      cursor {
        node {
          id
          name
        }
      }
    }
    pageInfo {
      hasNextPage
    }
  }
}

簡単ですね。

オフセットベース

さて、どうしてもオフセットベースでやりたいという場合があります。
カーソルベースはFacebookTwitterで見られる無限スクロールには使い勝手がいいですが、Googleのようなページ指定ができるページネーションの場合そのままでは難しいです。

f:id:mizumotok:20210727175723p:plain
Googleのページネーション

Connection Typeとは別にオフセットベースのためのTypeを定義してもいいですが、graphql-rubyのカーソルの定義をよく見るとオフセットが使われています。

def cursor_for(item)
  load_nodes
  # index in nodes + existing offset + 1 (because it's offset, not index)
  offset = nodes.index(item) + 1 + (@paged_nodes_offset || 0) + (relation_offset(items) || 0)
  encode(offset.to_s)
end

lib/graphql/pagination/relation_connection.rb

オフセットのエンコードでは、base64エンコードしてから簡単な細工をしているだけです。

def urlsafe_encode64(bin, padding:)
  str = strict_encode64(bin)
  str.tr!("+/", "-_")
  str.delete!("=") unless padding
  str
end

def urlsafe_decode64(str)
  str = str.tr("-_", "+/")
  if !str.end_with?("=") && str.length % 4 != 0
    str = str.ljust((str.length + 3) & ~3, "=")
  end
  strict_decode64(str)
end

lib/graphql/schema/base_64_bp.rb

ここで何もエンコードをしなければいいだけですね。

ドキュメントではカーソルをカスタマイズする方法が紹介されていますので、実装してみましょう。

module OffsetEncoder
  def self.encode(txt, nonce: false)
    txt
  end

  def self.decode(txt, nonce: false)
    txt
  end
end

class MySchema < GraphQL::Schema
  # ...
  cursor_encoder(OffsetEncoder)
end

これでcurosrにはオフセットが入ってきます。ただし文字列です。
例えば、1ページあたり10件で5ページ目を取得する場合は、items(first: 10, after: '50')となります。

件数表示

最終ページまたは件数を表示したいという要件もよくあります。endCurosrで最後の要素のオフセットをとってくることもできますが、実装がイマイチなので、totalCountを取得するようにConnection Typeを拡張しましょう。Connection Typeはgraphql-rubyではapp/graphql/types/base_connection.rbで定義されています。

module Types
  class BaseConnection < Types::BaseObject
    include GraphQL::Types::Relay::ConnectionBehaviors

    # total_count を追加
    field :total_count, Integer, null: false

    def total_count
      object.items.size
    end
  end
end
query MyQuery {
  items(first:10, after: '50') {
    edges {
      cursor {
        node {
          id
          name
        }
      }
    }
    totalCount
  }
}

これでオフセットベースのページネーションが完成です。
やったことはCursorEncoderのカスタマイズとtotalCountフィールドの追加だけです。

まとめ