diff --git a/.rubocop.yml b/.rubocop.yml index 639b37c..293c044 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -13,7 +13,7 @@ Metrics/BlockLength: - spec/**/* Metrics/ClassLength: - Max: 198 + Max: 210 Metrics/CyclomaticComplexity: Max: 15 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2040551..4c4bcfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ These are the latest changes on the project's `master` branch that have not yet ### Added - Test against Ruby version 3.2 +### Fixed +- Ensure timestamp `order_by` fields will have expected paginated results by honoring of timestamps down to microsecond resolution on comparison. + ## [0.3.0] - 2022-07-08 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 705b5f7..9851b55 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -7,26 +7,36 @@ PATH GEM remote: https://rubygems.org/ specs: - activemodel (6.1.7.6) - activesupport (= 6.1.7.6) - activerecord (6.1.7.6) - activemodel (= 6.1.7.6) - activesupport (= 6.1.7.6) - activesupport (6.1.7.6) + activemodel (7.1.0) + activesupport (= 7.1.0) + activerecord (7.1.0) + activemodel (= 7.1.0) + activesupport (= 7.1.0) + timeout (>= 0.4.0) + activesupport (7.1.0) + base64 + bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) + connection_pool (>= 2.2.5) + drb i18n (>= 1.6, < 2) minitest (>= 5.1) + mutex_m tzinfo (~> 2.0) - zeitwerk (~> 2.3) ast (2.4.2) base64 (0.1.1) + bigdecimal (3.1.4) concurrent-ruby (1.2.2) + connection_pool (2.4.1) diff-lcs (1.5.0) + drb (2.1.1) + ruby2_keywords i18n (1.14.1) concurrent-ruby (~> 1.0) json (2.6.3) language_server-protocol (3.17.0.3) minitest (5.19.0) + mutex_m (0.1.2) mysql2 (0.5.5) parallel (1.23.0) parser (3.2.2.3) @@ -65,10 +75,11 @@ GEM rubocop-ast (1.29.0) parser (>= 3.2.1.0) ruby-progressbar (1.13.0) + ruby2_keywords (0.0.5) + timeout (0.4.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (2.4.2) - zeitwerk (2.6.11) PLATFORMS ruby diff --git a/lib/rails_cursor_pagination.rb b/lib/rails_cursor_pagination.rb index 6404530..4605315 100644 --- a/lib/rails_cursor_pagination.rb +++ b/lib/rails_cursor_pagination.rb @@ -164,6 +164,8 @@ class InvalidCursorError < ParameterError; end require_relative 'rails_cursor_pagination/cursor' + require_relative 'rails_cursor_pagination/timestamp_cursor' + class << self # Allows to configure this gem. Currently supported configuration values # are: diff --git a/lib/rails_cursor_pagination/paginator.rb b/lib/rails_cursor_pagination/paginator.rb index b90c515..99806c4 100644 --- a/lib/rails_cursor_pagination/paginator.rb +++ b/lib/rails_cursor_pagination/paginator.rb @@ -365,7 +365,7 @@ def filter_value # @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 + cursor_class.from_record(record: record, order_field: @order_field).encode end # Decode the provided cursor. Either just returns the cursor's ID or in case @@ -375,7 +375,25 @@ def cursor_for_record(record) # @return [Integer, Array] def decoded_cursor memoize(:decoded_cursor) do - Cursor.decode(encoded_string: @cursor, order_field: @order_field) + cursor_class.decode(encoded_string: @cursor, order_field: @order_field) + end + end + + # Returns the appropriate class for the cursor based on the SQL type of the + # column used for ordering the relation. + # + # @return [Class] + def cursor_class + order_field_type = @relation + .column_for_attribute(@order_field) + .sql_type_metadata + .type + + case order_field_type + when :datetime + TimestampCursor + else + Cursor end end diff --git a/lib/rails_cursor_pagination/timestamp_cursor.rb b/lib/rails_cursor_pagination/timestamp_cursor.rb new file mode 100644 index 0000000..eb8cc7b --- /dev/null +++ b/lib/rails_cursor_pagination/timestamp_cursor.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module RailsCursorPagination + # Cursor class that's used to uniquely identify a record and serialize and + # deserialize this cursor so that it can be used for pagination. + # This class expects the `order_field` of the record to be a timestamp and is + # to be used only when sorting a + class TimestampCursor < Cursor + class << self + # Decode the provided encoded cursor. Returns an instance of this + # `RailsCursorPagination::Cursor` class containing both the ID and the + # ordering field value. The ordering field is expected to be a timestamp + # and is always decoded in the UTC timezone. + # + # @param encoded_string [String] + # The encoded cursor + # @param order_field [Symbol] + # The column that is being ordered on. It needs to be a timestamp of a + # class that responds to `#strftime`. + # @raise [RailsCursorPagination::InvalidCursorError] + # In case the given `encoded_string` cannot be decoded properly + # @return [RailsCursorPagination::TimestampCursor] + # Instance of this class with a properly decoded timestamp cursor + def decode(encoded_string:, order_field:) + decoded = JSON.parse(Base64.strict_decode64(encoded_string)) + + new( + id: decoded[1], + order_field: order_field, + # Turn the order field value into a `Time` instance in UTC. A Rational + # number allows us to represent fractions of seconds, including the + # microseconds. In this way we can preserve the order of items with a + # microsecond precision. + # This also allows us to keep the size of the cursor small by using + # just a number instead of having to pass seconds and the fraction of + # seconds separately. + order_field_value: Time.at(decoded[0].to_r / (10**6)).utc + ) + rescue ArgumentError, JSON::ParserError + raise InvalidCursorError, + "The given cursor `#{encoded_string}` " \ + 'could not be decoded to a timestamp' + end + end + + # Initializes the record. Overrides `Cursor`'s initializer making all params + # mandatory. + # + # @param id [Integer] + # The ID of the cursor record + # @param order_field [Symbol] + # The column or virtual column for ordering + # @param order_field_value [Object] + # The value that the +order_field+ of the record contains + def initialize(id:, order_field:, order_field_value:) + super id: id, + order_field: order_field, + order_field_value: order_field_value + end + + # Encodes the cursor as an array containing the timestamp as microseconds + # from UNIX epoch and the id of the object + # + # @raise [RailsCursorPagination::ParameterError] + # The order field value needs to respond to `#strftime` to use the + # `TimestampCursor` class. Otherwise, a `ParameterError` is raised. + # @return [String] + def encode + unless @order_field_value.respond_to?(:strftime) + raise ParameterError, + "Could not encode #{@order_field} " \ + "with value #{@order_field_value}." \ + 'It does not respond to #strftime. Is it a timestamp?' + end + + Base64.strict_encode64( + [ + @order_field_value.strftime('%s%6N').to_i, + @id + ].to_json + ) + end + end +end diff --git a/spec/rails_cursor_pagination/paginator_spec.rb b/spec/rails_cursor_pagination/paginator_spec.rb index b4369b0..dfe14ff 100644 --- a/spec/rails_cursor_pagination/paginator_spec.rb +++ b/spec/rails_cursor_pagination/paginator_spec.rb @@ -182,589 +182,1347 @@ post_13 ] end - let(:posts_by_author) do - # Posts are first ordered by the author's name and then, in case of two - # posts having the same author, by ID - [ - # All posts by "Jane" - post_2, - post_3, - post_5, - post_7, - post_13, - # All posts by "Jess" - post_9, - post_10, - # All posts by "John" - post_1, - post_4, - post_6, - post_8, - post_11, - post_12 - ] + + shared_examples_for 'a query that works with a descending `order`' do + let(:params) { super().merge(order: :desc) } + + it_behaves_like 'a well working query that also supports SELECT' end - let(:cursor_object) { nil } - let(:cursor_object_plain) { nil } - let(:cursor_object_desc) { nil } - let(:cursor_object_by_author) { nil } - let(:cursor_object_by_author_desc) { nil } - let(:query_cursor_base) { cursor_object&.id } - let(:query_cursor) { Base64.strict_encode64(query_cursor_base.to_json) } - let(:order_by_column) { nil } + shared_examples_for 'a query that returns no data when relation is empty' do + let(:relation) { Post.where(author: 'keks') } - shared_examples_for 'a properly returned response' do - let(:expected_start_cursor) do - if expected_posts.any? - Base64.strict_encode64( - expected_cursor.call(expected_posts.first).to_json - ) - end - end - let(:expected_end_cursor) do - if expected_posts.any? - Base64.strict_encode64( - expected_cursor.call(expected_posts.last).to_json - ) - end + it_behaves_like 'a well working query that also supports SELECT' do + let(:expected_posts) { [] } + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { false } + let(:expected_total) { 0 } end - let(:expected_attributes) { %i[id author content] } + end - it 'has the correct format' do - is_expected.to be_a Hash - is_expected.to have_key :page - is_expected.to have_key :page_info + context 'when order_by is not a timestamp' do + let(:posts_by_order_by_column) do + # Posts are first ordered by the author's name and then, in case of two + # posts having the same author, by ID + [ + # All posts by "Jane" + post_2, + post_3, + post_5, + post_7, + post_13, + # All posts by "Jess" + post_9, + post_10, + # All posts by "John" + post_1, + post_4, + post_6, + post_8, + post_11, + post_12 + ] end - describe 'for :page_info' do - subject { result[:page_info] } + let(:cursor_object) { nil } + let(:cursor_object_plain) { nil } + let(:cursor_object_desc) { nil } + let(:cursor_object_by_order_by_column) { nil } + let(:cursor_object_by_order_by_column_desc) { nil } + let(:query_cursor_base) { cursor_object&.id } + let(:query_cursor) { Base64.strict_encode64(query_cursor_base.to_json) } + let(:order_by_column) { nil } + + shared_examples_for 'a properly returned response' do + let(:expected_start_cursor) do + if expected_posts.any? + Base64.strict_encode64( + expected_cursor.call(expected_posts.first).to_json + ) + end + end + let(:expected_end_cursor) do + if expected_posts.any? + Base64.strict_encode64( + expected_cursor.call(expected_posts.last).to_json + ) + end + end + let(:expected_attributes) do + %i[id author content updated_at created_at] + end + + it 'has the correct format' do + is_expected.to be_a Hash + is_expected.to have_key :page + is_expected.to have_key :page_info + end + + describe 'for :page_info' do + subject { result[:page_info] } + + it 'includes all relevant meta info' do + is_expected.to be_a Hash - it 'includes all relevant meta info' do + expect(subject.keys).to contain_exactly :has_previous_page, + :has_next_page, + :start_cursor, + :end_cursor + + is_expected.to( + include has_previous_page: expected_has_previous_page, + has_next_page: expected_has_next_page, + start_cursor: expected_start_cursor, + end_cursor: expected_end_cursor + ) + end + end + + describe 'for :page' do + subject { result[:page] } + + let(:returned_parsed_cursors) do + subject + .pluck(:cursor) + .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + end + + it 'contains the right data' do + is_expected.to be_an Array + is_expected.to all be_a Hash + is_expected.to all include :cursor, :data + + expect(subject.pluck(:data)).to all be_a Post + expect(subject.pluck(:data)).to match_array expected_posts + expect(subject.pluck(:data)).to eq expected_posts + expect(subject.pluck(:data).map(&:attributes).map(&:keys)) + .to all match_array expected_attributes.map(&:to_s) + + expect(subject.pluck(:cursor)).to all be_a String + expect(subject.pluck(:cursor)).to all be_present + expect(returned_parsed_cursors) + .to eq(expected_posts.map { |post| expected_cursor.call(post) }) + end + end + + it 'does not return the total by default' do is_expected.to be_a Hash + is_expected.to_not have_key :total + end - expect(subject.keys).to contain_exactly :has_previous_page, - :has_next_page, - :start_cursor, - :end_cursor + context 'when passing `with_total: true`' do + subject(:result) { instance.fetch(with_total: true) } - is_expected.to include has_previous_page: expected_has_previous_page, - has_next_page: expected_has_next_page, - start_cursor: expected_start_cursor, - end_cursor: expected_end_cursor + it 'also includes the `total` of records' do + is_expected.to have_key :total + expect(subject[:total]).to eq expected_total + end end end - describe 'for :page' do - subject { result[:page] } + shared_examples_for 'a well working query that also supports SELECT' do + context 'when SELECTing all columns' do + context 'without calling select' do + it_behaves_like 'a properly returned response' + end + + context 'including the "*" select' do + let(:selected_attributes) { ['*'] } - let(:returned_parsed_cursors) do - subject - .pluck(:cursor) - .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + it_behaves_like 'a properly returned response' + end end - it 'contains the right data' do - is_expected.to be_an Array - is_expected.to all be_a Hash - is_expected.to all include :cursor, :data + context 'when SELECTing only some columns' do + let(:selected_attributes) { %i[id author] } + let(:relation) { super().select(*selected_attributes) } - expect(subject.pluck(:data)).to all be_a Post - expect(subject.pluck(:data)).to match_array expected_posts - expect(subject.pluck(:data)).to eq expected_posts - expect(subject.pluck(:data).map(&:attributes).map(&:keys)) - .to all match_array expected_attributes.map(&:to_s) + it_behaves_like 'a properly returned response' do + let(:expected_attributes) { %i[id author] } + end + + context 'and not including any cursor-relevant column' do + let(:selected_attributes) { %i[content] } - expect(subject.pluck(:cursor)).to all be_a String - expect(subject.pluck(:cursor)).to all be_present - expect(returned_parsed_cursors) - .to eq(expected_posts.map { |post| expected_cursor.call(post) }) + it_behaves_like 'a properly returned response' do + let(:expected_attributes) do + %i[id content].tap do |attributes| + attributes << order_by_column if order_by_column.present? + end + end + end + end end end - it 'does not return the total by default' do - is_expected.to be_a Hash - is_expected.to_not have_key :total - end + shared_examples_for 'a query that works with `order_by` param' do + let(:params) { super().merge(order_by: order_by_column) } + let(:order_by_column) { :author } + + it_behaves_like 'a well working query that also supports SELECT' - context 'when passing `with_total: true`' do - subject(:result) { instance.fetch(with_total: true) } + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } - it 'also includes the `total` of records' do - is_expected.to have_key :total - expect(subject[:total]).to eq expected_total + let(:expected_posts) { expected_posts_desc } end end - end - shared_examples_for 'a well working query that also supports SELECT' do - context 'when SELECTing all columns' do - context 'without calling select' do - it_behaves_like 'a properly returned response' + shared_examples 'for a working query' do + let(:expected_total) { relation.size } + + it_behaves_like 'a well working query that also supports SELECT' do + let(:cursor_object) { cursor_object_plain } + let(:query_cursor_base) { cursor_object&.id } + + let(:expected_posts) { expected_posts_plain } + let(:expected_cursor) { ->(post) { post.id } } + end + + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } + let(:query_cursor_base) { cursor_object&.id } + + let(:expected_posts) { expected_posts_desc } + let(:expected_cursor) { ->(post) { post.id } } end - context 'including the "*" select' do - let(:selected_attributes) { ['*'] } + it_behaves_like 'a query that works with `order_by` param' do + let(:cursor_object) { cursor_object_by_order_by_column } + let(:cursor_object_desc) { cursor_object_by_order_by_column_desc } + let(:query_cursor_base) do + [cursor_object&.send(order_by_column), cursor_object&.id] + end - it_behaves_like 'a properly returned response' + let(:expected_posts) { expected_posts_by_order_by_column } + let(:expected_posts_desc) { expected_posts_by_order_by_column_desc } + let(:expected_cursor) do + lambda { |post| + [post.send(order_by_column), post.id] + } + end end + + it_behaves_like 'a query that returns no data when relation is empty' end - context 'when SELECTing only some columns' do - let(:selected_attributes) { %i[id author] } - let(:relation) { super().select(*selected_attributes) } + context 'when neither first/last/limit nor before/after are passed' do + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(10) } + let(:expected_posts_desc) { posts.reverse.first(10) } + + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(10) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(10) + end - it_behaves_like 'a properly returned response' do - let(:expected_attributes) { %i[id author] } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } end - context 'and not including any cursor-relevant column' do - let(:selected_attributes) { %i[content] } + context 'when a different default_page_size has been set' do + let(:custom_page_size) { 2 } - it_behaves_like 'a properly returned response' do - let(:expected_attributes) do - %i[id content].tap do |attributes| - attributes << order_by_column if order_by_column.present? - end + before do + RailsCursorPagination.configure do |config| + config.default_page_size = custom_page_size end end - end - end - end - shared_examples_for 'a query that works with a descending `order`' do - let(:params) { super().merge(order: :desc) } + after { RailsCursorPagination.configure(&:reset!) } - it_behaves_like 'a well working query that also supports SELECT' - end + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(custom_page_size) } + let(:expected_posts_desc) { posts.reverse.first(custom_page_size) } - shared_examples_for 'a query that works with `order_by` param' do - let(:params) { super().merge(order_by: order_by_column) } - let(:order_by_column) { :author } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(custom_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(custom_page_size) + end - it_behaves_like 'a well working query that also supports SELECT' + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end - it_behaves_like 'a query that works with a descending `order`' do - let(:cursor_object) { cursor_object_desc } + context 'when a max_page_size has been set' do + let(:max_page_size) { 2 } - let(:expected_posts) { expected_posts_desc } - end - end + before do + RailsCursorPagination.configure do |config| + config.max_page_size = max_page_size + end + end - shared_examples_for 'a query that returns no data when relation is empty' do - let(:relation) { Post.where(author: 'keks') } + after { RailsCursorPagination.configure(&:reset!) } - it_behaves_like 'a well working query that also supports SELECT' do - let(:expected_posts) { [] } - let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { false } - let(:expected_total) { 0 } - end - end + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(max_page_size) } + let(:expected_posts_desc) { posts.reverse.first(max_page_size) } - shared_examples 'for a working query' do - let(:expected_total) { relation.size } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(max_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(max_page_size) + end - it_behaves_like 'a well working query that also supports SELECT' do - let(:cursor_object) { cursor_object_plain } - let(:query_cursor_base) { cursor_object&.id } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end - let(:expected_posts) { expected_posts_plain } - let(:expected_cursor) { ->(post) { post.id } } - end + context 'when attempting to go over the limit' do + let(:params) { { first: 5 } } - it_behaves_like 'a query that works with a descending `order`' do - let(:cursor_object) { cursor_object_desc } - let(:query_cursor_base) { cursor_object&.id } + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(max_page_size) } + let(:expected_posts_desc) { posts.reverse.first(max_page_size) } - let(:expected_posts) { expected_posts_desc } - let(:expected_cursor) { ->(post) { post.id } } - end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(max_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(max_page_size) + end - it_behaves_like 'a query that works with `order_by` param' do - let(:cursor_object) { cursor_object_by_author } - let(:cursor_object_desc) { cursor_object_by_author_desc } - let(:query_cursor_base) { [cursor_object&.author, cursor_object&.id] } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end + end + + context 'when `order` and `order_by` are explicitly set to `nil`' do + let(:params) { super().merge(order: nil, order_by: nil) } - let(:expected_posts) { expected_posts_by_author } - let(:expected_posts_desc) { expected_posts_by_author_desc } - let(:expected_cursor) { ->(post) { [post.author, post.id] } } + it_behaves_like 'a well working query that also supports SELECT' do + let(:expected_posts) { posts.first(10) } + let(:expected_cursor) { ->(post) { post.id } } + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end end - it_behaves_like 'a query that returns no data when relation is empty' - end + context 'when only passing first' do + let(:params) { { first: 2 } } + + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } - context 'when neither first/last/limit nor before/after are passed' do - include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(10) } - let(:expected_posts_desc) { posts.reverse.first(10) } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(2) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(2) + end - let(:expected_posts_by_author) { posts_by_author.first(10) } - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(10) + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } - end + context 'when there are less records than requested' do + let(:params) { { first: posts.size + 1 } } + + include_examples 'for a working query' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } - context 'when a different default_page_size has been set' do - let(:custom_page_size) { 2 } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column } + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse + end - before do - RailsCursorPagination.configure do |config| - config.default_page_size = custom_page_size + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { false } end end + end - after { RailsCursorPagination.configure(&:reset!) } + context 'when only passing limit' do + let(:params) { { limit: 2 } } include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(custom_page_size) } - let(:expected_posts_desc) { posts.reverse.first(custom_page_size) } + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } - let(:expected_posts_by_author) do - posts_by_author.first(custom_page_size) + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(2) end - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(custom_page_size) + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(2) end let(:expected_has_next_page) { true } let(:expected_has_previous_page) { false } end - end - context 'when a max_page_size has been set' do - let(:max_page_size) { 2 } + context 'when there are less records than requested' do + let(:params) { { first: posts.size + 1 } } + + include_examples 'for a working query' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } - before do - RailsCursorPagination.configure do |config| - config.max_page_size = max_page_size + let(:expected_posts_by_order_by_column) { posts_by_order_by_column } + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { false } end end + end - after { RailsCursorPagination.configure(&:reset!) } + context 'when passing `after`' do + let(:params) { { after: query_cursor } } include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(max_page_size) } - let(:expected_posts_desc) { posts.reverse.first(max_page_size) } + let(:cursor_object_plain) { posts[0] } + let(:expected_posts_plain) { posts[1..10] } + + let(:cursor_object_desc) { posts[-1] } + let(:expected_posts_desc) { posts[-11..-2].reverse } + + let(:cursor_object_by_order_by_column) { posts_by_order_by_column[0] } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[1..10] + end - let(:expected_posts_by_author) do - posts_by_author.first(max_page_size) + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-1] end - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(max_page_size) + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-11..-2].reverse end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } end - context 'when attempting to go over the limit' do - let(:params) { { first: 5 } } + context 'and `first`' do + let(:params) { super().merge(first: 2) } include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(max_page_size) } - let(:expected_posts_desc) { posts.reverse.first(max_page_size) } + let(:cursor_object_plain) { posts[2] } + let(:expected_posts_plain) { posts[3..4] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-4..-3].reverse } - let(:expected_posts_by_author) do - posts_by_author.first(max_page_size) + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[2] end - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(max_page_size) + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[3..4] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-4..-3].reverse end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining after cursor' do + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-2] } + let(:expected_posts_plain) { posts[-1..] } + + let(:cursor_object_desc) { posts[1] } + let(:expected_posts_desc) { posts[0..0].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-1..] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[0..0].reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { true } + end + end + end + + context 'and `limit`' do + let(:params) { super().merge(limit: 2) } + + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[2] } + let(:expected_posts_plain) { posts[3..4] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-4..-3].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[3..4] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-4..-3].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining after cursor' do + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-2] } + let(:expected_posts_plain) { posts[-1..] } + + let(:cursor_object_desc) { posts[1] } + let(:expected_posts_desc) { posts[0..0].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-1..] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[0..0].reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { true } + end end end end - context 'when `order` and `order_by` are explicitly set to `nil`' do - let(:params) { super().merge(order: nil, order_by: nil) } + context 'when passing `before`' do + let(:params) { { before: query_cursor } } - it_behaves_like 'a well working query that also supports SELECT' do - let(:expected_posts) { posts.first(10) } - let(:expected_cursor) { ->(post) { post.id } } + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-11..-2] } + + let(:cursor_object_desc) { posts[0] } + let(:expected_posts_desc) { posts[1..10].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-11..-2] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[0] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[1..10].reverse + end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } + end + + context 'and `last`' do + let(:params) { super().merge(last: 2) } + + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-3..-2] } + + let(:cursor_object_desc) { posts[2] } + let(:expected_posts_desc) { posts[3..4].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-3..-2] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[3..4].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining before cursor' do + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[1] } + let(:expected_posts_plain) { posts[0..0] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-1..].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[0..0] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-1..].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end + end + + context 'and `limit`' do + let(:params) { super().merge(limit: 2) } + + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-3..-2] } + + let(:cursor_object_desc) { posts[2] } + let(:expected_posts_desc) { posts[3..4].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-3..-2] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[3..4].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining before cursor' do + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[1] } + let(:expected_posts_plain) { posts[0..0] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-1..].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[0..0] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-1..].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end end end end - context 'when only passing first' do - let(:params) { { first: 2 } } + context 'when order_by is a timestamp' do + let(:posts_by_order_by_column) do + # Posts are first ordered by the created_at + [ + post_1, + post_2, + post_3, + post_4, + post_5, + post_6, + post_7, + post_8, + post_9, + post_10, + post_11, + post_12, + post_13 + ] + end - include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(2) } - let(:expected_posts_desc) { posts.reverse.first(2) } + let(:cursor_object) { nil } + let(:cursor_object_plain) { nil } + let(:cursor_object_desc) { nil } + let(:cursor_object_by_order_by_column) { nil } + let(:cursor_object_by_order_by_column_desc) { nil } + let(:query_cursor_base) { cursor_object&.id } + let(:query_cursor) { Base64.strict_encode64(query_cursor_base.to_json) } + let(:order_by_column) { nil } + + shared_examples_for 'a properly returned response' do + let(:expected_start_cursor) do + if expected_posts.any? + Base64.strict_encode64( + expected_cursor.call(expected_posts.first).to_json + ) + end + end + let(:expected_end_cursor) do + if expected_posts.any? + Base64.strict_encode64( + expected_cursor.call(expected_posts.last).to_json + ) + end + end + let(:expected_attributes) do + %i[id author content updated_at created_at] + end - let(:expected_posts_by_author) { posts_by_author.first(2) } - let(:expected_posts_by_author_desc) { posts_by_author.reverse.first(2) } + it 'has the correct format' do + is_expected.to be_a Hash + is_expected.to have_key :page + is_expected.to have_key :page_info + end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } - end + describe 'for :page_info' do + subject { result[:page_info] } - context 'when there are less records than requested' do - let(:params) { { first: posts.size + 1 } } + it 'includes all relevant meta info' do + is_expected.to be_a Hash - include_examples 'for a working query' do - let(:expected_posts_plain) { posts } - let(:expected_posts_desc) { posts.reverse } + expect(subject.keys).to contain_exactly :has_previous_page, + :has_next_page, + :start_cursor, + :end_cursor - let(:expected_posts_by_author) { posts_by_author } - let(:expected_posts_by_author_desc) { posts_by_author.reverse } + is_expected.to( + include has_previous_page: expected_has_previous_page, + has_next_page: expected_has_next_page, + start_cursor: expected_start_cursor, + end_cursor: expected_end_cursor + ) + end + end - let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { false } + describe 'for :page' do + subject { result[:page] } + + let(:returned_parsed_cursors) do + subject + .pluck(:cursor) + .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + end + + it 'contains the right data' do + is_expected.to be_an Array + is_expected.to all be_a Hash + is_expected.to all include :cursor, :data + + expect(subject.pluck(:data)).to all be_a Post + expect(subject.pluck(:data)).to match_array expected_posts + expect(subject.pluck(:data)).to eq expected_posts + + expect(subject.pluck(:data).map(&:attributes).map(&:keys)) + .to all match_array expected_attributes.map(&:to_s) + + expect(subject.pluck(:cursor)).to all be_a String + expect(subject.pluck(:cursor)).to all be_present + expect(returned_parsed_cursors) + .to eq(expected_posts.map { |post| expected_cursor.call(post) }) + end + end + + it 'does not return the total by default' do + is_expected.to be_a Hash + is_expected.to_not have_key :total + end + + context 'when passing `with_total: true`' do + subject(:result) { instance.fetch(with_total: true) } + + it 'also includes the `total` of records' do + is_expected.to have_key :total + expect(subject[:total]).to eq expected_total + end end end - end - context 'when only passing limit' do - let(:params) { { limit: 2 } } + shared_examples_for 'a well working query that also supports SELECT' do + context 'when SELECTing all columns' do + context 'without calling select' do + it_behaves_like 'a properly returned response' + end - include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(2) } - let(:expected_posts_desc) { posts.reverse.first(2) } + context 'including the "*" select' do + let(:selected_attributes) { ['*'] } - let(:expected_posts_by_author) { posts_by_author.first(2) } - let(:expected_posts_by_author_desc) { posts_by_author.reverse.first(2) } + it_behaves_like 'a properly returned response' + end + end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + context 'when SELECTing only some columns' do + let(:selected_attributes) { %i[id created_at] } + let(:relation) { super().select(*selected_attributes) } + + it_behaves_like 'a properly returned response' do + let(:expected_attributes) { %i[id created_at] } + end + + context 'and not including any cursor-relevant column' do + let(:selected_attributes) { %i[content author] } + + it_behaves_like 'a properly returned response' do + let(:expected_attributes) do + %i[id content author].tap do |attributes| + attributes << order_by_column if order_by_column.present? + end + end + end + end + end end - context 'when there are less records than requested' do - let(:params) { { first: posts.size + 1 } } + shared_examples_for 'a query ordered by a timestamp column' do + let(:params) { super().merge(order_by: :created_at) } + let(:order_by_column) { :created_at } - include_examples 'for a working query' do - let(:expected_posts_plain) { posts } - let(:expected_posts_desc) { posts.reverse } + it_behaves_like 'a well working query that also supports SELECT' - let(:expected_posts_by_author) { posts_by_author } - let(:expected_posts_by_author_desc) { posts_by_author.reverse } + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } - let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { false } + let(:expected_posts) { expected_posts_desc } end end - end - context 'when passing `after`' do - let(:params) { { after: query_cursor } } + shared_examples 'a working query ordered by a timestamp column' do + let(:expected_total) { relation.size } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[0] } - let(:expected_posts_plain) { posts[1..10] } + it_behaves_like 'a well working query that also supports SELECT' do + let(:cursor_object) { cursor_object_plain } + let(:query_cursor_base) { cursor_object&.id } - let(:cursor_object_desc) { posts[-1] } - let(:expected_posts_desc) { posts[-11..-2].reverse } + let(:expected_posts) { expected_posts_plain } + let(:expected_cursor) { ->(post) { post.id } } + end - let(:cursor_object_by_author) { posts_by_author[0] } - let(:expected_posts_by_author) { posts_by_author[1..10] } + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } + let(:query_cursor_base) { cursor_object&.id } - let(:cursor_object_by_author_desc) { posts_by_author[-1] } - let(:expected_posts_by_author_desc) { posts_by_author[-11..-2].reverse } + let(:expected_posts) { expected_posts_desc } + let(:expected_cursor) { ->(post) { post.id } } + end + + it_behaves_like 'a query ordered by a timestamp column' do + let(:cursor_object) { cursor_object_by_order_by_column } + let(:cursor_object_desc) { cursor_object_by_order_by_column_desc } + let(:query_cursor_base) do + [ + cursor_object&.created_at&.strftime('%s%6N')&.to_i, + cursor_object&.id + ] + end + let(:expected_posts) { expected_posts_by_order_by_column } + let(:expected_posts_desc) { expected_posts_by_order_by_column_desc } + let(:expected_cursor) do + lambda { |post| + [ + post.created_at.strftime('%s%6N').to_i, + post.id + ] + } + end + end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + it_behaves_like 'a query that returns no data when relation is empty' end - context 'and `first`' do - let(:params) { super().merge(first: 2) } + context 'when neither first/last/limit nor before/after are passed' do + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(10) } + let(:expected_posts_desc) { posts.reverse.first(10) } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[2] } - let(:expected_posts_plain) { posts[3..4] } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(10) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(10) + end - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-4..-3].reverse } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end - let(:cursor_object_by_author) { posts_by_author[2] } - let(:expected_posts_by_author) { posts_by_author[3..4] } + context 'when a different default_page_size has been set' do + let(:custom_page_size) { 2 } - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-4..-3].reverse + before do + RailsCursorPagination.configure do |config| + config.default_page_size = custom_page_size + end end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + after { RailsCursorPagination.configure(&:reset!) } + + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(custom_page_size) } + let(:expected_posts_desc) { posts.reverse.first(custom_page_size) } + + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(custom_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(custom_page_size) + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end end - context 'when not enough records are remaining after cursor' do - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[-2] } - let(:expected_posts_plain) { posts[-1..] } + context 'when a max_page_size has been set' do + let(:max_page_size) { 2 } - let(:cursor_object_desc) { posts[1] } - let(:expected_posts_desc) { posts[0..0].reverse } + before do + RailsCursorPagination.configure do |config| + config.max_page_size = max_page_size + end + end - let(:cursor_object_by_author) { posts_by_author[-2] } - let(:expected_posts_by_author) { posts_by_author[-1..] } + after { RailsCursorPagination.configure(&:reset!) } - let(:cursor_object_by_author_desc) { posts_by_author[1] } - let(:expected_posts_by_author_desc) do - posts_by_author[0..0].reverse + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(max_page_size) } + let(:expected_posts_desc) { posts.reverse.first(max_page_size) } + + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(max_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(max_page_size) end - let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { true } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + + context 'when attempting to go over the limit' do + let(:params) { { first: 5 } } + + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(max_page_size) } + let(:expected_posts_desc) { posts.reverse.first(max_page_size) } + + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(max_page_size) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(max_page_size) + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end end end - end - context 'and `limit`' do - let(:params) { super().merge(limit: 2) } + context 'when `order` and `order_by` are explicitly set to `nil`' do + let(:params) { super().merge(order: nil, order_by: nil) } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[2] } - let(:expected_posts_plain) { posts[3..4] } + it_behaves_like 'a well working query that also supports SELECT' do + let(:expected_posts) { posts.first(10) } + let(:expected_cursor) { ->(post) { post.id } } + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end + end - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-4..-3].reverse } + context 'when only passing first' do + let(:params) { { first: 2 } } - let(:cursor_object_by_author) { posts_by_author[2] } - let(:expected_posts_by_author) { posts_by_author[3..4] } + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-4..-3].reverse + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(2) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(2) end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + let(:expected_has_previous_page) { false } end - context 'when not enough records are remaining after cursor' do - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[-2] } - let(:expected_posts_plain) { posts[-1..] } - - let(:cursor_object_desc) { posts[1] } - let(:expected_posts_desc) { posts[0..0].reverse } + context 'when there are less records than requested' do + let(:params) { { first: posts.size + 1 } } - let(:cursor_object_by_author) { posts_by_author[-2] } - let(:expected_posts_by_author) { posts_by_author[-1..] } + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } - let(:cursor_object_by_author_desc) { posts_by_author[1] } - let(:expected_posts_by_author_desc) do - posts_by_author[0..0].reverse + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse end let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { true } + let(:expected_has_previous_page) { false } end end end - end - context 'when passing `before`' do - let(:params) { { before: query_cursor } } + context 'when only passing limit' do + let(:params) { { limit: 2 } } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[-1] } - let(:expected_posts_plain) { posts[-11..-2] } + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } - let(:cursor_object_desc) { posts[0] } - let(:expected_posts_desc) { posts[1..10].reverse } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column.first(2) + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(2) + end - let(:cursor_object_by_author) { posts_by_author[-1] } - let(:expected_posts_by_author) { posts_by_author[-11..-2] } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + + context 'when there are less records than requested' do + let(:params) { { first: posts.size + 1 } } - let(:cursor_object_by_author_desc) { posts_by_author[0] } - let(:expected_posts_by_author_desc) { posts_by_author[1..10].reverse } + include_examples 'a working query ordered by a timestamp column' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { false } + end + end end - context 'and `last`' do - let(:params) { super().merge(last: 2) } + context 'when passing `after`' do + let(:params) { { after: query_cursor } } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[-1] } - let(:expected_posts_plain) { posts[-3..-2] } + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[0] } + let(:expected_posts_plain) { posts[1..10] } - let(:cursor_object_desc) { posts[2] } - let(:expected_posts_desc) { posts[3..4].reverse } + let(:cursor_object_desc) { posts[-1] } + let(:expected_posts_desc) { posts[-11..-2].reverse } - let(:cursor_object_by_author) { posts_by_author[-1] } - let(:expected_posts_by_author) { posts_by_author[-3..-2] } + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[0] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[1..10] + end - let(:cursor_object_by_author_desc) { posts_by_author[2] } - let(:expected_posts_by_author_desc) { posts_by_author[3..4].reverse } + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-11..-2].reverse + end let(:expected_has_next_page) { true } let(:expected_has_previous_page) { true } end - context 'when not enough records are remaining before cursor' do - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[1] } - let(:expected_posts_plain) { posts[0..0] } + context 'and `first`' do + let(:params) { super().merge(first: 2) } + + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[2] } + let(:expected_posts_plain) { posts[3..4] } let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-1..].reverse } + let(:expected_posts_desc) { posts[-4..-3].reverse } - let(:cursor_object_by_author) { posts_by_author[1] } - let(:expected_posts_by_author) { posts_by_author[0..0] } + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[3..4] + end - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-1..].reverse + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-4..-3].reverse end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining after cursor' do + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[-2] } + let(:expected_posts_plain) { posts[-1..] } + + let(:cursor_object_desc) { posts[1] } + let(:expected_posts_desc) { posts[0..0].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-1..] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[0..0].reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { true } + end + end + end + + context 'and `limit`' do + let(:params) { super().merge(limit: 2) } + + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[2] } + let(:expected_posts_plain) { posts[3..4] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-4..-3].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[3..4] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-4..-3].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining after cursor' do + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[-2] } + let(:expected_posts_plain) { posts[-1..] } + + let(:cursor_object_desc) { posts[1] } + let(:expected_posts_desc) { posts[0..0].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-1..] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[0..0].reverse + end + + let(:expected_has_next_page) { false } + let(:expected_has_previous_page) { true } + end end end end - context 'and `limit`' do - let(:params) { super().merge(limit: 2) } + context 'when passing `before`' do + let(:params) { { before: query_cursor } } - include_examples 'for a working query' do + include_examples 'a working query ordered by a timestamp column' do let(:cursor_object_plain) { posts[-1] } - let(:expected_posts_plain) { posts[-3..-2] } + let(:expected_posts_plain) { posts[-11..-2] } - let(:cursor_object_desc) { posts[2] } - let(:expected_posts_desc) { posts[3..4].reverse } + let(:cursor_object_desc) { posts[0] } + let(:expected_posts_desc) do + posts[1..10].reverse + end - let(:cursor_object_by_author) { posts_by_author[-1] } - let(:expected_posts_by_author) { posts_by_author[-3..-2] } + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-11..-2] + end - let(:cursor_object_by_author_desc) { posts_by_author[2] } - let(:expected_posts_by_author_desc) do - posts_by_author[3..4].reverse + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[0] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[1..10].reverse end let(:expected_has_next_page) { true } let(:expected_has_previous_page) { true } end - context 'when not enough records are remaining before cursor' do - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[1] } - let(:expected_posts_plain) { posts[0..0] } + context 'and `last`' do + let(:params) { super().merge(last: 2) } - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-1..].reverse } + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-3..-2] } - let(:cursor_object_by_author) { posts_by_author[1] } - let(:expected_posts_by_author) { posts_by_author[0..0] } + let(:cursor_object_desc) { posts[2] } + let(:expected_posts_desc) { posts[3..4].reverse } - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-1..].reverse + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-3..-2] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[3..4].reverse end let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining before cursor' do + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[1] } + let(:expected_posts_plain) { posts[0..0] } + + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-1..].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[0..0] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-1..].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end + end + + context 'and `limit`' do + let(:params) { super().merge(limit: 2) } + + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-3..-2] } + + let(:cursor_object_desc) { posts[2] } + let(:expected_posts_desc) { posts[3..4].reverse } + + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[-1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[-3..-2] + end + + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[3..4].reverse + end + + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'when not enough records are remaining before cursor' do + include_examples 'a working query ordered by a timestamp column' do + let(:cursor_object_plain) { posts[1] } + let(:expected_posts_plain) { posts[0..0] } + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-1..].reverse } + let(:cursor_object_by_order_by_column) do + posts_by_order_by_column[1] + end + let(:expected_posts_by_order_by_column) do + posts_by_order_by_column[0..0] + end + let(:cursor_object_by_order_by_column_desc) do + posts_by_order_by_column[-2] + end + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column[-1..].reverse + end + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end end end end diff --git a/spec/rails_cursor_pagination/timestamp_cursor_spec.rb b/spec/rails_cursor_pagination/timestamp_cursor_spec.rb new file mode 100644 index 0000000..5575a2a --- /dev/null +++ b/spec/rails_cursor_pagination/timestamp_cursor_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +RSpec.describe RailsCursorPagination::TimestampCursor do + describe '#encode' do + let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } + + context 'when ordering by a column that is not a timestamp' do + subject(:encoded) do + described_class.from_record(record: record, order_field: :author).encode + end + + it 'raises an error' do + expect { subject }.to( + raise_error( + RailsCursorPagination::ParameterError, + 'Could not encode author ' \ + "with value #{record.author}." \ + 'It does not respond to #strftime. Is it a timestamp?' + ) + ) + end + end + + context 'when ordering by a timestamp column' do + subject(:encoded) do + described_class + .from_record(record: record, order_field: :created_at) + .encode + end + + it 'produces a valid string' do + expect(encoded).to be_a(String) + end + + it 'can be decoded back to the originally encoded value' do + decoded = described_class.decode(encoded_string: encoded, + order_field: :created_at) + expect(decoded.id).to eq record.id + expect(decoded.order_field_value).to eq record.created_at + end + end + end + + describe '.decode' do + context 'when decoding an encoded message with a timestamp order field' do + let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } + let(:encoded) do + described_class + .from_record(record: record, order_field: :created_at) + .encode + end + + subject(:decoded) do + described_class.decode(encoded_string: encoded, + order_field: :created_at) + end + + it 'decodes the string successfully' do + expect(decoded.id).to eq record.id + expect(decoded.order_field_value).to eq record.created_at + expect(decoded.order_field_value.strftime('%s%6N')).to( + eq record.created_at.strftime('%s%6N') + ) + end + end + end + + describe '.from_record' do + let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } + + subject(:from_record) do + described_class.from_record(record: record, order_field: :created_at) + end + + it 'returns a cursor with the same ID as the record' do + expect(from_record).to be_a(RailsCursorPagination::Cursor) + expect(from_record.id).to eq record.id + end + + it 'returns a cursor with the order_field_value as the record' do + expect(from_record.order_field_value).to eq record.created_at + end + end + + describe '.new' do + subject(:cursor) do + described_class.new id: 1, + order_field: :created_at, + order_field_value: Time.now + end + + it 'returns an instance of a TimestampCursor' do + expect(cursor).to be_a(RailsCursorPagination::TimestampCursor) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5ae51c2..85c64b4 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -36,6 +36,7 @@ class Post < ActiveRecord::Base; end ActiveRecord::Migration.create_table :posts do |t| t.string :author t.string :content + t.timestamps end config.before(:each) { Post.delete_all }