├── .gitignore ├── .rspec ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── cursor_pagination.gemspec ├── lib ├── cursor_pagination.rb └── cursor_pagination │ ├── action_view_helper.rb │ ├── active_record_extension.rb │ ├── active_record_model_extension.rb │ ├── page_scope_methods.rb │ └── version.rb └── spec ├── fake_app ├── active_record │ ├── config.rb │ └── models.rb └── rails.rb ├── features └── entities_feature_spec.rb ├── helpers └── cursor_pagination_helper_spec.rb ├── models └── entity_spec.rb ├── spec_helper.rb └── support └── spec_shared.rb /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format progress 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in cursor_pagination.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Sergey Kukunin 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Abandoned 2 | 3 | This repository is old and unmaintained for a long time. Consider using other pagination gems, for example https://github.com/ddnexus/pagy 4 | 5 | # CursorPagination 6 | 7 | ActiveRecord plugin for cursor based pagination. It uses specific model's column and rpp (results per page) to paginate your content. 8 | 9 | The main advantage against traditional pagination (limmit and offset), that the one URL on specific page will contain the data set, despite the newly added entities. It may be useful on the projects, where new entities are added often, and specific page now isn't specific page tomorrow. 10 | 11 | ## Installation 12 | 13 | Add this line to your application's Gemfile: 14 | 15 | gem 'cursor_pagination' 16 | 17 | And then execute: 18 | 19 | $ bundle 20 | 21 | Or install it yourself as: 22 | 23 | $ gem install cursor_pagination 24 | 25 | ## Usage 26 | 27 | Just use `cursor` scope on your model chain: 28 | 29 | User.where(active: true).cursor(params[:cursor]).per(20) 30 | 31 | It will get 20 users by passed cursor. You can omit `per` scope: 32 | 33 | User.where(active: true).cursor(params[:cursor]) 34 | 35 | In this case, the limit of entities per page will be 25 by default. 36 | 37 | You can pass options as second argument for `cursor` scope 38 | 39 | User.order('id DESC').cursor(params[:cursor], reverse: true).per(20) 40 | 41 | You can also use multi columns for your entities 42 | 43 | News.order('published_at DESC, id DESC').cursor(params[:cursor], columns: { published_at: { reverse: true }, id: { reverse: true } }).per(20) 44 | 45 | ## How it works? 46 | 47 | Actually, cursor is column value of specific entity, it uses to get all entities, later than specific. For example, if you have the Users set with IDs from 1 to 5, 48 | 49 | User.cursor(2).per(20) 50 | 51 | will return maximum 20 users after ID 2 (in this case, users with IDs 3, 4 and 5). 52 | 53 | **Make sure that your objects are ordered by cursored column. If you use DESC order, use `reverse: true` option in `cursor` scope.** 54 | 55 | ## Options 56 | 57 | At this point, `cursor` scope accepts these options: 58 | 59 | * `reverse`: Set it to true, if your set are ordered descendingly (_DESC_). Default: _false_ 60 | * `column`: column value of cursor. For example, if you order your data set by *updated_at*, set *updated_at* column for cursor. Default: _id_ 61 | * `columns`: hash with columns information, where key is the column name and the value is column options. The available options is 62 | * reverse: The same as global `reverse` option, but related to current column. Default: _false_ 63 | 64 | ## Scope methods 65 | 66 | * `first_page?/last_page?` - **true/false** 67 | 68 | ``` 69 | @users = User.cursor(params[:cursor]).per(20) 70 | @users.first_page? 71 | ``` 72 | 73 | * `next_cursor/previous_cursor` - **cursor, nil or -1**. Returns the column value for cursor of next/previous page. 74 | _nil_ is valid cursor too. It means first page. If cursor is unavailable (there isn't pages anymore), returns _-1_. 75 | 76 | ## Helpers 77 | 78 | * `next_cursor_url/previous_cursor_url` - **string** 79 | 80 | ``` 81 | next_cursor_url(scope, url_options = {}) 82 | previous_cursor_url(scope, url_options = {}) 83 | ``` 84 | 85 | Returns the URL for next/previous cursor page or nil, if there isn't next/previous cursor available. 86 | 87 | ``` 88 | <%= next_cursor_url(@users) %> # users/?cursor=3 89 | <%= previous_cursor_url(@users) %> # users/?cursor=1 90 | ``` 91 | 92 | * `next_cursor_link/previous_cursor_link` - **string** 93 | 94 | ``` 95 | next_cursor_link(scope, name, url_options = {}, html_options = {}) 96 | previous_cursor_link(scope, name, url_options = {}, html_options = {}) 97 | ``` 98 | 99 | Returns the A html element with URL on next/previous cursor or nil, if there isn't next/previous cursor available. Accepts the same arguments, as `link_to` helper method, but scope object as first argument. 100 | 101 | ``` 102 | # 103 | <%= next_cursor_link(scope, 'Next Page') %> 104 | # Previous Page 105 | <%= previous_cursor_link(scope, 'Previous Page') %> 106 | ``` 107 | 108 | 109 | ## Contributing 110 | 111 | 1. Fork it 112 | 2. Create your feature branch (`git checkout -b my-new-feature`) 113 | 3. Commit your changes (`git commit -am 'Add some feature'`) 114 | 4. Push to the branch (`git push origin my-new-feature`) 115 | 5. Create new Pull Request 116 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | -------------------------------------------------------------------------------- /cursor_pagination.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'cursor_pagination/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "cursor_pagination" 8 | spec.version = CursorPagination::VERSION 9 | spec.authors = ["Sergey Kukunin"] 10 | spec.email = ["sergey.kukunin@gmail.com"] 11 | spec.description = %q{ActiveRecord plugin for cursor based pagination. Uses some column and rpp (results per page) to paginate your content. The main advantage against traditional pagination (limmit and offset), that the one URL on specific page will contain the data set, despite the newly added entities. It may be useful on the projects, where new entities are added often, and specific page now isn't specific page tomorrow.} 12 | spec.summary = %q{ActiveRecord plugin for cursor based pagination} 13 | spec.homepage = "https://github.com/Kukunin/cursor_pagination" 14 | spec.license = "MIT" 15 | 16 | spec.add_dependency "activerecord", ['>= 3.1'] 17 | spec.add_dependency "actionpack", ['>= 3.1'] 18 | spec.add_development_dependency "rspec-rails" 19 | spec.add_development_dependency "railties" 20 | spec.add_development_dependency "capybara" 21 | spec.add_development_dependency "sqlite3-ruby" 22 | spec.add_development_dependency "database_cleaner", ['< 1.1.0'] 23 | 24 | spec.files = `git ls-files`.split($/) 25 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 26 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 27 | spec.require_paths = ["lib"] 28 | 29 | spec.add_development_dependency "bundler", "~> 1.3" 30 | spec.add_development_dependency "rake" 31 | end 32 | -------------------------------------------------------------------------------- /lib/cursor_pagination.rb: -------------------------------------------------------------------------------- 1 | require 'action_view' 2 | 3 | require "cursor_pagination/version" 4 | 5 | module CursorPagination 6 | 7 | class Cursor 8 | attr_reader :cursor 9 | 10 | def initialize(cursor) 11 | @cursor = cursor 12 | end 13 | 14 | def self.decode(cursor) 15 | unless cursor.nil? 16 | new YAML.load(Base64.strict_decode64(cursor)) 17 | else 18 | new nil 19 | end 20 | end 21 | 22 | def self.value_from_entity(entity, columns) 23 | value = [] 24 | columns.each_key do |column| 25 | value << entity.send(column) 26 | end 27 | value.size == 1 ? value.first : value 28 | end 29 | 30 | def self.from_entity(entity, columns) 31 | new value_from_entity(entity, columns) 32 | end 33 | 34 | def encoded 35 | Base64.strict_encode64 cursor.to_yaml 36 | end 37 | 38 | def empty? 39 | cursor.nil? || invalid? 40 | end 41 | 42 | def invalid? 43 | cursor == -1 44 | end 45 | 46 | def value 47 | @cursor 48 | end 49 | 50 | def to_s 51 | cursor.nil? ? nil : encoded 52 | end 53 | end 54 | end 55 | 56 | #Include ActiveRecord extension 57 | require "cursor_pagination/active_record_extension" 58 | ::ActiveRecord::Base.send :include, CursorPagination::ActiveRecordExtension 59 | 60 | #Include ActionView Helper 61 | require 'cursor_pagination/action_view_helper' 62 | ActiveSupport.on_load(:action_view) do 63 | ::ActionView::Base.send :include, ::CursorPagination::ActionViewHelper 64 | end 65 | -------------------------------------------------------------------------------- /lib/cursor_pagination/action_view_helper.rb: -------------------------------------------------------------------------------- 1 | module CursorPagination 2 | module ActionViewHelper 3 | def next_cursor_link(scope, name, params = {}, options = {}, &block) 4 | url = next_cursor_url(scope, params) 5 | link_to_unless url.nil?, name, url, options.reverse_merge(:rel => 'next') do 6 | block.call if block 7 | end 8 | end 9 | 10 | def next_cursor_url(scope, params = {}) 11 | url_for(params.merge(cursor: scope.next_cursor)) unless scope.last_page? 12 | end 13 | 14 | def previous_cursor_link(scope, name, params = {}, options = {}, &block) 15 | url = previous_cursor_url(scope, params) 16 | link_to_unless url.nil?, name, url, options.reverse_merge(:rel => 'previous') do 17 | block.call if block 18 | end 19 | end 20 | 21 | def previous_cursor_url(scope, params = {}) 22 | url_for(params.merge(cursor: scope.previous_cursor)) unless scope.first_page? 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/cursor_pagination/active_record_extension.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/concern' 2 | require 'cursor_pagination/active_record_model_extension' 3 | 4 | module CursorPagination 5 | module ActiveRecordExtension 6 | extend ActiveSupport::Concern 7 | 8 | included do 9 | # Future subclasses will pick up the model extension 10 | class << self 11 | def inherited_with_cursor_pagination(kls) #:nodoc: 12 | inherited_without_cursor_pagination kls 13 | kls.send(:include, CursorPagination::ActiveRecordModelExtension) if kls.superclass == ActiveRecord::Base 14 | end 15 | alias_method_chain :inherited, :cursor_pagination 16 | end 17 | 18 | # Existing subclasses pick up the model extension as well 19 | self.descendants.each do |kls| 20 | kls.send(:include, CursorPagination::ActiveRecordModelExtension) if kls.superclass == ActiveRecord::Base 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/cursor_pagination/active_record_model_extension.rb: -------------------------------------------------------------------------------- 1 | require 'cursor_pagination/page_scope_methods' 2 | 3 | module CursorPagination 4 | module ActiveRecordModelExtension 5 | extend ActiveSupport::Concern 6 | 7 | included do 8 | def self.cursor(cursor, options = {}) 9 | cursor = Cursor.decode(cursor) unless cursor.is_a? Cursor 10 | 11 | options.reverse_merge! column: :id, reverse: false, columns: {} 12 | if options[:columns].empty? 13 | options[:columns][options[:column]] = { reverse: options[:reverse] } 14 | end 15 | 16 | scoped_method = ActiveRecord::VERSION::STRING < '4.0' ? :scoped : :all 17 | origin_scope = self.send scoped_method 18 | scope = origin_scope.extending(CursorPagination::PageScopeMethods) 19 | 20 | scope.current_cursor = cursor 21 | scope.cursor_options = options 22 | scope._origin_scope = origin_scope 23 | 24 | unless cursor.empty? 25 | cursor_value = [*cursor.value] 26 | scope = scope.where _cursor_to_where(options[:columns], cursor_value) 27 | end 28 | 29 | scope.limit(25) 30 | end 31 | 32 | private 33 | def self._cursor_to_where(columns, cursor, reverse = false) 34 | _cursor_to_where_recursion(0, arel_table, columns.to_a, cursor, reverse) 35 | end 36 | 37 | def self._cursor_to_where_recursion(i, t, columns, cursor, reverse = false) 38 | column = columns[i] 39 | method = column.last[:reverse] ? :lt : :gt 40 | method = (method == :lt ? :gt : :lt) if reverse 41 | if (columns.size - i) == 1 #last column 42 | method = (method == :lt ? :lteq : :gteq) if reverse 43 | t[column.first].send method, cursor[i] 44 | else 45 | t[column.first].send(method, cursor[i]).or(t[column.first].eq(cursor[i]).and(_cursor_to_where_recursion(i+1, t, columns, cursor, reverse))) 46 | end 47 | end 48 | 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/cursor_pagination/page_scope_methods.rb: -------------------------------------------------------------------------------- 1 | module CursorPagination 2 | module PageScopeMethods 3 | def current_cursor 4 | @current_cursor 5 | end 6 | 7 | def current_cursor=(cursor) 8 | @current_cursor = cursor 9 | end 10 | 11 | def _origin_scope 12 | @origin_scope 13 | end 14 | 15 | def _origin_scope=(scope) 16 | @origin_scope = scope 17 | end 18 | 19 | def cursor_options 20 | @cursor_options 21 | end 22 | 23 | def cursor_options=(options) 24 | @cursor_options = options 25 | end 26 | 27 | def per(num) 28 | limit(num) 29 | end 30 | 31 | def first_page? 32 | previous_cursor.value == -1 33 | end 34 | 35 | def last_page? 36 | next_cursor.value == -1 37 | end 38 | 39 | def previous_cursor 40 | Cursor.new(if current_cursor.empty? 41 | -1 42 | else 43 | scope = _origin_scope.limit(limit_value+1).reverse_order 44 | columns = cursor_options[:columns] 45 | 46 | cursor_value = [*current_cursor.value] 47 | scope = scope.where _cursor_to_where(columns, cursor_value, true) 48 | result = scope.to_a 49 | 50 | case result.size 51 | when limit_value+1 52 | Cursor.value_from_entity result.last, columns 53 | when 0 54 | -1 #no previous page 55 | else 56 | nil #first page, incomplete 57 | end 58 | end) 59 | end 60 | 61 | def next_cursor 62 | Cursor.new(if last.nil? 63 | -1 64 | else 65 | # try to get something after last cursor 66 | cursor = Cursor.from_entity last, cursor_options[:columns] 67 | _origin_scope.cursor(cursor, cursor_options).per(1).count.zero? ? -1 : cursor.value 68 | end) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/cursor_pagination/version.rb: -------------------------------------------------------------------------------- 1 | module CursorPagination 2 | VERSION = "0.0.3" 3 | end 4 | -------------------------------------------------------------------------------- /spec/fake_app/active_record/config.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.configurations = {'test' => {:adapter => 'sqlite3', :database => ':memory:'}} 2 | ActiveRecord::Base.default_timezone = :utc 3 | ActiveRecord::Base.establish_connection('test') 4 | -------------------------------------------------------------------------------- /spec/fake_app/active_record/models.rb: -------------------------------------------------------------------------------- 1 | # models 2 | class Entity < ActiveRecord::Base 3 | 4 | end 5 | 6 | #migrations 7 | class CreateAllTables < ActiveRecord::Migration 8 | def self.up 9 | create_table :entities do |t| 10 | t.integer :custom 11 | t.datetime :custom_time 12 | t.timestamps 13 | end 14 | end 15 | end 16 | 17 | ActiveRecord::Migration.verbose = false 18 | CreateAllTables.up 19 | -------------------------------------------------------------------------------- /spec/fake_app/rails.rb: -------------------------------------------------------------------------------- 1 | # require 'rails/all' 2 | require 'action_controller/railtie' 3 | require 'action_view/railtie' 4 | 5 | require 'fake_app/active_record/config' if defined? ActiveRecord 6 | 7 | # config 8 | app = Class.new(Rails::Application) 9 | app.config.secret_token = 'f47a93ac1ff6843777fed92f966d61dc' 10 | app.config.session_store :cookie_store, :key => '_myapp_session' 11 | app.config.active_support.deprecation = :log 12 | app.config.eager_load = false 13 | # Rais.root 14 | app.config.root = File.dirname(__FILE__) 15 | Rails.backtrace_cleaner.remove_silencers! 16 | app.initialize! 17 | 18 | # routes 19 | app.routes.draw do 20 | resources :entities 21 | 22 | namespace :two_column do 23 | resources :entities 24 | end 25 | end 26 | 27 | #models 28 | require 'fake_app/active_record/models' if defined? ActiveRecord 29 | 30 | # controllers 31 | class ApplicationController < ActionController::Base; end 32 | class EntitiesController < ApplicationController 33 | 34 | def index 35 | @entities = Entity.order('custom ASC').cursor(params[:cursor], column: :custom).per(1) 36 | render :inline => %q/ 37 | <%= previous_cursor_link(@entities, "Previous Page") %> 38 | <%= @entities.map { |n| "Custom #{n.custom}" }.join("\n") %> 39 | <%= next_cursor_link(@entities, "Next Page") %>/ 40 | end 41 | 42 | end 43 | 44 | module TwoColumn 45 | class EntitiesController < ApplicationController 46 | def index 47 | @entities = Entity.order('custom_time DESC, id DESC').cursor(params[:cursor], columns: { custom_time: { reverse: true }, id: { reverse: true } }).per(1) 48 | render :inline => %q/ 49 | <%= previous_cursor_link(@entities, "Previous Page") %> 50 | <%= @entities.map { |n| "Custom #{n.custom}" }.join("\n") %> 51 | <%= next_cursor_link(@entities, "Next Page") %>/ 52 | end 53 | 54 | end 55 | end 56 | 57 | # helpers 58 | Object.const_set(:ApplicationHelper, Module.new) 59 | -------------------------------------------------------------------------------- /spec/features/entities_feature_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe EntitiesController do 4 | 5 | subject { page } 6 | 7 | shared_examples_for "first page" do 8 | it { should have_content "Custom 1" } 9 | it { should_not have_link "Previous Page" } 10 | it { should have_link "Next Page" } 11 | end 12 | shared_examples_for "second page" do 13 | it { should have_content "Custom 2" } 14 | it { should have_link "Previous Page" } 15 | it { should have_link "Next Page" } 16 | end 17 | shared_examples_for "third page" do 18 | it { should have_content "Custom 3" } 19 | it { should have_link "Previous Page" } 20 | it { should have_link "Next Page" } 21 | end 22 | shared_examples_for "last page" do 23 | it { should have_content "Custom 4" } 24 | it { should have_link "Previous Page" } 25 | it { should_not have_link "Next Page" } 26 | end 27 | shared_examples_for "previous first page" do 28 | describe "previous page" do 29 | before { click_link "Previous Page" } 30 | it_should_behave_like "first page" 31 | end 32 | end 33 | shared_examples_for "previous second page" do 34 | describe "previous page" do 35 | before { click_link "Previous Page" } 36 | it_should_behave_like "second page" 37 | 38 | it_should_behave_like "previous first page" 39 | end 40 | end 41 | 42 | 43 | shared_examples_for "cursor pagination" do 44 | it_should_behave_like "first page" 45 | 46 | describe "second page" do 47 | before { click_link "Next Page" } 48 | it_should_behave_like "second page" 49 | 50 | describe "third page" do 51 | before { click_link "Next Page" } 52 | it_should_behave_like "third page" 53 | 54 | describe "last page" do 55 | before { click_link "Next Page" } 56 | it_should_behave_like "last page" 57 | 58 | describe "previous page" do 59 | before { click_link "Previous Page" } 60 | it_should_behave_like "third page" 61 | 62 | it_should_behave_like "previous second page" 63 | end 64 | end 65 | 66 | it_should_behave_like "previous second page" 67 | end 68 | 69 | it_should_behave_like "previous first page" 70 | end 71 | end 72 | 73 | context "by custom" do 74 | include_context "entities" 75 | 76 | before { visit entities_path } 77 | 78 | it_should_behave_like "cursor pagination" 79 | end 80 | 81 | context "by two-columns" do 82 | before do 83 | two_minutes_ago = 2.minutes.ago 84 | Entity.create! custom: 1, custom_time: 1.minute.ago 85 | Entity.create! custom: 3, custom_time: two_minutes_ago 86 | Entity.create! custom: 2, custom_time: two_minutes_ago 87 | Entity.create! custom: 4, custom_time: 3.minutes.ago 88 | 89 | visit two_column_entities_path 90 | end 91 | 92 | it_should_behave_like "cursor pagination" 93 | end 94 | 95 | end 96 | -------------------------------------------------------------------------------- /spec/helpers/cursor_pagination_helper_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe CursorPagination::ActionViewHelper do 4 | 5 | include_context "entities" 6 | 7 | describe '#previous_cursor_link' do 8 | context 'having previous pages' do 9 | context 'the default behaviour' do 10 | subject { helper.previous_cursor_link second_page, 'Previous', {:controller => 'entities', :action => 'index'} } 11 | it { should be_a String } 12 | it { should match(/rel="previous"/) } 13 | it { should_not match(/cursor=/) } 14 | end 15 | context 'the third page' do 16 | subject { helper.previous_cursor_link third_page, 'Previous', {:controller => 'entities', :action => 'index'} } 17 | it { should be_a String } 18 | it { should match(/rel="previous"/) } 19 | it { should match("cursor=#{CGI.escape(c(first_entity.id).to_s)}") } 20 | end 21 | context 'overriding rel=' do 22 | subject { helper.previous_cursor_link second_page, 'Previous', {:controller => 'entities', :action => 'index'}, {:rel => 'external'} } 23 | it { should match(/rel="external"/) } 24 | end 25 | end 26 | context 'the first page' do 27 | subject { helper.previous_cursor_link first_page, 'Previous', {:controller => 'entities', :action => 'index'} } 28 | it { should be_nil } 29 | end 30 | end 31 | 32 | describe '#next_cursor_link' do 33 | context 'having more page' do 34 | context 'the default behaviour' do 35 | subject { helper.next_cursor_link first_page, 'More', {:controller => 'entities', :action => 'index'} } 36 | it { should be_a String } 37 | it { should match(/rel="next"/) } 38 | it { should match("cursor=#{CGI.escape(c(first_entity.id).to_s)}") } 39 | end 40 | context 'overriding rel=' do 41 | subject { helper.next_cursor_link first_page, 'More', {:controller => 'entities', :action => 'index'}, { :rel => 'external' } } 42 | it { should match(/rel="external"/) } 43 | end 44 | end 45 | context 'the last page' do 46 | subject { helper.next_cursor_link last_page, 'More', {:controller => 'entities', :action => 'index'} } 47 | it { should be_nil } 48 | end 49 | end 50 | 51 | end 52 | -------------------------------------------------------------------------------- /spec/models/entity_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | describe Entity do 4 | 5 | include_context "entities" 6 | 7 | specify do 8 | first_entity.id.should be < second_entity.id 9 | first_entity.custom.should be > second_entity.custom 10 | end 11 | 12 | describe "cursor-specific options" do 13 | let(:first_options) do 14 | { column: :custom, reverse: false } 15 | end 16 | let(:second_options) do 17 | { column: :id, reverse: true } 18 | end 19 | 20 | it "doesn't overlap each other" do 21 | first_scope = Entity.cursor(nil, first_options) 22 | second_scope = Entity.cursor(nil, second_options) 23 | 24 | first_scope.per(10).cursor_options.should eq first_options 25 | second_scope.cursor_options.should eq second_options 26 | end 27 | end 28 | 29 | describe "cursor_options" do 30 | it "accepts default values" do 31 | options = Entity.cursor(nil).cursor_options 32 | options[:columns].should eq id: { reverse: false } 33 | end 34 | 35 | it "accepts short column notation" do 36 | options = Entity.cursor(nil, column: :custom, reverse: true).cursor_options 37 | options[:columns].should eq custom: { reverse: true } 38 | end 39 | 40 | it "prefer full columns notation" do 41 | full_options = { custom: { reverse: false } } 42 | options = Entity.cursor(nil, column: :custom_time, reverse: true, columns: full_options).cursor_options 43 | options[:columns].should eq full_options 44 | end 45 | end 46 | 47 | describe "#cursor method" do 48 | it "returns first entity only" do 49 | result = first_page.to_a 50 | result.size.should eq 1 51 | result.first.should eq first_entity 52 | end 53 | 54 | it "returns second entity only" do 55 | result = second_page.to_a 56 | result.size.should eq 1 57 | result.first.should eq second_entity 58 | end 59 | 60 | it "support different orders" do 61 | result = Entity.order('id DESC').cursor(c(second_entity.id), reverse: true).per(1).to_a 62 | result.size.should eq 1 63 | result.first.should eq first_entity 64 | end 65 | 66 | it "support different columns" do 67 | result = Entity.cursor(c(second_entity.id), column: :custom).per(1).to_a 68 | result.size.should eq 1 69 | result.first.should eq first_entity 70 | end 71 | 72 | describe "time columns" do 73 | let(:columns) do 74 | { custom_time: { reverse: false }, id: { reverse: false } } 75 | end 76 | let(:scope) { Entity.order('custom_time ASC, id ASC') } 77 | let(:first_page) { scope.cursor(nil, columns: columns ).per(1) } 78 | specify { first_page.next_cursor.value.should eq [last_entity.custom_time, last_entity.id] } 79 | let(:second_page) { scope.cursor(first_page.next_cursor, columns: columns).per(1) } 80 | specify { second_page.next_cursor.value.should eq [third_entity.custom_time, third_entity.id] } 81 | let(:previous_page) { scope.cursor(second_page.previous_cursor, columns: columns).per(1) } 82 | specify { previous_page.next_cursor.value.should eq [last_entity.custom_time, last_entity.id] } 83 | let(:third_page) { scope.cursor(second_page.next_cursor, columns: columns).per(1) } 84 | specify { third_page.next_cursor.value.should eq [second_entity.custom_time, second_entity.id] } 85 | 86 | specify { first_page.first.should eq last_entity } 87 | specify { second_page.first.should eq third_entity } 88 | specify { third_page.first.should eq second_entity } 89 | specify { previous_page.first.should eq last_entity } 90 | end 91 | 92 | context "without #per method" do 93 | before do 94 | 25.times { Entity.create! } 95 | end 96 | 97 | it "returns all Entities" do 98 | result = Entity.cursor(nil).to_a 99 | result.size.should eq 25 100 | end 101 | end 102 | end 103 | 104 | describe "cursor methods" do 105 | # nil is valid cursor too, it means first page 106 | # -1 means unavailable cursor (last or first page) 107 | describe "#next_cursor" do 108 | ##Default settings 109 | specify { Entity.cursor(nil).per(10).next_cursor.value.should eq -1 } 110 | specify { Entity.cursor(nil).per(10).should be_last_page } 111 | specify { first_page.next_cursor.should be_a CursorPagination::Cursor } 112 | specify { first_page.next_cursor.value.should eq first_entity.id} 113 | specify { first_page.should_not be_last_page} 114 | specify { last_page.next_cursor.value.should eq -1 } 115 | 116 | ##Reverse order 117 | specify { Entity.order('id DESC').cursor(nil, reverse: true).per(1).next_cursor.value.should eq last_entity.id } 118 | specify { Entity.order('id DESC').cursor(c(second_entity.id), reverse: true).per(1).next_cursor.value.should eq -1 } 119 | 120 | ##With custom column 121 | specify { Entity.order('custom ASC').cursor(c(second_entity.custom), column: :custom).per(1).next_cursor.value.should eq -1 } 122 | specify { Entity.order('custom ASC').cursor(c(third_entity.custom), column: :custom).per(1).next_cursor.value.should eq second_entity.custom } 123 | specify { Entity.order('custom ASC').cursor(nil, column: :custom).per(1).next_cursor.value.should eq last_entity.custom } 124 | end 125 | 126 | describe "#previous_cursor" do 127 | ##Default settings 128 | #no previous page 129 | specify { Entity.cursor(nil).previous_cursor.value.should eq -1 } 130 | specify { Entity.cursor(nil).should be_first_page } 131 | #not full previous page 132 | specify { Entity.cursor(c(first_entity.id)).previous_cursor.value.should be_nil } 133 | specify { Entity.cursor(c(first_entity.id)).should_not be_first_page } 134 | #full previous page 135 | specify { third_page.previous_cursor.value.should eq first_entity.id } 136 | specify { third_page.should_not be_first_page } 137 | 138 | ##Reverse order 139 | specify { Entity.order('id DESC').cursor(nil, reverse: true).previous_cursor.value.should eq -1 } 140 | specify { Entity.order('id DESC').cursor(c(last_entity.id), reverse: true).previous_cursor.value.should be_nil } 141 | specify { Entity.order('id DESC').cursor(c(third_entity.id), reverse: true).per(1).previous_cursor.value.should eq last_entity.id } 142 | 143 | ##With custom column 144 | specify { Entity.order('custom ASC').cursor(nil, column: :custom).previous_cursor.value.should eq -1 } 145 | specify { Entity.order('custom ASC').cursor(c(last_entity.custom), column: :custom).previous_cursor.value.should be_nil } 146 | specify { Entity.order('custom ASC').cursor(c(third_entity.custom), column: :custom).per(1).previous_cursor.value.should eq last_entity.custom } 147 | specify { Entity.order('custom ASC').cursor(c(second_entity.custom), column: :custom).per(1).previous_cursor.value.should eq third_entity.custom } 148 | specify { Entity.order('custom ASC').cursor(c(first_entity.custom), column: :custom).per(1).previous_cursor.value.should eq second_entity.custom } 149 | end 150 | end 151 | 152 | describe "._cursor_to_where" do 153 | let(:time) { Time.at 1378132362 } 154 | let(:columns) do 155 | { id: { reverse: true }, custom: { reverse: false }, custom_time: { reverse: false } } 156 | end 157 | let(:cursor_value) { [1,2,time] } 158 | let(:cursor) { CursorPagination::Cursor.new cursor_value } 159 | 160 | context "with direct sql" do 161 | specify do 162 | target_sql = "(\"entities\".\"id\" < 1 OR \"entities\".\"id\" = 1 AND (\"entities\".\"custom\" > 2 OR \"entities\".\"custom\" = 2 AND \"entities\".\"custom_time\" > '2013-09-02 14:32:42.000000'))" 163 | where = Entity.send(:_cursor_to_where, columns, cursor_value) 164 | where.to_sql.should eq target_sql 165 | end 166 | end 167 | 168 | context "with reversed sql" do 169 | specify do 170 | target_sql = "(\"entities\".\"id\" > 1 OR \"entities\".\"id\" = 1 AND (\"entities\".\"custom\" < 2 OR \"entities\".\"custom\" = 2 AND \"entities\".\"custom_time\" <= '2013-09-02 14:32:42.000000'))" 171 | where = Entity.send(:_cursor_to_where, columns, cursor_value, true) 172 | where.to_sql.should eq target_sql 173 | end 174 | end 175 | end 176 | end 177 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # Require this file using `require "spec_helper"` to ensure that it is only 4 | # loaded once. 5 | require 'active_record' 6 | require 'cursor_pagination' 7 | 8 | require 'fake_app/rails' 9 | require 'rspec/rails' 10 | require 'capybara/rspec' 11 | 12 | require 'database_cleaner' 13 | 14 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 15 | # 16 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 17 | RSpec.configure do |config| 18 | config.treat_symbols_as_metadata_keys_with_true_values = true 19 | config.run_all_when_everything_filtered = true 20 | config.filter_run :focus 21 | 22 | # Run specs in random order to surface order dependencies. If you find an 23 | # order dependency and want to debug it, you can fix the order by providing 24 | # the seed, which is printed after each run. 25 | # --seed 1234 26 | config.order = 'random' 27 | 28 | config.before(:suite) do 29 | DatabaseCleaner.strategy = :transaction 30 | DatabaseCleaner.clean_with(:truncation) 31 | end 32 | 33 | config.before(:each) do 34 | DatabaseCleaner.start 35 | end 36 | 37 | config.after(:each) do 38 | DatabaseCleaner.clean 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/support/spec_shared.rb: -------------------------------------------------------------------------------- 1 | shared_context "entities" do 2 | before do 3 | 4.times { |n| Entity.create! custom: (4 - n), custom_time: n.minutes.ago} 4 | end 5 | 6 | let(:entities) { Entity.all.to_a } 7 | let(:first_entity) { entities.first } 8 | let(:second_entity) { entities[1] } 9 | let(:third_entity) { entities[2] } 10 | let(:last_entity) { entities[3] } 11 | 12 | let(:first_page) { Entity.cursor(nil).per(1) } 13 | let(:second_page) { Entity.cursor(c(first_entity.id)).per(1) } 14 | let(:third_page) { Entity.cursor(c(second_entity.id)).per(1) } 15 | let(:last_page) { Entity.cursor(c(third_entity.id)).per(1) } 16 | 17 | def c(cursor) 18 | CursorPagination::Cursor.new cursor 19 | end 20 | end 21 | --------------------------------------------------------------------------------