diff --git a/app/helpers/keyset_helper.rb b/app/helpers/keyset_helper.rb new file mode 100644 index 0000000000000000000000000000000000000000..e7f6f88409139c6966fdbb25ffc5e247ccef3910 --- /dev/null +++ b/app/helpers/keyset_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module KeysetHelper + def keyset_paginate(paginator, without_first_and_last_pages: false) + page_params = params.to_unsafe_h + + render('kaminari/gitlab/keyset_paginator', { + paginator: paginator, + without_first_and_last_pages: without_first_and_last_pages, + page_params: page_params + }) + end +end diff --git a/app/views/kaminari/gitlab/_keyset_paginator.html.haml b/app/views/kaminari/gitlab/_keyset_paginator.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..f64c70dadfcc8945b1959484211e9a2efe4c07df --- /dev/null +++ b/app/views/kaminari/gitlab/_keyset_paginator.html.haml @@ -0,0 +1,30 @@ +- previous_path = url_for(page_params.merge(cursor: paginator.cursor_for_previous_page)) +- next_path = url_for(page_params.merge(cursor: paginator.cursor_for_next_page)) + +.gl-pagination.gl-mt-3 + %ul.pagination.justify-content-center + + - if paginator.has_previous_page? + - unless without_first_and_last_pages + %li.page-item + - first_page_path = url_for(page_params.merge(cursor: paginator.cursor_for_first_page)) + = link_to first_page_path, rel: 'first', class: 'page-link' do + = sprite_icon('angle-double-left', size: 8) + = s_('Pagination|First') + + %li.page-item.prev + = link_to previous_path, rel: 'prev', class: 'page-link' do + = sprite_icon('angle-left', size: 8) + = s_('Pagination|Prev') + + - if paginator.has_next_page? + %li.page-item.next + = link_to next_path, rel: 'next', class: 'page-link' do + = s_('Pagination|Next') + = sprite_icon('angle-right', size: 8) + - unless without_first_and_last_pages + %li.page-item + - last_page_path = url_for(page_params.merge(cursor: paginator.cursor_for_last_page)) + = link_to last_page_path, rel: 'last', class: 'page-link' do + = s_('Pagination|Last') + = sprite_icon('angle-double-right', size: 8) diff --git a/config/initializers/active_record_keyset_pagination.rb b/config/initializers/active_record_keyset_pagination.rb new file mode 100644 index 0000000000000000000000000000000000000000..f8c2ada52551d6a84bff3a22835d339ba7c49ffb --- /dev/null +++ b/config/initializers/active_record_keyset_pagination.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module PaginatorExtension + # This method loads the records for the requested page and returns a keyset paginator object. + def keyset_paginate(cursor: nil, per_page: 20) + Gitlab::Pagination::Keyset::Paginator.new(scope: self.dup, cursor: cursor, per_page: per_page) + end +end + +ActiveSupport.on_load(:active_record) do + ActiveRecord::Relation.include(PaginatorExtension) +end diff --git a/lib/gitlab/pagination/keyset/paginator.rb b/lib/gitlab/pagination/keyset/paginator.rb new file mode 100644 index 0000000000000000000000000000000000000000..2ec4472fcd6a0a059607f076a8a2c7087feaff1a --- /dev/null +++ b/lib/gitlab/pagination/keyset/paginator.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module Gitlab + module Pagination + module Keyset + class Paginator + include Enumerable + + module Base64CursorConverter + def self.dump(cursor_attributes) + Base64.urlsafe_encode64(Gitlab::Json.dump(cursor_attributes)) + end + + def self.parse(cursor) + Gitlab::Json.parse(Base64.urlsafe_decode64(cursor)).with_indifferent_access + end + end + + FORWARD_DIRECTION = 'n' + BACKWARD_DIRECTION = 'p' + + UnsupportedScopeOrder = Class.new(StandardError) + + # scope - ActiveRecord::Relation object with order by clause + # cursor - Encoded cursor attributes as String. Empty value will requests the first page. + # per_page - Number of items per page. + # cursor_converter - Object that serializes and de-serializes the cursor attributes. Implements dump and parse methods. + # direction_key - Symbol that will be the hash key of the direction within the cursor. (default: _kd => keyset direction) + def initialize(scope:, cursor: nil, per_page: 20, cursor_converter: Base64CursorConverter, direction_key: :_kd) + @keyset_scope = build_scope(scope) + @order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(@keyset_scope) + @per_page = per_page + @cursor_converter = cursor_converter + @direction_key = direction_key + @has_another_page = false + @at_last_page = false + @at_first_page = false + @cursor_attributes = decode_cursor_attributes(cursor) + + set_pagination_helper_flags! + end + + # rubocop: disable CodeReuse/ActiveRecord + def records + @records ||= begin + items = if paginate_backward? + reversed_order + .apply_cursor_conditions(keyset_scope, cursor_attributes) + .reorder(reversed_order) + .limit(per_page_plus_one) + .to_a + else + order + .apply_cursor_conditions(keyset_scope, cursor_attributes) + .limit(per_page_plus_one) + .to_a + end + + @has_another_page = items.size == per_page_plus_one + items.pop if @has_another_page + items.reverse! if paginate_backward? + items + end + end + # rubocop: enable CodeReuse/ActiveRecord + + # This and has_previous_page? methods are direction aware. In case we paginate backwards, + # has_next_page? will mean that we have a previous page. + def has_next_page? + records + + if at_last_page? + false + elsif paginate_forward? + @has_another_page + elsif paginate_backward? + true + end + end + + def has_previous_page? + records + + if at_first_page? + false + elsif paginate_backward? + @has_another_page + elsif paginate_forward? + true + end + end + + def cursor_for_next_page + if has_next_page? + data = order.cursor_attributes_for_node(records.last) + data[direction_key] = FORWARD_DIRECTION + cursor_converter.dump(data) + else + nil + end + end + + def cursor_for_previous_page + if has_previous_page? + data = order.cursor_attributes_for_node(records.first) + data[direction_key] = BACKWARD_DIRECTION + cursor_converter.dump(data) + end + end + + def cursor_for_first_page + cursor_converter.dump({ direction_key => FORWARD_DIRECTION }) + end + + def cursor_for_last_page + cursor_converter.dump({ direction_key => BACKWARD_DIRECTION }) + end + + delegate :each, :empty?, :any?, to: :records + + private + + attr_reader :keyset_scope, :order, :per_page, :cursor_converter, :direction_key, :cursor_attributes + + delegate :reversed_order, to: :order + + def at_last_page? + @at_last_page + end + + def at_first_page? + @at_first_page + end + + def per_page_plus_one + per_page + 1 + end + + def decode_cursor_attributes(cursor) + cursor.blank? ? {} : cursor_converter.parse(cursor) + end + + def set_pagination_helper_flags! + @direction = cursor_attributes.delete(direction_key.to_s) + + if cursor_attributes.blank? && @direction.blank? + @at_first_page = true + @direction = FORWARD_DIRECTION + elsif cursor_attributes.blank? + if paginate_forward? + @at_first_page = true + else + @at_last_page = true + end + end + end + + def paginate_backward? + @direction == BACKWARD_DIRECTION + end + + def paginate_forward? + @direction == FORWARD_DIRECTION + end + + def build_scope(scope) + keyset_aware_scope, success = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(scope) + + raise(UnsupportedScopeOrder, 'The order on the scope does not support keyset pagination') unless success + + keyset_aware_scope + end + end + end + end +end diff --git a/lib/gitlab/pagination/keyset/simple_order_builder.rb b/lib/gitlab/pagination/keyset/simple_order_builder.rb index 5ac5737c3be2013e59741b81a2eb72f17e626eec..76d6bbadaa4a2ae82460c8e5fe338959fc7ec086 100644 --- a/lib/gitlab/pagination/keyset/simple_order_builder.rb +++ b/lib/gitlab/pagination/keyset/simple_order_builder.rb @@ -26,6 +26,8 @@ def initialize(scope:) def build order = if order_values.empty? primary_key_descending_order + elsif Gitlab::Pagination::Keyset::Order.keyset_aware?(scope) + Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope) elsif ordered_by_primary_key? primary_key_order elsif ordered_by_other_column? diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 56965ae57c148cb9afb34f47925bf4a51e0ffd37..5c9754b74ec3b9399a1ca0daf041366013ee1824 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -23703,6 +23703,9 @@ msgstr "" msgid "Pages Domain" msgstr "" +msgid "Pagination|First" +msgstr "" + msgid "Pagination|Go to first page" msgstr "" @@ -23715,6 +23718,9 @@ msgstr "" msgid "Pagination|Go to previous page" msgstr "" +msgid "Pagination|Last" +msgstr "" + msgid "Pagination|Last »" msgstr "" diff --git a/spec/helpers/keyset_helper_spec.rb b/spec/helpers/keyset_helper_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..2e4bf537e2f4889c20a38c325d19f478c716496e --- /dev/null +++ b/spec/helpers/keyset_helper_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true +require 'spec_helper' + +RSpec.describe KeysetHelper, type: :controller do + controller(Admin::UsersController) do + def index + @users = User + .where(admin: false) + .order(id: :desc) + .keyset_paginate(cursor: params[:cursor], per_page: 2) + + render inline: "<%= keyset_paginate @users %>", layout: false # rubocop: disable Rails/RenderInline + end + end + + render_views + + let(:admin) { create(:admin) } + + before do + sign_in(admin) + end + + context 'with admin mode', :enable_admin_mode do + context 'when no users are present' do + it 'does not render pagination links' do + get :index + + expect(response.body).not_to include(s_('Pagination|First')) + expect(response.body).not_to include(s_('Pagination|Prev')) + expect(response.body).not_to include(s_('Pagination|Next')) + expect(response.body).not_to include(s_('Pagination|Last')) + end + end + + context 'when one user is present' do + before do + create(:user) + end + + it 'does not render pagination links' do + get :index + + expect(response.body).not_to include(s_('Pagination|First')) + expect(response.body).not_to include(s_('Pagination|Prev')) + expect(response.body).not_to include(s_('Pagination|Next')) + expect(response.body).not_to include(s_('Pagination|Last')) + end + end + + context 'when more users are present' do + let_it_be(:users) { create_list(:user, 5) } + + let(:paginator) { User.where(admin: false).order(id: :desc).keyset_paginate(per_page: 2) } + + context 'when on the first page' do + it 'renders the next and last links' do + get :index + + expect(response.body).not_to include(s_('Pagination|First')) + expect(response.body).not_to include(s_('Pagination|Prev')) + expect(response.body).to include(s_('Pagination|Next')) + expect(response.body).to include(s_('Pagination|Last')) + end + end + + context 'when at the last page' do + it 'renders the prev and first links' do + cursor = paginator.cursor_for_last_page + + get :index, params: { cursor: cursor } + + expect(response.body).to include(s_('Pagination|First')) + expect(response.body).to include(s_('Pagination|Prev')) + expect(response.body).not_to include(s_('Pagination|Next')) + expect(response.body).not_to include(s_('Pagination|Last')) + end + end + + context 'when at the second page' do + it 'renders all links' do + cursor = paginator.cursor_for_next_page + + get :index, params: { cursor: cursor } + + expect(response.body).to include(s_('Pagination|First')) + expect(response.body).to include(s_('Pagination|Prev')) + expect(response.body).to include(s_('Pagination|Next')) + expect(response.body).to include(s_('Pagination|Last')) + end + end + end + end +end diff --git a/spec/lib/gitlab/pagination/keyset/paginator_spec.rb b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..3c9a89138761c8b5856ab797ee5d2c2dd269c4dd --- /dev/null +++ b/spec/lib/gitlab/pagination/keyset/paginator_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Pagination::Keyset::Paginator do + let_it_be(:project_1) { create(:project, created_at: 10.weeks.ago) } + let_it_be(:project_2) { create(:project, created_at: 2.weeks.ago) } + let_it_be(:project_3) { create(:project, created_at: 3.weeks.ago) } + let_it_be(:project_4) { create(:project, created_at: 5.weeks.ago) } + let_it_be(:project_5) { create(:project, created_at: 2.weeks.ago) } + + describe 'pagination' do + let(:per_page) { 10 } + let(:cursor) { nil } + let(:scope) { Project.order(created_at: :asc, id: :asc) } + let(:expected_order) { [project_1, project_4, project_3, project_2, project_5] } + + subject(:paginator) { scope.keyset_paginate(cursor: cursor, per_page: per_page) } + + context 'when per_page is greater than the record count' do + it { expect(paginator.records).to eq(expected_order) } + it { is_expected.not_to have_next_page } + it { is_expected.not_to have_previous_page } + + it 'has no next and previous cursor values' do + expect(paginator.cursor_for_next_page).to be_nil + expect(paginator.cursor_for_previous_page).to be_nil + end + end + + context 'when 0 records are returned' do + let(:scope) { Project.where(id: non_existing_record_id).order(created_at: :asc, id: :asc) } + + it { expect(paginator.records).to be_empty } + it { is_expected.not_to have_next_page } + it { is_expected.not_to have_previous_page } + end + + context 'when page size is smaller than the record count' do + let(:per_page) { 2 } + + it { expect(paginator.records).to eq(expected_order.first(2)) } + it { is_expected.to have_next_page } + it { is_expected.not_to have_previous_page } + + it 'has next page cursor' do + expect(paginator.cursor_for_next_page).not_to be_nil + end + + it 'does not have previous page cursor' do + expect(paginator.cursor_for_previous_page).to be_nil + end + + context 'when on the second page' do + let(:cursor) { scope.keyset_paginate(per_page: per_page).cursor_for_next_page } + + it { expect(paginator.records).to eq(expected_order[2...4]) } + it { is_expected.to have_next_page } + it { is_expected.to have_previous_page } + + context 'and then going back to the first page' do + let(:previous_page_cursor) { scope.keyset_paginate(cursor: cursor, per_page: per_page).cursor_for_previous_page } + + subject(:paginator) { scope.keyset_paginate(cursor: previous_page_cursor, per_page: per_page) } + + it { expect(paginator.records).to eq(expected_order.first(2)) } + it { is_expected.to have_next_page } + it { is_expected.not_to have_previous_page } + end + end + + context 'when jumping to the last page' do + let(:cursor) { scope.keyset_paginate(per_page: per_page).cursor_for_last_page } + + it { expect(paginator.records).to eq(expected_order.last(2)) } + it { is_expected.not_to have_next_page } + it { is_expected.to have_previous_page } + + context 'when paginating backwards' do + let(:previous_page_cursor) { scope.keyset_paginate(cursor: cursor, per_page: per_page).cursor_for_previous_page } + + subject(:paginator) { scope.keyset_paginate(cursor: previous_page_cursor, per_page: per_page) } + + it { expect(paginator.records).to eq(expected_order[-4...-2]) } + it { is_expected.to have_next_page } + it { is_expected.to have_previous_page } + end + + context 'when jumping to the first page' do + let(:first_page_cursor) { scope.keyset_paginate(cursor: cursor, per_page: per_page).cursor_for_first_page } + + subject(:paginator) { scope.keyset_paginate(cursor: first_page_cursor, per_page: per_page) } + + it { expect(paginator.records).to eq(expected_order.first(2)) } + it { is_expected.to have_next_page } + it { is_expected.not_to have_previous_page } + end + end + end + + describe 'default keyset direction parameter' do + let(:cursor_converter_class) { Gitlab::Pagination::Keyset::Paginator::Base64CursorConverter } + let(:per_page) { 2 } + + it 'exposes the direction parameter in the cursor' do + cursor = paginator.cursor_for_next_page + + expect(cursor_converter_class.parse(cursor)[:_kd]).to eq(described_class::FORWARD_DIRECTION) + end + end + end + + context 'when unsupported order is given' do + it 'raises error' do + scope = Project.order(path: :asc, name: :asc, id: :desc) # Cannot build 3 column order automatically + + expect { scope.keyset_paginate }.to raise_error(/does not support keyset pagination/) + end + end +end