├── .ruby-version ├── .ruby-gemset ├── .rspec ├── lib ├── rails_cursor_pagination │ ├── version.rb │ ├── configuration.rb │ ├── timestamp_cursor.rb │ ├── cursor.rb │ └── paginator.rb └── rails_cursor_pagination.rb ├── bin ├── setup └── console ├── .gitignore ├── Gemfile ├── Gemfile-postgres ├── Rakefile ├── .github ├── dependabot.yml └── workflows │ └── test.yml ├── spec ├── rails_cursor_pagination_spec.rb ├── rails_cursor_pagination │ ├── version_spec.rb │ ├── configuration_spec.rb │ ├── timestamp_cursor_spec.rb │ ├── cursor_spec.rb │ └── paginator_spec.rb └── spec_helper.rb ├── .rubocop.yml ├── LICENSE.txt ├── rails_cursor_pagination.gemspec ├── Gemfile-postgres.lock ├── Gemfile.lock ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md └── README.md /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.0.6 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | rails_cursor_pagination -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsCursorPagination 4 | VERSION = '0.4.0' 5 | end 6 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in rails_cursor_pagination.gemspec 6 | gemspec 7 | 8 | gem 'rake' 9 | 10 | gem 'rspec' 11 | 12 | gem 'rubocop' 13 | 14 | gem 'mysql2' 15 | -------------------------------------------------------------------------------- /Gemfile-postgres: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in rails_cursor_pagination.gemspec 6 | gemspec 7 | 8 | gem 'rake' 9 | 10 | gem 'rspec' 11 | 12 | gem 'rubocop' 13 | 14 | gem 'pg' 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/gem_tasks' 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require 'rubocop/rake_task' 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: bundler 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "04:00" 8 | open-pull-requests-limit: 10 9 | ignore: 10 | - dependency-name: rubocop 11 | versions: 12 | - 1.12.0 13 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'rails_cursor_pagination' 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | # (If you use this, don't forget to add pry to your Gemfile!) 11 | # require "pry" 12 | # Pry.start 13 | 14 | require 'irb' 15 | IRB.start(__FILE__) 16 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination do 4 | it { is_expected.to be_a Module } 5 | 6 | describe '.configure' do 7 | let(:configuration) { RailsCursorPagination::Configuration.instance } 8 | after { configuration.reset! } 9 | 10 | it 'can be used to configure the gem' do 11 | expect do 12 | described_class.configure do |config| 13 | config.default_page_size = 42 14 | end 15 | end.to change { configuration.default_page_size }.to(42) 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination/version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination::VERSION do 4 | subject(:version_number) { RailsCursorPagination::VERSION } 5 | 6 | it { is_expected.not_to be nil } 7 | it { is_expected.to be_a String } 8 | 9 | it 'uses gem-flavored semantic versioning' do 10 | is_expected.to match(/^\d+.\d+.\d+(.[\w\d]+)?$/) 11 | end 12 | 13 | describe 'CHANGELOG.md' do 14 | let(:changelog_file_path) { "#{File.dirname(__FILE__)}/../../CHANGELOG.md" } 15 | subject { File.read(changelog_file_path) } 16 | 17 | it 'includes a section for the current version' do 18 | is_expected.to match(/^## \[#{version_number}\] - 20\d\d-\d\d-\d\d$/) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'singleton' 4 | 5 | module RailsCursorPagination 6 | # Configuration class to set the default gem settings. Accessible via 7 | # `RailsCursorPagination.configure`. 8 | # 9 | # Usage: 10 | # 11 | # RailsCursorPagination.configure do |config| 12 | # config.default_page_size = 42 13 | # config.max_page_size = 100 14 | # end 15 | # 16 | class Configuration 17 | include Singleton 18 | 19 | attr_accessor :default_page_size, :max_page_size 20 | 21 | # Ensure the default values are set on first initialization 22 | def initialize 23 | reset! 24 | end 25 | 26 | # Reset all values to their defaults 27 | def reset! 28 | @default_page_size = 10 29 | @max_page_size = nil 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination::Configuration do 4 | # Ensure that all values are back to their defaults after each test 5 | after { described_class.instance.reset! } 6 | 7 | describe '.instance' do 8 | it 'returns a singleton instance' do 9 | expect(described_class.instance).to equal described_class.instance 10 | end 11 | 12 | it 'sets the default values' do 13 | expect(described_class.instance.default_page_size).to eq 10 14 | expect(described_class.instance.max_page_size).to be_nil 15 | end 16 | end 17 | 18 | describe '#reset!' do 19 | before { described_class.instance.default_page_size = 42 } 20 | 21 | it 'resets the settings to their default' do 22 | expect { described_class.instance.reset! } 23 | .to change { described_class.instance.default_page_size }.to(10) 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.7 3 | NewCops: enable 4 | 5 | Metrics/AbcSize: 6 | Max: 26 7 | 8 | # DSLs inheritly uses long blocks, like describe and contexts in RSpec, 9 | # therefore disable this rule for specs and for the gemspec file 10 | Metrics/BlockLength: 11 | Exclude: 12 | - rails_cursor_pagination.gemspec 13 | - spec/**/* 14 | 15 | Metrics/ClassLength: 16 | Max: 210 17 | 18 | Metrics/CyclomaticComplexity: 19 | Max: 15 20 | 21 | Metrics/MethodLength: 22 | Max: 25 23 | 24 | Metrics/ParameterLists: 25 | Max: 8 26 | 27 | Metrics/PerceivedComplexity: 28 | Max: 13 29 | 30 | Layout/LineLength: 31 | Max: 80 32 | 33 | Naming/VariableNumber: 34 | EnforcedStyle: snake_case 35 | 36 | # In Ruby 3.0 interpolated strings will no longer be frozen automatically, so 37 | # to ensure consistent performance, we have to manually call `String#freeze` in 38 | # some places. 39 | Style/RedundantFreeze: 40 | Enabled: false 41 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 XING GmbH & Co. KG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'rails_cursor_pagination' 5 | require 'active_record' 6 | require 'base64' 7 | 8 | # This dummy ActiveRecord class is used for testing 9 | class Post < ActiveRecord::Base; end 10 | 11 | RSpec.configure do |config| 12 | # Enable flags like --only-failures and --next-failure 13 | config.example_status_persistence_file_path = '.rspec_status' 14 | 15 | # Disable RSpec exposing methods globally on `Module` and `main` 16 | config.disable_monkey_patching! 17 | 18 | config.expect_with :rspec do |c| 19 | c.syntax = :expect 20 | end 21 | 22 | # Set up database to use for tests 23 | ActiveRecord::Base.logger = Logger.new(ENV['VERBOSE'] ? $stdout : nil) 24 | ActiveRecord::Migration.verbose = ENV.fetch('VERBOSE', nil) 25 | 26 | ActiveRecord::Base.establish_connection( 27 | adapter: ENV.fetch('DB_ADAPTER', 'mysql2'), 28 | database: 'rails_cursor_pagination_testing', 29 | host: ENV.fetch('DB_HOST', nil), 30 | username: ENV.fetch('DB_USER', nil) 31 | ) 32 | 33 | # Ensure we have an empty `posts` table with the right format 34 | ActiveRecord::Migration.drop_table :posts, if_exists: true 35 | 36 | ActiveRecord::Migration.create_table :posts do |t| 37 | t.string :author 38 | t.string :content 39 | t.timestamps 40 | end 41 | 42 | config.before(:each) { Post.delete_all } 43 | config.after(:each) { Post.delete_all } 44 | end 45 | -------------------------------------------------------------------------------- /rails_cursor_pagination.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'lib/rails_cursor_pagination/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'rails_cursor_pagination' 7 | spec.version = RailsCursorPagination::VERSION 8 | spec.authors = ['Nicolas Fricke'] 9 | spec.email = ['mail@nicolasfricke.com'] 10 | 11 | spec.summary = 12 | 'Add cursor pagination to your ActiveRecord backed application.' 13 | spec.description = 14 | 'This library is an implementation of cursor pagination for ActiveRecord ' \ 15 | 'relations. Where a regular limit & offset pagination has issues with ' \ 16 | 'items that are being deleted from or added to the collection on ' \ 17 | 'previous pages, cursor pagination will continue to offer a stable set ' \ 18 | 'regardless of changes to the base relation.' 19 | spec.homepage = 'https://github.com/xing/rails_cursor_pagination' 20 | spec.license = 'MIT' 21 | spec.required_ruby_version = Gem::Requirement.new('>= 2.7.0') 22 | 23 | spec.metadata['homepage_uri'] = spec.homepage 24 | spec.metadata['source_code_uri'] = spec.homepage 25 | spec.metadata['changelog_uri'] = "#{spec.homepage}/blob/master/CHANGELOG.md" 26 | spec.metadata['rubygems_mfa_required'] = 'true' 27 | 28 | # Specify which files should be added to the gem when it is released. 29 | # By manually choosing what files to distribute we ensure that our gem is as 30 | # small as possible while still containing all relevant code and documentation 31 | # (as part of e.g. the README.md) as well as licensing information. 32 | spec.files = Dir.glob(%w[ 33 | lib/**/* 34 | CHANGELOG.md 35 | CODE_OF_CONDUCT.md 36 | LICENSE.txt 37 | README.md 38 | ]) 39 | spec.require_paths = ['lib'] 40 | 41 | spec.add_dependency 'activerecord', '>= 6.0' 42 | end 43 | -------------------------------------------------------------------------------- /Gemfile-postgres.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rails_cursor_pagination (0.4.0) 5 | activerecord (>= 6.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.1.0) 11 | activesupport (= 7.1.0) 12 | activerecord (7.1.0) 13 | activemodel (= 7.1.0) 14 | activesupport (= 7.1.0) 15 | timeout (>= 0.4.0) 16 | activesupport (7.1.0) 17 | base64 18 | bigdecimal 19 | concurrent-ruby (~> 1.0, >= 1.0.2) 20 | connection_pool (>= 2.2.5) 21 | drb 22 | i18n (>= 1.6, < 2) 23 | minitest (>= 5.1) 24 | mutex_m 25 | tzinfo (~> 2.0) 26 | ast (2.4.2) 27 | base64 (0.1.1) 28 | bigdecimal (3.1.4) 29 | concurrent-ruby (1.2.2) 30 | connection_pool (2.4.1) 31 | diff-lcs (1.5.0) 32 | drb (2.1.1) 33 | ruby2_keywords 34 | i18n (1.14.1) 35 | concurrent-ruby (~> 1.0) 36 | json (2.6.3) 37 | language_server-protocol (3.17.0.3) 38 | minitest (5.20.0) 39 | mutex_m (0.1.2) 40 | parallel (1.23.0) 41 | parser (3.2.2.4) 42 | ast (~> 2.4.1) 43 | racc 44 | pg (1.5.3) 45 | racc (1.7.1) 46 | rainbow (3.1.1) 47 | rake (13.0.6) 48 | regexp_parser (2.8.1) 49 | rexml (3.2.6) 50 | rspec (3.12.0) 51 | rspec-core (~> 3.12.0) 52 | rspec-expectations (~> 3.12.0) 53 | rspec-mocks (~> 3.12.0) 54 | rspec-core (3.12.2) 55 | rspec-support (~> 3.12.0) 56 | rspec-expectations (3.12.3) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.12.0) 59 | rspec-mocks (3.12.6) 60 | diff-lcs (>= 1.2.0, < 2.0) 61 | rspec-support (~> 3.12.0) 62 | rspec-support (3.12.1) 63 | rubocop (1.56.4) 64 | base64 (~> 0.1.1) 65 | json (~> 2.3) 66 | language_server-protocol (>= 3.17.0) 67 | parallel (~> 1.10) 68 | parser (>= 3.2.2.3) 69 | rainbow (>= 2.2.2, < 4.0) 70 | regexp_parser (>= 1.8, < 3.0) 71 | rexml (>= 3.2.5, < 4.0) 72 | rubocop-ast (>= 1.28.1, < 2.0) 73 | ruby-progressbar (~> 1.7) 74 | unicode-display_width (>= 2.4.0, < 3.0) 75 | rubocop-ast (1.29.0) 76 | parser (>= 3.2.1.0) 77 | ruby-progressbar (1.13.0) 78 | ruby2_keywords (0.0.5) 79 | timeout (0.4.0) 80 | tzinfo (2.0.6) 81 | concurrent-ruby (~> 1.0) 82 | unicode-display_width (2.5.0) 83 | 84 | PLATFORMS 85 | ruby 86 | 87 | DEPENDENCIES 88 | pg 89 | rails_cursor_pagination! 90 | rake 91 | rspec 92 | rubocop 93 | 94 | BUNDLED WITH 95 | 2.2.33 96 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | rails_cursor_pagination (0.4.0) 5 | activerecord (>= 6.0) 6 | 7 | GEM 8 | remote: https://rubygems.org/ 9 | specs: 10 | activemodel (7.1.0) 11 | activesupport (= 7.1.0) 12 | activerecord (7.1.0) 13 | activemodel (= 7.1.0) 14 | activesupport (= 7.1.0) 15 | timeout (>= 0.4.0) 16 | activesupport (7.1.0) 17 | base64 18 | bigdecimal 19 | concurrent-ruby (~> 1.0, >= 1.0.2) 20 | connection_pool (>= 2.2.5) 21 | drb 22 | i18n (>= 1.6, < 2) 23 | minitest (>= 5.1) 24 | mutex_m 25 | tzinfo (~> 2.0) 26 | ast (2.4.2) 27 | base64 (0.1.1) 28 | bigdecimal (3.1.4) 29 | concurrent-ruby (1.2.2) 30 | connection_pool (2.4.1) 31 | diff-lcs (1.5.0) 32 | drb (2.1.1) 33 | ruby2_keywords 34 | i18n (1.14.1) 35 | concurrent-ruby (~> 1.0) 36 | json (2.6.3) 37 | language_server-protocol (3.17.0.3) 38 | minitest (5.20.0) 39 | mutex_m (0.1.2) 40 | mysql2 (0.5.5) 41 | parallel (1.23.0) 42 | parser (3.2.2.4) 43 | ast (~> 2.4.1) 44 | racc 45 | racc (1.7.1) 46 | rainbow (3.1.1) 47 | rake (13.0.6) 48 | regexp_parser (2.8.1) 49 | rexml (3.2.6) 50 | rspec (3.12.0) 51 | rspec-core (~> 3.12.0) 52 | rspec-expectations (~> 3.12.0) 53 | rspec-mocks (~> 3.12.0) 54 | rspec-core (3.12.2) 55 | rspec-support (~> 3.12.0) 56 | rspec-expectations (3.12.3) 57 | diff-lcs (>= 1.2.0, < 2.0) 58 | rspec-support (~> 3.12.0) 59 | rspec-mocks (3.12.6) 60 | diff-lcs (>= 1.2.0, < 2.0) 61 | rspec-support (~> 3.12.0) 62 | rspec-support (3.12.1) 63 | rubocop (1.56.4) 64 | base64 (~> 0.1.1) 65 | json (~> 2.3) 66 | language_server-protocol (>= 3.17.0) 67 | parallel (~> 1.10) 68 | parser (>= 3.2.2.3) 69 | rainbow (>= 2.2.2, < 4.0) 70 | regexp_parser (>= 1.8, < 3.0) 71 | rexml (>= 3.2.5, < 4.0) 72 | rubocop-ast (>= 1.28.1, < 2.0) 73 | ruby-progressbar (~> 1.7) 74 | unicode-display_width (>= 2.4.0, < 3.0) 75 | rubocop-ast (1.29.0) 76 | parser (>= 3.2.1.0) 77 | ruby-progressbar (1.13.0) 78 | ruby2_keywords (0.0.5) 79 | timeout (0.4.0) 80 | tzinfo (2.0.6) 81 | concurrent-ruby (~> 1.0) 82 | unicode-display_width (2.5.0) 83 | 84 | PLATFORMS 85 | ruby 86 | 87 | DEPENDENCIES 88 | mysql2 89 | rails_cursor_pagination! 90 | rake 91 | rspec 92 | rubocop 93 | 94 | BUNDLED WITH 95 | 2.2.33 96 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination/timestamp_cursor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination::TimestampCursor do 4 | describe '#encode' do 5 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 6 | 7 | context 'when ordering by a column that is not a timestamp' do 8 | subject(:encoded) do 9 | described_class.from_record(record: record, order_field: :author).encode 10 | end 11 | 12 | it 'raises an error' do 13 | expect { subject }.to( 14 | raise_error( 15 | RailsCursorPagination::ParameterError, 16 | 'Could not encode author ' \ 17 | "with value #{record.author}." \ 18 | 'It does not respond to #strftime. Is it a timestamp?' 19 | ) 20 | ) 21 | end 22 | end 23 | 24 | context 'when ordering by a timestamp column' do 25 | subject(:encoded) do 26 | described_class 27 | .from_record(record: record, order_field: :created_at) 28 | .encode 29 | end 30 | 31 | it 'produces a valid string' do 32 | expect(encoded).to be_a(String) 33 | end 34 | 35 | it 'can be decoded back to the originally encoded value' do 36 | decoded = described_class.decode(encoded_string: encoded, 37 | order_field: :created_at) 38 | expect(decoded.id).to eq record.id 39 | expect(decoded.order_field_value).to eq record.created_at 40 | end 41 | end 42 | end 43 | 44 | describe '.decode' do 45 | context 'when decoding an encoded message with a timestamp order field' do 46 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 47 | let(:encoded) do 48 | described_class 49 | .from_record(record: record, order_field: :created_at) 50 | .encode 51 | end 52 | 53 | subject(:decoded) do 54 | described_class.decode(encoded_string: encoded, 55 | order_field: :created_at) 56 | end 57 | 58 | it 'decodes the string successfully' do 59 | expect(decoded.id).to eq record.id 60 | expect(decoded.order_field_value).to eq record.created_at 61 | expect(decoded.order_field_value.strftime('%s%6N')).to( 62 | eq record.created_at.strftime('%s%6N') 63 | ) 64 | end 65 | end 66 | end 67 | 68 | describe '.from_record' do 69 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 70 | 71 | subject(:from_record) do 72 | described_class.from_record(record: record, order_field: :created_at) 73 | end 74 | 75 | it 'returns a cursor with the same ID as the record' do 76 | expect(from_record).to be_a(RailsCursorPagination::Cursor) 77 | expect(from_record.id).to eq record.id 78 | end 79 | 80 | it 'returns a cursor with the order_field_value as the record' do 81 | expect(from_record.order_field_value).to eq record.created_at 82 | end 83 | end 84 | 85 | describe '.new' do 86 | subject(:cursor) do 87 | described_class.new id: 1, 88 | order_field: :created_at, 89 | order_field_value: Time.now 90 | end 91 | 92 | it 'returns an instance of a TimestampCursor' do 93 | expect(cursor).to be_a(RailsCursorPagination::TimestampCursor) 94 | end 95 | end 96 | end 97 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Run Rubocop and RSpec 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | rubocop: 11 | runs-on: ubuntu-22.04 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Ruby 15 | uses: ruby/setup-ruby@v1 16 | with: 17 | ruby-version: 2.7 18 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 19 | - name: Run Rubocop 20 | run: bundle exec rake rubocop 21 | 22 | 23 | # MySQL 5.7 is not supported on Ubuntu 22, therefore using MacOS for this 24 | # version 25 | test-on-mysql-5-7: 26 | runs-on: macos-13 27 | strategy: 28 | matrix: 29 | ruby-version: ['2.7', '3.0', '3.1'] 30 | mysql-version: ['5.7'] 31 | 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: ankane/setup-mysql@v1 35 | with: 36 | mysql-version: ${{ matrix.mysql-version }} 37 | - name: Create the test database 38 | run: mysqladmin create rails_cursor_pagination_testing 39 | - name: Set up Ruby 40 | uses: ruby/setup-ruby@v1 41 | with: 42 | ruby-version: ${{ matrix.ruby-version }} 43 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 44 | - name: Run tests 45 | run: bundle exec rake spec 46 | env: 47 | DB_ADAPTER: mysql2 48 | DB_HOST: 127.0.0.1 49 | DB_USER: root 50 | 51 | 52 | test-on-mysql-8: 53 | runs-on: ubuntu-22.04 54 | strategy: 55 | matrix: 56 | ruby-version: ['2.7', '3.0', '3.1', '3.2'] 57 | mysql-version: ['8.0'] 58 | 59 | steps: 60 | - uses: actions/checkout@v2 61 | - uses: ankane/setup-mysql@v1 62 | with: 63 | mysql-version: ${{ matrix.mysql-version }} 64 | - name: Create the test database 65 | run: mysqladmin create rails_cursor_pagination_testing 66 | - name: Set up Ruby 67 | uses: ruby/setup-ruby@v1 68 | with: 69 | ruby-version: ${{ matrix.ruby-version }} 70 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 71 | - name: Run tests 72 | run: bundle exec rake spec 73 | env: 74 | DB_ADAPTER: mysql2 75 | DB_HOST: 127.0.0.1 76 | DB_USER: root 77 | 78 | 79 | test-on-postgres: 80 | runs-on: ubuntu-22.04 81 | strategy: 82 | matrix: 83 | ruby-version: ['2.7', '3.0', '3.1', '3.2'] 84 | postgres-version: [12, 13, 14, 15] 85 | env: 86 | BUNDLE_GEMFILE: Gemfile-postgres 87 | 88 | steps: 89 | - uses: actions/checkout@v2 90 | - uses: ankane/setup-postgres@v1 91 | with: 92 | postgres-version: ${{ matrix.postgres-version }} 93 | - name: Create the test database 94 | run: createdb rails_cursor_pagination_testing 95 | - name: Set up Ruby 96 | uses: ruby/setup-ruby@v1 97 | with: 98 | ruby-version: ${{ matrix.ruby-version }} 99 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 100 | - name: Run tests 101 | run: bundle exec rake spec 102 | env: 103 | DB_ADAPTER: postgresql 104 | DB_HOST: 127.0.0.1 105 | DB_USER: ${{ env.USER }} 106 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination/timestamp_cursor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsCursorPagination 4 | # Cursor class that's used to uniquely identify a record and serialize and 5 | # deserialize this cursor so that it can be used for pagination. 6 | # This class expects the `order_field` of the record to be a timestamp and is 7 | # to be used only when sorting a 8 | class TimestampCursor < Cursor 9 | class << self 10 | # Decode the provided encoded cursor. Returns an instance of this 11 | # `RailsCursorPagination::Cursor` class containing both the ID and the 12 | # ordering field value. The ordering field is expected to be a timestamp 13 | # and is always decoded in the UTC timezone. 14 | # 15 | # @param encoded_string [String] 16 | # The encoded cursor 17 | # @param order_field [Symbol] 18 | # The column that is being ordered on. It needs to be a timestamp of a 19 | # class that responds to `#strftime`. 20 | # @raise [RailsCursorPagination::InvalidCursorError] 21 | # In case the given `encoded_string` cannot be decoded properly 22 | # @return [RailsCursorPagination::TimestampCursor] 23 | # Instance of this class with a properly decoded timestamp cursor 24 | def decode(encoded_string:, order_field:) 25 | decoded = JSON.parse(Base64.strict_decode64(encoded_string)) 26 | 27 | new( 28 | id: decoded[1], 29 | order_field: order_field, 30 | # Turn the order field value into a `Time` instance in UTC. A Rational 31 | # number allows us to represent fractions of seconds, including the 32 | # microseconds. In this way we can preserve the order of items with a 33 | # microsecond precision. 34 | # This also allows us to keep the size of the cursor small by using 35 | # just a number instead of having to pass seconds and the fraction of 36 | # seconds separately. 37 | order_field_value: Time.at(decoded[0].to_r / (10**6)).utc 38 | ) 39 | rescue ArgumentError, JSON::ParserError 40 | raise InvalidCursorError, 41 | "The given cursor `#{encoded_string}` " \ 42 | 'could not be decoded to a timestamp' 43 | end 44 | end 45 | 46 | # Initializes the record. Overrides `Cursor`'s initializer making all params 47 | # mandatory. 48 | # 49 | # @param id [Integer] 50 | # The ID of the cursor record 51 | # @param order_field [Symbol] 52 | # The column or virtual column for ordering 53 | # @param order_field_value [Object] 54 | # The value that the +order_field+ of the record contains 55 | def initialize(id:, order_field:, order_field_value:) 56 | super id: id, 57 | order_field: order_field, 58 | order_field_value: order_field_value 59 | end 60 | 61 | # Encodes the cursor as an array containing the timestamp as microseconds 62 | # from UNIX epoch and the id of the object 63 | # 64 | # @raise [RailsCursorPagination::ParameterError] 65 | # The order field value needs to respond to `#strftime` to use the 66 | # `TimestampCursor` class. Otherwise, a `ParameterError` is raised. 67 | # @return [String] 68 | def encode 69 | unless @order_field_value.respond_to?(:strftime) 70 | raise ParameterError, 71 | "Could not encode #{@order_field} " \ 72 | "with value #{@order_field_value}." \ 73 | 'It does not respond to #strftime. Is it a timestamp?' 74 | end 75 | 76 | Base64.strict_encode64( 77 | [ 78 | @order_field_value.strftime('%s%6N').to_i, 79 | @id 80 | ].to_json 81 | ) 82 | end 83 | end 84 | end 85 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | These are the latest changes on the project's `master` branch that have not yet been released. 10 | 11 | 16 | 17 | ## [0.4.0] - 2023-10-06 18 | 19 | ### Changed 20 | - **Breaking change:** Raised minimum required Ruby version to 2.7 21 | - **Breaking change:** Raised minimum required `activerecord` version to 6.0 22 | 23 | ### Added 24 | - Test against Ruby version 3.2 25 | 26 | ### Fixed 27 | - **Breaking change:** Ensure timestamp `order_by` fields (like `created_at`) will paginate results by honoring timestamp order down to microsecond resolution on comparison. This was done by changing the cursor logic for timestamp fields, which means that the cursors strings change from version 0.3.0 to 0.4.0 and old cursors cannot be decoded by the new gem version anymore. 28 | 29 | ## [0.3.0] - 2022-07-08 30 | 31 | ### Added 32 | - Add a `limit` param to paginator that can be used instead either `first` or `last` 33 | - Add a `max_page_size` to the configuration, allowing to set a global limit to the page size (non overridable): Default `nil` 34 | - Support explicitly requesting all columns via `.select(*)` without re-including the requested column 35 | 36 | ### Removed 37 | - **Breaking change:** Drop support for Ruby 2.5 (EOL 2021-03-31) 38 | 39 | ### Changed 40 | - **Breaking change:** Remove nesting of `ParameterError` and `InvalidCursorError` errors, they are now directly nested under the main gem module. So they're now `RailsCursorPagination::ParameterError` and `RailsCursorPagination::InvalidCursorError`. 41 | - Refactor paginator cursor interactions into exposed `RailsCursorPagination::Cursor` class 42 | - Require multi-factor-authentication to publish the gem on Rubygems 43 | 44 | ## [0.2.0] - 2021-04-19 45 | 46 | ### Changed 47 | - **Breaking change:** The way records are retrieved from a given cursor has been changed to no longer use `CONCAT` but instead simply use a compound `WHERE` clause in case of a custom order and having both the custom field as well as the `id` field in the `ORDER BY` query. This is a breaking change since it now changes the internal order of how records with the same value of the `order_by` field are returned. 48 | - Remove `ORDER BY` clause from `COUNT` queries 49 | 50 | ### Fixed 51 | - Only trigger one SQL query to load the records from the database and use it to determine if there was a previous / is a next page 52 | - Memoize the `Paginator#page` method which is invoked multiple times to prevent it from mapping over the `records` again and again and rebuilding all cursors 53 | 54 | ### Added 55 | - Description about `order_by` on arbitrary SQL to README.md 56 | 57 | ## [0.1.3] - 2021-03-17 58 | 59 | ### Changed 60 | - Make the gem publicly available via github.com/xing/rails_cursor_pagination and release it to Rubygems.org 61 | - Reference changelog file in the gemspec instead of the general releases Github tab 62 | 63 | ### Removed 64 | - Remove bulk from release: The previous gem releases contained files like the content of the `bin` folder or the Gemfile used for testing. Since this is not useful for gem users, adjust the gemspec file accordingly. 65 | 66 | ## [0.1.2] - 2021-02-04 67 | 68 | ### Fixed 69 | - Pagination for relations in which a custom `SELECT` does not contain cursor-relevant fields like `:id` or the field specified via `order_by` 70 | 71 | ## [0.1.1] - 2021-01-21 72 | 73 | ### Added 74 | - Add support for handling `nil` for `order` and `order_by` values as if they were not passed 75 | 76 | ### Fixed 77 | - Pagination for relations that use a custom `SELECT` 78 | 79 | ## [0.1.0-pre] - 2021-01-12 80 | 81 | ### Add 82 | - First version of the gem, including pagination, custom ordering by column and sort-order. 83 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination/cursor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'base64' 4 | 5 | module RailsCursorPagination 6 | # Cursor class that's used to uniquely identify a record and serialize and 7 | # deserialize this cursor so that it can be used for pagination. 8 | class Cursor 9 | attr_reader :id, :order_field_value 10 | 11 | class << self 12 | # Generate a cursor for the given record and ordering field. The cursor 13 | # encodes all the data required to then paginate based on it with the 14 | # given ordering field. 15 | # 16 | # @param record [ActiveRecord] 17 | # Model instance for which we want the cursor 18 | # @param order_field [Symbol] 19 | # Column or virtual column of the record that the relation is ordered by 20 | # @return [Cursor] 21 | def from_record(record:, order_field: :id) 22 | new(id: record.id, order_field: order_field, 23 | order_field_value: record[order_field]) 24 | end 25 | 26 | # Decode the provided encoded cursor. Returns an instance of this 27 | # +RailsCursorPagination::Cursor+ class containing either just the 28 | # cursor's ID or in case of pagination on any other field, containing 29 | # both the ID and the ordering field value. 30 | # 31 | # @param encoded_string [String] 32 | # The encoded cursor 33 | # @param order_field [Symbol] 34 | # Optional. The column that is being ordered on in case it's not the ID 35 | # column 36 | # @return [RailsCursorPagination::Cursor] 37 | def decode(encoded_string:, order_field: :id) 38 | decoded = JSON.parse(Base64.strict_decode64(encoded_string)) 39 | if order_field == :id 40 | if decoded.is_a?(Array) 41 | raise InvalidCursorError, 42 | "The given cursor `#{encoded_string}` was decoded as " \ 43 | "`#{decoded}` but could not be parsed" 44 | end 45 | new(id: decoded, order_field: :id) 46 | else 47 | unless decoded.is_a?(Array) && decoded.size == 2 48 | raise InvalidCursorError, 49 | "The given cursor `#{encoded_string}` was decoded as " \ 50 | "`#{decoded}` but could not be parsed" 51 | end 52 | new(id: decoded[1], order_field: order_field, 53 | order_field_value: decoded[0]) 54 | end 55 | rescue ArgumentError, JSON::ParserError 56 | raise InvalidCursorError, 57 | "The given cursor `#{encoded_string}` could not be decoded" 58 | end 59 | end 60 | 61 | # Initializes the record 62 | # 63 | # @param id [Integer] 64 | # The ID of the cursor record 65 | # @param order_field [Symbol] 66 | # The column or virtual column for ordering 67 | # @param order_field_value [Object] 68 | # Optional. The value that the +order_field+ of the record contains in 69 | # case that the order field is not the ID 70 | def initialize(id:, order_field: :id, order_field_value: nil) 71 | @id = id 72 | @order_field = order_field 73 | @order_field_value = order_field_value 74 | 75 | return if !custom_order_field? || !order_field_value.nil? 76 | 77 | raise ParameterError, 'The `order_field` was set to ' \ 78 | "`#{@order_field.inspect}` but " \ 79 | 'no `order_field_value` was set' 80 | end 81 | 82 | # Generate an encoded string for this cursor. The cursor encodes all the 83 | # data required to then paginate based on it with the given ordering field. 84 | # 85 | # If we only order by ID, the cursor doesn't need to include any other data. 86 | # But if we order by any other field, the cursor needs to include both the 87 | # value from this other field as well as the records ID to resolve the order 88 | # of duplicates in the non-ID field. 89 | # 90 | # @return [String] 91 | def encode 92 | unencoded_cursor = 93 | if custom_order_field? 94 | [@order_field_value, @id] 95 | else 96 | @id 97 | end 98 | Base64.strict_encode64(unencoded_cursor.to_json) 99 | end 100 | 101 | private 102 | 103 | # Returns true when the order has been overridden from the default (ID) 104 | # 105 | # @return [Boolean] 106 | def custom_order_field? 107 | @order_field != :id 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. 8 | 9 | ## Our Standards 10 | 11 | Examples of behavior that contributes to a positive environment for our community include: 12 | 13 | * Demonstrating empathy and kindness toward other people 14 | * Being respectful of differing opinions, viewpoints, and experiences 15 | * Giving and gracefully accepting constructive feedback 16 | * Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience 17 | * Focusing on what is best not just for us as individuals, but for the overall community 18 | 19 | Examples of unacceptable behavior include: 20 | 21 | * The use of sexualized language or imagery, and sexual attention or 22 | advances of any kind 23 | * Trolling, insulting or derogatory comments, and personal or political attacks 24 | * Public or private harassment 25 | * Publishing others' private information, such as a physical or email 26 | address, without their explicit permission 27 | * Other conduct which could reasonably be considered inappropriate in a 28 | professional setting 29 | 30 | ## Enforcement Responsibilities 31 | 32 | Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. 33 | 34 | Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. 35 | 36 | ## Scope 37 | 38 | This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. 39 | 40 | ## Enforcement 41 | 42 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at mail@nicolasfricke.com. All complaints will be reviewed and investigated promptly and fairly. 43 | 44 | All community leaders are obligated to respect the privacy and security of the reporter of any incident. 45 | 46 | ## Enforcement Guidelines 47 | 48 | Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: 49 | 50 | ### 1. Correction 51 | 52 | **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. 53 | 54 | **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. 55 | 56 | ### 2. Warning 57 | 58 | **Community Impact**: A violation through a single incident or series of actions. 59 | 60 | **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. 61 | 62 | ### 3. Temporary Ban 63 | 64 | **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. 65 | 66 | **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. 67 | 68 | ### 4. Permanent Ban 69 | 70 | **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. 71 | 72 | **Consequence**: A permanent ban from any sort of public interaction within the community. 73 | 74 | ## Attribution 75 | 76 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.0, 77 | available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 78 | 79 | Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 80 | 81 | [homepage]: https://www.contributor-covenant.org 82 | 83 | For answers to common questions about this code of conduct, see the FAQ at 84 | https://www.contributor-covenant.org/faq. Translations are available at https://www.contributor-covenant.org/translations. 85 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This library allows to paginate through a passed relation using a cursor 4 | # and first/after or last/before parameters. It also supports ordering by 5 | # any column on the relation in either ascending or descending order. 6 | # 7 | # Cursor pagination allows to paginate results and gracefully deal with 8 | # deletions / additions on previous pages. Where a regular limit / offset 9 | # pagination would jump in results if a record on a previous page gets deleted 10 | # or added while requesting the next page, cursor pagination just returns the 11 | # records following the one identified in the request. 12 | # 13 | # How this works is that it uses a "cursor", which is an encoded value that 14 | # uniquely identifies a given row for the requested order. Then, based on 15 | # this cursor, you can request the "n FIRST records AFTER the cursor" 16 | # (forward-pagination) or the "n LAST records BEFORE the cursor" (backward- 17 | # pagination). 18 | # 19 | # As an example, assume we have a table called "posts" with this data: 20 | # 21 | # | id | author | 22 | # |----|--------| 23 | # | 1 | Jane | 24 | # | 2 | John | 25 | # | 3 | John | 26 | # | 4 | Jane | 27 | # | 5 | Jane | 28 | # | 6 | John | 29 | # | 7 | John | 30 | # 31 | # Now if we make a basic request without any `first`/`after`, `last`/`before`, 32 | # custom `order` or `order_by` column, this will just request the first page 33 | # of this relation. 34 | # 35 | # RailsCursorPagination::Paginator 36 | # .new(relation) 37 | # .fetch 38 | # 39 | # Assume that our default page size here is 2 and we would get a query like 40 | # this: 41 | # 42 | # SELECT * 43 | # FROM "posts" 44 | # ORDER BY "posts"."id" ASC 45 | # LIMIT 2 46 | # 47 | # This will return the first page of results, containing post #1 and #2. Since 48 | # no custom order is defined, each item in the returned collection will have a 49 | # cursor that only encodes the record's ID. 50 | # 51 | # If we want to now request the next page, we can pass in the cursor of record 52 | # #2 which would be "Mg==". So now we can request the next page by calling: 53 | # 54 | # RailsCursorPagination::Paginator 55 | # .new(relation, first: 2, after: "Mg==") 56 | # .fetch 57 | # 58 | # And this will decode the given cursor and issue a query like: 59 | # 60 | # SELECT * 61 | # FROM "posts" 62 | # WHERE "posts"."id" > 2 63 | # ORDER BY "posts"."id" ASC 64 | # LIMIT 2 65 | # 66 | # Which would return posts #3 and #4. If we now want to paginate back, we can 67 | # request the posts that came before the first post, whose cursor would be 68 | # "Mw==": 69 | # 70 | # RailsCursorPagination::Paginator 71 | # .new(relation, last: 2, before: "Mw==") 72 | # .fetch 73 | # 74 | # Since we now paginate backward, the resulting SQL query needs to be flipped 75 | # around to get the last two records that have an ID smaller than the given 76 | # one: 77 | # 78 | # SELECT * 79 | # FROM "posts" 80 | # WHERE "posts"."id" < 3 81 | # ORDER BY "posts"."id" DESC 82 | # LIMIT 2 83 | # 84 | # This would return posts #2 and #1. Since we still requested them in 85 | # ascending order, the result will be reversed before it is returned. 86 | # 87 | # Now, in case that the user wants to order by a column different than the ID, 88 | # we require this information in our cursor. Therefore, when requesting the 89 | # first page like this: 90 | # 91 | # RailsCursorPagination::Paginator 92 | # .new(relation, order_by: :author) 93 | # .fetch 94 | # 95 | # This will issue the following SQL query: 96 | # 97 | # SELECT * 98 | # FROM "posts" 99 | # ORDER BY "posts"."author" ASC, "posts"."id" ASC 100 | # LIMIT 2 101 | # 102 | # As you can see, this will now order by the author first, and if two records 103 | # have the same author it will order them by ID. Ordering only the author is not 104 | # enough since we cannot know if the custom column only has unique values. 105 | # And we need to guarantee the correct order of ambiguous records independent 106 | # of the direction of ordering. This unique order is the basis of being able 107 | # to paginate forward and backward repeatedly and getting the correct records. 108 | # 109 | # The query will then return records #1 and #4. But the cursor for these 110 | # records will also be different to the previous query where we ordered by ID 111 | # only. It is important that the cursor encodes all the data we need to 112 | # uniquely identify a row and filter based upon it. Therefore, we need to 113 | # encode the same information as we used for the ordering in our SQL query. 114 | # Hence, the cursor for pagination with a custom column contains a tuple of 115 | # data, the first record being the custom order column followed by the 116 | # record's ID. 117 | # 118 | # Therefore, the cursor of record #4 will encode `['Jane', 4]`, which yields 119 | # this cursor: "WyJKYW5lIiw0XQ==". 120 | # 121 | # If we now want to request the next page via: 122 | # 123 | # RailsCursorPagination::Paginator 124 | # .new(relation, order_by: :author, first: 2, after: "WyJKYW5lIiw0XQ==") 125 | # .fetch 126 | # 127 | # We get this SQL query: 128 | # 129 | # SELECT * 130 | # FROM "posts" 131 | # WHERE (author > 'Jane' OR (author = 'Jane') AND ("posts"."id" > 4)) 132 | # ORDER BY "posts"."author" ASC, "posts"."id" ASC 133 | # LIMIT 2 134 | # 135 | # You can see how the cursor is being used by the WHERE clause to uniquely 136 | # identify the row and properly filter based on this. We only want to get 137 | # records that either have a name that is alphabetically after `"Jane"` or 138 | # another `"Jane"` record with an ID that is higher than `4`. We will get the 139 | # records #5 and #2 as response. 140 | # 141 | # When using a custom `order_by`, this affects both filtering as well as 142 | # ordering. Therefore, it is recommended to add an index for columns that are 143 | # frequently used for ordering. In our test case we would want to add a compound 144 | # index for the `(author, id)` column combination. Databases like MySQL and 145 | # Postgres are able to then use the leftmost part of the index, in our case 146 | # `author`, by its own _or_ can use it combined with the `id` index. 147 | # 148 | module RailsCursorPagination 149 | class Error < StandardError; end 150 | 151 | # Generic error that gets raised when invalid parameters are passed to the 152 | # pagination 153 | class ParameterError < Error; end 154 | 155 | # Error that gets raised if a cursor given as `before` or `after` cannot be 156 | # properly parsed 157 | class InvalidCursorError < ParameterError; end 158 | 159 | require_relative 'rails_cursor_pagination/version' 160 | 161 | require_relative 'rails_cursor_pagination/configuration' 162 | 163 | require_relative 'rails_cursor_pagination/paginator' 164 | 165 | require_relative 'rails_cursor_pagination/cursor' 166 | 167 | require_relative 'rails_cursor_pagination/timestamp_cursor' 168 | 169 | class << self 170 | # Allows to configure this gem. Currently supported configuration values 171 | # are: 172 | # * default_page_size - defines how many items are returned when not 173 | # passing an explicit `first` or `last` parameter 174 | # 175 | # Usage: 176 | # 177 | # RailsCursorPagination.configure do |config| 178 | # config.default_page_size = 42 179 | # end 180 | # 181 | # @yield [config] Yields a block to configure the gem as explained above 182 | # @yieldparam config [RailsCursorPagination::Configuration] 183 | # Configuration instance that can be used to set up this gem 184 | def configure(&_block) 185 | yield(Configuration.instance) 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination/cursor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination::Cursor do 4 | describe '#encode' do 5 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 6 | 7 | context 'when ordering by id implicitly' do 8 | subject(:encoded) do 9 | described_class.from_record(record: record).encode 10 | end 11 | 12 | it 'produces a valid string' do 13 | expect(encoded).to be_a(String) 14 | end 15 | 16 | it 'can be decoded back to the originally encoded value' do 17 | decoded = described_class.decode(encoded_string: encoded) 18 | expect(decoded.id).to eq record.id 19 | end 20 | end 21 | 22 | context 'when ordering by id explicitly' do 23 | subject(:encoded) do 24 | described_class.from_record(record: record, order_field: :id).encode 25 | end 26 | 27 | it 'produces a valid string' do 28 | expect(encoded).to be_a(String) 29 | end 30 | 31 | it 'can be decoded back to the originally encoded value' do 32 | decoded = described_class.decode(encoded_string: encoded, 33 | order_field: :id) 34 | expect(decoded.id).to eq record.id 35 | end 36 | end 37 | 38 | context 'when ordering by author' do 39 | subject(:encoded) do 40 | described_class.from_record(record: record, order_field: :author).encode 41 | end 42 | 43 | it 'produces a valid string' do 44 | expect(encoded).to be_a(String) 45 | end 46 | 47 | it 'can be decoded back to the originally encoded value' do 48 | decoded = described_class.decode(encoded_string: encoded, 49 | order_field: :author) 50 | expect(decoded.id).to eq record.id 51 | expect(decoded.order_field_value).to eq record.author 52 | end 53 | end 54 | end 55 | 56 | describe '.from_record' do 57 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 58 | 59 | context 'when not specifying the order_field' do 60 | subject(:from_record) { described_class.from_record(record: record) } 61 | 62 | it 'returns a cursor with the same ID as the record' do 63 | expect(from_record).to be_a(RailsCursorPagination::Cursor) 64 | expect(from_record.id).to eq record.id 65 | end 66 | end 67 | 68 | context 'when specifying the order_field' do 69 | subject(:from_record) do 70 | described_class.from_record(record: record, order_field: :author) 71 | end 72 | 73 | it 'returns a cursor with the same ID as the record' do 74 | expect(from_record).to be_a(RailsCursorPagination::Cursor) 75 | expect(from_record.id).to eq record.id 76 | end 77 | 78 | it 'returns a cursor with the order_field_value as the record' do 79 | expect(from_record.order_field_value).to eq record.author 80 | end 81 | end 82 | end 83 | 84 | describe '.decode' do 85 | context 'when decoding an encoded message with order_field :id' do 86 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 87 | let(:encoded) { described_class.from_record(record: record).encode } 88 | 89 | context 'and the order_field to decode is set to :id (implicitly)' do 90 | subject(:decoded) do 91 | described_class.decode(encoded_string: encoded) 92 | end 93 | 94 | it 'decodes the string succesfully' do 95 | expect(decoded.id).to eq record.id 96 | end 97 | end 98 | 99 | context 'and the order_field to decode is set to :id (explicitly)' do 100 | subject(:decoded) do 101 | described_class.decode(encoded_string: encoded, order_field: :id) 102 | end 103 | 104 | it 'decodes the string succesfully' do 105 | expect(decoded.id).to eq record.id 106 | end 107 | end 108 | 109 | context 'and the order_field to decode is set to :author' do 110 | subject(:decoded) do 111 | described_class.decode(encoded_string: encoded, order_field: :author) 112 | end 113 | 114 | it 'raises an InvalidCursorError' do 115 | message = "The given cursor `#{encoded}` was decoded as " \ 116 | "`#{record.id}` but could not be parsed" 117 | expect { decoded }.to raise_error( 118 | RailsCursorPagination::InvalidCursorError, 119 | message 120 | ) 121 | end 122 | end 123 | end 124 | 125 | context 'when decoding an encoded message with order_field :author' do 126 | let(:record) { Post.create! id: 1, author: 'John', content: 'Post 1' } 127 | let(:encoded) do 128 | described_class.from_record(record: record, order_field: :author).encode 129 | end 130 | 131 | context 'and the order_field to decode is set to :id' do 132 | subject(:decoded) do 133 | described_class.decode(encoded_string: encoded) 134 | end 135 | 136 | it 'raises an InvalidCursorError' do 137 | message = "The given cursor `#{encoded}` was decoded as " \ 138 | "`[\"#{record.author}\", #{record.id}]` " \ 139 | 'but could not be parsed' 140 | expect { decoded }.to raise_error( 141 | RailsCursorPagination::InvalidCursorError, 142 | message 143 | ) 144 | end 145 | end 146 | 147 | context 'and the order_field to decode is set to :author' do 148 | subject(:decoded) do 149 | described_class.decode(encoded_string: encoded, order_field: :author) 150 | end 151 | 152 | it 'decodes the string succesfully' do 153 | expect(decoded.id).to eq record.id 154 | expect(decoded.order_field_value).to eq record.author 155 | end 156 | end 157 | end 158 | 159 | context 'when decoding a message that did not come from a known encoder' do 160 | let(:encoded) { 'SomeGarbageString' } 161 | 162 | context 'and the order_field to decode is set to :id' do 163 | subject(:decoded) do 164 | described_class.decode(encoded_string: encoded) 165 | end 166 | 167 | it 'raises an InvalidCursorError' do 168 | message = "The given cursor `#{encoded}` " \ 169 | 'could not be decoded' 170 | expect { decoded }.to raise_error( 171 | RailsCursorPagination::InvalidCursorError, 172 | message 173 | ) 174 | end 175 | end 176 | 177 | context 'and the order_field to decode is set to :author' do 178 | subject(:decoded) do 179 | described_class.decode(encoded_string: encoded, order_field: :author) 180 | end 181 | 182 | it 'raises an InvalidCursorError' do 183 | message = "The given cursor `#{encoded}` " \ 184 | 'could not be decoded' 185 | expect { decoded }.to raise_error( 186 | RailsCursorPagination::InvalidCursorError, 187 | message 188 | ) 189 | end 190 | end 191 | end 192 | end 193 | 194 | describe '.new' do 195 | context 'when initialized with an id' do 196 | context 'and only an id' do 197 | subject(:cursor) { described_class.new(id: 13) } 198 | 199 | it 'returns an instance of a Cursor' do 200 | expect(cursor).to be_a(RailsCursorPagination::Cursor) 201 | end 202 | end 203 | 204 | context 'and an order_field' do 205 | context 'but no order_field_value' do 206 | subject(:cursor) { described_class.new(id: 13, order_field: :author) } 207 | 208 | it 'raises a ParameterError' do 209 | message = 'The `order_field` was set to ' \ 210 | '`:author` but no `order_field_value` was set' 211 | expect { cursor }.to raise_error( 212 | RailsCursorPagination::ParameterError, 213 | message 214 | ) 215 | end 216 | end 217 | 218 | context 'and an order_field_value' do 219 | subject(:cursor) do 220 | described_class.new(id: 13, order_field: :author, 221 | order_field_value: 'Thomas') 222 | end 223 | 224 | it 'returns an instance of a Cursor' do 225 | expect(cursor).to be_a(RailsCursorPagination::Cursor) 226 | end 227 | end 228 | end 229 | end 230 | end 231 | end 232 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RailsCursorPagination 2 | 3 | [![Gem Version](https://badge.fury.io/rb/rails_cursor_pagination.svg)](https://badge.fury.io/rb/rails_cursor_pagination) 4 | [![License](http://img.shields.io/badge/license-MIT-brightgreen.svg)](https://tldrlegal.com/license/mit-license) 5 | [![Tests](https://github.com/xing/rails_cursor_pagination/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/xing/rails_cursor_pagination/actions/workflows/test.yml?query=branch%3Amaster) 6 | 7 | This library allows to paginate through an `ActiveRecord` relation using cursor pagination. 8 | It also supports ordering by any column on the relation in either ascending or descending order. 9 | 10 | Cursor pagination allows to paginate results and gracefully deal with deletions / additions on previous pages. 11 | Where a regular limit / offset pagination would jump in results if a record on a previous page gets deleted or added while requesting the next page, cursor pagination just returns the records following the one identified in the request. 12 | 13 | To learn more about cursor pagination, check out the _"How does it work?"_ section below. 14 | 15 | ## Installation 16 | 17 | Add this line to your application's Gemfile: 18 | 19 | ```ruby 20 | gem 'rails_cursor_pagination' 21 | ``` 22 | 23 | And then execute: 24 | 25 | ```sh 26 | $ bundle install 27 | ``` 28 | 29 | Or install it yourself as: 30 | 31 | ```sh 32 | $ gem install rails_cursor_pagination 33 | ``` 34 | 35 | ## Usage 36 | 37 | Using it is very straight forward by just interfacing with the `RailsCursorPagination::Paginator` class. 38 | 39 | Let's assume we have an `ActiveRecord` model called `Post` of which we want to fetch some data and then paginate through it. 40 | Therefore, we first apply our scopes, `where` clauses or other functionality as usual: 41 | 42 | ```ruby 43 | posts = Post.where(author: 'Jane') 44 | ``` 45 | 46 | And then we pass these posts to our paginator to fetch the first response page: 47 | 48 | ```ruby 49 | RailsCursorPagination::Paginator.new(posts).fetch(with_total: true) 50 | ``` 51 | 52 | This will return a data structure similar to the following: 53 | ``` 54 | { 55 | total: 42, 56 | page_info: { 57 | has_previous_page: false, 58 | has_next_page: true, 59 | start_cursor: "MQ==", 60 | end_cursor: "MTA=" 61 | }, 62 | page: [ 63 | { cursor: "MQ==", data: # }, 64 | { cursor: "Mg==", data: # }, 65 | ..., 66 | { cursor: "MTA=", data: # } 67 | ] 68 | } 69 | ``` 70 | 71 | Note that any ordering of the relation at this stage will be ignored by the gem. 72 | Take a look at the next section _"Ordering"_ to see how you can have an order different than ascending IDs. 73 | Read the _"The passed relation"_ to learn more about the relation that can be passed to the paginator. 74 | 75 | As you saw in the request, `with_total` is an option. 76 | If omitted, or set to `false`, the resulting hash will lack the `:total` key, but this will also cause one DB query less. 77 | It is therefore recommended to only pass `with_total: true` when requested by the user. 78 | So in the next examples we will also leave it away. 79 | 80 | To then get the next result page, you simply need to pass the last cursor of the returned page item via: 81 | 82 | ```ruby 83 | RailsCursorPagination::Paginator 84 | .new(posts, after: 'MTA=') 85 | .fetch 86 | ``` 87 | 88 | This will then fetch the next result page. 89 | You can also just as easily paginate to previous pages by using `before` instead of `after` and using the first cursor of the current page. 90 | 91 | ```ruby 92 | RailsCursorPagination::Paginator 93 | .new(posts, before: "MTE=") 94 | .fetch 95 | ``` 96 | 97 | By default, this will always return up to 10 results. 98 | But you can also specify how many records should be returned. 99 | You can pass `first: 2` to get the very first 2 records of the relation: 100 | 101 | ```ruby 102 | RailsCursorPagination::Paginator 103 | .new(posts, first: 2) 104 | .fetch 105 | ``` 106 | 107 | Then, you can also combine `first` with `after` to get the first X records after a given one: 108 | 109 | ```ruby 110 | RailsCursorPagination::Paginator 111 | .new(posts, first: 2, after: 'MTA=') 112 | .fetch 113 | ``` 114 | 115 | Or you can combine `before` with `last` to get the last X records before a given one: 116 | 117 | ```ruby 118 | RailsCursorPagination::Paginator 119 | .new(posts, last: 2, before: 'MTA=') 120 | .fetch 121 | ``` 122 | 123 | Alternatively, you can use the `limit` column with either `after` or `before`. 124 | This will behave like either `first` or `last` respectively and fetch X records. 125 | 126 | ```ruby 127 | RailsCursorPagination::Paginator 128 | .new(posts, limit: 2, after: 'MTA=') 129 | .fetch 130 | ``` 131 | 132 | ```ruby 133 | RailsCursorPagination::Paginator 134 | .new(posts, limit: 2, before: 'MTA=') 135 | .fetch 136 | ``` 137 | 138 | ### Ordering 139 | 140 | As said, this gem ignores any previous ordering added to the passed relation. 141 | But you can still paginate through relations with an order different than by ascending IDs. 142 | 143 | ### The `order` parameter 144 | 145 | The first option you can pass is the `order` parameter. 146 | It allows you to order the relation in reverse, descending. 147 | 148 | ```ruby 149 | RailsCursorPagination::Paginator 150 | .new(posts, order: :desc) 151 | .fetch 152 | ``` 153 | 154 | The default is `:asc`, therefore this doesn't need to be passed. 155 | 156 | ### The `order_by` parameter 157 | 158 | However, you can also specify a different column to order the results by. 159 | Therefore, the `order_by` parameter needs to be passed. 160 | 161 | ```ruby 162 | RailsCursorPagination::Paginator 163 | .new(posts, order_by: :author) 164 | .fetch 165 | ``` 166 | 167 | This will now order the records ascending by the `:author` field. 168 | You can also combine the two: 169 | 170 | ```ruby 171 | RailsCursorPagination::Paginator 172 | .new(posts, order_by: :author, order: :desc) 173 | .fetch 174 | ``` 175 | 176 | This will then sort the results by the author field in a descending order. 177 | Of course, this can both be combined with `first`, `last`, `before`, and `after`. 178 | 179 | **Important:** 180 | If your app regularly orders by another column, you might want to add a database index for this. 181 | Say that your order column is `author` then you'll want to add a compound index on `(author, id)`. 182 | If your table is called `posts` you can use a query like this in MySQL or Postgres: 183 | ```sql 184 | CREATE INDEX index_posts_on_author_and_id ON posts (author, id); 185 | ``` 186 | Or you can just do it via an `ActiveRecord::Migration`: 187 | ```ruby 188 | class AddAuthorAndIdIndexToPosts < ActiveRecord::Migration 189 | def change 190 | add_index :posts, %i[author id] 191 | end 192 | end 193 | ``` 194 | 195 | Please take a look at the _"How does it work?"_ to find out more why this is necessary. 196 | 197 | #### Order by more complex logic 198 | 199 | Sometimes you might not only want to oder by a column ascending or descending, but need more complex logic. 200 | Imagine you would also store the post's `category` on the `posts` table (as a plain string for simplicity's sake). 201 | And the category could be `pinned`, `announcement`, or `general`. 202 | Then you might want to show all `pinned` posts first, followed by the `announcement` ones and lastly show the `general` posts. 203 | 204 | In MySQL you could e.g. use a `FIELD(category, 'pinned', 'announcement', 'general')` query in the `ORDER BY` clause to achieve this. 205 | However, you cannot add an index to such a statement. 206 | And therefore, the performance of this is – especially when using cursor pagination where we not only have an `ORDER BY` clause but also need it twice in the `WHERE` clauses – is pretty dismal. 207 | 208 | For this reason, the gem currently only supports ordering by natural columns of the relation. 209 | You **cannot** pass a generic SQL query to the `order_by` parameter. 210 | 211 | Implementing support for arbitrary SQL queries would also be fairly complex to handle in this gem. 212 | We would have to ensure that SQL injection attacks aren't possible by passing malicious code to the `oder_by` parameter. 213 | And we would need to return the data produced by the statement so that it can be encoded in the cursor. 214 | This is, for now, out of scope of the functionality of this gem. 215 | 216 | What is recommended if you _do_ need to order by more complex logic is to have a separate column that you only use for ordering. 217 | You can use `ActiveRecord` hooks to automatically update this column whenever you change your data. 218 | Or, for example in MySQL, you can also use a [generated column](https://dev.mysql.com/doc/refman/5.7/en/create-table-generated-columns.html) that is automatically being updated by the database based on some stored logic. 219 | 220 | ### Configuration options 221 | 222 | You can also change the default page size to a value that better fits the needs of your application. 223 | So if a user doesn't request a given `first` or `last` value, the default amount of records is being returned. 224 | 225 | To change the default, simply add an initializer to your app that does the following: 226 | 227 | ```ruby 228 | RailsCursorPagination.configure do |config| 229 | config.default_page_size = 50 230 | end 231 | ``` 232 | 233 | This would set the default page size to 50. 234 | 235 | You can also select a global `max_page_size` to prevent a client from requesting too large a page. 236 | 237 | ```ruby 238 | RailsCursorPagination.configure do |config| 239 | config.max_page_size = 100 240 | end 241 | ``` 242 | 243 | ### The passed relation 244 | 245 | The relation passed to the `RailsCursorPagination::Paginator` needs to be an instance of an `ActiveRecord::Relation`. 246 | So if you e.g. have a `Post` model that inherits from `ActiveRecord::Base`, you can initialize your paginator like this: 247 | 248 | ```ruby 249 | RailsCursorPagination::Paginator 250 | .new(Post.all) 251 | ``` 252 | 253 | This would then paginate over all post records in your database. 254 | 255 | #### Limiting the paginated records 256 | 257 | As shown above, you can also apply `.where` clauses to filter your records before pagination: 258 | 259 | ```ruby 260 | RailsCursorPagination::Paginator 261 | .new(Post.where(author: 'Jane')) 262 | ``` 263 | 264 | This would only paginate over Jane's records. 265 | 266 | #### Limiting the queried fields 267 | 268 | You can also use `.select` to limit the fields that are requested from the database. 269 | If, for example, your post contains a very big `content` field that you don't want to return on the paginated index endpoint, you can select to only get the fields relevant to you: 270 | 271 | ```ruby 272 | RailsCursorPagination::Paginator 273 | .new(Post.select(:id, :author)) 274 | ``` 275 | 276 | One important thing to note is that the ID of the record _will always be returned_, whether you selected it or not. 277 | This is due to how the cursor is generated. 278 | It requires the record's ID to always be present. 279 | Therefore, even if it is not selected by you, it will be added to the query. 280 | 281 | The same goes for any field that is specified via `order_by:`, this field is also required for building the cursor and will therefore automatically be requested from the database. 282 | 283 | ## How does it work? 284 | 285 | The _cursor_ that we use for the `before` or `after` query encodes a value that uniquely identifies a given row _for the requested order_. 286 | Then, based on this cursor, you can request the _"`n` **first** records **after** the cursor"_ (forward-pagination) or the _"`n` **last** records **before** the cursor"_ (backward-pagination). 287 | 288 | As an example, assume we have a table called "posts" with this data: 289 | 290 | | id | author | 291 | |----|--------| 292 | | 1 | Jane | 293 | | 2 | John | 294 | | 3 | John | 295 | | 4 | Jane | 296 | | 5 | Jane | 297 | | 6 | John | 298 | | 7 | John | 299 | 300 | Now if we make a basic request without any `first`/`after`, `last`/`before`, custom `order` or `order_by` column, this will just request the first page of this relation. 301 | 302 | ```ruby 303 | RailsCursorPagination::Paginator 304 | .new(relation) 305 | .fetch 306 | ``` 307 | 308 | Assume that our default page size here is 2 and we would get a query like this: 309 | 310 | ```sql 311 | SELECT * 312 | FROM "posts" 313 | ORDER BY "posts"."id" ASC 314 | LIMIT 2 315 | ``` 316 | 317 | This will return the first page of results, containing post #1 and #2. 318 | Since no custom order is defined, each item in the returned collection will have a cursor that only encodes the record's ID. 319 | 320 | If we want to now request the next page, we can pass in the cursor of record #2 which would be `"Mg=="`. 321 | So now we can request the next page by calling: 322 | 323 | ```ruby 324 | RailsCursorPagination::Paginator 325 | .new(relation, first: 2, after: "Mg==") 326 | .fetch 327 | ``` 328 | 329 | And this will decode the given cursor and issue a query like: 330 | 331 | ```sql 332 | SELECT * 333 | FROM "posts" 334 | WHERE "posts"."id" > 2 335 | ORDER BY "posts"."id" ASC 336 | LIMIT 2 337 | ``` 338 | 339 | Which would return posts #3 and #4. 340 | If we now want to paginate back, we can request the posts that came before the first post, whose cursor would be `"Mw=="`: 341 | 342 | ```ruby 343 | RailsCursorPagination::Paginator 344 | .new(relation, last: 2, before: "Mw==") 345 | .fetch 346 | ``` 347 | 348 | Since we now paginate backward, the resulting SQL query needs to be flipped around to get the last two records that have an ID smaller than the given one: 349 | 350 | ```sql 351 | SELECT * 352 | FROM "posts" 353 | WHERE "posts"."id" < 3 354 | ORDER BY "posts"."id" DESC 355 | LIMIT 2 356 | ``` 357 | 358 | This would return posts #2 and #1. 359 | Since we still requested them in ascending order, the result will be reversed before it is returned. 360 | 361 | Now, in case that the user wants to order by a column different than the ID, we require this information in our cursor. 362 | Therefore, when requesting the first page like this: 363 | 364 | ```ruby 365 | RailsCursorPagination::Paginator 366 | .new(relation, order_by: :author) 367 | .fetch 368 | ``` 369 | 370 | This will issue the following SQL query: 371 | 372 | ```sql 373 | SELECT * 374 | FROM "posts" 375 | ORDER BY "posts"."author" ASC, "posts"."id" ASC 376 | LIMIT 2 377 | ``` 378 | 379 | As you can see, this will now order by the author first, and if two records have the same author it will order them by ID. 380 | Ordering only the author is not enough since we cannot know if the custom column only has unique values. 381 | And we need to guarantee the correct order of ambiguous records independent of the direction of ordering. 382 | This unique order is the basis of being able to paginate forward and backward repeatedly and getting the correct records. 383 | 384 | The query will then return records #1 and #4. 385 | But the cursor for these records will also be different to the previous query where we ordered by ID only. 386 | It is important that the cursor encodes all the data we need to uniquely identify a row and filter based upon it. 387 | Therefore, we need to encode the same information as we used for the ordering in our SQL query. 388 | Hence, the cursor for pagination with a custom column contains a tuple of data, the first record being the custom order column followed by the record's ID. 389 | 390 | Therefore, the cursor of record #4 will encode `['Jane', 4]`, which yields this cursor: `"WyJKYW5lIiw0XQ=="`. 391 | 392 | If we now want to request the next page via: 393 | 394 | ```ruby 395 | RailsCursorPagination::Paginator 396 | .new(relation, order_by: :author, first: 2, after: "WyJKYW5lIiw0XQ==") 397 | .fetch 398 | ``` 399 | 400 | We get this SQL query: 401 | 402 | ```sql 403 | SELECT * 404 | FROM "posts" 405 | WHERE (author > 'Jane' OR (author = 'Jane') AND ("posts"."id" > 4)) 406 | ORDER BY "posts"."author" ASC, "posts"."id" ASC 407 | LIMIT 2 408 | ``` 409 | 410 | You can see how the cursor is being used by the WHERE clause to uniquely identify the row and properly filter based on this. 411 | We only want to get records that either have a name that is alphabetically _after_ `"Jane"` or another `"Jane"` record with an ID that is higher than `4`. 412 | We will get the records #5 and #2 as response. 413 | 414 | When using a custom `order_by`, this affects both filtering as well as ordering. 415 | Therefore, it is recommended to add an index for columns that are frequently used for ordering. 416 | In our test case we would want to add a compound index for the `(author, id)` column combination. 417 | Databases like MySQL and Postgres are able to then use the leftmost part of the index, in our case `author`, by its own _or_ can use it combined with the `id` index. 418 | 419 | ## Development 420 | 421 | Make sure you have MySQL installed on your machine and create a database with the name `rails_cursor_pagination_testing`. 422 | 423 | After checking out the repo, run `bin/setup` to install dependencies. 424 | Then, run `rake spec` to run the tests. 425 | You can also run `bin/console` for an interactive prompt that will allow you to experiment. 426 | 427 | To install this gem onto your local machine, run `bundle exec rake install`. 428 | To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). 429 | 430 | ## Supported environments 431 | 432 | This gem should run in any project that uses: 433 | * Ruby 434 | * `ActiveRecord` 435 | * Postgres or MySQL 436 | 437 | We aim to support all versions that are still actively maintained and extend support until one year past the version's EOL. 438 | While we think it's important to stay up-to-date with versions and update as soon as an EOL is reached, we know that this is not always immediately possible. 439 | This way, we hope to strike a balance between being usable by most projects without forcing them to upgrade, but also keeping the supported version combinations manageable. 440 | 441 | This project is tested against different permutations of Ruby versions and DB versions, both Postgres and MySQL. 442 | Please check the [test automation file under `./.github/workflows/test.yml`](.github/workflows/test.yml) to see all officially supported combinations. 443 | 444 | ## Contributing 445 | 446 | Bug reports and pull requests are welcome on GitHub at https://github.com/xing/rails_cursor_pagination. 447 | This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/xing/rails_cursor_pagination/blob/master/CODE_OF_CONDUCT.md). 448 | 449 | If you open a pull request, please make sure to also document your changes in the `CHANGELOG.md`. 450 | This way, your change can be properly announced in the next release. 451 | 452 | ## License 453 | 454 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 455 | 456 | ## Code of Conduct 457 | 458 | Everyone interacting in the RailsCursorPagination project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/xing/rails_cursor_pagination/blob/master/CODE_OF_CONDUCT.md). 459 | -------------------------------------------------------------------------------- /lib/rails_cursor_pagination/paginator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module RailsCursorPagination 4 | # Use this Paginator class to effortlessly paginate through ActiveRecord 5 | # relations using cursor pagination. For more details on how this works, 6 | # read the top-level documentation of the `RailsCursorPagination` module. 7 | # 8 | # Usage: 9 | # RailsCursorPagination::Paginator 10 | # .new(relation, order_by: :author, first: 2, after: "WyJKYW5lIiw0XQ==") 11 | # .fetch 12 | # 13 | class Paginator 14 | # Create a new instance of the `RailsCursorPagination::Paginator` 15 | # 16 | # @param relation [ActiveRecord::Relation] 17 | # Relation that will be paginated. 18 | # @param limit [Integer, nil] 19 | # Number of records to return in pagination. Can be combined with either 20 | # `after` or `before` as an alternative to `first` or `last`. 21 | # @param first [Integer, nil] 22 | # Number of records to return in a forward pagination. Can be combined 23 | # with `after`. 24 | # @param after [String, nil] 25 | # Cursor to paginate forward from. Can be combined with `first`. 26 | # @param last [Integer, nil] 27 | # Number of records to return. Must be used together with `before`. 28 | # @param before [String, nil] 29 | # Cursor to paginate upto (excluding). Can be combined with `last`. 30 | # @param order_by [Symbol, String, nil] 31 | # Column to order by. If none is provided, will default to ID column. 32 | # NOTE: this will cause the query to filter on both the given column as 33 | # well as the ID column. So you might want to add a compound index to your 34 | # database similar to: 35 | # ```sql 36 | # CREATE INDEX ON (, id) 37 | # ``` 38 | # @param order [Symbol, nil] 39 | # Ordering to apply, either `:asc` or `:desc`. Defaults to `:asc`. 40 | # 41 | # @raise [RailsCursorPagination::ParameterError] 42 | # If any parameter is not valid 43 | def initialize(relation, limit: nil, first: nil, after: nil, last: nil, 44 | before: nil, order_by: nil, order: nil) 45 | order_by ||= :id 46 | order ||= :asc 47 | 48 | ensure_valid_params_values!(relation, order, limit, first, last) 49 | ensure_valid_params_combinations!(first, last, limit, before, after) 50 | 51 | @order_field = order_by 52 | @order_direction = order 53 | @relation = relation 54 | 55 | @cursor = before || after 56 | @is_forward_pagination = before.blank? 57 | 58 | @page_size = 59 | first || 60 | last || 61 | limit || 62 | RailsCursorPagination::Configuration.instance.default_page_size 63 | 64 | if Configuration.instance.max_page_size && 65 | Configuration.instance.max_page_size < @page_size 66 | @page_size = Configuration.instance.max_page_size 67 | end 68 | 69 | @memos = {} 70 | end 71 | 72 | # Get the paginated result, including the actual `page` with its data items 73 | # and cursors as well as some meta data in `page_info` and an optional 74 | # `total` of records across all pages. 75 | # 76 | # @param with_total [TrueClass, FalseClass] 77 | # @return [Hash] with keys :page, :page_info, and optional :total 78 | def fetch(with_total: false) 79 | { 80 | **(with_total ? { total: total } : {}), 81 | page_info: page_info, 82 | page: page 83 | } 84 | end 85 | 86 | private 87 | 88 | # Ensure that the parameters of this service have valid values, otherwise 89 | # raise a `RailsCursorPagination::ParameterError`. 90 | # 91 | # @param relation [ActiveRecord::Relation] 92 | # Relation that will be paginated. 93 | # @param order [Symbol] 94 | # Must be :asc or :desc 95 | # @param limit [Integer, nil] 96 | # Optional, must be positive 97 | # @param first [Integer, nil] 98 | # Optional, must be positive 99 | # @param last [Integer, nil] 100 | # Optional, must be positive 101 | # with `first` or `limit` 102 | # 103 | # @raise [RailsCursorPagination::ParameterError] 104 | # If any parameter is not valid 105 | def ensure_valid_params_values!(relation, order, limit, first, last) 106 | unless relation.is_a?(ActiveRecord::Relation) 107 | raise ParameterError, 108 | 'The first argument must be an ActiveRecord::Relation, but was ' \ 109 | "the #{relation.class} `#{relation.inspect}`" 110 | end 111 | unless %i[asc desc].include?(order) 112 | raise ParameterError, 113 | "`order` must be either :asc or :desc, but was `#{order}`" 114 | end 115 | if first.present? && first.negative? 116 | raise ParameterError, "`first` cannot be negative, but was `#{first}`" 117 | end 118 | if last.present? && last.negative? 119 | raise ParameterError, "`last` cannot be negative, but was `#{last}`" 120 | end 121 | if limit.present? && limit.negative? 122 | raise ParameterError, "`limit` cannot be negative, but was `#{limit}`" 123 | end 124 | 125 | true 126 | end 127 | 128 | # Ensure that the parameters of this service are combined in a valid way. 129 | # Otherwise raise a +RailsCursorPagination::ParameterError+. 130 | # 131 | # @param limit [Integer, nil] 132 | # Optional, cannot be combined with `last` or `first` 133 | # @param first [Integer, nil] 134 | # Optional, cannot be combined with `last` or `limit` 135 | # @param after [String, nil] 136 | # Optional, cannot be combined with `before` 137 | # @param last [Integer, nil] 138 | # Optional, requires `before`, cannot be combined 139 | # with `first` or `limit` 140 | # @param before [String, nil] 141 | # Optional, cannot be combined with `after` 142 | # 143 | # @raise [RailsCursorPagination::ParameterError] 144 | # If parameters are combined in an invalid way 145 | def ensure_valid_params_combinations!(first, last, limit, before, after) 146 | if first.present? && last.present? 147 | raise ParameterError, '`first` cannot be combined with `last`' 148 | end 149 | if first.present? && limit.present? 150 | raise ParameterError, '`limit` cannot be combined with `first`' 151 | end 152 | if last.present? && limit.present? 153 | raise ParameterError, '`limit` cannot be combined with `last`' 154 | end 155 | if before.present? && after.present? 156 | raise ParameterError, '`before` cannot be combined with `after`' 157 | end 158 | if last.present? && before.blank? 159 | raise ParameterError, '`last` must be combined with `before`' 160 | end 161 | 162 | true 163 | end 164 | 165 | # Get meta information about the current page 166 | # 167 | # @return [Hash] 168 | def page_info 169 | { 170 | has_previous_page: previous_page?, 171 | has_next_page: next_page?, 172 | start_cursor: start_cursor, 173 | end_cursor: end_cursor 174 | } 175 | end 176 | 177 | # Get the records for the given page along with their cursors 178 | # 179 | # @return [Array] List of hashes, each with a `cursor` and `data` 180 | def page 181 | memoize :page do 182 | records.map do |item| 183 | { 184 | cursor: cursor_for_record(item), 185 | data: item 186 | } 187 | end 188 | end 189 | end 190 | 191 | # Get the total number of records in the given relation 192 | # 193 | # @return [Integer] 194 | def total 195 | memoize(:total) { @relation.reorder('').size } 196 | end 197 | 198 | # Check if the pagination direction is forward 199 | # 200 | # @return [TrueClass, FalseClass] 201 | def paginate_forward? 202 | @is_forward_pagination 203 | end 204 | 205 | # Check if the user requested to order on a field different than the ID. If 206 | # a different field was requested, we have to change our pagination logic to 207 | # accommodate for this. 208 | # 209 | # @return [TrueClass, FalseClass] 210 | def custom_order_field? 211 | @order_field.downcase.to_sym != :id 212 | end 213 | 214 | # Check if there is a page before the current one. 215 | # 216 | # @return [TrueClass, FalseClass] 217 | def previous_page? 218 | if paginate_forward? 219 | # When paginating forward, we can only have a previous page if we were 220 | # provided with a cursor and there were records discarded after applying 221 | # this filter. These records would have to be on previous pages. 222 | @cursor.present? && 223 | filtered_and_sorted_relation.reorder('').size < total 224 | else 225 | # When paginating backwards, if we managed to load one more record than 226 | # requested, this record will be available on the previous page. 227 | records_plus_one.size > @page_size 228 | end 229 | end 230 | 231 | # Check if there is another page after the current one. 232 | # 233 | # @return [TrueClass, FalseClass] 234 | def next_page? 235 | if paginate_forward? 236 | # When paginating forward, if we managed to load one more record than 237 | # requested, this record will be available on the next page. 238 | records_plus_one.size > @page_size 239 | else 240 | # When paginating backward, if applying our cursor reduced the number 241 | # records returned, we know that the missing records will be on 242 | # subsequent pages. 243 | filtered_and_sorted_relation.reorder('').size < total 244 | end 245 | end 246 | 247 | # Load the correct records and return them in the right order 248 | # 249 | # @return [Array] 250 | def records 251 | records = records_plus_one.first(@page_size) 252 | 253 | paginate_forward? ? records : records.reverse 254 | end 255 | 256 | # Apply limit to filtered and sorted relation that contains one item more 257 | # than the user-requested page size. This is useful for determining if there 258 | # is an additional page available without having to do a separate DB query. 259 | # Then, fetch the records from the database to prevent multiple queries to 260 | # load the records and count them. 261 | # 262 | # @return [ActiveRecord::Relation] 263 | def records_plus_one 264 | memoize :records_plus_one do 265 | filtered_and_sorted_relation.limit(@page_size + 1).load 266 | end 267 | end 268 | 269 | # Cursor of the first record on the current page 270 | # 271 | # @return [String, nil] 272 | def start_cursor 273 | return if page.empty? 274 | 275 | page.first[:cursor] 276 | end 277 | 278 | # Cursor of the last record on the current page 279 | # 280 | # @return [String, nil] 281 | def end_cursor 282 | return if page.empty? 283 | 284 | page.last[:cursor] 285 | end 286 | 287 | # Get the order we need to apply to our SQL query. In case we are paginating 288 | # backwards, this has to be the inverse of what the user requested, since 289 | # our database can only apply the limit to following records. In the case of 290 | # backward pagination, we then reverse the order of the loaded records again 291 | # in `#records` to return them in the right order to the user. 292 | # 293 | # Examples: 294 | # - first 2 after 4 ascending 295 | # -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2 296 | # - first 2 after 4 descending ^ as requested 297 | # -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2 298 | # but: ^ as requested 299 | # - last 2 before 4 ascending 300 | # -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2 301 | # - last 2 before 4 descending ^ reversed 302 | # -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2 303 | # ^ reversed 304 | # 305 | # @return [Symbol] Either :asc or :desc 306 | def pagination_sorting 307 | return @order_direction if paginate_forward? 308 | 309 | @order_direction == :asc ? :desc : :asc 310 | end 311 | 312 | # Get the right operator to use in the SQL WHERE clause for filtering based 313 | # on the given cursor. This is dependent on the requested order and 314 | # pagination direction. 315 | # 316 | # If we paginate forward and want ascending records, or if we paginate 317 | # backward and want descending records we need records that have a higher 318 | # value than our cursor. 319 | # 320 | # On the contrary, if we paginate forward but want descending records, or 321 | # if we paginate backwards and want ascending records, we need them to have 322 | # lower values than our cursor. 323 | # 324 | # Examples: 325 | # - first 2 after 4 ascending 326 | # -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2 327 | # - last 2 before 4 descending ^ records with higher value than cursor 328 | # -> SELECT * FROM table WHERE id > 4 ODER BY id ASC LIMIT 2 329 | # but: ^ records with higher value than cursor 330 | # - first 2 after 4 descending 331 | # -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2 332 | # - last 2 before 4 ascending ^ records with lower value than cursor 333 | # -> SELECT * FROM table WHERE id < 4 ODER BY id DESC LIMIT 2 334 | # ^ records with lower value than cursor 335 | # 336 | # @return [String] either '<' or '>' 337 | def filter_operator 338 | if paginate_forward? 339 | @order_direction == :asc ? '>' : '<' 340 | else 341 | @order_direction == :asc ? '<' : '>' 342 | end 343 | end 344 | 345 | # The value our relation is filtered by. This is either just the cursor's ID 346 | # if we use the default order, or it is the combination of the custom order 347 | # field's value and its ID, joined by a dash. 348 | # 349 | # @return [Integer, String] 350 | def filter_value 351 | return decoded_cursor.id unless custom_order_field? 352 | 353 | "#{decoded_cursor.order_field_value}-#{decoded_cursor.id}" 354 | end 355 | 356 | # Generate a cursor for the given record and ordering field. The cursor 357 | # encodes all the data required to then paginate based on it with the given 358 | # ordering field. 359 | # 360 | # If we only order by ID, the cursor doesn't need to include any other data. 361 | # But if we order by any other field, the cursor needs to include both the 362 | # value from this other field as well as the records ID to resolve the order 363 | # of duplicates in the non-ID field. 364 | # 365 | # @param record [ActiveRecord] Model instance for which we want the cursor 366 | # @return [String] 367 | def cursor_for_record(record) 368 | cursor_class.from_record(record: record, order_field: @order_field).encode 369 | end 370 | 371 | # Decode the provided cursor. Either just returns the cursor's ID or in case 372 | # of pagination on any other field, returns a tuple of first the cursor 373 | # record's other field's value followed by its ID. 374 | # 375 | # @return [Integer, Array] 376 | def decoded_cursor 377 | memoize(:decoded_cursor) do 378 | cursor_class.decode(encoded_string: @cursor, order_field: @order_field) 379 | end 380 | end 381 | 382 | # Returns the appropriate class for the cursor based on the SQL type of the 383 | # column used for ordering the relation. 384 | # 385 | # @return [Class] 386 | def cursor_class 387 | order_field_type = @relation 388 | .column_for_attribute(@order_field) 389 | .sql_type_metadata 390 | .type 391 | 392 | case order_field_type 393 | when :datetime 394 | TimestampCursor 395 | else 396 | Cursor 397 | end 398 | end 399 | 400 | # Ensure that the relation has the ID column and any potential `order_by` 401 | # column selected. These are required to generate the record's cursor and 402 | # therefore it's crucial that they are part of the selected fields. 403 | # 404 | # @return [ActiveRecord::Relation] 405 | def relation_with_cursor_fields 406 | return @relation if @relation.select_values.blank? || 407 | @relation.select_values.include?('*') 408 | 409 | relation = @relation 410 | 411 | unless @relation.select_values.include?(:id) 412 | relation = relation.select(:id) 413 | end 414 | 415 | if custom_order_field? && !@relation.select_values.include?(@order_field) 416 | relation = relation.select(@order_field) 417 | end 418 | 419 | relation 420 | end 421 | 422 | # The given relation with the right ordering applied. Takes custom order 423 | # columns as well as custom direction and pagination into account. 424 | # 425 | # @return [ActiveRecord::Relation] 426 | def sorted_relation 427 | unless custom_order_field? 428 | return relation_with_cursor_fields.reorder id: pagination_sorting.upcase 429 | end 430 | 431 | relation_with_cursor_fields 432 | .reorder(@order_field => pagination_sorting.upcase, 433 | id: pagination_sorting.upcase) 434 | end 435 | 436 | # Return a properly escaped reference to the ID column prefixed with the 437 | # table name. This prefixing is important in case of another model having 438 | # been joined to the passed relation. 439 | # 440 | # @return [String (frozen)] 441 | def id_column 442 | escaped_table_name = @relation.quoted_table_name 443 | escaped_id_column = @relation.connection.quote_column_name(:id) 444 | 445 | "#{escaped_table_name}.#{escaped_id_column}".freeze 446 | end 447 | 448 | # Applies the filtering based on the provided cursor and order column to the 449 | # sorted relation. 450 | # 451 | # In case a custom `order_by` field is provided, we have to filter based on 452 | # this field and the ID column to ensure reproducible results. 453 | # 454 | # To better understand this, let's consider our example with the `posts` 455 | # table. Say that we're paginating forward and add `order_by: :author` to 456 | # the call, and if the cursor that is passed encodes `['Jane', 4]`. In this 457 | # case we will have to select all posts that either have an author whose 458 | # name is alphanumerically greater than 'Jane', or if the author is 'Jane' 459 | # we have to ensure that the post's ID is greater than `4`. 460 | # 461 | # So our SQL WHERE clause needs to be something like: 462 | # WHERE author > 'Jane' OR author = 'Jane' AND id > 4 463 | # 464 | # @return [ActiveRecord::Relation] 465 | def filtered_and_sorted_relation 466 | memoize :filtered_and_sorted_relation do 467 | next sorted_relation if @cursor.blank? 468 | 469 | unless custom_order_field? 470 | next sorted_relation.where "#{id_column} #{filter_operator} ?", 471 | decoded_cursor.id 472 | end 473 | 474 | sorted_relation 475 | .where("#{@order_field} #{filter_operator} ?", 476 | decoded_cursor.order_field_value) 477 | .or( 478 | sorted_relation 479 | .where("#{@order_field} = ?", decoded_cursor.order_field_value) 480 | .where("#{id_column} #{filter_operator} ?", decoded_cursor.id) 481 | ) 482 | end 483 | end 484 | 485 | # Ensures that given block is only executed exactly once and on subsequent 486 | # calls returns result from first execution. Useful for memoizing methods. 487 | # 488 | # @param key [Symbol] 489 | # Name or unique identifier of the method that is being memoized 490 | # @yieldreturn [Object] 491 | # @return [Object] Whatever the block returns 492 | def memoize(key, &_block) 493 | return @memos[key] if @memos.key?(key) 494 | 495 | @memos[key] = yield 496 | end 497 | end 498 | end 499 | -------------------------------------------------------------------------------- /spec/rails_cursor_pagination/paginator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe RailsCursorPagination::Paginator do 4 | subject(:instance) { described_class.new(relation, **params) } 5 | 6 | let(:relation) { Post.all } 7 | let(:params) { {} } 8 | 9 | describe '.new' do 10 | context 'when passing valid parameters' do 11 | shared_examples 'for a working combination with `order` param' do 12 | context 'and custom order' do 13 | context 'set to ascending' do 14 | let(:params) { super().merge(order: :asc) } 15 | 16 | it { is_expected.to be_a described_class } 17 | end 18 | 19 | context 'set to descending' do 20 | let(:params) { super().merge(order: :desc) } 21 | 22 | it { is_expected.to be_a described_class } 23 | end 24 | end 25 | end 26 | 27 | shared_examples 'for a working combination with `order_by` param' do 28 | context 'and custom order_by' do 29 | let(:params) { super().merge(order_by: :author) } 30 | 31 | it { is_expected.to be_a described_class } 32 | 33 | include_examples 'for a working combination with `order` param' 34 | end 35 | end 36 | 37 | shared_examples 'for a working parameter combination' do 38 | it { is_expected.to be_a described_class } 39 | 40 | include_examples 'for a working combination with `order` param' 41 | include_examples 'for a working combination with `order_by` param' 42 | end 43 | 44 | context 'when only passing the relation' do 45 | include_examples 'for a working parameter combination' 46 | end 47 | 48 | context 'when passing only first' do 49 | let(:params) { { first: 2 } } 50 | 51 | include_examples 'for a working parameter combination' 52 | end 53 | 54 | context 'when passing first and after' do 55 | let(:params) { { first: 2, after: 'abc' } } 56 | 57 | include_examples 'for a working parameter combination' 58 | end 59 | 60 | context 'when passing last and before' do 61 | let(:params) { { last: 2, before: 'xyz' } } 62 | 63 | include_examples 'for a working parameter combination' 64 | end 65 | end 66 | 67 | context 'when passing invalid parameters' do 68 | shared_examples 'for a ParameterError with the right message' do |message| 69 | it 'raises an error with the right message' do 70 | expect { subject } 71 | .to raise_error RailsCursorPagination::ParameterError, 72 | message 73 | end 74 | end 75 | 76 | context 'not passing an ActiveRecord::Relation as first argument' do 77 | let(:relation) { :tasty_cookies } 78 | 79 | include_examples 'for a ParameterError with the right message', 80 | 'The first argument must be an ' \ 81 | 'ActiveRecord::Relation, but was the Symbol ' \ 82 | '`:tasty_cookies`' 83 | end 84 | 85 | context 'passing an invalid `order`' do 86 | let(:params) { super().merge(order: :happiness) } 87 | 88 | include_examples 'for a ParameterError with the right message', 89 | '`order` must be either :asc or :desc, but was ' \ 90 | '`happiness`' 91 | end 92 | 93 | context 'passing both `first` and `limit`' do 94 | let(:params) { super().merge(first: 2, limit: 3) } 95 | 96 | include_examples 'for a ParameterError with the right message', 97 | '`limit` cannot be combined with `first`' 98 | end 99 | 100 | context 'passing both `last` and `limit`' do 101 | let(:params) { super().merge(last: 2, limit: 3) } 102 | 103 | include_examples 'for a ParameterError with the right message', 104 | '`limit` cannot be combined with `last`' 105 | end 106 | 107 | context 'passing both `first` and `last`' do 108 | let(:params) { super().merge(first: 2, last: 3) } 109 | 110 | include_examples 'for a ParameterError with the right message', 111 | '`first` cannot be combined with `last`' 112 | end 113 | 114 | context 'passing both `before` and `after`' do 115 | let(:params) { super().merge(before: 'qwe', after: 'asd') } 116 | 117 | include_examples 'for a ParameterError with the right message', 118 | '`before` cannot be combined with `after`' 119 | end 120 | 121 | context 'passing only `last` without `after`' do 122 | let(:params) { super().merge(last: 5) } 123 | 124 | include_examples 'for a ParameterError with the right message', 125 | '`last` must be combined with `before`' 126 | end 127 | 128 | context 'passing a negative `limit`' do 129 | let(:params) { super().merge(limit: -10) } 130 | 131 | include_examples 'for a ParameterError with the right message', 132 | '`limit` cannot be negative, but was `-10`' 133 | end 134 | 135 | context 'passing a negative `first`' do 136 | let(:params) { super().merge(first: -7) } 137 | 138 | include_examples 'for a ParameterError with the right message', 139 | '`first` cannot be negative, but was `-7`' 140 | end 141 | 142 | context 'passing a negative `last`' do 143 | let(:params) { super().merge(last: -4, before: 'qwe') } 144 | 145 | include_examples 'for a ParameterError with the right message', 146 | '`last` cannot be negative, but was `-4`' 147 | end 148 | end 149 | end 150 | 151 | describe '#fetch' do 152 | subject(:result) { instance.fetch } 153 | 154 | let(:post_1) { Post.create! id: 1, author: 'John', content: 'Post 1' } 155 | let(:post_2) { Post.create! id: 2, author: 'Jane', content: 'Post 2' } 156 | let(:post_3) { Post.create! id: 3, author: 'Jane', content: 'Post 3' } 157 | let(:post_4) { Post.create! id: 4, author: 'John', content: 'Post 4' } 158 | let(:post_5) { Post.create! id: 5, author: 'Jane', content: 'Post 5' } 159 | let(:post_6) { Post.create! id: 6, author: 'John', content: 'Post 6' } 160 | let(:post_7) { Post.create! id: 7, author: 'Jane', content: 'Post 7' } 161 | let(:post_8) { Post.create! id: 8, author: 'John', content: 'Post 8' } 162 | let(:post_9) { Post.create! id: 9, author: 'Jess', content: 'Post 9' } 163 | let(:post_10) { Post.create! id: 10, author: 'Jess', content: 'Post 10' } 164 | let(:post_11) { Post.create! id: 11, author: 'John', content: 'Post 11' } 165 | let(:post_12) { Post.create! id: 12, author: 'John', content: 'Post 12' } 166 | let(:post_13) { Post.create! id: 13, author: 'Jane', content: 'Post 13' } 167 | 168 | let!(:posts) do 169 | [ 170 | post_1, 171 | post_2, 172 | post_3, 173 | post_4, 174 | post_5, 175 | post_6, 176 | post_7, 177 | post_8, 178 | post_9, 179 | post_10, 180 | post_11, 181 | post_12, 182 | post_13 183 | ] 184 | end 185 | 186 | shared_examples_for 'a query that works with a descending `order`' do 187 | let(:params) { super().merge(order: :desc) } 188 | 189 | it_behaves_like 'a well working query that also supports SELECT' 190 | end 191 | 192 | shared_examples_for 'a query that returns no data when relation is empty' do 193 | let(:relation) { Post.where(author: 'keks') } 194 | 195 | it_behaves_like 'a well working query that also supports SELECT' do 196 | let(:expected_posts) { [] } 197 | let(:expected_has_next_page) { false } 198 | let(:expected_has_previous_page) { false } 199 | let(:expected_total) { 0 } 200 | end 201 | end 202 | 203 | context 'when order_by is not a timestamp' do 204 | let(:posts_by_order_by_column) do 205 | # Posts are first ordered by the author's name and then, in case of two 206 | # posts having the same author, by ID 207 | [ 208 | # All posts by "Jane" 209 | post_2, 210 | post_3, 211 | post_5, 212 | post_7, 213 | post_13, 214 | # All posts by "Jess" 215 | post_9, 216 | post_10, 217 | # All posts by "John" 218 | post_1, 219 | post_4, 220 | post_6, 221 | post_8, 222 | post_11, 223 | post_12 224 | ] 225 | end 226 | 227 | let(:cursor_object) { nil } 228 | let(:cursor_object_plain) { nil } 229 | let(:cursor_object_desc) { nil } 230 | let(:cursor_object_by_order_by_column) { nil } 231 | let(:cursor_object_by_order_by_column_desc) { nil } 232 | let(:query_cursor_base) { cursor_object&.id } 233 | let(:query_cursor) { Base64.strict_encode64(query_cursor_base.to_json) } 234 | let(:order_by_column) { nil } 235 | 236 | shared_examples_for 'a properly returned response' do 237 | let(:expected_start_cursor) do 238 | if expected_posts.any? 239 | Base64.strict_encode64( 240 | expected_cursor.call(expected_posts.first).to_json 241 | ) 242 | end 243 | end 244 | let(:expected_end_cursor) do 245 | if expected_posts.any? 246 | Base64.strict_encode64( 247 | expected_cursor.call(expected_posts.last).to_json 248 | ) 249 | end 250 | end 251 | let(:expected_attributes) do 252 | %i[id author content updated_at created_at] 253 | end 254 | 255 | it 'has the correct format' do 256 | is_expected.to be_a Hash 257 | is_expected.to have_key :page 258 | is_expected.to have_key :page_info 259 | end 260 | 261 | describe 'for :page_info' do 262 | subject { result[:page_info] } 263 | 264 | it 'includes all relevant meta info' do 265 | is_expected.to be_a Hash 266 | 267 | expect(subject.keys).to contain_exactly :has_previous_page, 268 | :has_next_page, 269 | :start_cursor, 270 | :end_cursor 271 | 272 | is_expected.to( 273 | include has_previous_page: expected_has_previous_page, 274 | has_next_page: expected_has_next_page, 275 | start_cursor: expected_start_cursor, 276 | end_cursor: expected_end_cursor 277 | ) 278 | end 279 | end 280 | 281 | describe 'for :page' do 282 | subject { result[:page] } 283 | 284 | let(:returned_parsed_cursors) do 285 | subject 286 | .pluck(:cursor) 287 | .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } 288 | end 289 | 290 | it 'contains the right data' do 291 | is_expected.to be_an Array 292 | is_expected.to all be_a Hash 293 | is_expected.to all include :cursor, :data 294 | 295 | expect(subject.pluck(:data)).to all be_a Post 296 | expect(subject.pluck(:data)).to match_array expected_posts 297 | expect(subject.pluck(:data)).to eq expected_posts 298 | expect(subject.pluck(:data).map(&:attributes).map(&:keys)) 299 | .to all match_array expected_attributes.map(&:to_s) 300 | 301 | expect(subject.pluck(:cursor)).to all be_a String 302 | expect(subject.pluck(:cursor)).to all be_present 303 | expect(returned_parsed_cursors) 304 | .to eq(expected_posts.map { |post| expected_cursor.call(post) }) 305 | end 306 | end 307 | 308 | it 'does not return the total by default' do 309 | is_expected.to be_a Hash 310 | is_expected.to_not have_key :total 311 | end 312 | 313 | context 'when passing `with_total: true`' do 314 | subject(:result) { instance.fetch(with_total: true) } 315 | 316 | it 'also includes the `total` of records' do 317 | is_expected.to have_key :total 318 | expect(subject[:total]).to eq expected_total 319 | end 320 | end 321 | end 322 | 323 | shared_examples_for 'a well working query that also supports SELECT' do 324 | context 'when SELECTing all columns' do 325 | context 'without calling select' do 326 | it_behaves_like 'a properly returned response' 327 | end 328 | 329 | context 'including the "*" select' do 330 | let(:selected_attributes) { ['*'] } 331 | 332 | it_behaves_like 'a properly returned response' 333 | end 334 | end 335 | 336 | context 'when SELECTing only some columns' do 337 | let(:selected_attributes) { %i[id author] } 338 | let(:relation) { super().select(*selected_attributes) } 339 | 340 | it_behaves_like 'a properly returned response' do 341 | let(:expected_attributes) { %i[id author] } 342 | end 343 | 344 | context 'and not including any cursor-relevant column' do 345 | let(:selected_attributes) { %i[content] } 346 | 347 | it_behaves_like 'a properly returned response' do 348 | let(:expected_attributes) do 349 | %i[id content].tap do |attributes| 350 | attributes << order_by_column if order_by_column.present? 351 | end 352 | end 353 | end 354 | end 355 | end 356 | end 357 | 358 | shared_examples_for 'a query that works with `order_by` param' do 359 | let(:params) { super().merge(order_by: order_by_column) } 360 | let(:order_by_column) { :author } 361 | 362 | it_behaves_like 'a well working query that also supports SELECT' 363 | 364 | it_behaves_like 'a query that works with a descending `order`' do 365 | let(:cursor_object) { cursor_object_desc } 366 | 367 | let(:expected_posts) { expected_posts_desc } 368 | end 369 | end 370 | 371 | shared_examples 'for a working query' do 372 | let(:expected_total) { relation.size } 373 | 374 | it_behaves_like 'a well working query that also supports SELECT' do 375 | let(:cursor_object) { cursor_object_plain } 376 | let(:query_cursor_base) { cursor_object&.id } 377 | 378 | let(:expected_posts) { expected_posts_plain } 379 | let(:expected_cursor) { ->(post) { post.id } } 380 | end 381 | 382 | it_behaves_like 'a query that works with a descending `order`' do 383 | let(:cursor_object) { cursor_object_desc } 384 | let(:query_cursor_base) { cursor_object&.id } 385 | 386 | let(:expected_posts) { expected_posts_desc } 387 | let(:expected_cursor) { ->(post) { post.id } } 388 | end 389 | 390 | it_behaves_like 'a query that works with `order_by` param' do 391 | let(:cursor_object) { cursor_object_by_order_by_column } 392 | let(:cursor_object_desc) { cursor_object_by_order_by_column_desc } 393 | let(:query_cursor_base) do 394 | [cursor_object&.send(order_by_column), cursor_object&.id] 395 | end 396 | 397 | let(:expected_posts) { expected_posts_by_order_by_column } 398 | let(:expected_posts_desc) { expected_posts_by_order_by_column_desc } 399 | let(:expected_cursor) do 400 | lambda { |post| 401 | [post.send(order_by_column), post.id] 402 | } 403 | end 404 | end 405 | 406 | it_behaves_like 'a query that returns no data when relation is empty' 407 | end 408 | 409 | context 'when neither first/last/limit nor before/after are passed' do 410 | include_examples 'for a working query' do 411 | let(:expected_posts_plain) { posts.first(10) } 412 | let(:expected_posts_desc) { posts.reverse.first(10) } 413 | 414 | let(:expected_posts_by_order_by_column) do 415 | posts_by_order_by_column.first(10) 416 | end 417 | let(:expected_posts_by_order_by_column_desc) do 418 | posts_by_order_by_column.reverse.first(10) 419 | end 420 | 421 | let(:expected_has_next_page) { true } 422 | let(:expected_has_previous_page) { false } 423 | end 424 | 425 | context 'when a different default_page_size has been set' do 426 | let(:custom_page_size) { 2 } 427 | 428 | before do 429 | RailsCursorPagination.configure do |config| 430 | config.default_page_size = custom_page_size 431 | end 432 | end 433 | 434 | after { RailsCursorPagination.configure(&:reset!) } 435 | 436 | include_examples 'for a working query' do 437 | let(:expected_posts_plain) { posts.first(custom_page_size) } 438 | let(:expected_posts_desc) { posts.reverse.first(custom_page_size) } 439 | 440 | let(:expected_posts_by_order_by_column) do 441 | posts_by_order_by_column.first(custom_page_size) 442 | end 443 | let(:expected_posts_by_order_by_column_desc) do 444 | posts_by_order_by_column.reverse.first(custom_page_size) 445 | end 446 | 447 | let(:expected_has_next_page) { true } 448 | let(:expected_has_previous_page) { false } 449 | end 450 | end 451 | 452 | context 'when a max_page_size has been set' do 453 | let(:max_page_size) { 2 } 454 | 455 | before do 456 | RailsCursorPagination.configure do |config| 457 | config.max_page_size = max_page_size 458 | end 459 | end 460 | 461 | after { RailsCursorPagination.configure(&:reset!) } 462 | 463 | include_examples 'for a working query' do 464 | let(:expected_posts_plain) { posts.first(max_page_size) } 465 | let(:expected_posts_desc) { posts.reverse.first(max_page_size) } 466 | 467 | let(:expected_posts_by_order_by_column) do 468 | posts_by_order_by_column.first(max_page_size) 469 | end 470 | let(:expected_posts_by_order_by_column_desc) do 471 | posts_by_order_by_column.reverse.first(max_page_size) 472 | end 473 | 474 | let(:expected_has_next_page) { true } 475 | let(:expected_has_previous_page) { false } 476 | end 477 | 478 | context 'when attempting to go over the limit' do 479 | let(:params) { { first: 5 } } 480 | 481 | include_examples 'for a working query' do 482 | let(:expected_posts_plain) { posts.first(max_page_size) } 483 | let(:expected_posts_desc) { posts.reverse.first(max_page_size) } 484 | 485 | let(:expected_posts_by_order_by_column) do 486 | posts_by_order_by_column.first(max_page_size) 487 | end 488 | let(:expected_posts_by_order_by_column_desc) do 489 | posts_by_order_by_column.reverse.first(max_page_size) 490 | end 491 | 492 | let(:expected_has_next_page) { true } 493 | let(:expected_has_previous_page) { false } 494 | end 495 | end 496 | end 497 | 498 | context 'when `order` and `order_by` are explicitly set to `nil`' do 499 | let(:params) { super().merge(order: nil, order_by: nil) } 500 | 501 | it_behaves_like 'a well working query that also supports SELECT' do 502 | let(:expected_posts) { posts.first(10) } 503 | let(:expected_cursor) { ->(post) { post.id } } 504 | 505 | let(:expected_has_next_page) { true } 506 | let(:expected_has_previous_page) { false } 507 | end 508 | end 509 | end 510 | 511 | context 'when only passing first' do 512 | let(:params) { { first: 2 } } 513 | 514 | include_examples 'for a working query' do 515 | let(:expected_posts_plain) { posts.first(2) } 516 | let(:expected_posts_desc) { posts.reverse.first(2) } 517 | 518 | let(:expected_posts_by_order_by_column) do 519 | posts_by_order_by_column.first(2) 520 | end 521 | let(:expected_posts_by_order_by_column_desc) do 522 | posts_by_order_by_column.reverse.first(2) 523 | end 524 | 525 | let(:expected_has_next_page) { true } 526 | let(:expected_has_previous_page) { false } 527 | end 528 | 529 | context 'when there are less records than requested' do 530 | let(:params) { { first: posts.size + 1 } } 531 | 532 | include_examples 'for a working query' do 533 | let(:expected_posts_plain) { posts } 534 | let(:expected_posts_desc) { posts.reverse } 535 | 536 | let(:expected_posts_by_order_by_column) { posts_by_order_by_column } 537 | let(:expected_posts_by_order_by_column_desc) do 538 | posts_by_order_by_column.reverse 539 | end 540 | 541 | let(:expected_has_next_page) { false } 542 | let(:expected_has_previous_page) { false } 543 | end 544 | end 545 | end 546 | 547 | context 'when only passing limit' do 548 | let(:params) { { limit: 2 } } 549 | 550 | include_examples 'for a working query' do 551 | let(:expected_posts_plain) { posts.first(2) } 552 | let(:expected_posts_desc) { posts.reverse.first(2) } 553 | 554 | let(:expected_posts_by_order_by_column) do 555 | posts_by_order_by_column.first(2) 556 | end 557 | let(:expected_posts_by_order_by_column_desc) do 558 | posts_by_order_by_column.reverse.first(2) 559 | end 560 | 561 | let(:expected_has_next_page) { true } 562 | let(:expected_has_previous_page) { false } 563 | end 564 | 565 | context 'when there are less records than requested' do 566 | let(:params) { { first: posts.size + 1 } } 567 | 568 | include_examples 'for a working query' do 569 | let(:expected_posts_plain) { posts } 570 | let(:expected_posts_desc) { posts.reverse } 571 | 572 | let(:expected_posts_by_order_by_column) { posts_by_order_by_column } 573 | let(:expected_posts_by_order_by_column_desc) do 574 | posts_by_order_by_column.reverse 575 | end 576 | 577 | let(:expected_has_next_page) { false } 578 | let(:expected_has_previous_page) { false } 579 | end 580 | end 581 | end 582 | 583 | context 'when passing `after`' do 584 | let(:params) { { after: query_cursor } } 585 | 586 | include_examples 'for a working query' do 587 | let(:cursor_object_plain) { posts[0] } 588 | let(:expected_posts_plain) { posts[1..10] } 589 | 590 | let(:cursor_object_desc) { posts[-1] } 591 | let(:expected_posts_desc) { posts[-11..-2].reverse } 592 | 593 | let(:cursor_object_by_order_by_column) { posts_by_order_by_column[0] } 594 | let(:expected_posts_by_order_by_column) do 595 | posts_by_order_by_column[1..10] 596 | end 597 | 598 | let(:cursor_object_by_order_by_column_desc) do 599 | posts_by_order_by_column[-1] 600 | end 601 | let(:expected_posts_by_order_by_column_desc) do 602 | posts_by_order_by_column[-11..-2].reverse 603 | end 604 | 605 | let(:expected_has_next_page) { true } 606 | let(:expected_has_previous_page) { true } 607 | end 608 | 609 | context 'and `first`' do 610 | let(:params) { super().merge(first: 2) } 611 | 612 | include_examples 'for a working query' do 613 | let(:cursor_object_plain) { posts[2] } 614 | let(:expected_posts_plain) { posts[3..4] } 615 | 616 | let(:cursor_object_desc) { posts[-2] } 617 | let(:expected_posts_desc) { posts[-4..-3].reverse } 618 | 619 | let(:cursor_object_by_order_by_column) do 620 | posts_by_order_by_column[2] 621 | end 622 | let(:expected_posts_by_order_by_column) do 623 | posts_by_order_by_column[3..4] 624 | end 625 | 626 | let(:cursor_object_by_order_by_column_desc) do 627 | posts_by_order_by_column[-2] 628 | end 629 | let(:expected_posts_by_order_by_column_desc) do 630 | posts_by_order_by_column[-4..-3].reverse 631 | end 632 | 633 | let(:expected_has_next_page) { true } 634 | let(:expected_has_previous_page) { true } 635 | end 636 | 637 | context 'when not enough records are remaining after cursor' do 638 | include_examples 'for a working query' do 639 | let(:cursor_object_plain) { posts[-2] } 640 | let(:expected_posts_plain) { posts[-1..] } 641 | 642 | let(:cursor_object_desc) { posts[1] } 643 | let(:expected_posts_desc) { posts[0..0].reverse } 644 | 645 | let(:cursor_object_by_order_by_column) do 646 | posts_by_order_by_column[-2] 647 | end 648 | let(:expected_posts_by_order_by_column) do 649 | posts_by_order_by_column[-1..] 650 | end 651 | 652 | let(:cursor_object_by_order_by_column_desc) do 653 | posts_by_order_by_column[1] 654 | end 655 | let(:expected_posts_by_order_by_column_desc) do 656 | posts_by_order_by_column[0..0].reverse 657 | end 658 | 659 | let(:expected_has_next_page) { false } 660 | let(:expected_has_previous_page) { true } 661 | end 662 | end 663 | end 664 | 665 | context 'and `limit`' do 666 | let(:params) { super().merge(limit: 2) } 667 | 668 | include_examples 'for a working query' do 669 | let(:cursor_object_plain) { posts[2] } 670 | let(:expected_posts_plain) { posts[3..4] } 671 | 672 | let(:cursor_object_desc) { posts[-2] } 673 | let(:expected_posts_desc) { posts[-4..-3].reverse } 674 | 675 | let(:cursor_object_by_order_by_column) do 676 | posts_by_order_by_column[2] 677 | end 678 | let(:expected_posts_by_order_by_column) do 679 | posts_by_order_by_column[3..4] 680 | end 681 | 682 | let(:cursor_object_by_order_by_column_desc) do 683 | posts_by_order_by_column[-2] 684 | end 685 | let(:expected_posts_by_order_by_column_desc) do 686 | posts_by_order_by_column[-4..-3].reverse 687 | end 688 | 689 | let(:expected_has_next_page) { true } 690 | let(:expected_has_previous_page) { true } 691 | end 692 | 693 | context 'when not enough records are remaining after cursor' do 694 | include_examples 'for a working query' do 695 | let(:cursor_object_plain) { posts[-2] } 696 | let(:expected_posts_plain) { posts[-1..] } 697 | 698 | let(:cursor_object_desc) { posts[1] } 699 | let(:expected_posts_desc) { posts[0..0].reverse } 700 | 701 | let(:cursor_object_by_order_by_column) do 702 | posts_by_order_by_column[-2] 703 | end 704 | let(:expected_posts_by_order_by_column) do 705 | posts_by_order_by_column[-1..] 706 | end 707 | 708 | let(:cursor_object_by_order_by_column_desc) do 709 | posts_by_order_by_column[1] 710 | end 711 | let(:expected_posts_by_order_by_column_desc) do 712 | posts_by_order_by_column[0..0].reverse 713 | end 714 | 715 | let(:expected_has_next_page) { false } 716 | let(:expected_has_previous_page) { true } 717 | end 718 | end 719 | end 720 | end 721 | 722 | context 'when passing `before`' do 723 | let(:params) { { before: query_cursor } } 724 | 725 | include_examples 'for a working query' do 726 | let(:cursor_object_plain) { posts[-1] } 727 | let(:expected_posts_plain) { posts[-11..-2] } 728 | 729 | let(:cursor_object_desc) { posts[0] } 730 | let(:expected_posts_desc) { posts[1..10].reverse } 731 | 732 | let(:cursor_object_by_order_by_column) do 733 | posts_by_order_by_column[-1] 734 | end 735 | let(:expected_posts_by_order_by_column) do 736 | posts_by_order_by_column[-11..-2] 737 | end 738 | 739 | let(:cursor_object_by_order_by_column_desc) do 740 | posts_by_order_by_column[0] 741 | end 742 | let(:expected_posts_by_order_by_column_desc) do 743 | posts_by_order_by_column[1..10].reverse 744 | end 745 | 746 | let(:expected_has_next_page) { true } 747 | let(:expected_has_previous_page) { true } 748 | end 749 | 750 | context 'and `last`' do 751 | let(:params) { super().merge(last: 2) } 752 | 753 | include_examples 'for a working query' do 754 | let(:cursor_object_plain) { posts[-1] } 755 | let(:expected_posts_plain) { posts[-3..-2] } 756 | 757 | let(:cursor_object_desc) { posts[2] } 758 | let(:expected_posts_desc) { posts[3..4].reverse } 759 | 760 | let(:cursor_object_by_order_by_column) do 761 | posts_by_order_by_column[-1] 762 | end 763 | let(:expected_posts_by_order_by_column) do 764 | posts_by_order_by_column[-3..-2] 765 | end 766 | 767 | let(:cursor_object_by_order_by_column_desc) do 768 | posts_by_order_by_column[2] 769 | end 770 | let(:expected_posts_by_order_by_column_desc) do 771 | posts_by_order_by_column[3..4].reverse 772 | end 773 | 774 | let(:expected_has_next_page) { true } 775 | let(:expected_has_previous_page) { true } 776 | end 777 | 778 | context 'when not enough records are remaining before cursor' do 779 | include_examples 'for a working query' do 780 | let(:cursor_object_plain) { posts[1] } 781 | let(:expected_posts_plain) { posts[0..0] } 782 | 783 | let(:cursor_object_desc) { posts[-2] } 784 | let(:expected_posts_desc) { posts[-1..].reverse } 785 | 786 | let(:cursor_object_by_order_by_column) do 787 | posts_by_order_by_column[1] 788 | end 789 | let(:expected_posts_by_order_by_column) do 790 | posts_by_order_by_column[0..0] 791 | end 792 | 793 | let(:cursor_object_by_order_by_column_desc) do 794 | posts_by_order_by_column[-2] 795 | end 796 | let(:expected_posts_by_order_by_column_desc) do 797 | posts_by_order_by_column[-1..].reverse 798 | end 799 | 800 | let(:expected_has_next_page) { true } 801 | let(:expected_has_previous_page) { false } 802 | end 803 | end 804 | end 805 | 806 | context 'and `limit`' do 807 | let(:params) { super().merge(limit: 2) } 808 | 809 | include_examples 'for a working query' do 810 | let(:cursor_object_plain) { posts[-1] } 811 | let(:expected_posts_plain) { posts[-3..-2] } 812 | 813 | let(:cursor_object_desc) { posts[2] } 814 | let(:expected_posts_desc) { posts[3..4].reverse } 815 | 816 | let(:cursor_object_by_order_by_column) do 817 | posts_by_order_by_column[-1] 818 | end 819 | let(:expected_posts_by_order_by_column) do 820 | posts_by_order_by_column[-3..-2] 821 | end 822 | 823 | let(:cursor_object_by_order_by_column_desc) do 824 | posts_by_order_by_column[2] 825 | end 826 | let(:expected_posts_by_order_by_column_desc) do 827 | posts_by_order_by_column[3..4].reverse 828 | end 829 | 830 | let(:expected_has_next_page) { true } 831 | let(:expected_has_previous_page) { true } 832 | end 833 | 834 | context 'when not enough records are remaining before cursor' do 835 | include_examples 'for a working query' do 836 | let(:cursor_object_plain) { posts[1] } 837 | let(:expected_posts_plain) { posts[0..0] } 838 | 839 | let(:cursor_object_desc) { posts[-2] } 840 | let(:expected_posts_desc) { posts[-1..].reverse } 841 | 842 | let(:cursor_object_by_order_by_column) do 843 | posts_by_order_by_column[1] 844 | end 845 | let(:expected_posts_by_order_by_column) do 846 | posts_by_order_by_column[0..0] 847 | end 848 | 849 | let(:cursor_object_by_order_by_column_desc) do 850 | posts_by_order_by_column[-2] 851 | end 852 | let(:expected_posts_by_order_by_column_desc) do 853 | posts_by_order_by_column[-1..].reverse 854 | end 855 | 856 | let(:expected_has_next_page) { true } 857 | let(:expected_has_previous_page) { false } 858 | end 859 | end 860 | end 861 | end 862 | end 863 | 864 | context 'when order_by is a timestamp' do 865 | let(:posts_by_order_by_column) do 866 | # Posts are first ordered by the created_at 867 | [ 868 | post_1, 869 | post_2, 870 | post_3, 871 | post_4, 872 | post_5, 873 | post_6, 874 | post_7, 875 | post_8, 876 | post_9, 877 | post_10, 878 | post_11, 879 | post_12, 880 | post_13 881 | ] 882 | end 883 | 884 | let(:cursor_object) { nil } 885 | let(:cursor_object_plain) { nil } 886 | let(:cursor_object_desc) { nil } 887 | let(:cursor_object_by_order_by_column) { nil } 888 | let(:cursor_object_by_order_by_column_desc) { nil } 889 | let(:query_cursor_base) { cursor_object&.id } 890 | let(:query_cursor) { Base64.strict_encode64(query_cursor_base.to_json) } 891 | let(:order_by_column) { nil } 892 | 893 | shared_examples_for 'a properly returned response' do 894 | let(:expected_start_cursor) do 895 | if expected_posts.any? 896 | Base64.strict_encode64( 897 | expected_cursor.call(expected_posts.first).to_json 898 | ) 899 | end 900 | end 901 | let(:expected_end_cursor) do 902 | if expected_posts.any? 903 | Base64.strict_encode64( 904 | expected_cursor.call(expected_posts.last).to_json 905 | ) 906 | end 907 | end 908 | let(:expected_attributes) do 909 | %i[id author content updated_at created_at] 910 | end 911 | 912 | it 'has the correct format' do 913 | is_expected.to be_a Hash 914 | is_expected.to have_key :page 915 | is_expected.to have_key :page_info 916 | end 917 | 918 | describe 'for :page_info' do 919 | subject { result[:page_info] } 920 | 921 | it 'includes all relevant meta info' do 922 | is_expected.to be_a Hash 923 | 924 | expect(subject.keys).to contain_exactly :has_previous_page, 925 | :has_next_page, 926 | :start_cursor, 927 | :end_cursor 928 | 929 | is_expected.to( 930 | include has_previous_page: expected_has_previous_page, 931 | has_next_page: expected_has_next_page, 932 | start_cursor: expected_start_cursor, 933 | end_cursor: expected_end_cursor 934 | ) 935 | end 936 | end 937 | 938 | describe 'for :page' do 939 | subject { result[:page] } 940 | 941 | let(:returned_parsed_cursors) do 942 | subject 943 | .pluck(:cursor) 944 | .map { |cursor| JSON.parse(Base64.strict_decode64(cursor)) } 945 | end 946 | 947 | it 'contains the right data' do 948 | is_expected.to be_an Array 949 | is_expected.to all be_a Hash 950 | is_expected.to all include :cursor, :data 951 | 952 | expect(subject.pluck(:data)).to all be_a Post 953 | expect(subject.pluck(:data)).to match_array expected_posts 954 | expect(subject.pluck(:data)).to eq expected_posts 955 | 956 | expect(subject.pluck(:data).map(&:attributes).map(&:keys)) 957 | .to all match_array expected_attributes.map(&:to_s) 958 | 959 | expect(subject.pluck(:cursor)).to all be_a String 960 | expect(subject.pluck(:cursor)).to all be_present 961 | expect(returned_parsed_cursors) 962 | .to eq(expected_posts.map { |post| expected_cursor.call(post) }) 963 | end 964 | end 965 | 966 | it 'does not return the total by default' do 967 | is_expected.to be_a Hash 968 | is_expected.to_not have_key :total 969 | end 970 | 971 | context 'when passing `with_total: true`' do 972 | subject(:result) { instance.fetch(with_total: true) } 973 | 974 | it 'also includes the `total` of records' do 975 | is_expected.to have_key :total 976 | expect(subject[:total]).to eq expected_total 977 | end 978 | end 979 | end 980 | 981 | shared_examples_for 'a well working query that also supports SELECT' do 982 | context 'when SELECTing all columns' do 983 | context 'without calling select' do 984 | it_behaves_like 'a properly returned response' 985 | end 986 | 987 | context 'including the "*" select' do 988 | let(:selected_attributes) { ['*'] } 989 | 990 | it_behaves_like 'a properly returned response' 991 | end 992 | end 993 | 994 | context 'when SELECTing only some columns' do 995 | let(:selected_attributes) { %i[id created_at] } 996 | let(:relation) { super().select(*selected_attributes) } 997 | 998 | it_behaves_like 'a properly returned response' do 999 | let(:expected_attributes) { %i[id created_at] } 1000 | end 1001 | 1002 | context 'and not including any cursor-relevant column' do 1003 | let(:selected_attributes) { %i[content author] } 1004 | 1005 | it_behaves_like 'a properly returned response' do 1006 | let(:expected_attributes) do 1007 | %i[id content author].tap do |attributes| 1008 | attributes << order_by_column if order_by_column.present? 1009 | end 1010 | end 1011 | end 1012 | end 1013 | end 1014 | end 1015 | 1016 | shared_examples_for 'a query ordered by a timestamp column' do 1017 | let(:params) { super().merge(order_by: :created_at) } 1018 | let(:order_by_column) { :created_at } 1019 | 1020 | it_behaves_like 'a well working query that also supports SELECT' 1021 | 1022 | it_behaves_like 'a query that works with a descending `order`' do 1023 | let(:cursor_object) { cursor_object_desc } 1024 | 1025 | let(:expected_posts) { expected_posts_desc } 1026 | end 1027 | end 1028 | 1029 | shared_examples 'a working query ordered by a timestamp column' do 1030 | let(:expected_total) { relation.size } 1031 | 1032 | it_behaves_like 'a well working query that also supports SELECT' do 1033 | let(:cursor_object) { cursor_object_plain } 1034 | let(:query_cursor_base) { cursor_object&.id } 1035 | 1036 | let(:expected_posts) { expected_posts_plain } 1037 | let(:expected_cursor) { ->(post) { post.id } } 1038 | end 1039 | 1040 | it_behaves_like 'a query that works with a descending `order`' do 1041 | let(:cursor_object) { cursor_object_desc } 1042 | let(:query_cursor_base) { cursor_object&.id } 1043 | 1044 | let(:expected_posts) { expected_posts_desc } 1045 | let(:expected_cursor) { ->(post) { post.id } } 1046 | end 1047 | 1048 | it_behaves_like 'a query ordered by a timestamp column' do 1049 | let(:cursor_object) { cursor_object_by_order_by_column } 1050 | let(:cursor_object_desc) { cursor_object_by_order_by_column_desc } 1051 | let(:query_cursor_base) do 1052 | [ 1053 | cursor_object&.created_at&.strftime('%s%6N')&.to_i, 1054 | cursor_object&.id 1055 | ] 1056 | end 1057 | let(:expected_posts) { expected_posts_by_order_by_column } 1058 | let(:expected_posts_desc) { expected_posts_by_order_by_column_desc } 1059 | let(:expected_cursor) do 1060 | lambda { |post| 1061 | [ 1062 | post.created_at.strftime('%s%6N').to_i, 1063 | post.id 1064 | ] 1065 | } 1066 | end 1067 | end 1068 | 1069 | it_behaves_like 'a query that returns no data when relation is empty' 1070 | end 1071 | 1072 | context 'when neither first/last/limit nor before/after are passed' do 1073 | include_examples 'a working query ordered by a timestamp column' do 1074 | let(:expected_posts_plain) { posts.first(10) } 1075 | let(:expected_posts_desc) { posts.reverse.first(10) } 1076 | 1077 | let(:expected_posts_by_order_by_column) do 1078 | posts_by_order_by_column.first(10) 1079 | end 1080 | let(:expected_posts_by_order_by_column_desc) do 1081 | posts_by_order_by_column.reverse.first(10) 1082 | end 1083 | 1084 | let(:expected_has_next_page) { true } 1085 | let(:expected_has_previous_page) { false } 1086 | end 1087 | 1088 | context 'when a different default_page_size has been set' do 1089 | let(:custom_page_size) { 2 } 1090 | 1091 | before do 1092 | RailsCursorPagination.configure do |config| 1093 | config.default_page_size = custom_page_size 1094 | end 1095 | end 1096 | 1097 | after { RailsCursorPagination.configure(&:reset!) } 1098 | 1099 | include_examples 'a working query ordered by a timestamp column' do 1100 | let(:expected_posts_plain) { posts.first(custom_page_size) } 1101 | let(:expected_posts_desc) { posts.reverse.first(custom_page_size) } 1102 | 1103 | let(:expected_posts_by_order_by_column) do 1104 | posts_by_order_by_column.first(custom_page_size) 1105 | end 1106 | let(:expected_posts_by_order_by_column_desc) do 1107 | posts_by_order_by_column.reverse.first(custom_page_size) 1108 | end 1109 | 1110 | let(:expected_has_next_page) { true } 1111 | let(:expected_has_previous_page) { false } 1112 | end 1113 | end 1114 | 1115 | context 'when a max_page_size has been set' do 1116 | let(:max_page_size) { 2 } 1117 | 1118 | before do 1119 | RailsCursorPagination.configure do |config| 1120 | config.max_page_size = max_page_size 1121 | end 1122 | end 1123 | 1124 | after { RailsCursorPagination.configure(&:reset!) } 1125 | 1126 | include_examples 'a working query ordered by a timestamp column' do 1127 | let(:expected_posts_plain) { posts.first(max_page_size) } 1128 | let(:expected_posts_desc) { posts.reverse.first(max_page_size) } 1129 | 1130 | let(:expected_posts_by_order_by_column) do 1131 | posts_by_order_by_column.first(max_page_size) 1132 | end 1133 | let(:expected_posts_by_order_by_column_desc) do 1134 | posts_by_order_by_column.reverse.first(max_page_size) 1135 | end 1136 | 1137 | let(:expected_has_next_page) { true } 1138 | let(:expected_has_previous_page) { false } 1139 | end 1140 | 1141 | context 'when attempting to go over the limit' do 1142 | let(:params) { { first: 5 } } 1143 | 1144 | include_examples 'a working query ordered by a timestamp column' do 1145 | let(:expected_posts_plain) { posts.first(max_page_size) } 1146 | let(:expected_posts_desc) { posts.reverse.first(max_page_size) } 1147 | 1148 | let(:expected_posts_by_order_by_column) do 1149 | posts_by_order_by_column.first(max_page_size) 1150 | end 1151 | let(:expected_posts_by_order_by_column_desc) do 1152 | posts_by_order_by_column.reverse.first(max_page_size) 1153 | end 1154 | 1155 | let(:expected_has_next_page) { true } 1156 | let(:expected_has_previous_page) { false } 1157 | end 1158 | end 1159 | end 1160 | 1161 | context 'when `order` and `order_by` are explicitly set to `nil`' do 1162 | let(:params) { super().merge(order: nil, order_by: nil) } 1163 | 1164 | it_behaves_like 'a well working query that also supports SELECT' do 1165 | let(:expected_posts) { posts.first(10) } 1166 | let(:expected_cursor) { ->(post) { post.id } } 1167 | 1168 | let(:expected_has_next_page) { true } 1169 | let(:expected_has_previous_page) { false } 1170 | end 1171 | end 1172 | end 1173 | 1174 | context 'when only passing first' do 1175 | let(:params) { { first: 2 } } 1176 | 1177 | include_examples 'a working query ordered by a timestamp column' do 1178 | let(:expected_posts_plain) { posts.first(2) } 1179 | let(:expected_posts_desc) { posts.reverse.first(2) } 1180 | 1181 | let(:expected_posts_by_order_by_column) do 1182 | posts_by_order_by_column.first(2) 1183 | end 1184 | let(:expected_posts_by_order_by_column_desc) do 1185 | posts_by_order_by_column.reverse.first(2) 1186 | end 1187 | 1188 | let(:expected_has_next_page) { true } 1189 | let(:expected_has_previous_page) { false } 1190 | end 1191 | 1192 | context 'when there are less records than requested' do 1193 | let(:params) { { first: posts.size + 1 } } 1194 | 1195 | include_examples 'a working query ordered by a timestamp column' do 1196 | let(:expected_posts_plain) { posts } 1197 | let(:expected_posts_desc) { posts.reverse } 1198 | 1199 | let(:expected_posts_by_order_by_column) do 1200 | posts_by_order_by_column 1201 | end 1202 | let(:expected_posts_by_order_by_column_desc) do 1203 | posts_by_order_by_column.reverse 1204 | end 1205 | 1206 | let(:expected_has_next_page) { false } 1207 | let(:expected_has_previous_page) { false } 1208 | end 1209 | end 1210 | end 1211 | 1212 | context 'when only passing limit' do 1213 | let(:params) { { limit: 2 } } 1214 | 1215 | include_examples 'a working query ordered by a timestamp column' do 1216 | let(:expected_posts_plain) { posts.first(2) } 1217 | let(:expected_posts_desc) { posts.reverse.first(2) } 1218 | 1219 | let(:expected_posts_by_order_by_column) do 1220 | posts_by_order_by_column.first(2) 1221 | end 1222 | let(:expected_posts_by_order_by_column_desc) do 1223 | posts_by_order_by_column.reverse.first(2) 1224 | end 1225 | 1226 | let(:expected_has_next_page) { true } 1227 | let(:expected_has_previous_page) { false } 1228 | end 1229 | 1230 | context 'when there are less records than requested' do 1231 | let(:params) { { first: posts.size + 1 } } 1232 | 1233 | include_examples 'a working query ordered by a timestamp column' do 1234 | let(:expected_posts_plain) { posts } 1235 | let(:expected_posts_desc) { posts.reverse } 1236 | 1237 | let(:expected_posts_by_order_by_column) do 1238 | posts_by_order_by_column 1239 | end 1240 | let(:expected_posts_by_order_by_column_desc) do 1241 | posts_by_order_by_column.reverse 1242 | end 1243 | 1244 | let(:expected_has_next_page) { false } 1245 | let(:expected_has_previous_page) { false } 1246 | end 1247 | end 1248 | end 1249 | 1250 | context 'when passing `after`' do 1251 | let(:params) { { after: query_cursor } } 1252 | 1253 | include_examples 'a working query ordered by a timestamp column' do 1254 | let(:cursor_object_plain) { posts[0] } 1255 | let(:expected_posts_plain) { posts[1..10] } 1256 | 1257 | let(:cursor_object_desc) { posts[-1] } 1258 | let(:expected_posts_desc) { posts[-11..-2].reverse } 1259 | 1260 | let(:cursor_object_by_order_by_column) do 1261 | posts_by_order_by_column[0] 1262 | end 1263 | let(:expected_posts_by_order_by_column) do 1264 | posts_by_order_by_column[1..10] 1265 | end 1266 | 1267 | let(:cursor_object_by_order_by_column_desc) do 1268 | posts_by_order_by_column[-1] 1269 | end 1270 | let(:expected_posts_by_order_by_column_desc) do 1271 | posts_by_order_by_column[-11..-2].reverse 1272 | end 1273 | 1274 | let(:expected_has_next_page) { true } 1275 | let(:expected_has_previous_page) { true } 1276 | end 1277 | 1278 | context 'and `first`' do 1279 | let(:params) { super().merge(first: 2) } 1280 | 1281 | include_examples 'a working query ordered by a timestamp column' do 1282 | let(:cursor_object_plain) { posts[2] } 1283 | let(:expected_posts_plain) { posts[3..4] } 1284 | 1285 | let(:cursor_object_desc) { posts[-2] } 1286 | let(:expected_posts_desc) { posts[-4..-3].reverse } 1287 | 1288 | let(:cursor_object_by_order_by_column) do 1289 | posts_by_order_by_column[2] 1290 | end 1291 | let(:expected_posts_by_order_by_column) do 1292 | posts_by_order_by_column[3..4] 1293 | end 1294 | 1295 | let(:cursor_object_by_order_by_column_desc) do 1296 | posts_by_order_by_column[-2] 1297 | end 1298 | let(:expected_posts_by_order_by_column_desc) do 1299 | posts_by_order_by_column[-4..-3].reverse 1300 | end 1301 | 1302 | let(:expected_has_next_page) { true } 1303 | let(:expected_has_previous_page) { true } 1304 | end 1305 | 1306 | context 'when not enough records are remaining after cursor' do 1307 | include_examples 'a working query ordered by a timestamp column' do 1308 | let(:cursor_object_plain) { posts[-2] } 1309 | let(:expected_posts_plain) { posts[-1..] } 1310 | 1311 | let(:cursor_object_desc) { posts[1] } 1312 | let(:expected_posts_desc) { posts[0..0].reverse } 1313 | 1314 | let(:cursor_object_by_order_by_column) do 1315 | posts_by_order_by_column[-2] 1316 | end 1317 | let(:expected_posts_by_order_by_column) do 1318 | posts_by_order_by_column[-1..] 1319 | end 1320 | 1321 | let(:cursor_object_by_order_by_column_desc) do 1322 | posts_by_order_by_column[1] 1323 | end 1324 | let(:expected_posts_by_order_by_column_desc) do 1325 | posts_by_order_by_column[0..0].reverse 1326 | end 1327 | 1328 | let(:expected_has_next_page) { false } 1329 | let(:expected_has_previous_page) { true } 1330 | end 1331 | end 1332 | end 1333 | 1334 | context 'and `limit`' do 1335 | let(:params) { super().merge(limit: 2) } 1336 | 1337 | include_examples 'a working query ordered by a timestamp column' do 1338 | let(:cursor_object_plain) { posts[2] } 1339 | let(:expected_posts_plain) { posts[3..4] } 1340 | 1341 | let(:cursor_object_desc) { posts[-2] } 1342 | let(:expected_posts_desc) { posts[-4..-3].reverse } 1343 | 1344 | let(:cursor_object_by_order_by_column) do 1345 | posts_by_order_by_column[2] 1346 | end 1347 | let(:expected_posts_by_order_by_column) do 1348 | posts_by_order_by_column[3..4] 1349 | end 1350 | 1351 | let(:cursor_object_by_order_by_column_desc) do 1352 | posts_by_order_by_column[-2] 1353 | end 1354 | let(:expected_posts_by_order_by_column_desc) do 1355 | posts_by_order_by_column[-4..-3].reverse 1356 | end 1357 | 1358 | let(:expected_has_next_page) { true } 1359 | let(:expected_has_previous_page) { true } 1360 | end 1361 | 1362 | context 'when not enough records are remaining after cursor' do 1363 | include_examples 'a working query ordered by a timestamp column' do 1364 | let(:cursor_object_plain) { posts[-2] } 1365 | let(:expected_posts_plain) { posts[-1..] } 1366 | 1367 | let(:cursor_object_desc) { posts[1] } 1368 | let(:expected_posts_desc) { posts[0..0].reverse } 1369 | 1370 | let(:cursor_object_by_order_by_column) do 1371 | posts_by_order_by_column[-2] 1372 | end 1373 | let(:expected_posts_by_order_by_column) do 1374 | posts_by_order_by_column[-1..] 1375 | end 1376 | 1377 | let(:cursor_object_by_order_by_column_desc) do 1378 | posts_by_order_by_column[1] 1379 | end 1380 | let(:expected_posts_by_order_by_column_desc) do 1381 | posts_by_order_by_column[0..0].reverse 1382 | end 1383 | 1384 | let(:expected_has_next_page) { false } 1385 | let(:expected_has_previous_page) { true } 1386 | end 1387 | end 1388 | end 1389 | end 1390 | 1391 | context 'when passing `before`' do 1392 | let(:params) { { before: query_cursor } } 1393 | 1394 | include_examples 'a working query ordered by a timestamp column' do 1395 | let(:cursor_object_plain) { posts[-1] } 1396 | let(:expected_posts_plain) { posts[-11..-2] } 1397 | 1398 | let(:cursor_object_desc) { posts[0] } 1399 | let(:expected_posts_desc) do 1400 | posts[1..10].reverse 1401 | end 1402 | 1403 | let(:cursor_object_by_order_by_column) do 1404 | posts_by_order_by_column[-1] 1405 | end 1406 | let(:expected_posts_by_order_by_column) do 1407 | posts_by_order_by_column[-11..-2] 1408 | end 1409 | 1410 | let(:cursor_object_by_order_by_column_desc) do 1411 | posts_by_order_by_column[0] 1412 | end 1413 | let(:expected_posts_by_order_by_column_desc) do 1414 | posts_by_order_by_column[1..10].reverse 1415 | end 1416 | 1417 | let(:expected_has_next_page) { true } 1418 | let(:expected_has_previous_page) { true } 1419 | end 1420 | 1421 | context 'and `last`' do 1422 | let(:params) { super().merge(last: 2) } 1423 | 1424 | include_examples 'a working query ordered by a timestamp column' do 1425 | let(:cursor_object_plain) { posts[-1] } 1426 | let(:expected_posts_plain) { posts[-3..-2] } 1427 | 1428 | let(:cursor_object_desc) { posts[2] } 1429 | let(:expected_posts_desc) { posts[3..4].reverse } 1430 | 1431 | let(:cursor_object_by_order_by_column) do 1432 | posts_by_order_by_column[-1] 1433 | end 1434 | let(:expected_posts_by_order_by_column) do 1435 | posts_by_order_by_column[-3..-2] 1436 | end 1437 | 1438 | let(:cursor_object_by_order_by_column_desc) do 1439 | posts_by_order_by_column[2] 1440 | end 1441 | let(:expected_posts_by_order_by_column_desc) do 1442 | posts_by_order_by_column[3..4].reverse 1443 | end 1444 | 1445 | let(:expected_has_next_page) { true } 1446 | let(:expected_has_previous_page) { true } 1447 | end 1448 | 1449 | context 'when not enough records are remaining before cursor' do 1450 | include_examples 'a working query ordered by a timestamp column' do 1451 | let(:cursor_object_plain) { posts[1] } 1452 | let(:expected_posts_plain) { posts[0..0] } 1453 | 1454 | let(:cursor_object_desc) { posts[-2] } 1455 | let(:expected_posts_desc) { posts[-1..].reverse } 1456 | 1457 | let(:cursor_object_by_order_by_column) do 1458 | posts_by_order_by_column[1] 1459 | end 1460 | let(:expected_posts_by_order_by_column) do 1461 | posts_by_order_by_column[0..0] 1462 | end 1463 | 1464 | let(:cursor_object_by_order_by_column_desc) do 1465 | posts_by_order_by_column[-2] 1466 | end 1467 | let(:expected_posts_by_order_by_column_desc) do 1468 | posts_by_order_by_column[-1..].reverse 1469 | end 1470 | 1471 | let(:expected_has_next_page) { true } 1472 | let(:expected_has_previous_page) { false } 1473 | end 1474 | end 1475 | end 1476 | 1477 | context 'and `limit`' do 1478 | let(:params) { super().merge(limit: 2) } 1479 | 1480 | include_examples 'a working query ordered by a timestamp column' do 1481 | let(:cursor_object_plain) { posts[-1] } 1482 | let(:expected_posts_plain) { posts[-3..-2] } 1483 | 1484 | let(:cursor_object_desc) { posts[2] } 1485 | let(:expected_posts_desc) { posts[3..4].reverse } 1486 | 1487 | let(:cursor_object_by_order_by_column) do 1488 | posts_by_order_by_column[-1] 1489 | end 1490 | let(:expected_posts_by_order_by_column) do 1491 | posts_by_order_by_column[-3..-2] 1492 | end 1493 | 1494 | let(:cursor_object_by_order_by_column_desc) do 1495 | posts_by_order_by_column[2] 1496 | end 1497 | let(:expected_posts_by_order_by_column_desc) do 1498 | posts_by_order_by_column[3..4].reverse 1499 | end 1500 | 1501 | let(:expected_has_next_page) { true } 1502 | let(:expected_has_previous_page) { true } 1503 | end 1504 | 1505 | context 'when not enough records are remaining before cursor' do 1506 | include_examples 'a working query ordered by a timestamp column' do 1507 | let(:cursor_object_plain) { posts[1] } 1508 | let(:expected_posts_plain) { posts[0..0] } 1509 | let(:cursor_object_desc) { posts[-2] } 1510 | let(:expected_posts_desc) { posts[-1..].reverse } 1511 | let(:cursor_object_by_order_by_column) do 1512 | posts_by_order_by_column[1] 1513 | end 1514 | let(:expected_posts_by_order_by_column) do 1515 | posts_by_order_by_column[0..0] 1516 | end 1517 | let(:cursor_object_by_order_by_column_desc) do 1518 | posts_by_order_by_column[-2] 1519 | end 1520 | let(:expected_posts_by_order_by_column_desc) do 1521 | posts_by_order_by_column[-1..].reverse 1522 | end 1523 | let(:expected_has_next_page) { true } 1524 | let(:expected_has_previous_page) { false } 1525 | end 1526 | end 1527 | end 1528 | end 1529 | end 1530 | end 1531 | end 1532 | --------------------------------------------------------------------------------