class RailsCursorPagination::Paginator

Use this Paginator class to effortlessly paginate through ActiveRecord relations using cursor pagination. For more details on how this works, read the top-level documentation of the `RailsCursorPagination` module.

Usage:

RailsCursorPagination::Paginator
  .new(relation, order_by: :author, first: 2, after: "WyJKYW5lIiw0XQ==")
  .fetch

Public Class Methods

new(relation, first: nil, after: nil, last: nil, before: nil, order_by: nil, order: nil) click to toggle source

Create a new instance of the `RailsCursorPagination::Paginator`

@param relation [ActiveRecord::Relation]

Relation that will be paginated.

@param first [Integer, nil]

Number of records to return in a forward pagination. Can be combined
with `after`.

@param after [String, nil]

Cursor to paginate forward from. Can be combined with `first`.

@param last [Integer, nil]

Number of records to return. Must be used together with `before`.

@param before [String, nil]

Cursor to paginate upto (excluding). Can be combined with `last`.

@param order_by [Symbol, String, nil]

Column to order by. If none is provided, will default to ID column.
NOTE: this will cause the query to filter on both the given column as
well as the ID column. So you might want to add a compound index to your
database similar to:
```sql
  CREATE INDEX <index_name> ON <table_name> (<order_by_field>, id)
```

@param order [Symbol, nil]

Ordering to apply, either `:asc` or `:desc`. Defaults to `:asc`.

@raise [RailsCursorPagination::Paginator::ParameterError]

If any parameter is not valid
# File lib/rails_cursor_pagination/paginator.rb, line 48
def initialize(relation, first: nil, after: nil, last: nil, before: nil,
               order_by: nil, order: nil)
  order_by ||= :id
  order ||= :asc

  ensure_valid_params!(relation, first, after, last, before, order)

  @order_field = order_by
  @order_direction = order
  @relation = relation

  @cursor = before || after
  @is_forward_pagination = before.blank?

  @page_size =
    first ||
    last ||
    RailsCursorPagination::Configuration.instance.default_page_size

  @memos = {}
end

Public Instance Methods

fetch(with_total: false) click to toggle source

Get the paginated result, including the actual `page` with its data items and cursors as well as some meta data in `page_info` and an optional `total` of records across all pages.

@param with_total [TrueClass, FalseClass] @return [Hash] with keys :page, :page_info, and optional :total

# File lib/rails_cursor_pagination/paginator.rb, line 76
def fetch(with_total: false)
  {
    **(with_total ? { total: total } : {}),
    page_info: page_info,
    page: page
  }
end

Private Instance Methods

cursor_for_record(record) click to toggle source

Generate a cursor for the given record and ordering field. The cursor encodes all the data required to then paginate based on it with the given ordering field.

If we only order by ID, the cursor doesn't need to include any other data. But if we order by any other field, the cursor needs to include both the value from this other field as well as the records ID to resolve the order of duplicates in the non-ID field.

@param record [ActiveRecord] Model instance for which we want the cursor @return [String]

# File lib/rails_cursor_pagination/paginator.rb, line 336
def cursor_for_record(record)
  unencoded_cursor =
    if custom_order_field?
      [record[@order_field], record.id]
    else
      record.id
    end

  Base64.strict_encode64(unencoded_cursor.to_json)
end
custom_order_field?() click to toggle source

Check if the user requested to order on a field different than the ID. If a different field was requested, we have to change our pagination logic to accommodate for this.

@return [TrueClass, FalseClass]

# File lib/rails_cursor_pagination/paginator.rb, line 179
def custom_order_field?
  @order_field.downcase.to_sym != :id
end
decoded_cursor() click to toggle source

Decode the provided cursor. Either just returns the cursor's ID or in case of pagination on any other field, returns a tuple of first the cursor record's other field's value followed by its ID.

@return [Integer, Array]

# File lib/rails_cursor_pagination/paginator.rb, line 352
def decoded_cursor
  memoize(:decoded_cursor) { JSON.parse(Base64.strict_decode64(@cursor)) }
rescue ArgumentError, JSON::ParserError
  raise InvalidCursorError,
        "The given cursor `#{@cursor.inspect}` could not be decoded"
end
decoded_cursor_field() click to toggle source

@raise [InvalidCursorError] in case the cursor is not a tuple @return [Object]

# File lib/rails_cursor_pagination/paginator.rb, line 376
def decoded_cursor_field
  unless decoded_cursor.is_a? Array
    raise InvalidCursorError,
          "The given cursor `#{@cursor}` was decoded as "\
          "`#{decoded_cursor.inspect}` but could not be parsed"
  end

  decoded_cursor.first
end
decoded_cursor_id() click to toggle source

Return the ID of the cursor's record. In case we use an ordering by ID, this is all the data the cursor encodes. Otherwise, it's the second element of the tuple encoded by the cursor.

@return [Integer]

# File lib/rails_cursor_pagination/paginator.rb, line 364
def decoded_cursor_id
  return decoded_cursor unless decoded_cursor.is_a? Array

  decoded_cursor.last
end
end_cursor() click to toggle source

Cursor of the last record on the current page

@return [String, nil]

# File lib/rails_cursor_pagination/paginator.rb, line 250
def end_cursor
  return if page.empty?

  page.last[:cursor]
end
ensure_valid_params!(relation, first, after, last, before, order) click to toggle source

Ensure that the parameters of this service are valid. Otherwise raise a `RailsCursorPagination::Paginator::ParameterError`.

@param relation [ActiveRecord::Relation]

Relation that will be paginated.

@param first [Integer, nil]

Optional, must be positive, cannot be combined with `last`

@param after [String, nil]

Optional, cannot be combined with `before`

@param last [Integer, nil]

Optional, must be positive, requires `before`, cannot be combined
with `first`

@param before [String, nil]

Optional, cannot be combined with `after`

@param order [Symbol]

Optional, must be :asc or :desc

@raise [RailsCursorPagination::Paginator::ParameterError]

If any parameter is not valid
# File lib/rails_cursor_pagination/paginator.rb, line 105
def ensure_valid_params!(relation, first, after, last, before, order)
  unless relation.is_a?(ActiveRecord::Relation)
    raise ParameterError,
          'The first argument must be an ActiveRecord::Relation, but was '\
          "the #{relation.class} `#{relation.inspect}`"
  end
  unless %i[asc desc].include?(order)
    raise ParameterError,
          "`order` must be either :asc or :desc, but was `#{order}`"
  end
  if first.present? && last.present?
    raise ParameterError, '`first` cannot be combined with `last`'
  end
  if before.present? && after.present?
    raise ParameterError, '`before` cannot be combined with `after`'
  end
  if last.present? && before.blank?
    raise ParameterError, '`last` must be combined with `before`'
  end
  if first.present? && first.negative?
    raise ParameterError, "`first` cannot be negative, but was `#{first}`"
  end
  if last.present? && last.negative?
    raise ParameterError, "`last` cannot be negative, but was `#{last}`"
  end

  true
end
filter_operator() click to toggle source

Get the right operator to use in the SQL WHERE clause for filtering based on the given cursor. This is dependent on the requested order and pagination direction.

If we paginate forward and want ascending records, or if we paginate backward and want descending records we need records that have a higher value than our cursor.

On the contrary, if we paginate forward but want descending records, or if we paginate backwards and want ascending records, we need them to have lower values than our cursor.

Examples:

- first 2 after 4 ascending
  -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2
- last 2 before 4 descending      ^ records with higher value than cursor
  -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2
but:                              ^ records with higher value than cursor
- first 2 after 4 descending
  -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2
- last 2 before 4 ascending       ^ records with lower value than cursor
  -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2
                                  ^ records with lower value than cursor

@return [String] either '<' or '>'

# File lib/rails_cursor_pagination/paginator.rb, line 306
def filter_operator
  if paginate_forward?
    @order_direction == :asc ? '>' : '<'
  else
    @order_direction == :asc ? '<' : '>'
  end
end
filter_value() click to toggle source

The value our relation is filtered by. This is either just the cursor's ID if we use the default order, or it is the combination of the custom order field's value and its ID, joined by a dash.

@return [Integer, String]

# File lib/rails_cursor_pagination/paginator.rb, line 319
def filter_value
  return decoded_cursor_id unless custom_order_field?

  "#{decoded_cursor_field}-#{decoded_cursor_id}"
end
filtered_and_sorted_relation() click to toggle source

Applies the filtering based on the provided cursor and order column to the sorted relation.

In case a custom `order_by` field is provided, we have to filter based on this field and the ID column to ensure reproducible results.

To better understand this, let's consider our example with the `posts` table. Say that we're paginating forward and add `order_by: :author` to the call, and if the cursor that is passed encodes `['Jane', 4]`. In this case we will have to select all posts that either have an author whose name is alphanumerically greater than 'Jane', or if the author is 'Jane' we have to ensure that the post's ID is greater than `4`.

So our SQL WHERE clause needs to be something like:

WHERE author > 'Jane' OR author = 'Jane' AND id > 4

@return [ActiveRecord::Relation]

# File lib/rails_cursor_pagination/paginator.rb, line 450
def filtered_and_sorted_relation
  memoize :filtered_and_sorted_relation do
    next sorted_relation if @cursor.blank?

    unless custom_order_field?
      next sorted_relation.where "#{id_column} #{filter_operator} ?",
                                 decoded_cursor_id
    end

    sorted_relation
      .where("#{@order_field} #{filter_operator} ?", decoded_cursor_field)
      .or(
        sorted_relation
          .where("#{@order_field} = ?", decoded_cursor_field)
          .where("#{id_column} #{filter_operator} ?", decoded_cursor_id)
      )
  end
end
id_column() click to toggle source

Return a properly escaped reference to the ID column prefixed with the table name. This prefixing is important in case of another model having been joined to the passed relation.

@return [String (frozen)]

# File lib/rails_cursor_pagination/paginator.rb, line 426
def id_column
  escaped_table_name = @relation.quoted_table_name
  escaped_id_column = @relation.connection.quote_column_name(:id)

  "#{escaped_table_name}.#{escaped_id_column}".freeze
end
memoize(key) { || ... } click to toggle source

Ensures that given block is only executed exactly once and on subsequent calls returns result from first execution. Useful for memoizing methods.

@param key [Symbol]

Name or unique identifier of the method that is being memoized

@yieldreturn [Object] @return [Object] Whatever the block returns

# File lib/rails_cursor_pagination/paginator.rb, line 476
def memoize(key, &_block)
  return @memos[key] if @memos.key?(key)

  @memos[key] = yield
end
next_page?() click to toggle source

Check if there is another page after the current one.

@return [TrueClass, FalseClass]

# File lib/rails_cursor_pagination/paginator.rb, line 203
def next_page?
  if paginate_forward?
    # When paginating forward, if we managed to load one more record than
    # requested, this record will be available on the next page.
    records_plus_one.size > @page_size
  else
    # When paginating backward, if applying our cursor reduced the number
    # records returned, we know that the missing records will be on
    # subsequent pages.
    filtered_and_sorted_relation.reorder('').size < total
  end
end
page() click to toggle source

Get the records for the given page along with their cursors

@return [Array<Hash>] List of hashes, each with a `cursor` and `data`

# File lib/rails_cursor_pagination/paginator.rb, line 149
def page
  memoize :page do
    records.map do |item|
      {
        cursor: cursor_for_record(item),
        data: item
      }
    end
  end
end
page_info() click to toggle source

Get meta information about the current page

@return [Hash]

# File lib/rails_cursor_pagination/paginator.rb, line 137
def page_info
  {
    has_previous_page: previous_page?,
    has_next_page: next_page?,
    start_cursor: start_cursor,
    end_cursor: end_cursor
  }
end
paginate_forward?() click to toggle source

Check if the pagination direction is forward

@return [TrueClass, FalseClass]

# File lib/rails_cursor_pagination/paginator.rb, line 170
def paginate_forward?
  @is_forward_pagination
end
pagination_sorting() click to toggle source

Get the order we need to apply to our SQL query. In case we are paginating backwards, this has to be the inverse of what the user requested, since our database can only apply the limit to following records. In the case of backward pagination, we then reverse the order of the loaded records again in `#records` to return them in the right order to the user.

Examples:

- first 2 after 4 ascending
  -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2
- first 2 after 4 descending                      ^ as requested
  -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2
but:                                              ^ as requested
- last 2 before 4 ascending
  -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2
- last 2 before 4 descending                      ^ reversed
  -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2
                                                  ^ reversed

@return [Symbol] Either :asc or :desc

# File lib/rails_cursor_pagination/paginator.rb, line 275
def pagination_sorting
  return @order_direction if paginate_forward?

  @order_direction == :asc ? :desc : :asc
end
previous_page?() click to toggle source

Check if there is a page before the current one.

@return [TrueClass, FalseClass]

# File lib/rails_cursor_pagination/paginator.rb, line 186
def previous_page?
  if paginate_forward?
    # When paginating forward, we can only have a previous page if we were
    # provided with a cursor and there were records discarded after applying
    # this filter. These records would have to be on previous pages.
    @cursor.present? &&
      filtered_and_sorted_relation.reorder('').size < total
  else
    # When paginating backwards, if we managed to load one more record than
    # requested, this record will be available on the previous page.
    records_plus_one.size > @page_size
  end
end
records() click to toggle source

Load the correct records and return them in the right order

@return [Array<ActiveRecord>]

# File lib/rails_cursor_pagination/paginator.rb, line 219
def records
  records = records_plus_one.first(@page_size)

  paginate_forward? ? records : records.reverse
end
records_plus_one() click to toggle source

Apply limit to filtered and sorted relation that contains one item more than the user-requested page size. This is useful for determining if there is an additional page available without having to do a separate DB query. Then, fetch the records from the database to prevent multiple queries to load the records and count them.

@return [ActiveRecord::Relation]

# File lib/rails_cursor_pagination/paginator.rb, line 232
def records_plus_one
  memoize :records_plus_one do
    filtered_and_sorted_relation.limit(@page_size + 1).load
  end
end
relation_with_cursor_fields() click to toggle source

Ensure that the relation has the ID column and any potential `order_by` column selected. These are required to generate the record's cursor and therefore it's crucial that they are part of the selected fields.

@return [ActiveRecord::Relation]

# File lib/rails_cursor_pagination/paginator.rb, line 391
def relation_with_cursor_fields
  return @relation if @relation.select_values.blank?

  relation = @relation

  unless @relation.select_values.include?(:id)
    relation = relation.select(:id)
  end

  if custom_order_field? && !@relation.select_values.include?(@order_field)
    relation = relation.select(@order_field)
  end

  relation
end
sorted_relation() click to toggle source

The given relation with the right ordering applied. Takes custom order columns as well as custom direction and pagination into account.

@return [ActiveRecord::Relation]

# File lib/rails_cursor_pagination/paginator.rb, line 411
def sorted_relation
  unless custom_order_field?
    return relation_with_cursor_fields.reorder id: pagination_sorting.upcase
  end

  relation_with_cursor_fields
    .reorder(@order_field => pagination_sorting.upcase,
             id: pagination_sorting.upcase)
end
start_cursor() click to toggle source

Cursor of the first record on the current page

@return [String, nil]

# File lib/rails_cursor_pagination/paginator.rb, line 241
def start_cursor
  return if page.empty?

  page.first[:cursor]
end
total() click to toggle source

Get the total number of records in the given relation

@return [Integer]

# File lib/rails_cursor_pagination/paginator.rb, line 163
def total
  memoize(:total) { @relation.reorder('').size }
end