キーセットのページ分割

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 節で使われる場合、生成されたデータベースクエリをチェックし、正しいインデックス設定が使われていることを確認することをお勧めします。詳細な情報はページ分割ガイドラインのページにあります。

note
最初のページのクエリ性能は良く見えるかもしれませんが、2番目のページ(カーソル属性がクエリで使用される)では性能が悪くなるかもしれません。1ページ目と2ページ目の両方のクエリのパフォーマンスを常に検証することをお勧めします。

タイブレーク(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