Skip to content

Commit

Permalink
add cursor based pagination
Browse files Browse the repository at this point in the history
  • Loading branch information
accessd committed Mar 20, 2017
1 parent 18eccd6 commit 8376c1e
Show file tree
Hide file tree
Showing 18 changed files with 804 additions and 6 deletions.
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ script: bundle exec rspec
env:
- PAGINATOR=kaminari
- PAGINATOR=will_paginate
- PAGINATOR=cursor
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: <http://localhost:3000/movies>; rel="first",
<http://localhost:3000/movies?after=90>; rel="last",
<http://localhost:3000/movies?after=70>; rel="next",
<http://localhost:3000/movies?before=61>; 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:
Expand Down
3 changes: 3 additions & 0 deletions api-pagination.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions lib/api-pagination/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -103,6 +105,10 @@ def last_page?() !next_page end

@paginator = :will_paginate
end

def use_cursor_paginator
@paginator = :cursor
end
end

class << self
Expand Down
5 changes: 5 additions & 0 deletions lib/api-pagination/hooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions lib/cursor/active_record_extension.rb
Original file line number Diff line number Diff line change
@@ -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
41 changes: 41 additions & 0 deletions lib/cursor/active_record_model_extension.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
require_relative 'config'
require_relative 'configuration_methods'
require_relative 'page_scope_methods'

module Cursor
module ActiveRecordModelExtension
extend ActiveSupport::Concern

class_methods do
cattr_accessor :total_count
end

included do
self.send(:include, Cursor::ConfigurationMethods)

def self.cursor_page(options = {})
(options || {}).to_hash.symbolize_keys!
options[:direction] = options.keys.include?(:after) ? :after : :before

cursor_id = options[options[:direction]]
self.total_count = self.count
on_cursor(cursor_id, options[:direction]).
in_direction(options[:direction]).
limit(options[:per_page] || default_per_page).
extending(Cursor::PageScopeMethods)
end

def self.on_cursor(cursor_id, direction)
if cursor_id.nil?
where(nil)
else
where(["#{self.table_name}.id #{direction == :after ? '>' : '<'} ?", cursor_id])
end
end

def self.in_direction(direction)
reorder("#{self.table_name}.id #{direction == :after ? 'ASC' : 'DESC'}")
end
end
end
end
31 changes: 31 additions & 0 deletions lib/cursor/config.rb
Original file line number Diff line number Diff line change
@@ -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
36 changes: 36 additions & 0 deletions lib/cursor/configuration_methods.rb
Original file line number Diff line number Diff line change
@@ -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 <tt>paginates_per</tt>
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 <tt>max_paginates_per</tt>
def max_per_page
(defined?(@_max_per_page) && @_max_per_page) || Cursor.config.max_per_page
end

end
end
end
66 changes: 66 additions & 0 deletions lib/cursor/page_scope_methods.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 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 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)
{}.tap do |h|
h[:prev] = prev_url(request_url) unless prev_cursor.nil?
h[:next] = next_url(request_url) unless next_cursor.nil?
end
end
end
end
43 changes: 41 additions & 2 deletions lib/rails/pagination.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 = detect_pagination_method(options)
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]
Expand All @@ -21,8 +22,46 @@ 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 detect_pagination_method(options = {})
options.delete(:paginate_method) || '_paginate_collection'
end

def _cursor_paginate_collection(collection, options={})
options[:per_page] ||= ApiPagination.config.per_page_param(params)

options[:before] = params[:before] if params.key?(:before)
options[:after] = params[:after] if params.key?(:after)
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)
Expand Down
Loading

0 comments on commit 8376c1e

Please sign in to comment.