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 15, 2017
1 parent 18eccd6 commit 435f26a
Show file tree
Hide file tree
Showing 18 changed files with 858 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
45 changes: 45 additions & 0 deletions lib/cursor/active_record_model_extension.rb
Original file line number Diff line number Diff line change
@@ -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
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
110 changes: 110 additions & 0 deletions lib/cursor/page_scope_methods.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 435f26a

Please sign in to comment.