From 62a40df54cdd8c8771d6376ca063023914c8aafd Mon Sep 17 00:00:00 2001 From: Amanda Raymond Date: Fri, 13 Jan 2023 14:40:38 -0500 Subject: [PATCH] add support precision of DateTime selectors when encoding/decoding cursors add spec coverage for timestamp order_by selector in pagination fetch Bump version to 0.3.1 --- .github/workflows/test.yml | 4 +- .ruby-version | 2 +- CHANGELOG.md | 5 + Gemfile | 1 + Gemfile.lock | 46 +- lib/rails_cursor_pagination/cursor.rb | 40 +- lib/rails_cursor_pagination/version.rb | 4 +- spec/rails_cursor_pagination/cursor_spec.rb | 51 + .../rails_cursor_pagination/paginator_spec.rb | 1425 ++++++++++++----- spec/spec_helper.rb | 6 +- 10 files changed, 1165 insertions(+), 419 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c8a170b..683bb9e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - ruby-version: ['2.6', '2.7', '3.0', '3.1'] + ruby-version: ['2.7', '3.0', '3.1'] mysql-version: ['5.7', '8.0'] steps: @@ -49,7 +49,7 @@ jobs: runs-on: ubuntu-20.04 strategy: matrix: - ruby-version: ['2.6', '2.7', '3.0', '3.1'] + ruby-version: ['2.7', '3.0', '3.1'] postgres-version: [14, 13, 12] steps: diff --git a/.ruby-version b/.ruby-version index 849c0c4..a4dd9db 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-2.7.5 +2.7.4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e55d44..e5e02d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,11 @@ These are the latest changes on the project's `master` branch that have not yet Follow the same format as previous releases by categorizing your feature into "Added", "Changed", "Deprecated", "Removed", "Fixed", or "Security". ---> +## [0.3.1] - 2023-01-13 + +### Fixed +- Ensure DateTime order_by fields will have expected paginated results by honoring of timestamps down to nanosecond on comparison. + ## [0.3.0] - 2022-07-08 ### Added diff --git a/Gemfile b/Gemfile index b136579..b082c36 100644 --- a/Gemfile +++ b/Gemfile @@ -14,3 +14,4 @@ gem 'rubocop', '~> 1.31' gem 'mysql2', '~> 0.5' gem 'pg', '~> 1.4' + diff --git a/Gemfile.lock b/Gemfile.lock index 75b9706..867cfb0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,38 +1,37 @@ PATH remote: . specs: - rails_cursor_pagination (0.3.0) + rails_cursor_pagination (0.3.1) activerecord (>= 5.0) GEM remote: https://rubygems.org/ specs: - activemodel (6.1.6) - activesupport (= 6.1.6) - activerecord (6.1.6) - activemodel (= 6.1.6) - activesupport (= 6.1.6) - activesupport (6.1.6) + activemodel (7.0.4.2) + activesupport (= 7.0.4.2) + activerecord (7.0.4.2) + activemodel (= 7.0.4.2) + activesupport (= 7.0.4.2) + activesupport (7.0.4.2) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) - zeitwerk (~> 2.3) ast (2.4.2) - concurrent-ruby (1.1.10) + concurrent-ruby (1.2.0) diff-lcs (1.5.0) - i18n (1.10.0) + i18n (1.12.0) concurrent-ruby (~> 1.0) - json (2.6.2) - minitest (5.15.0) - mysql2 (0.5.4) + json (2.6.3) + minitest (5.17.0) + mysql2 (0.5.5) parallel (1.22.1) - parser (3.1.2.0) + parser (3.2.0.0) ast (~> 2.4.1) pg (1.4.1) rainbow (3.1.1) rake (13.0.6) - regexp_parser (2.5.0) + regexp_parser (2.6.2) rexml (3.2.5) rspec (3.11.0) rspec-core (~> 3.11.0) @@ -47,23 +46,22 @@ GEM diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.11.0) rspec-support (3.11.0) - rubocop (1.31.2) + rubocop (1.44.1) json (~> 2.3) parallel (~> 1.10) - parser (>= 3.1.0.0) + parser (>= 3.2.0.0) rainbow (>= 2.2.2, < 4.0) regexp_parser (>= 1.8, < 3.0) rexml (>= 3.2.5, < 4.0) - rubocop-ast (>= 1.18.0, < 2.0) + rubocop-ast (>= 1.24.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.18.0) + unicode-display_width (>= 2.4.0, < 3.0) + rubocop-ast (1.24.1) parser (>= 3.1.1.0) ruby-progressbar (1.11.0) - tzinfo (2.0.4) + tzinfo (2.0.5) concurrent-ruby (~> 1.0) - unicode-display_width (2.2.0) - zeitwerk (2.5.4) + unicode-display_width (2.4.2) PLATFORMS ruby @@ -77,4 +75,4 @@ DEPENDENCIES rubocop (~> 1.31) BUNDLED WITH - 2.1.4 + 2.2.3 diff --git a/lib/rails_cursor_pagination/cursor.rb b/lib/rails_cursor_pagination/cursor.rb index cfa475c..7aad4ad 100644 --- a/lib/rails_cursor_pagination/cursor.rb +++ b/lib/rails_cursor_pagination/cursor.rb @@ -42,18 +42,35 @@ def decode(encoded_string:, order_field: :id) end new(id: decoded, order_field: :id) else - unless decoded.is_a?(Array) && decoded.size == 2 - raise InvalidCursorError, - "The given cursor `#{encoded_string}` was decoded as " \ - "`#{decoded}` but could not be parsed" - end - new(id: decoded[1], order_field: order_field, - order_field_value: decoded[0]) + decode_custom_order_field(encoded_string: encoded_string, + decoded: decoded, order_field: order_field) end rescue ArgumentError, JSON::ParserError raise InvalidCursorError, "The given cursor `#{encoded_string}` could not be decoded" end + + def decode_custom_order_field(encoded_string:, decoded:, order_field:) + unless decoded.is_a?(Array) && decoded.size == 2 + raise InvalidCursorError, + "The given cursor `#{encoded_string}` was decoded as " \ + "`#{decoded}` but could not be parsed" + end + if decoded[0].is_a?(Hash) && ['seconds', 'nanoseconds'].all? { |key| decoded[0].key? key } + new( + id: decoded[1], + order_field: order_field, + order_field_value: Time.at( + decoded[0]['seconds'], + decoded[0]['nanoseconds'], + :nsec + ) + ) + else + new(id: decoded[1], order_field: order_field, + order_field_value: decoded[0]) + end + end end # Initializes the record @@ -89,7 +106,14 @@ def initialize(id:, order_field: :id, order_field_value: nil) def encode unencoded_cursor = if custom_order_field? - [@order_field_value, @id] + if @order_field_value.respond_to?(:strftime) + [{ + seconds: order_field_value.to_i, + nanoseconds: order_field_value.nsec + }, @id] + else + [@order_field_value, @id] + end else @id end diff --git a/lib/rails_cursor_pagination/version.rb b/lib/rails_cursor_pagination/version.rb index 8ee2435..47c9b04 100644 --- a/lib/rails_cursor_pagination/version.rb +++ b/lib/rails_cursor_pagination/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module RailsCursorPagination - VERSION = '0.3.0' -end + VERSION = '0.3.1' +end \ No newline at end of file diff --git a/spec/rails_cursor_pagination/cursor_spec.rb b/spec/rails_cursor_pagination/cursor_spec.rb index 0939d70..45d0779 100644 --- a/spec/rails_cursor_pagination/cursor_spec.rb +++ b/spec/rails_cursor_pagination/cursor_spec.rb @@ -51,6 +51,23 @@ expect(decoded.order_field_value).to eq record.author end end + + context 'when ordering by created_at' 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 '.from_record' do @@ -156,6 +173,40 @@ end end + context 'when decoding an encoded message with order_field :created_at' 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 + + context 'and the order_field to decode is set to :id' do + subject(:decoded) do + described_class.decode(encoded_string: encoded) + end + + it 'raises an InvalidCursorError' do + cursor = [{"seconds"=> record.created_at.to_i, "nanoseconds"=> record.created_at.nsec}, record.id] + msg = %Q|The given cursor `#{encoded}` was decoded as `#{cursor}` but could not be parsed| + expect { decoded }.to raise_error( + ::RailsCursorPagination::InvalidCursorError, + msg + ) + end + end + + context 'and the order_field to decode is set to :created_at' do + subject(:decoded) do + described_class.decode(encoded_string: encoded, order_field: :created_at) + end + + it 'decodes the string succesfully' do + expect(decoded.id).to eq record.id + expect(decoded.order_field_value).to eq record.created_at + expect(decoded.order_field_value.nsec).to eq record.created_at.nsec + end + end + end + context 'when decoding a message that did not come from a known encoder' do let(:encoded) { 'SomeGarbageString' } diff --git a/spec/rails_cursor_pagination/paginator_spec.rb b/spec/rails_cursor_pagination/paginator_spec.rb index e02211a..8b71109 100644 --- a/spec/rails_cursor_pagination/paginator_spec.rb +++ b/spec/rails_cursor_pagination/paginator_spec.rb @@ -182,589 +182,1254 @@ 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 'for basic order_by params' 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) { %i[id author content updated_at created_at] } - it 'includes all relevant meta info' do + 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 - expect(subject.keys).to contain_exactly :has_previous_page, - :has_next_page, - :start_cursor, - :end_cursor + 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 + 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 - end - describe 'for :page' do - subject { result[:page] } + describe 'for :page' do + subject { result[:page] } + + let(:returned_parsed_cursors) do + subject + .pluck(:cursor) + .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + end - let(:returned_parsed_cursors) do - subject - .pluck(:cursor) - .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + 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 '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 + 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.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) + context 'when passing `with_total: true`' do + subject(:result) { instance.fetch(with_total: true) } - 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 'also includes the `total` of records' do + is_expected.to have_key :total + expect(subject[:total]).to eq expected_total + 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 + 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) { ['*'] } + + # it_behaves_like 'a properly returned response' + # end + # end + + context 'when SELECTing only some columns' do + let(:selected_attributes) { %i[id author] } + let(:relation) { super().select(*selected_attributes) } + + # 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] } + + # 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 - context 'when passing `with_total: true`' do - subject(:result) { instance.fetch(with_total: true) } + 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 'also includes the `total` of records' do - is_expected.to have_key :total - expect(subject[:total]).to eq expected_total + it_behaves_like 'a well working query that also supports SELECT' + + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } + + 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) { [cursor_object&.send(order_by_column), cursor_object&.id] } - 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) { ->(post) { [post.send(order_by_column), post.id] } } 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) { posts_by_order_by_column.first(10) } + 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 + + 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) } - 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] } + 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_posts) { expected_posts_by_author } - let(:expected_posts_desc) { expected_posts_by_author_desc } - let(:expected_cursor) { ->(post) { [post.author, 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 } } - 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) } + include_examples 'for a working query' do + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } + + let(:expected_posts_by_order_by_column) { posts_by_order_by_column.first(2) } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column.reverse.first(2) } - 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 } } - context 'when a different default_page_size has been set' do - let(:custom_page_size) { 2 } + include_examples 'for a working query' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } + + let(:expected_posts_by_order_by_column) { posts_by_order_by_column } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column.reverse } - 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) - end - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(custom_page_size) - end + let(:expected_posts_by_order_by_column) { posts_by_order_by_column.first(2) } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column.reverse.first(2) } 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 } + + let(:expected_posts_by_order_by_column) { posts_by_order_by_column } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column.reverse } - before do - RailsCursorPagination.configure do |config| - config.max_page_size = max_page_size + 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(:expected_posts_by_author) do - posts_by_author.first(max_page_size) - end - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(max_page_size) - end + 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) { posts_by_order_by_column[1..10] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[-1] } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column[-11..-2].reverse } 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(:cursor_object_by_order_by_column) { posts_by_order_by_column[2] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[3..4] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[-2] } + 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(:expected_posts_by_author) do - posts_by_author.first(max_page_size) + let(:cursor_object_by_order_by_column) { posts_by_order_by_column[-2] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[-1..] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[1] } + 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 - let(:expected_posts_by_author_desc) do - posts_by_author.reverse.first(max_page_size) + 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) { posts_by_order_by_column[2] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[3..4] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[-2] } + 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) { posts_by_order_by_column[-2] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[-1..] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[1] } + 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) { posts_by_order_by_column[-1] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[-11..-2] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[0] } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column[1..10].reverse } let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } + let(:expected_has_previous_page) { true } end - end - end - context 'when only passing first' do - let(:params) { { first: 2 } } + context 'and `last`' do + let(:params) { super().merge(last: 2) } - include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(2) } - let(:expected_posts_desc) { posts.reverse.first(2) } + include_examples 'for a working query' do + let(:cursor_object_plain) { posts[-1] } + let(:expected_posts_plain) { posts[-3..-2] } - let(:expected_posts_by_author) { posts_by_author.first(2) } - let(:expected_posts_by_author_desc) { posts_by_author.reverse.first(2) } + let(:cursor_object_desc) { posts[2] } + let(:expected_posts_desc) { posts[3..4].reverse } - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } - end + let(:cursor_object_by_order_by_column) { posts_by_order_by_column[-1] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[-3..-2] } - context 'when there are less records than requested' do - let(:params) { { first: posts.size + 1 } } + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[2] } + let(:expected_posts_by_order_by_column_desc) { posts_by_order_by_column[3..4].reverse } - include_examples 'for a working query' do - let(:expected_posts_plain) { posts } - let(:expected_posts_desc) { posts.reverse } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end - let(:expected_posts_by_author) { posts_by_author } - let(:expected_posts_by_author_desc) { posts_by_author.reverse } + 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(:expected_has_next_page) { false } - let(:expected_has_previous_page) { false } + let(:cursor_object_desc) { posts[-2] } + let(:expected_posts_desc) { posts[-1..].reverse } + + let(:cursor_object_by_order_by_column) { posts_by_order_by_column[1] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[0..0] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[-2] } + 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) { posts_by_order_by_column[-1] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[-3..-2] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[2] } + 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) { posts_by_order_by_column[1] } + let(:expected_posts_by_order_by_column) { posts_by_order_by_column[0..0] } + + let(:cursor_object_by_order_by_column_desc) { posts_by_order_by_column[-2] } + 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 limit' do - let(:params) { { limit: 2 } } + context 'for timestamped order_by params, i.e. created_at' 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 + + 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) { %i[id author content updated_at created_at] } + + 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 - include_examples 'for a working query' do - let(:expected_posts_plain) { posts.first(2) } - let(:expected_posts_desc) { posts.reverse.first(2) } + describe 'for :page_info' do + subject { result[:page_info] } - let(:expected_posts_by_author) { posts_by_author.first(2) } - let(:expected_posts_by_author_desc) { posts_by_author.reverse.first(2) } + it 'includes all relevant meta info' do + is_expected.to be_a Hash - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { false } - end + expect(subject.keys).to contain_exactly :has_previous_page, + :has_next_page, + :start_cursor, + :end_cursor - context 'when there are less records than requested' do - let(:params) { { first: posts.size + 1 } } + 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 - include_examples 'for a working query' do - let(:expected_posts_plain) { posts } - let(:expected_posts_desc) { posts.reverse } + describe 'for :page' do + subject { result[:page] } - let(:expected_posts_by_author) { posts_by_author } - let(:expected_posts_by_author_desc) { posts_by_author.reverse } + let(:returned_parsed_cursors) do + subject + .pluck(:cursor) + .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } + end - let(:expected_has_next_page) { false } - let(:expected_has_previous_page) { false } + 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 passing `after`' do - let(:params) { { after: query_cursor } } + 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) { ['*'] } + + it_behaves_like 'a properly returned response' + end + end - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[0] } - let(:expected_posts_plain) { posts[1..10] } + 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 - let(:cursor_object_desc) { posts[-1] } - let(:expected_posts_desc) { posts[-11..-2].reverse } + shared_examples_for 'a query that works with timestamped `order_by` param' do + let(:params) { super().merge(order_by: :created_at) } + let(:order_by_column) { :created_at } - let(:cursor_object_by_author) { posts_by_author[0] } - let(:expected_posts_by_author) { posts_by_author[1..10] } + it_behaves_like 'a well working query that also supports SELECT' - let(:cursor_object_by_author_desc) { posts_by_author[-1] } - let(:expected_posts_by_author_desc) { posts_by_author[-11..-2].reverse } + it_behaves_like 'a query that works with a descending `order`' do + let(:cursor_object) { cursor_object_desc } - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + let(:expected_posts) { expected_posts_desc } + end end - context 'and `first`' do - let(:params) { super().merge(first: 2) } + shared_examples 'for a working query with timestamped `order_by`' do + let(:expected_total) { relation.size } - 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(:cursor_object) { cursor_object_plain } + let(:query_cursor_base) { cursor_object&.id } - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-4..-3].reverse } + let(:expected_posts) { expected_posts_plain } + let(:expected_cursor) { ->(post) { post.id } } + end - let(:cursor_object_by_author) { posts_by_author[2] } - let(:expected_posts_by_author) { posts_by_author[3..4] } + 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 + + it_behaves_like 'a query that works with timestamped `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) { [ + { + "seconds"=> cursor_object&.created_at&.to_i, + "nanoseconds"=> cursor_object&.created_at&.nsec + }, + cursor_object&.id + ] } + let(:expected_posts) { expected_posts_by_order_by_column } + let(:expected_posts_desc) { expected_posts_by_order_by_column_desc } + let(:expected_cursor) { ->(post) {[ + { + "seconds"=> post.created_at.to_i, + "nanoseconds"=> post.created_at.nsec + }, + post.id + ]}} + end + + it_behaves_like 'a query that returns no data when relation is empty' + end + + context 'when neither first/last/limit nor before/after are passed' do - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-4..-3].reverse + include_examples 'for a working query with timestamped `order_by`' do + let(:expected_posts_plain) { posts.first(10) } + let(:expected_posts_desc) { posts.reverse.first(10) } + + let(:expected_posts_by_order_by_column) { posts_by_order_by_column.first(10) } + let(:expected_posts_by_order_by_column_desc) do + posts_by_order_by_column.reverse.first(10) 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..] } + context 'when a different default_page_size has been set' do + let(:custom_page_size) { 2 } - let(:cursor_object_desc) { posts[1] } - let(:expected_posts_desc) { posts[0..0].reverse } + before do + RailsCursorPagination.configure do |config| + config.default_page_size = custom_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 'for a working query with timestamped `order_by`' 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) { false } - let(:expected_has_previous_page) { true } + 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) } + context 'when a max_page_size has been set' do + let(:max_page_size) { 2 } - include_examples 'for a working query' do - let(:cursor_object_plain) { posts[2] } - let(:expected_posts_plain) { posts[3..4] } + before do + RailsCursorPagination.configure do |config| + config.max_page_size = max_page_size + end + end - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-4..-3].reverse } + after { RailsCursorPagination.configure(&:reset!) } - let(:cursor_object_by_author) { posts_by_author[2] } - let(:expected_posts_by_author) { posts_by_author[3..4] } + include_examples 'for a working query with timestamped `order_by`' 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(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-4..-3].reverse + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + context 'when attempting to go over the limit' do + let(:params) { { first: 5 } } + + include_examples 'for a working query with timestamped `order_by`' 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 - 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 `order` and `order_by` are explicitly set to `nil`' do + let(:params) { super().merge(order: nil, order_by: nil) } + + 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(:cursor_object_desc) { posts[1] } - let(:expected_posts_desc) { posts[0..0].reverse } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { false } + end + end + end - let(:cursor_object_by_author) { posts_by_author[-2] } - let(:expected_posts_by_author) { posts_by_author[-1..] } + context 'when only passing first' do + let(:params) { { first: 2 } } - let(:cursor_object_by_author_desc) { posts_by_author[1] } - let(:expected_posts_by_author_desc) do - posts_by_author[0..0].reverse - end + include_examples 'for a working query with timestamped `order_by`' do + let(:expected_posts_plain) { posts.first(2) } + let(:expected_posts_desc) { posts.reverse.first(2) } + + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column.first(2) + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column.reverse.first(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 } } + + include_examples 'for a working query with timestamped `order_by`' do + let(:expected_posts_plain) { posts } + let(:expected_posts_desc) { posts.reverse } + + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column.reverse + } 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 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column.first(2) + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column.reverse.first(2) + } - 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 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column.reverse + } + + 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 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column[0] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[1..10] + } - 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) { + posts_by_order_by_column[-1] + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column[-11..-2].reverse + } 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 'for a working query with timestamped `order_by`' 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_order_by_column) { + posts_by_order_by_column[2] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[3..4] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[-2] + } + 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 with timestamped `order_by`' 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) { + posts_by_order_by_column[-2] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[-1..] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[1] + } + 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 - let(:cursor_object_by_author) { posts_by_author[1] } - let(:expected_posts_by_author) { posts_by_author[0..0] } + context 'and `limit`' do + let(:params) { super().merge(limit: 2) } - let(:cursor_object_by_author_desc) { posts_by_author[-2] } - let(:expected_posts_by_author_desc) do - posts_by_author[-1..].reverse + include_examples 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column[2] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[3..4] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[-2] + } + 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 with timestamped `order_by`' 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) { + posts_by_order_by_column[-2] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[-1..] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[1] + } + 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) } - - include_examples 'for a working query' do + context 'when passing `before`' do + let(:params) { { before: query_cursor } } + + include_examples 'for a working query with timestamped `order_by`' 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[0] } + let(:expected_posts_desc) { + posts[1..10].reverse + } + + let(:cursor_object_by_order_by_column) { + posts_by_order_by_column[-1] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[-11..-2] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[0] + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column[1..10].reverse + } - let(:cursor_object_desc) { posts[2] } - let(:expected_posts_desc) { posts[3..4].reverse } + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } + end + + context 'and `last`' do + let(:params) { super().merge(last: 2) } + + include_examples 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column[-1] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[-3..-2] + } - 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_desc) { + posts_by_order_by_column[2] + } + let(:expected_posts_by_order_by_column_desc) { + posts_by_order_by_column[3..4].reverse + } - let(:cursor_object_by_author_desc) { posts_by_author[2] } - let(:expected_posts_by_author_desc) do - posts_by_author[3..4].reverse + let(:expected_has_next_page) { true } + let(:expected_has_previous_page) { true } end - let(:expected_has_next_page) { true } - let(:expected_has_previous_page) { true } + context 'when not enough records are remaining before cursor' do + include_examples 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column[1] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[0..0] + } + + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[-2] + } + 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 '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 `limit`' do + let(:params) { super().merge(limit: 2) } - let(:cursor_object_desc) { posts[-2] } - let(:expected_posts_desc) { posts[-1..].reverse } + include_examples 'for a working query with timestamped `order_by`' 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_author) { posts_by_author[1] } - let(:expected_posts_by_author) { posts_by_author[0..0] } + let(:cursor_object_by_order_by_column) { + posts_by_order_by_column[-1] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[-3..-2] + } - 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) { + posts_by_order_by_column[2] + } + 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 'for a working query with timestamped `order_by`' 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) { + posts_by_order_by_column[1] + } + let(:expected_posts_by_order_by_column) { + posts_by_order_by_column[0..0] + } + let(:cursor_object_by_order_by_column_desc) { + posts_by_order_by_column[-2] + } + 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/spec_helper.rb b/spec/spec_helper.rb index 72db9d6..2b1d8af 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,6 +3,7 @@ require 'bundler/setup' require 'rails_cursor_pagination' require 'active_record' +require 'base64' # This dummy ActiveRecord class is used for testing class Post < ActiveRecord::Base; end @@ -23,18 +24,19 @@ class Post < ActiveRecord::Base; end ActiveRecord::Migration.verbose = ENV.fetch('VERBOSE', nil) ActiveRecord::Base.establish_connection( - adapter: ENV.fetch('DB_ADAPTER', 'mysql2'), + adapter: ENV.fetch('DB_ADAPTER', 'postgresql'), database: 'rails_cursor_pagination_testing', host: ENV.fetch('DB_HOST', nil), username: ENV.fetch('DB_USER', nil) ) - + # Ensure we have an empty `posts` table with the right format ActiveRecord::Migration.drop_table :posts, if_exists: true ActiveRecord::Migration.create_table :posts do |t| t.string :author t.string :content + t.timestamps end config.before(:each) { Post.delete_all }