# 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' # 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_order_options: {}) @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) @keyset_order_options = keyset_order_options 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, keyset_order_options) .reorder(reversed_order) .limit(per_page_plus_one) .to_a else order .apply_cursor_conditions(keyset_scope, cursor_attributes, keyset_order_options) .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, :keyset_order_options 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) unless success keyset_aware_scope end end end end end