From a1e45f4c42f281819e675dd65f0530bf6f0bebc8 Mon Sep 17 00:00:00 2001 From: Nicolas Klein Date: Fri, 8 Jul 2022 11:41:14 +0100 Subject: [PATCH] Refactors into a Page Class --- .rubocop.yml | 2 +- lib/rails_cursor_pagination.rb | 2 + lib/rails_cursor_pagination/page.rb | 326 ++++++++++++++++++++++ lib/rails_cursor_pagination/paginator.rb | 340 ++--------------------- 4 files changed, 351 insertions(+), 319 deletions(-) create mode 100644 lib/rails_cursor_pagination/page.rb diff --git a/.rubocop.yml b/.rubocop.yml index 61779bc..66a677a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,7 +13,7 @@ Metrics/BlockLength: - spec/**/* Metrics/ClassLength: - Max: 198 + Max: 139 Metrics/CyclomaticComplexity: Max: 15 diff --git a/lib/rails_cursor_pagination.rb b/lib/rails_cursor_pagination.rb index 6404530..6ba9878 100644 --- a/lib/rails_cursor_pagination.rb +++ b/lib/rails_cursor_pagination.rb @@ -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' diff --git a/lib/rails_cursor_pagination/page.rb b/lib/rails_cursor_pagination/page.rb new file mode 100644 index 0000000..773f27a --- /dev/null +++ b/lib/rails_cursor_pagination/page.rb @@ -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] + 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? && !@relation.select_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 diff --git a/lib/rails_cursor_pagination/paginator.rb b/lib/rails_cursor_pagination/paginator.rb index b90c515..4df6355 100644 --- a/lib/rails_cursor_pagination/paginator.rb +++ b/lib/rails_cursor_pagination/paginator.rb @@ -77,14 +77,33 @@ def initialize(relation, limit: nil, first: nil, after: nil, last: nil, # @return [Hash] with keys :page, :page_info, and optional :total def fetch(with_total: false) { - **(with_total ? { total: total } : {}), - page_info: page_info, - page: page + **(with_total ? { total: result.total } : {}), + page_info: result.page_info, + page: result.page } end + def page_info + result.page_info + end + + def records + result.records + end + private + def result + @result ||= Page.new( + order_field: @order_field, + order_direction: @order_direction, + relation: @relation, + cursor: @cursor, + paginate_forward: @is_forward_pagination, + page_size: @page_size + ) + end + # Ensure that the parameters of this service have valid values, otherwise # raise a `RailsCursorPagination::ParameterError`. # @@ -161,320 +180,5 @@ def ensure_valid_params_combinations!(first, last, limit, before, after) true end - - # Get meta information about the current page - # - # @return [Hash] - def page_info - { - has_previous_page: previous_page?, - has_next_page: next_page?, - start_cursor: start_cursor, - end_cursor: end_cursor - } - end - - # Get the records for the given page along with their cursors - # - # @return [Array] List of hashes, each with a `cursor` and `data` - def page - memoize :page do - records.map do |item| - { - cursor: cursor_for_record(item), - data: item - } - end - end - end - - # Get the total number of records in the given relation - # - # @return [Integer] - def total - memoize(:total) { @relation.reorder('').size } - end - - # Check if the pagination direction is forward - # - # @return [TrueClass, FalseClass] - def paginate_forward? - @is_forward_pagination - 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 - - # Load the correct records and return them in the right order - # - # @return [Array] - def records - records = records_plus_one.first(@page_size) - - paginate_forward? ? records : records.reverse - 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 - - # 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] - def filter_value - return decoded_cursor.id unless custom_order_field? - - "#{decoded_cursor.order_field_value}-#{decoded_cursor.id}" - 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? && !@relation.select_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