class Pageturner

Constants

ASC
COMPARATOR_INTERNAL_BUG
DESC
GREATER_THAN_OPERATOR
LESS_THAN_OPERATOR
LOOKAHEAD

Public Class Methods

new(anchor_column:, anchor_value:, ar_relation:, sort_direction:, page_size:, anchor_id:, anchor_column_active_record_identifier: nil, anchor_column_sql_identifier: nil) click to toggle source

@param [String] anchor_column - Field to paginate on. @param [Anything, nil] anchor_value - Value of the anchor_column for the record to paginate from. @param [ActiveRecord::Relation] ar_relation - Resource collection to paginate. @param [String] sort_direction - Order of the pagination. Valid values: Pageturner::ASC, Pageturner::DESC. @param [Number] anchor_id - ID of the record to paginate from. @param [String, nil] anchor_column_active_record_identifier - Method chain that references the anchor column in the model you are paginating. @param [String, nil] anchor_column_sql_identifier - SQL identifier that references the anchor column in your database schema.

# File lib/pageturner.rb, line 16
def initialize(anchor_column:, anchor_value:, ar_relation:, sort_direction:, page_size:, anchor_id:, anchor_column_active_record_identifier: nil, anchor_column_sql_identifier: nil)
  @anchor_column = anchor_column

  @anchor_column_active_record_identifier = anchor_column_active_record_identifier || @anchor_column
  @anchor_column_sql_identifier = anchor_column_sql_identifier || @anchor_column

  @anchor_value = anchor_value

  @ar_relation = ar_relation

  # Always select id to use it as secondary sort to prevent non-deterministic ordering when primary sort is on a non-unique column.
  unless primary_key_selected?(@ar_relation)
    raise Exception::PrimaryKeyNotSelected, "The provided ActiveRecord::Relation does not have the primary key selected. Cursor pagination requires a primary key to paginate undeterministic columns."
  end

  @sort_direction = sort_direction
  @page_size = page_size
  @anchor_id = anchor_id

  unless [Pageturner::ASC, Pageturner::DESC].include?(@sort_direction)
    raise Exception::InvalidSortDirection, "The provided order is not supported. Supported order values are: '#{ASC}', '#{DESC}'"
  end
end

Public Instance Methods

has_next_page?() click to toggle source
# File lib/pageturner.rb, line 40
def has_next_page?
  # Load the current page.
  self.resources

  @has_next_page
end
next_cursor() click to toggle source
# File lib/pageturner.rb, line 54
def next_cursor
  {
    anchor_column: @anchor_column,
    anchor_id: self.resources.last&.id,
    anchor_value: try_chain(self.resources.last, @anchor_column_active_record_identifier),
    page_size: @page_size,
    sort_direction: @sort_direction
  }
end
resources() click to toggle source
# File lib/pageturner.rb, line 47
def resources
  # Memoize expensive querying.
  return @result if defined?(@result)

  @result = calculate_resources
end

Private Instance Methods

apply_order_and_limit(ar_relation) click to toggle source
# File lib/pageturner.rb, line 117
def apply_order_and_limit(ar_relation)
  ar_relation
    .order("#{@anchor_column_sql_identifier} #{@sort_direction}", @ar_relation.primary_key => @sort_direction)
    .limit(@page_size + LOOKAHEAD)
end
calculate_first_page() click to toggle source
# File lib/pageturner.rb, line 87
def calculate_first_page
  apply_order_and_limit(@ar_relation)
end
calculate_next_page() click to toggle source
# File lib/pageturner.rb, line 91
def calculate_next_page
  # Heuristic to check if the `anchor_column_sql_identifier` is a joined column.
  if @anchor_column_sql_identifier.include?(".")
    table, column = @anchor_column_sql_identifier.split(".")

    qualified_anchor_column = "`#{table}`.`#{column}`"
  else
    qualified_anchor_column = "#{@ar_relation.quoted_table_name}.#{ActiveRecord::Base.connection.quote_column_name(@anchor_column)}"
  end

  qualified_anchor_pk_column = "#{@ar_relation.quoted_table_name}.#{@ar_relation.quoted_primary_key}"

  where_clause =
    if nulls_listed_first? && !@anchor_value.nil?
      @ar_relation.where("(#{qualified_anchor_column}, #{qualified_anchor_pk_column}) #{comparator_for_fetching_resources} (?, ?)", @anchor_value, @anchor_id)
    elsif nulls_listed_first? && @anchor_value.nil?
      @ar_relation.where("(#{qualified_anchor_column} IS NULL AND #{qualified_anchor_pk_column} #{comparator_for_fetching_resources} ?) OR (#{qualified_anchor_column} IS NOT NULL)", @anchor_id)
    elsif nulls_listed_last? && !@anchor_value.nil?
      @ar_relation.where("(#{qualified_anchor_column} IS NULL) OR ((#{qualified_anchor_column}, #{qualified_anchor_pk_column}) #{comparator_for_fetching_resources} (?, ?))", @anchor_value, @anchor_id)
    elsif nulls_listed_last? && @anchor_value.nil?
      @ar_relation.where("#{qualified_anchor_column} IS NULL AND #{qualified_anchor_pk_column} #{comparator_for_fetching_resources} ?", @anchor_id)
    end

  apply_order_and_limit(where_clause)
end
calculate_resources() click to toggle source
# File lib/pageturner.rb, line 66
def calculate_resources
  (can_calculate_nth_page? ? calculate_next_page : calculate_first_page).tap do |resources|
    @has_next_page = resources.size > @page_size
  end.take(@page_size)
end
can_calculate_nth_page?() click to toggle source
# File lib/pageturner.rb, line 83
def can_calculate_nth_page?
  @anchor_column && @anchor_id
end
comparator_for_fetching_resources() click to toggle source
# File lib/pageturner.rb, line 72
def comparator_for_fetching_resources
  case @sort_direction
  when Pageturner::ASC
    GREATER_THAN_OPERATOR
  when Pageturner::DESC
    LESS_THAN_OPERATOR
  else
    raise COMPARATOR_INTERNAL_BUG
  end
end
nulls_listed_first?() click to toggle source
# File lib/pageturner.rb, line 123
def nulls_listed_first?
  # MySQL sorts nulls first for ascending order, and null last for descending order.
  # TODO:
  #   The concept of null ordering should be database agnostic and modifiable.
  #   For example, UX might decide that we need some other type of ordering.
  @sort_direction == Pageturner::ASC
end
nulls_listed_last?() click to toggle source
# File lib/pageturner.rb, line 131
def nulls_listed_last?
  !nulls_listed_first?
end
primary_key_selected?(ar_relation) click to toggle source
# File lib/pageturner.rb, line 135
def primary_key_selected?(ar_relation)
  ar_relation.select_values.empty? || ar_relation.select_values.include?(@ar_relation.primary_key.to_sym)
end
try_chain(object, attribute_chain) click to toggle source

Given attribute_chain = “association.attribute”, it will call `.association.attribute` on `object`.

# File lib/pageturner.rb, line 140
def try_chain(object, attribute_chain)
  attribute_chain.split(".").reduce(object) { |o, command| o.try!(command) }
end