From 435f26a21450825efd2cb4ff886a9057396859a2 Mon Sep 17 00:00:00 2001 From: Accessd Date: Tue, 14 Mar 2017 21:32:45 +0300 Subject: [PATCH] add cursor based pagination --- .travis.yml | 1 + CONTRIBUTING.md | 2 +- README.md | 40 +++ api-pagination.gemspec | 3 + lib/api-pagination/configuration.rb | 6 + lib/api-pagination/hooks.rb | 5 + lib/cursor/active_record_extension.rb | 22 ++ lib/cursor/active_record_model_extension.rb | 45 ++++ lib/cursor/config.rb | 31 +++ lib/cursor/configuration_methods.rb | 36 +++ lib/cursor/page_scope_methods.rb | 110 ++++++++ lib/rails/pagination.rb | 44 +++- spec/cursor/rails_spec.rb | 264 ++++++++++++++++++++ spec/cursor/scopes_spec.rb | 141 +++++++++++ spec/grape_spec.rb | 2 +- spec/rails_spec.rb | 2 +- spec/spec_helper.rb | 44 +++- spec/support/tweets_controller.rb | 66 +++++ 18 files changed, 858 insertions(+), 6 deletions(-) create mode 100644 lib/cursor/active_record_extension.rb create mode 100644 lib/cursor/active_record_model_extension.rb create mode 100644 lib/cursor/config.rb create mode 100644 lib/cursor/configuration_methods.rb create mode 100644 lib/cursor/page_scope_methods.rb create mode 100644 spec/cursor/rails_spec.rb create mode 100644 spec/cursor/scopes_spec.rb create mode 100644 spec/support/tweets_controller.rb diff --git a/.travis.yml b/.travis.yml index 82d1bc4..3fb7b3f 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,3 +7,4 @@ script: bundle exec rspec env: - PAGINATOR=kaminari - PAGINATOR=will_paginate + - PAGINATOR=cursor diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index edd743b..8406620 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -3,6 +3,6 @@ 1. Fork it 2. Create your feature branch (`git checkout -b my-new-feature`) 3. Commit your changes and tests (`git commit -am 'Add some feature'`) -4. Run the tests (`PAGINATOR=kaminari bundle exec rspec; PAGINATOR=will_paginate bundle exec rspec`) +4. Run the tests (`PAGINATOR=kaminari bundle exec rspec; PAGINATOR=will_paginate bundle exec rspec; PAGINATOR=cursor bundle exec rspec`) 5. Push to the branch (`git push origin my-new-feature`) 6. Create a new Pull Request diff --git a/README.md b/README.md index e4f5f74..fc44440 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,32 @@ class API::ApplicationController < ActionController::Base end ``` +### Cursor based pagination + +In brief, it's really great in case of API when your entities create/destroy frequently. +For more information about subject please follow +[https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination](https://www.sitepoint.com/paginating-real-time-data-cursor-based-pagination) + +Current implementation based on Kaminari and compatible with it model scoped config options. +You can use it independently of Kaminari or WillPaginate. + +Just use `cursor_paginate` method instead of `pagination`: + + def cast + actors = Movie.find(params[:id]).actors + cursor_paginate json: actors, per_page: 10 + end + +You can configure the following default values by overriding these values using `Cursor.configure` method. + + default_per_page # 25 by default + max_per_page # nil by default + +Btw you can use cursor pagination as standalone feature: + + movies = Movie.cursor_page(after: 10).per(10) # Get 10 movies where id > 10 + movies = Movie.cursor_page(before: 51).per(10) # Get 10 moview where id < 51 + ## Grape With Grape, `paginate` is used to declare that your endpoint takes a `:page` and `:per_page` param. You can also directly specify a `:max_per_page` that users aren't allowed to go over. Then, inside your API endpoint, it simply takes your collection: @@ -158,6 +184,20 @@ Per-Page: 10 # ... ``` +And example for cursor based pagination: + +```bash +$ curl --include 'https://localhost:3000/movies?after=60' +HTTP/1.1 200 OK +Link: ; rel="first", + ; rel="last", + ; rel="next", + ; rel="prev" +Total: 100 +Per-Page: 10 +``` + + ## A Note on Kaminari and WillPaginate api-pagination requires either Kaminari or WillPaginate in order to function, but some users may find themselves in situations where their application includes both. For example, you may have included [ActiveAdmin][activeadmin] (which uses Kaminari for pagination) and WillPaginate to do your own pagination. While it's suggested that you remove one paginator gem or the other, if you're unable to do so, you _must_ configure api-pagination explicitly: diff --git a/api-pagination.gemspec b/api-pagination.gemspec index 11858b7..387e328 100644 --- a/api-pagination.gemspec +++ b/api-pagination.gemspec @@ -20,5 +20,8 @@ Gem::Specification.new do |s| s.add_development_dependency 'grape', '>= 0.10.0' s.add_development_dependency 'railties', '>= 3.0.0' s.add_development_dependency 'actionpack', '>= 3.0.0' + s.add_development_dependency 'activerecord', '>= 3.0.0' s.add_development_dependency 'sequel', '>= 4.9.0' + s.add_development_dependency 'pry' + s.add_development_dependency 'database_cleaner' end diff --git a/lib/api-pagination/configuration.rb b/lib/api-pagination/configuration.rb index 3514467..6782002 100644 --- a/lib/api-pagination/configuration.rb +++ b/lib/api-pagination/configuration.rb @@ -56,6 +56,8 @@ def paginator=(paginator) use_kaminari when :will_paginate use_will_paginate + when :cursor + use_cursor_paginator else raise StandardError, "Unknown paginator: #{paginator}" end @@ -103,6 +105,10 @@ def last_page?() !next_page end @paginator = :will_paginate end + + def use_cursor_paginator + @paginator = :cursor + end end class << self diff --git a/lib/api-pagination/hooks.rb b/lib/api-pagination/hooks.rb index ab61fe2..7ff6b8f 100644 --- a/lib/api-pagination/hooks.rb +++ b/lib/api-pagination/hooks.rb @@ -19,6 +19,11 @@ def self.rails_parent_controller ActiveSupport.on_load(:action_controller) do ApiPagination::Hooks.rails_parent_controller.send(:include, Rails::Pagination) end + + ActiveSupport.on_load(:active_record) do + require_relative '../cursor/active_record_extension' + ::ActiveRecord::Base.send :include, Cursor::ActiveRecordExtension + end end begin; require 'grape'; rescue LoadError; end diff --git a/lib/cursor/active_record_extension.rb b/lib/cursor/active_record_extension.rb new file mode 100644 index 0000000..9111828 --- /dev/null +++ b/lib/cursor/active_record_extension.rb @@ -0,0 +1,22 @@ +require 'cursor/active_record_model_extension' + +module Cursor + module ActiveRecordExtension + extend ActiveSupport::Concern + + module ClassMethods + # Future subclasses will pick up the model extension + def inherited(kls) #:nodoc: + super + kls.send(:include, Cursor::ActiveRecordModelExtension) if kls.superclass == ::ActiveRecord::Base + end + end + + included do + # Existing subclasses pick up the model extension as well + self.descendants.each do |kls| + kls.send(:include, Cursor::ActiveRecordModelExtension) if kls.superclass == ::ActiveRecord::Base + end + end + end +end diff --git a/lib/cursor/active_record_model_extension.rb b/lib/cursor/active_record_model_extension.rb new file mode 100644 index 0000000..faa63a1 --- /dev/null +++ b/lib/cursor/active_record_model_extension.rb @@ -0,0 +1,45 @@ +require_relative 'config' +require_relative 'configuration_methods' +require_relative 'page_scope_methods' + +module Cursor + module ActiveRecordModelExtension + extend ActiveSupport::Concern + + class_methods do + cattr_accessor :_current_cursor, :_base_relation, :_per_page + end + + included do + self.send(:include, Cursor::ConfigurationMethods) + + def self.cursor_page(options = {}) + opts = options.dup + (opts || {}).to_hash.symbolize_keys! + opts[:direction] = :after if opts[:after].present? + opts[:direction] ||= :before if opts[:before].present? + opts[:direction] ||= :after + + self._current_cursor = opts[opts[:direction]] && opts[opts[:direction]].to_i + self._base_relation = self + self._per_page = opts[:per_page].to_i + on_cursor(opts[:direction]). + in_direction(opts[:direction]). + limit(opts[:per_page] || default_per_page). + extending(Cursor::PageScopeMethods) + end + + def self.on_cursor(direction) + if _current_cursor.nil? + where(nil) + else + where(["#{self.table_name}.id #{direction == :after ? '>' : '<'} ?", _current_cursor]) + end + end + + def self.in_direction(direction) + reorder("#{self.table_name}.id #{direction == :after ? 'ASC' : 'DESC'}") + end + end + end +end diff --git a/lib/cursor/config.rb b/lib/cursor/config.rb new file mode 100644 index 0000000..55c9397 --- /dev/null +++ b/lib/cursor/config.rb @@ -0,0 +1,31 @@ +require 'active_support/configurable' + +module Cursor + # Configures global settings for Divination + # Cursor.configure do |config| + # config.default_per_page = 10 + # end + def self.configure(&block) + yield @config ||= Cursor::Configuration.new + end + + # Global settings for Cursor + def self.config + @config + end + + class Configuration #:nodoc: + include ActiveSupport::Configurable + config_accessor :default_per_page + config_accessor :max_per_page + + def param_name + config.param_name.respond_to?(:call) ? config.param_name.call : config.param_name + end + end + + configure do |config| + config.default_per_page = 25 + config.max_per_page = nil + end +end diff --git a/lib/cursor/configuration_methods.rb b/lib/cursor/configuration_methods.rb new file mode 100644 index 0000000..243ddf5 --- /dev/null +++ b/lib/cursor/configuration_methods.rb @@ -0,0 +1,36 @@ +module Cursor + module ConfigurationMethods + extend ActiveSupport::Concern + + module ClassMethods + # Overrides the default +per_page+ value per model + # class Article < ActiveRecord::Base + # paginates_per 10 + # end + def paginates_per(val) + @_default_per_page = val + end + + # This model's default +per_page+ value + # returns +default_per_page+ value unless explicitly overridden via paginates_per + def default_per_page + (defined?(@_default_per_page) && @_default_per_page) || Cursor.config.default_per_page + end + + # Overrides the max +per_page+ value per model + # class Article < ActiveRecord::Base + # max_paginates_per 100 + # end + def max_paginates_per(val) + @_max_per_page = val + end + + # This model's max +per_page+ value + # returns +max_per_page+ value unless explicitly overridden via max_paginates_per + def max_per_page + (defined?(@_max_per_page) && @_max_per_page) || Cursor.config.max_per_page + end + + end + end +end diff --git a/lib/cursor/page_scope_methods.rb b/lib/cursor/page_scope_methods.rb new file mode 100644 index 0000000..7f9065f --- /dev/null +++ b/lib/cursor/page_scope_methods.rb @@ -0,0 +1,110 @@ +module Cursor + module PageScopeMethods + def per(num) + if (n = num.to_i) <= 0 + self + elsif max_per_page && max_per_page < n + limit(max_per_page) + else + limit(n) + end + end + + def next_cursor + @_next_cursor ||= last.try!(:id) + end + + def prev_cursor + @_prev_cursor ||= first.try!(:id) + end + + def first_cursor + @_first_cursor ||= _base_relation.limit(limit_value).reorder('id ASC').first.try!(:id) + end + + def last_cursor + @_last_cursor ||= begin + last_id = _base_relation.limit(limit_value).reorder('id DESC').last.try!(:id) + return nil unless last_id + last_id - 1 + end + end + + def total_count + @_total_count ||= _base_relation.count + end + + def just_one_page? + total_count <= _per_page + end + + def first_page? + _current_cursor.nil? || _current_cursor == first_cursor + end + + def last_page? + _current_cursor && _current_cursor == last_cursor + end + + def next_url(request_url) + direction == :after ? + after_url(request_url, next_cursor) : + before_url(request_url, next_cursor) + end + + def prev_url(request_url) + direction == :after ? + before_url(request_url, prev_cursor) : + after_url(request_url, prev_cursor) + end + + def last_url(request_url) + after_url(request_url, last_cursor) + end + + def first_url(request_url) + after_url(request_url, nil) + end + + def before_url(request_url, cursor) + base, params = url_parts(request_url) + params.merge!('before' => cursor) unless cursor.nil? + params.to_query.length > 0 ? "#{base}?#{CGI.unescape(params.to_query)}" : base + end + + def after_url(request_url, cursor) + base, params = url_parts(request_url) + params.merge!('after' => cursor) unless cursor.nil? + params.to_query.length > 0 ? "#{base}?#{CGI.unescape(params.to_query)}" : base + end + + def url_parts(request_url) + base, params = request_url.split('?', 2) + params = Rack::Utils.parse_nested_query(params || '') + params.stringify_keys! + params.delete('before') + params.delete('after') + [base, params] + end + + def direction + return :after if prev_cursor.nil? && next_cursor.nil? + @_direction ||= prev_cursor < next_cursor ? :after : :before + end + + def pagination(request_url) + return {} if just_one_page? + {}.tap do |h| + unless first_page? + h[:prev] = prev_url(request_url) unless prev_cursor.nil? + h[:first] = first_url(request_url) unless first_cursor.nil? + end + + unless last_page? + h[:next] = next_url(request_url) unless next_cursor.nil? + h[:last] = last_url(request_url) unless last_cursor.nil? + end + end + end + end +end diff --git a/lib/rails/pagination.rb b/lib/rails/pagination.rb index 0b7fc66..83d1258 100644 --- a/lib/rails/pagination.rb +++ b/lib/rails/pagination.rb @@ -6,10 +6,11 @@ def paginate(*options_or_collection) options = options_or_collection.extract_options! collection = options_or_collection.first - return _paginate_collection(collection, options) if collection + paginate_method = options.delete(:paginate_method) || '_paginate_collection' + return send(paginate_method, collection, options) if collection collection = options[:json] || options[:xml] - collection = _paginate_collection(collection, options) + collection = send(paginate_method, collection, options) options[:json] = collection if options[:json] options[:xml] = collection if options[:xml] @@ -21,8 +22,47 @@ def paginate_with(collection) respond_with _paginate_collection(collection) end + def cursor_paginate(*options_or_collection) + options = options_or_collection.extract_options! + options.reverse_merge!(paginate_method: '_cursor_paginate_collection') + options_or_collection << options + paginate(*options_or_collection) + end + + def cursor_paginate_with(collection) + respond_with _cursor_paginate_collection(collection) + end + private + def _cursor_paginate_collection(collection, options={}) + options[:per_page] ||= ApiPagination.config.per_page_param(params) + + if params[:before].present? + options[:before] = params[:before] + end + if params[:after].present? + options[:after] = params[:after] + end + options[:per_page] ||= collection.default_per_page + + collection = collection.cursor_page(options) + + links = (headers['Link'] || "").split(',').map(&:strip) + collection.pagination(request.original_url).each do |k, url| + links << %(<#{url}>; rel="#{k}") + end + total_header = ApiPagination.config.total_header + per_page_header = ApiPagination.config.per_page_header + include_total = ApiPagination.config.include_total + headers['Link'] = links.join(', ') unless links.empty? + headers[per_page_header] = options[:per_page] + headers[total_header] = collection.total_count if include_total + + collection + end + + def _paginate_collection(collection, options={}) options[:page] = ApiPagination.config.page_param(params) options[:per_page] ||= ApiPagination.config.per_page_param(params) diff --git a/spec/cursor/rails_spec.rb b/spec/cursor/rails_spec.rb new file mode 100644 index 0000000..de16055 --- /dev/null +++ b/spec/cursor/rails_spec.rb @@ -0,0 +1,264 @@ +require 'spec_helper' + +if ApiPagination.config.paginator == :cursor + + require 'support/tweets_controller' + require 'support/shared_examples/existing_headers' + require 'support/shared_examples/first_page' + require 'support/shared_examples/middle_page' + require 'support/shared_examples/last_page' + + describe TweetsController, type: :controller do + before do + request.host = 'example.org' + end + + describe 'GET #index' do + let(:links) { response.headers['Link'].split(', ') } + let(:total) { response.headers['Total'].to_i } + let(:per_page) { response.headers['Per-Page'].to_i } + + context 'without enough items to give more than one page' do + before { get :index, params: {count: 10} } + + it 'should not paginate' do + expect(response.headers.keys).not_to include('Link') + end + + it 'should give a Total header' do + expect(total).to eq(10) + end + + it 'should give a Per-Page header' do + expect(per_page).to eq(10) + end + + it 'should list all tweets in the response body' do + body = (1..10).to_a + expect(response_values('n')).to eq(body) + end + end + + context 'with existing Link headers' do + before { Tweet.delete_all; get :index, params: {count: 30, with_headers: true} } + + it 'should keep existing Links' do + expect(links).to include('; rel="without"') + end + + it 'should contain pagination Links' do + expect(links).to include('; rel="next"') + expect(links).to include('; rel="last"') + end + + it 'should give a Total header' do + expect(total).to eq(30) + end + end + + context 'with enough items to paginate' do + context 'when on the first page' do + before { get :index, params: {count: 100} } + + it 'should not give a link with rel "first"' do + expect(links).not_to include('rel="first"') + end + + it 'should not give a link with rel "prev"' do + expect(links).not_to include('rel="prev"') + end + + it 'should give a link with rel "last"' do + expect(links).to include('; rel="last"') + end + + it 'should give a link with rel "next"' do + expect(links).to include('; rel="next"') + end + + it 'should give a Total header' do + expect(total).to eq(100) + end + + it 'should list the first page of tweets in the response body' do + body = (1..10).to_a + expect(response_values('n')).to eq(body) + end + end + + context 'when on the last page' do + before { get :index, params: {count: 100, after: 90} } + + it 'should not give a link with rel "last"' do + expect(links).not_to include('rel="last"') + end + + it 'should not give a link with rel "next"' do + expect(links).not_to include('rel="next"') + end + + it 'should give a link with rel "first"' do + expect(links).to include('; rel="first"') + end + + it 'should give a link with rel "prev"' do + expect(links).to include('; rel="prev"') + end + + it 'should give a Total header' do + expect(total).to eq(100) + end + + it 'should list the last page of tweets in the response body' do + body = (91..100).to_a + expect(response_values('n')).to eq(body) + end + end + + context 'when somewhere comfortably in the middle' do + before { get :index, params: {count: 100, before: 51} } + + it 'should give all pagination links' do + expect(links).to include('; rel="first"') + expect(links).to include('; rel="last"') + expect(links).to include('; rel="next"') + expect(links).to include('; rel="prev"') + end + + it 'should give a Total header' do + expect(total).to eq(100) + end + + it 'should list a middle page of numbers in the response body' do + body = (41..50).to_a.reverse + expect(response_values('n')).to eq(body) + end + end + end + + context 'providing a block' do + it 'yields to the block instead of implicitly rendering' do + get :index_with_custom_render, params: {count: 100} + + json = { tweets: (1..10).map { |n| { number: n } } }.to_json + + expect(response.body).to eq(json) + end + end + + context 'with custom response headers' do + before do + ApiPagination.config.total_header = 'X-Total-Count' + ApiPagination.config.per_page_header = 'X-Per-Page' + ApiPagination.config.page_header = 'X-Page' + + get :index, params: {count: 10} + end + + after do + ApiPagination.config.total_header = 'Total' + ApiPagination.config.per_page_header = 'Per-Page' + ApiPagination.config.page_header = nil + end + + let(:total) { response.header['X-Total-Count'].to_i } + let(:per_page) { response.header['X-Per-Page'].to_i } + let(:page) { response.header['X-Page'].to_i } + + it 'should give a X-Total-Count header' do + headers_keys = response.headers.keys + + expect(headers_keys).not_to include('Total') + expect(headers_keys).to include('X-Total-Count') + expect(total).to eq(10) + end + + it 'should give a X-Per-Page header' do + headers_keys = response.headers.keys + + expect(headers_keys).not_to include('Per-Page') + expect(headers_keys).to include('X-Per-Page') + expect(per_page).to eq(10) + end + end + + context 'configured not to include the total' do + before { ApiPagination.config.include_total = false } + + it 'should not include a Total header' do + get :index, params: {count: 10} + + expect(response.header['Total']).to be_nil + end + + after { ApiPagination.config.include_total = true } + end + + context 'custom per_page param' do + context 'per_page_param as a symbol' do + before do + ApiPagination.config.per_page_param = :foo + end + + after do + ApiPagination.config.per_page_param = :per_page + end + + it 'should work' do + get :index_with_no_per_page, params: {foo: 2, count: 100} + + expect(response.header['Per-Page']).to eq('2') + end + end + + context 'page_param as a block' do + before do + ApiPagination.config.per_page_param do |params| + params[:foo][:bar] + end + end + + after do + ApiPagination.config.per_page_param = :per_page + end + + it 'should work' do + get :index_with_no_per_page, params: {foo: {bar: 2}, count: 100} + + expect(response.header['Per-Page']).to eq('2') + end + end + end + + context 'default per page in model' do + before do + Tweet.class_eval do + paginates_per 6 + end + end + + after do + Tweet.class_eval do + paginates_per 25 + end + end + + it 'should use default per page from model' do + get :index_with_no_per_page, params: {count: 100} + + expect(response.header['Per-Page']).to eq(6) + end + + it 'should not fail if model does not respond to per page' do + Tweet.class_eval do + paginates_per nil + end + + get :index_with_no_per_page, params: {count: 100} + + expect(response.header['Per-Page']).to eq(Cursor.config.default_per_page) + end + end + end + end +end diff --git a/spec/cursor/scopes_spec.rb b/spec/cursor/scopes_spec.rb new file mode 100644 index 0000000..21fb338 --- /dev/null +++ b/spec/cursor/scopes_spec.rb @@ -0,0 +1,141 @@ +require 'spec_helper' + +if ApiPagination.config.paginator == :cursor + + shared_examples_for 'the first after page' do + it { expect(subject.count).to eq(25) } + it { expect(subject.first.text).to eq('tweet001') } + end + + shared_examples_for 'the first before page' do + it { expect(subject.count).to eq(25) } + it { expect(subject.first.text).to eq('tweet100') } + end + + shared_examples_for 'blank page' do + it { expect(subject.count).to eq(0) } + end + + describe Cursor::ActiveRecordExtension do + before do + 1.upto(100) {|i| Tweet.create!(n: i, text: "tweet#{'%03d' % i}")} + end + + [Tweet].each do |model_class| + context "for #{model_class}" do + describe '#page' do + context 'page 1 after' do + subject { model_class.cursor_page(after: 0) } + it_should_behave_like 'the first after page' + end + + context 'page 1 before' do + subject { model_class.cursor_page(before: 101) } + it_should_behave_like 'the first before page' + end + + context 'page 2 after' do + subject { model_class.cursor_page(after: 25) } + it { expect(subject.count).to eq(25) } + it { expect(subject.first.text).to eq('tweet026') } + end + + context 'page 2 before' do + subject { model_class.cursor_page(before: 75) } + it { expect(subject.count).to eq(25) } + it { expect(subject.first.text).to eq('tweet074') } + end + + context 'page without an argument' do + subject { model_class.cursor_page() } + it_should_behave_like 'the first after page' + end + + context 'after page < -1' do + subject { model_class.cursor_page(after: -1) } + it_should_behave_like 'the first after page' + end + + context 'after page > max page' do + subject { model_class.cursor_page(after: 1000) } + it_should_behave_like 'blank page' + end + + context 'before page < 0' do + subject { model_class.cursor_page(before: 0) } + it_should_behave_like 'blank page' + end + + context 'before page > max page' do + subject { model_class.cursor_page(before: 1000) } + it_should_behave_like 'the first before page' + end + + describe 'ensure #order_values is preserved' do + subject { model_class.order('id').cursor_page() } + it { expect(subject.order_values.uniq).to eq ["#{model_class.table_name}.id ASC"] } + end + end + + describe '#per' do + context 'default page per 5' do + subject { model_class.cursor_page.per(5) } + it { expect(subject.count).to eq(5) } + it { expect(subject.first.text).to eq('tweet001') } + end + + context "default page per nil (using default)" do + subject { model_class.cursor_page.per(nil) } + it { expect(subject.count).to eq(model_class.default_per_page) } + end + end + + describe '#next_cursor' do + + context 'after 1st page' do + subject { model_class.cursor_page(after: 0) } + it { expect(subject.next_cursor).to eq(25) } + end + + context 'after middle page' do + subject { model_class.cursor_page(after: 50) } + it { expect(subject.next_cursor).to eq(75) } + end + + context 'before 1st page' do + subject { model_class.cursor_page } + it { expect(subject.next_cursor).to eq(25) } + end + + context 'before middle page' do + subject { model_class.cursor_page(before: 50) } + it { expect(subject.next_cursor).to eq(25) } + end + + end + + describe '#prev_cursor' do + context 'after 1st page' do + subject { model_class.cursor_page(after: 0) } + it { expect(subject.prev_cursor).to eq(1) } + end + + context 'after middle page' do + subject { model_class.cursor_page(after: 50) } + it { expect(subject.prev_cursor).to eq(51) } + end + + context 'before 1st page' do + subject { model_class.cursor_page } + it { expect(subject.prev_cursor).to eq(1) } + end + + context 'before middle page' do + subject { model_class.cursor_page(before: 50) } + it { expect(subject.prev_cursor).to eq(49) } + end + end + end + end + end +end diff --git a/spec/grape_spec.rb b/spec/grape_spec.rb index 4a0b86d..fdccb53 100644 --- a/spec/grape_spec.rb +++ b/spec/grape_spec.rb @@ -4,7 +4,7 @@ require 'support/shared_examples/middle_page' require 'support/shared_examples/last_page' -describe NumbersAPI do +describe NumbersAPI, skip: testing_cursor? do describe 'GET #index' do let(:links) { last_response.headers['Link'].split(', ') } let(:total) { last_response.headers['Total'].to_i } diff --git a/spec/rails_spec.rb b/spec/rails_spec.rb index cf6d7a4..3011609 100644 --- a/spec/rails_spec.rb +++ b/spec/rails_spec.rb @@ -4,7 +4,7 @@ require 'support/shared_examples/middle_page' require 'support/shared_examples/last_page' -describe NumbersController, :type => :controller do +describe NumbersController, :type => :controller, skip: testing_cursor? do before { request.host = 'example.org' } describe 'GET #index' do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index b723f64..a33d095 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,13 +1,51 @@ require 'support/numbers_controller' require 'support/numbers_api' require 'api-pagination' +require 'pry' if ENV['PAGINATOR'].nil? warn 'No PAGINATOR set. Defaulting to kaminari. To test against will_paginate, run `PAGINATOR=will_paginate bundle exec rspec`' ENV['PAGINATOR'] = 'kaminari' end -require ENV['PAGINATOR'] +def testing_cursor? + ENV['PAGINATOR'] == 'cursor' +end + +if testing_cursor? + require 'sqlite3' + require 'active_record' + require 'database_cleaner' + + DatabaseCleaner[:active_record].strategy = :transaction if defined? ActiveRecord + + RSpec.configure do |config| + config.before :suite do + DatabaseCleaner.clean_with :truncation if defined? ActiveRecord + end + config.before :each do + DatabaseCleaner.start + end + config.after :each do + DatabaseCleaner.clean + end + end + + ActiveRecord::Base.establish_connection(adapter: 'sqlite3', database: ':memory:') + ActiveRecord::Schema.define do + self.verbose = false + create_table :tweets, force: true do |t| + t.integer :n + t.string :text + end + end + + class Tweet < ActiveRecord::Base + end +else + require ENV['PAGINATOR'] +end + ApiPagination.config.paginator = ENV['PAGINATOR'].to_sym require 'will_paginate/array' @@ -31,3 +69,7 @@ def app NumbersAPI end end + +def response_values(attr) + JSON.parse(response.body).map{|e| e[attr]} +end diff --git a/spec/support/tweets_controller.rb b/spec/support/tweets_controller.rb new file mode 100644 index 0000000..7859377 --- /dev/null +++ b/spec/support/tweets_controller.rb @@ -0,0 +1,66 @@ +require 'action_controller' +require 'api-pagination/hooks' + +Rails.application.routes.disable_clear_and_finalize = true +Rails.application.routes.draw do + resources :tweets, :only => [:index] do + collection do + get :index_with_custom_render + get :index_with_no_per_page + get :index_with_paginate_array_options + end + end +end + +class TweetsSerializer + def initialize(tweets) + @tweets = tweets + end + + def to_json(options = {}) + { tweets: @tweets.map { |t| { number: t.n } } }.to_json + end +end + +class TweetsController < ApiPagination::Hooks.rails_parent_controller + include Rails.application.routes.url_helpers + + def index + total = params.fetch(:count).to_i + + if params[:with_headers] + query = request.query_parameters.dup + query.delete(:with_headers) + headers['Link'] = %(<#{tweets_url}?#{query.to_param}>; rel="without") + end + create_tweets(total) + + cursor_paginate json: Tweet.all, per_page: 10 + end + + def index_with_custom_render + total = params.fetch(:count).to_i + create_tweets(total) + tweets = Tweet.all + tweets = cursor_paginate tweets, per_page: 10 + + render json: TweetsSerializer.new(tweets) + end + + def index_with_no_per_page + total = params.fetch(:count).to_i + create_tweets(total) + tweets = Tweet.all + tweets = cursor_paginate tweets + + render json: TweetsSerializer.new(tweets) + end + + private + + def create_tweets(count) + count.times do |n| + Tweet.create!(n: n + 1) + end + end +end