├── .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 | # Next Page
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 |
--------------------------------------------------------------------------------