GraphQLであるオブジェクトの配列をとってくるときにページネーションをしたいというユースケースはよくあります。GraphQLではページネーションのベストプラクティスとしてカーソルベースを取り上げています。この記事ではカーソルベースのページネーションの仕組みとgraphql-rubyでのカーソルベース、オフセットベース両方に対応したページネーションの実装方法について解説します。
GraphQLでは要件にあわせてページネーションを実装して構わないのですが、3パターンが考えられます。 GraphQLではカーソルベースをベストプラクティスとしてあげています。カーソルの実装は特に決まっておらず、実際にはIDやオフセットでもよいのですが、その場合はBase64エンコードすることが推奨されています。
graphql.org FacebookによるGraphQL実装であるRelayでは、カーソルベースページネーションを実現するために、Connectionsパターンを仕様として策定しています。
relay.dev Connection Typeを使用したGraphQLの例("opaqueCursor"のカーソルの次の10件を取得) 上記の例では graphql-rubyではConnection Typeが実装されていますので、あっという間にカーソルベースのページネーションが実現できます。 このようにすでに作成済みのTypes::ItemTypeに クライアントからは以下のようなクエリーを送れば、ページネーションに必要な情報を取得することができます。 簡単ですね。 さて、どうしてもオフセットベースでやりたいという場合があります。 Connection Typeとは別にオフセットベースのためのTypeを定義してもいいですが、graphql-rubyのカーソルの定義をよく見るとオフセットが使われています。 lib/graphql/pagination/relation_connection.rb オフセットのエンコードでは、base64エンコードしてから簡単な細工をしているだけです。 lib/graphql/schema/base_64_bp.rb ここで何もエンコードをしなければいいだけですね。 ドキュメントではカーソルをカスタマイズする方法が紹介されていますので、実装してみましょう。 これでcurosrにはオフセットが入ってきます。ただし文字列です。 最終ページまたは件数を表示したいという要件もよくあります。 これでオフセットベースのページネーションが完成です。GraphQLのページネーション
ページネーションのパターン
friends(first:2 offset:2)
のように指定して、次の2件を取得friends(first:2 after:$friendId)
のように指定して、取得済みの最後のfriendの次の2件を取得friends(first:2 after:$friendCursor)
のように指定して、取得済みの最後のfriendのカーソルから次の2点を取得カーソルベースページネーションを実現するConnection Type
{
user {
id
name
friends(first: 10, after: "opaqueCursor") {
edges {
cursor
node {
id
name
}
}
pageInfo {
hasNextPage
}
}
}
}
friends
がConnection Typeになります。
Connection Typeはフィールドとしてedges
とpageInfo
を持ちます。
edges
はEdge Typeの配列で、Edge Typeはフィールドとしてcurosr
とnode
を持ちます。
node
はどんな型でもいいのですが、欲しい情報の実体(ここではfriend)になります。配列は不可です。
pageInfo
はPageInfoという型で、フィールトドしてhasPreviousPage
、hasNextPage
、startCursor
、endCursor
を持ちます。フィールド名から意味はわかると思います。graphql-rubyでのページネーション
カーソルベース
field :items, Types::ItemType.connection_type, null: false
def items
obejct.items # Types::ItemTypeのコレクションを返す実装をする
end
.connection_type
をつけるだけでConnection Typeになります。first
、last
、after
、before
という引数は自動的に作られます。query MyQuery {
items(first:10, after: 'MTA') {
edges {
cursor {
node {
id
name
}
}
}
pageInfo {
hasNextPage
}
}
}
オフセットベース
カーソルベースはFacebookやTwitterで見られる無限スクロールには使い勝手がいいですが、Googleのようなページ指定ができるページネーションの場合そのままでは難しいです。
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
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
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
例えば、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フィールドの追加だけです。まとめ