Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft: Refactors into a Page Class #81

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Metrics/BlockLength:
- spec/**/*

Metrics/ClassLength:
Max: 198
Max: 139

Metrics/CyclomaticComplexity:
Max: 15
Expand Down
2 changes: 2 additions & 0 deletions lib/rails_cursor_pagination.rb
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ class InvalidCursorError < ParameterError; end

require_relative 'rails_cursor_pagination/configuration'

require_relative 'rails_cursor_pagination/page'

require_relative 'rails_cursor_pagination/paginator'

require_relative 'rails_cursor_pagination/cursor'
Expand Down
326 changes: 326 additions & 0 deletions lib/rails_cursor_pagination/page.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,326 @@
# frozen_string_literal: true

module RailsCursorPagination
# Internal usage - the Page class calculates and contains the results
# of a page after pagination - it assumes the attributes have been
# pre-filtered by the consumer class.

# Usage:
# RailsCursorPagination::Paginator
# .new(relation, order_by: :author, first: 2, after: "WyJKYW5lIiw0XQ==")
# .fetch
#
class Page
def initialize(order_field:, order_direction:, relation:,
cursor:, paginate_forward:, page_size:)
@order_field = order_field
@order_direction = order_direction
@relation = relation
@cursor = cursor
@paginate_forward = paginate_forward
@page_size = page_size

@memos = {}
end

def total
memoize(:total) { @relation.reorder('').size }
end

def page
memoize(:page) do
records.map do |item|
{
cursor: cursor_for_record(item),
data: item
}
end
end
end

# Load the correct records and return them in the right order
#
# @return [Array<ActiveRecord>]
def records
memoize(:records) do
records = records_plus_one.first(@page_size)
paginate_forward? ? records : records.reverse
end
end

def page_info
memoize(:page_info) do
{
has_previous_page: previous_page?,
has_next_page: next_page?,
start_cursor: start_cursor,
end_cursor: end_cursor
}
end
end

private

# Check if the pagination direction is forward
#
# @return [TrueClass, FalseClass]
def paginate_forward?
@paginate_forward
end

# 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]
def custom_order_field?
@order_field.downcase.to_sym != :id
end

# Check if there is a page before the current one.
#
# @return [TrueClass, FalseClass]
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

# Check if there is another page after the current one.
#
# @return [TrueClass, FalseClass]
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

# 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]
def records_plus_one
memoize :records_plus_one do
filtered_and_sorted_relation.limit(@page_size + 1).load
end
end

# Cursor of the first record on the current page
#
# @return [String, nil]
def start_cursor
return if page.empty?

page.first[:cursor]
end

# Cursor of the last record on the current page
#
# @return [String, nil]
def end_cursor
return if page.empty?

page.last[:cursor]
end

# 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
def pagination_sorting
return @order_direction if paginate_forward?

@order_direction == :asc ? :desc : :asc
end

# 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 '>'
def filter_operator
if paginate_forward?
@order_direction == :asc ? '>' : '<'
else
@order_direction == :asc ? '<' : '>'
end
end

# 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]
def cursor_for_record(record)
Cursor.from_record(record: record, order_field: @order_field).encode
end

# 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]
def decoded_cursor
memoize(:decoded_cursor) do
Cursor.decode(encoded_string: @cursor, order_field: @order_field)
end
end

# 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]
def relation_with_cursor_fields
return @relation if @relation.select_values.blank? ||
@relation.select_values.include?('*')

relation = @relation

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

if custom_order_field? && [email protected]_values.include?(@order_field)
relation = relation.select(@order_field)
end

relation
end

# The given relation with the right ordering applied. Takes custom order
# columns as well as custom direction and pagination into account.
#
# @return [ActiveRecord::Relation]
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

# 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)]
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

# 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]
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.order_field_value)
.or(
sorted_relation
.where("#{@order_field} = ?", decoded_cursor.order_field_value)
.where("#{id_column} #{filter_operator} ?", decoded_cursor.id)
)
end
end

# 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
def memoize(key, &_block)
return @memos[key] if @memos.key?(key)

@memos[key] = yield
end
end
end
Loading