キーセットのページ分割
KeysetページネーションライブラリはHAMLベースのビューやGitLabプロジェクト内のREST APIで使用することができます。
キーセットのページ分割とオフセットベースのページ分割との比較については、ページ分割のガイドラインのページをご覧ください。
APIの概要
概要
RailsコントローラのActiveRecord :
cursor = params[:cursor] # this is nil when the first page is requested
paginator = Project.order(:created_at).keyset_paginate(cursor: cursor, per_page: 20)
paginator.each do |project|
puts project.name # prints maximum 20 projects
end
使用方法
このライブラリは、ActiveRecordのリレーションに1つのメソッドを追加します:#keyset_paginate.
これはKaminariのpaginate メソッドと精神的に似ています(実装は違います)。
キーセットのページネーションは、単純なActiveRecordクエリでは設定なしで動作します:
- 1つのカラムによる順序付け。
- 最後のカラムが主キーである2つのカラムによる順序付け。
ライブラリは null 可能なカラムと区別できないカラムを検出し、それらに基づいて主キーを使用した特別な順序を追加します。これは、キーセットのページネーションが値による明確な順序を期待するために必要です:
Project.order(:created_at).keyset_paginate.records # ORDER BY created_at, id
Project.order(:name).keyset_paginate.records # ORDER BY name, id
Project.order(:created_at, id: :desc).keyset_paginate.records # ORDER BY created_at, id
Project.order(created_at: :asc, id: :desc).keyset_paginate.records # ORDER BY created_at, id DESC
keyset_paginate メソッドは、読み込まれたレコードとさまざまなページをリクエストするための追加情報を含む特別な paginator オブジェクトを返します。
このメソッドは以下のキーワード引数を受け付けます:
-
cursor- 次のページを要求するための、列値によるエンコード順 (nilも可能)。 -
per_page- ページごとにロードするレコード数 (デフォルトは 20)。 -
keyset_order_options- パフォーマンスセクションのUNIONクエリの例を参照してください (オプション)。
paginator オブジェクトには、次のメソッドがあります:
-
records- 現在のページのレコードを Pages で返します。 -
has_next_page?- 次のページがあるかどうかを示します。 -
has_previous_page?- 前のページがあるかどうかを示します。 -
cursor_for_next_page- 次のページを要求する場合はStringとしてエンコードされた Pages (nilでも可)。 -
cursor_for_previous_page- 前のページを要求する場合はStringとしてエンコードされた値 (nilとすることもできます)。 -
cursor_for_first_page- 最初のページをリクエストする場合はStringとしてエンコードされた Pages。 -
cursor_for_last_page- 最後のページをリクエストする場合は、Stringとしてエンコードされた Pages。 - paginator オブジェクトは
Enumerableモジュールを含み、列挙可能な機能をrecordsメソッド/配列に委譲します。
最初のページと2番目のページを取得するためのPages:
paginator = Project.order(:name).keyset_paginate
paginator.to_a # same as .records
cursor = paginator.cursor_for_next_page # encoded column attributes for the next page
paginator = Project.order(:name).keyset_paginate(cursor: cursor).records # loading the next page
キーセットのページネーションはページ番号をサポートしていないので、次のページへの移動は制限されています:
- 次のページ
- 前のページ
- 最終ページ
- 最初のページ
RailsでのHAMLビューの使い方
プロジェクトを名前順に並べた次のコントローラアクションを考えてみましょう:
def index
@projects = Project.order(:name).keyset_paginate(cursor: params[:cursor])
end
HAMLファイルでは、レコードをレンダリングできます:
- if @projects.any?
- @projects.each do |project|
.project-container
= project.name
= keyset_paginate @projects
パフォーマンス
キーセットのページネーションのパフォーマンスは、データベースのインデックス設定と、ORDER BY 節で使用するカラムの数に依存します。
主キー (id) によって順序付けする場合、主キーはデータベースインデックスによってカバーされるので、生成されるクエリは効率的です。
2つ以上のカラムがORDER BY 節で使われる場合、生成されたデータベースクエリをチェックし、正しいインデックス設定が使われていることを確認することをお勧めします。詳細な情報はページ分割ガイドラインのページにあります。
タイブレーク(id)カラムを使用したデータベースクエリの例:
SELECT "issues".*
FROM "issues"
WHERE (("issues"."id" > 99
AND "issues"."created_at" = '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" > '2021-02-16 11:26:17.408466')
OR ("issues"."created_at" IS NULL))
ORDER BY "issues"."created_at" DESC NULLS LAST, "issues"."id" DESC
LIMIT 20
OR クエリはPostgreSQLで最適化するのが難しいため、一般的にはUNION クエリ を使用することを勧めます。ORDER BY 節に複数の列が存在する場合、keyset pagination ライブラリは効率的なUNION を生成することができます。これは、Relation#keyset_paginateに渡されるオプションにuse_union_optimization: true オプションを指定した時に発生します。
使用例:
# Triggers a simple query for the first page.
paginator1 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, keyset_order_options: { use_union_optimization: true })
cursor = paginator1.cursor_for_next_page
# Triggers UNION query for the second page
paginator2 = Project.order(:created_at, id: :desc).keyset_paginate(per_page: 2, cursor: cursor, keyset_order_options: { use_union_optimization: true })
puts paginator2.records.to_a # UNION query
複雑な注文設定
一般的なORDER BY 設定は、keyset_paginate メソッドによって自動的に処理されるので、手動で設定する必要はありません。オーダーオブジェクトの設定が必要なエッジケースがいくつかあります:
-
NULLS LASTオーダーオブジェクトの設定が必要なエッジケースがいくつかあります。 - 機能ベースの順序付け。
-
iidのような、カスタムタイブレーク列を使用した注文。
これらのオーダオブジェクトは、標準的なActiveRecordのスコープとしてモデルクラスで定義することができ、これらのスコープを他の場所で使用できないような特別な動作はありません(Kaminari, background jobs)。
NULLS LAST オーダリング
次のスコープを考えてみましょう:
scope = Issue.where(project_id: 10).order(Issue.arel_table[:relative_position].desc.nulls_last)
# SELECT "issues".* FROM "issues" WHERE "issues"."project_id" = 10 ORDER BY relative_position DESC NULLS LAST
scope.keyset_paginate # raises: Gitlab::Pagination::Keyset::UnsupportedScopeOrder: The order on the scope does not support keyset pagination
keyset_paginate メソッドは、クエリの順序値がカスタムSQL文字列であり、Arel ASTノードではないため、エラーとなります。キーセット・ライブラリは、この種のクエリから設定値を自動的に推測することはできません。
キーセットのページネーションを動作させるには、カスタムオーダーオブジェクトを設定しなければなりません:
-
relative_position一意なインデックスが存在しないので、値が重複する可能性があります。 -
relative_position列にNULL制約がないため、NULL値が発生する可能性があります。この場合、NULL値が、結果セットの最初か最後 (NULLS LAST) のどこにあるかを判断する必要があります。 - キーセットのページネーションでは、異なる順序のカラムが必要なので、主キー (
id) を追加して順序を区別する必要があります。 - 最後のページにジャンプして逆方向のページ分割を行うと、実際には
ORDER BY節がORDER BY逆になります。ORDER BYこのため、逆順の節を用意ORDER BYする必要があります。
使用例:
order = Gitlab::Pagination::Keyset::Order.build([
# The attributes are documented in the `lib/gitlab/pagination/keyset/column_order_definition.rb` file
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'relative_position',
column_expression: Issue.arel_table[:relative_position],
order_expression: Issue.arel_table[:relative_position].desc.nulls_last,
reversed_order_expression: Issue.arel_table[:relative_position].asc.nulls_first,
nullable: :nulls_last,
order_direction: :desc,
distinct: false
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id',
order_expression: Issue.arel_table[:id].asc,
nullable: :not_nullable,
distinct: true
)
])
scope = Issue.where(project_id: 10).order(order) # or reorder()
scope.keyset_paginate.records # works
機能ベースの順序付け
次の例では、id に 10 を掛け、その値で並べ替えます。id 列は一意なので、列は 1 つだけ定義します:
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'id_times_ten',
order_expression: Arel.sql('id * 10').asc,
nullable: :not_nullable,
order_direction: :asc,
distinct: true,
add_to_projections: true
)
])
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(per_page: 5)
puts paginator.records.map(&:id_times_ten)
cursor = paginator.cursor_for_next_page
paginator = Issue.where(project_id: 10).order(order).keyset_paginate(cursor: cursor, per_page: 5)
puts paginator.records.map(&:id_times_ten)
add_to_projections フラグは、SELECT 節でカラム式を公開するようにページネータに指示します。これは必要なことです。キーセットのページ分割では、次のページをリクエストするためにレコードから最後の値を取り出す必要があるからです。
iid ベースの順序付け
イシューの順序付けを行う際、データベースはプロジェクト内でiid の値が区別されていることを確認します。project_id フィルタが存在する場合、1 つのカラムで並べ替えるだけでページネーションが機能します:
order = Gitlab::Pagination::Keyset::Order.build([
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'iid',
order_expression: Issue.arel_table[:iid].asc,
nullable: :not_nullable,
distinct: true
)
])
scope = Issue.where(project_id: 10).order(order)
scope.keyset_paginate.records # works