キーセットのページ分割
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