paging_cursor 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 5a49f5abf6c5a0d96017ad40e05592dd78a208a4
4
+ data.tar.gz: 4df33eda9a444e83f823ecff325ae23450b6fbe2
5
+ SHA512:
6
+ metadata.gz: 1b9804935cd5836c327590b37402b4bac7e9eeeca41ffa10703bf237388358c657ad313db67a44266f642f89e0581966f1667781569c8d3085f9b0190b8a6f72
7
+ data.tar.gz: 783305c3f6a8dd815daa4b78d905fd396b2425a1e8367523891212a8ea313b3a722b7b5b9fe3fc06b9cb51215704d70944501f1368cfd65f21ade0f2d8321f5e
data/LICENSE ADDED
@@ -0,0 +1,18 @@
1
+ Copyright (c) 2016 Rebecca "Becky" Segal
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
7
+ the Software, and to permit persons to whom the Software is furnished to do so,
8
+ subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
15
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
16
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
17
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
18
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # Usage
2
+
3
+ Use `.before`, `.after`, or `.cursor` in ActiveRecord queries to paginate data. A default limit is always applied, or you can override by including `.limit` in your request.
4
+
5
+ For example, say we have an app with User and Post models.
6
+
7
+ ```
8
+ # app/models/user.rb
9
+ class User < ActiveRecord::Base
10
+ has_many :posts
11
+ end
12
+
13
+ # app/models/post.rb
14
+ class Post < ActiveRecord::Base
15
+ belongs_to :user
16
+ end
17
+ ```
18
+
19
+ We can retrieve posts starting with the most recent
20
+
21
+ ```
22
+ Post.before # get the first page of most recent posts with the default limit
23
+ Post.before.limit(10) # get the 10 most recent posts
24
+ Post.before(999).limit(10) # get the 10 most recent posts that were created before the post with id=999
25
+ Post.where(user: User.first).before(999) # get the 10 most recent posts meeting a condition
26
+ User.first.posts.before # get the most recent posts belong to a user
27
+ ```
28
+
29
+
30
+ Likewise, we can results starting from the oldest records
31
+
32
+ ```
33
+ Post.after # get the first page of oldest posts with the default limit
34
+ Post.after.limit(10) # get the 10 oldest posts
35
+ Post.after(999).limit(10) # get the 10 oldest posts that are newer than the post with id=999
36
+ Post.where(user: User.first).after(999) # get the 10 oldest posts meeting a condition
37
+ User.first.posts.after # get the oldest posts belong to a user
38
+ ```
39
+
40
+ # Sorting results
41
+
42
+ By default, all results are returned in ascending order.
43
+
44
+
45
+ To customize, set a config option, class option or per-query option
46
+
47
+
48
+ # Adding pagination metadata to responses
49
+
50
+ This gem makes no assumptions about how you want to return pagination metadata in your responses. Arrays and results provide the cursor information you need.
51
+
52
+ ```
53
+ result = Post.before(999)
54
+ result.length
55
+ => 20
56
+ result.cursor_before
57
+ => 990 # the minimum id included in the result
58
+ result.cursor_after
59
+ => 998 # the maximum id included in the result
60
+ ```
61
+
62
+ TODO
63
+ * option for sorting results
64
+ * cursor methods on array
65
+ * global default for limits
66
+ * per-model setting for limits
@@ -0,0 +1,7 @@
1
+ module PagingCursor
2
+ end
3
+
4
+
5
+ require 'paging_cursor/config'
6
+ require 'paging_cursor/direction'
7
+ require 'paging_cursor/active_record'
@@ -0,0 +1,86 @@
1
+ require 'active_record'
2
+
3
+ module PagingCursor
4
+ module ActiveRecord
5
+
6
+ # TODO: option to set which column is used for pagination
7
+ # default = :id
8
+ module FinderMethods
9
+
10
+ # default order = after
11
+ def cursor(options={})
12
+ options = HashWithIndifferentAccess.new(options)
13
+ if options.has_key?(:before) || (!options.has_key?(:after) && PagingCursor.config.default_sort_order == :desc)
14
+ result = before(options[:before])
15
+ else
16
+ result = after(options[:after])
17
+ end
18
+ result.limit(options[:limit] || self.cursor_page_limit)
19
+ end
20
+
21
+ def before(cursor=nil)
22
+ result = where(cursor ? arel_table[primary_key].lt(cursor) : nil).reorder(arel_table[primary_key].desc)
23
+ result.sort_order = :desc
24
+ result.cursored = true
25
+ result
26
+ end
27
+
28
+ def after(cursor=nil)
29
+ result = where(arel_table[primary_key].gt(cursor || 0)).reorder(arel_table[primary_key].asc)
30
+ result.sort_order = :asc
31
+ result.cursored = true
32
+ result
33
+ end
34
+ end
35
+
36
+ module Limit
37
+ attr_accessor :cursor_page_limit
38
+
39
+ # TODO: allow setting default at global and model levels
40
+ def initialize *a
41
+ self.default_page_limit = 25
42
+ super *a
43
+ end
44
+
45
+ def cursor_page_limit
46
+ @cursor_page_limit ||= PagingCursor.config.default_page_limit
47
+ end
48
+ end
49
+
50
+ module SortedResults
51
+ attr_accessor :sort_order, :cursored
52
+
53
+ def initialize *a
54
+ self.sort_order = :asc
55
+ self.cursored = false # todo, separate module??
56
+ super *a
57
+ end
58
+
59
+ def to_a
60
+ return super unless self.cursored
61
+ r = ::PagingCursor::Array.new(super)
62
+ if self.sort_order != PagingCursor.config.default_sort_order.to_sym
63
+ r.reverse!
64
+ end
65
+ r
66
+ end
67
+ end
68
+
69
+ ::ActiveRecord::Base.extend FinderMethods
70
+ ::ActiveRecord::Base.extend Limit
71
+
72
+ klasses = [::ActiveRecord::Relation]
73
+ if defined? ::ActiveRecord::Associations::CollectionProxy
74
+ klasses << ::ActiveRecord::Associations::CollectionProxy
75
+ else
76
+ klasses << ::ActiveRecord::Associations::AssociationCollection
77
+ end
78
+
79
+ # # support pagination on associations and scopes
80
+ klasses.each do |klass|
81
+ klass.send(:prepend, SortedResults)
82
+ klass.send(:include, FinderMethods)
83
+ klass.send(:include, ::PagingCursor::Direction)
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,31 @@
1
+ require 'active_support/configurable'
2
+
3
+ module PagingCursor
4
+
5
+ def self.configure(&block)
6
+ yield @config ||= PagingCursor::Configuration.new
7
+ end
8
+
9
+ def self.config
10
+ @config
11
+ end
12
+
13
+ class Configuration
14
+ include ActiveSupport::Configurable
15
+ config_accessor :default_sort_order
16
+ config_accessor :default_page_limit
17
+
18
+ def param_name
19
+ config.param_name.respond_to?(:call) ? config.param_name.call : config.param_name
20
+ end
21
+
22
+ writer, line = 'def param_name=(value); config.param_name = value; end', __LINE__
23
+ singleton_class.class_eval writer, __FILE__, line
24
+ class_eval writer, __FILE__, line
25
+ end
26
+
27
+ configure do |config|
28
+ config.default_sort_order = :asc
29
+ config.default_page_limit = 25
30
+ end
31
+ end
@@ -0,0 +1,26 @@
1
+ module PagingCursor
2
+ module Direction
3
+
4
+ # TODO: *terrible* shouldn't need to cast to array to ensure sort order
5
+ # def first(limit = nil)
6
+ # limit.nil? ? to_a.first : super
7
+ # end
8
+
9
+ # def last(limit = nil)
10
+ # limit.nil? ? to_a.last : super
11
+ # end
12
+
13
+ def cursor_before
14
+ self.collect(&:id).min
15
+ end
16
+
17
+ def cursor_after
18
+ self.collect(&:id).max
19
+ end
20
+ end
21
+
22
+ class Array < ::Array
23
+ end
24
+
25
+ Array.include Direction
26
+ end
@@ -0,0 +1,172 @@
1
+ require 'rails_helper'
2
+
3
+ describe 'finders' do
4
+ shared_examples_for 'the first 5 posts' do
5
+ specify { expect(result).to eq(Post.first(5)) }
6
+ end
7
+
8
+ shared_examples_for 'the last 5 posts' do
9
+ specify { expect(result).to eq(Post.last(5))}
10
+ end
11
+
12
+ shared_examples_for 'the first 5 posts after 2' do
13
+ specify { expect(result.collect(&:id)).to eq((@min+2 .. @min+6).to_a) }
14
+ end
15
+
16
+ shared_examples_for 'the last 5 posts except 2' do
17
+ specify { expect(result.collect(&:id)).to eq((@max-6 .. @max-2).to_a) }
18
+ end
19
+
20
+ before do
21
+ PagingCursor.config.default_sort_order = :asc
22
+ @user = User.create
23
+ 10.times do
24
+ Post.create(user_id: @user.id)
25
+ end
26
+ @min = Post.first.id
27
+ @max = Post.last.id
28
+ end
29
+
30
+ after do
31
+ Post.delete_all
32
+ User.delete_all
33
+ end
34
+
35
+ it 'accepts the limit parameter through cursor' do
36
+ expect(Post.cursor(limit: 2).size).to eq(2)
37
+ end
38
+
39
+ it 'accepts string and symbol eys' do
40
+ expect(Post.cursor('limit' => 2)).to eq(Post.cursor(limit: 2))
41
+ end
42
+
43
+ it 'does not override limit() with cursor option' do
44
+ expect(Post.cursor(limit: 2).limit(8).size).to eq(8)
45
+ end
46
+
47
+ it 'returns a PagingCursor::Array' do
48
+ expect(Post.after.to_a.class).to be(PagingCursor::Array)
49
+ end
50
+
51
+ it 'respects configured desc sort order' do
52
+ PagingCursor.config.default_sort_order = :desc
53
+ after = Post.after
54
+ before = Post.before
55
+ cursor = Post.cursor
56
+ expect(after[0].id).to be > after[1].id
57
+ expect(before[0].id).to be > before[1].id
58
+ expect(cursor[0].id).to be > cursor[1].id
59
+ end
60
+
61
+ it 'respects configured asc sort order' do
62
+ PagingCursor.config.default_sort_order = :asc
63
+ after = Post.after
64
+ before = Post.before
65
+ cursor = Post.cursor
66
+ expect(after[1].id).to be > after[0].id
67
+ expect(before[1].id).to be > before[0].id
68
+ expect(cursor[1].id).to be > cursor[0].id
69
+ end
70
+
71
+ context 'on active record' do
72
+ it_behaves_like "the first 5 posts" do
73
+ let(:result) { Post.after.limit(5) }
74
+ end
75
+
76
+ it_behaves_like "the last 5 posts" do
77
+ let(:result) { Post.before.limit(5) }
78
+ end
79
+
80
+ it_behaves_like "the first 5 posts after 2" do
81
+ let(:result) { Post.after(@min + 1).limit(5) }
82
+ end
83
+
84
+ it_behaves_like "the last 5 posts except 2" do
85
+ let(:result) { Post.before(@max-1).limit(5) }
86
+ end
87
+
88
+ it_behaves_like "the first 5 posts" do
89
+ let(:result) { Post.cursor(after: nil).limit(5) }
90
+ end
91
+
92
+ it_behaves_like "the last 5 posts" do
93
+ let(:result) { Post.cursor(before: nil).limit(5) }
94
+ end
95
+
96
+ it_behaves_like "the first 5 posts after 2" do
97
+ let(:result) { Post.cursor(after: @min + 1).limit(5) }
98
+ end
99
+
100
+ it_behaves_like "the last 5 posts except 2" do
101
+ let(:result) { Post.cursor(before: @max-1).limit(5) }
102
+ end
103
+ end
104
+
105
+ context 'on relations' do
106
+ it_behaves_like "the first 5 posts" do
107
+ let(:result) { Post.where(user_id: @user.id).after.limit(5) }
108
+ end
109
+
110
+ it_behaves_like "the last 5 posts" do
111
+ let(:result) { Post.where(user_id: @user.id).before.limit(5) }
112
+ end
113
+
114
+ it_behaves_like "the first 5 posts after 2" do
115
+ let(:result) { Post.where(user_id: @user.id).after(@min + 1).limit(5) }
116
+ end
117
+
118
+ it_behaves_like "the last 5 posts except 2" do
119
+ let(:result) { Post.where(user_id: @user.id).before(@max - 1).limit(5) }
120
+ end
121
+
122
+ it_behaves_like "the first 5 posts" do
123
+ let(:result) { Post.where(user_id: @user.id).cursor(after: nil).limit(5) }
124
+ end
125
+
126
+ it_behaves_like "the last 5 posts" do
127
+ let(:result) { Post.where(user_id: @user.id).cursor(before: nil).limit(5) }
128
+ end
129
+
130
+ it_behaves_like "the first 5 posts after 2" do
131
+ let(:result) { Post.where(user_id: @user.id).cursor(after: @min + 1).limit(5) }
132
+ end
133
+
134
+ it_behaves_like "the last 5 posts except 2" do
135
+ let(:result) { Post.where(user_id: @user.id).cursor(before: @max-1).limit(5) }
136
+ end
137
+ end
138
+
139
+ context 'on associations' do
140
+ it_behaves_like "the first 5 posts" do
141
+ let(:result) { @user.posts.after.limit(5) }
142
+ end
143
+
144
+ it_behaves_like "the last 5 posts" do
145
+ let(:result) { @user.posts.before.limit(5) }
146
+ end
147
+
148
+ it_behaves_like "the first 5 posts after 2" do
149
+ let(:result) { @user.posts.after(@min + 1).limit(5) }
150
+ end
151
+
152
+ it_behaves_like "the last 5 posts except 2" do
153
+ let(:result) { @user.posts.before(@max - 1).limit(5) }
154
+ end
155
+
156
+ it_behaves_like "the first 5 posts" do
157
+ let(:result) { @user.posts.cursor(after: nil).limit(5) }
158
+ end
159
+
160
+ it_behaves_like "the last 5 posts" do
161
+ let(:result) { @user.posts.cursor(before: nil).limit(5) }
162
+ end
163
+
164
+ it_behaves_like "the first 5 posts after 2" do
165
+ let(:result) { @user.posts.cursor(after: @min + 1).limit(5) }
166
+ end
167
+
168
+ it_behaves_like "the last 5 posts except 2" do
169
+ let(:result) { @user.posts.cursor(before: @max - 1).limit(5) }
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,21 @@
1
+ require 'rails_helper'
2
+
3
+ describe PagingCursor::Array do
4
+ it "adds before and after cursors" do
5
+ a = PagingCursor::Array.new([u1=User.create, u2=User.create])
6
+ expect(a.cursor_before).to eq(u1.id)
7
+ expect(a.cursor_after).to eq(u2.id)
8
+ end
9
+
10
+ it "adds correct cursors when array is not sorted" do
11
+ a = PagingCursor::Array.new([u1=User.create, u2=User.create].reverse)
12
+ expect(a.cursor_before).to eq(u1.id)
13
+ expect(a.cursor_after).to eq(u2.id)
14
+ end
15
+
16
+ it "adds cursors for non-id primary key columns" do
17
+ a = PagingCursor::Array.new([t1=Tag.create, t2=Tag.create])
18
+ expect(a.cursor_before).to eq(t1.name)
19
+ expect(a.cursor_after).to eq(t2.name)
20
+ end
21
+ end
@@ -0,0 +1,13 @@
1
+ ENV['RAILS_ENV'] ||= 'test'
2
+
3
+ require 'spec_helper'
4
+ require 'rails/all'
5
+ require 'rspec/rails'
6
+ ActiveRecord::Base.establish_connection adapter: "sqlite3", database: ":memory:"
7
+
8
+ load File.dirname(__FILE__) + '/support/schema.rb'
9
+ require File.dirname(__FILE__) + '/support/models.rb'
10
+
11
+ RSpec.configure do |config|
12
+
13
+ end
@@ -0,0 +1,7 @@
1
+ require 'bundler/setup'
2
+ Bundler.setup
3
+
4
+ require 'paging_cursor'
5
+
6
+ RSpec.configure do |config|
7
+ end
@@ -0,0 +1,11 @@
1
+ class User < ActiveRecord::Base
2
+ has_many :posts
3
+ end
4
+
5
+ class Post < ActiveRecord::Base
6
+ belongs_to :user
7
+ end
8
+
9
+ class Tag < ActiveRecord::Base
10
+ self.primary_key = 'name'
11
+ end
@@ -0,0 +1,14 @@
1
+ ActiveRecord::Schema.define do
2
+ self.verbose = false
3
+
4
+ create_table :users, force: true do |t|
5
+ end
6
+
7
+ create_table :posts, force: true do |t|
8
+ t.integer :user_id
9
+ end
10
+
11
+ create_table :tags, force: true, id: false do |t|
12
+ t.string :name, primary_key: true
13
+ end
14
+ end
metadata ADDED
@@ -0,0 +1,111 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: paging_cursor
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Becky Segal
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-04-23 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rspec
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rails
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 4.0.0
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 4.0.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: sqlite3
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rspec-rails
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ description: ActiveRecord and ActiveController extensions for cursor pagination
70
+ email: becsegal@gmail.com
71
+ executables: []
72
+ extensions: []
73
+ extra_rdoc_files: []
74
+ files:
75
+ - LICENSE
76
+ - README.md
77
+ - lib/paging_cursor.rb
78
+ - lib/paging_cursor/active_record.rb
79
+ - lib/paging_cursor/config.rb
80
+ - lib/paging_cursor/direction.rb
81
+ - spec/active_record_spec.rb
82
+ - spec/array_spec.rb
83
+ - spec/rails_helper.rb
84
+ - spec/spec_helper.rb
85
+ - spec/support/models.rb
86
+ - spec/support/schema.rb
87
+ homepage:
88
+ licenses:
89
+ - MIT
90
+ metadata: {}
91
+ post_install_message:
92
+ rdoc_options: []
93
+ require_paths:
94
+ - lib
95
+ required_ruby_version: !ruby/object:Gem::Requirement
96
+ requirements:
97
+ - - ">="
98
+ - !ruby/object:Gem::Version
99
+ version: '0'
100
+ required_rubygems_version: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ requirements: []
106
+ rubyforge_project:
107
+ rubygems_version: 2.4.6
108
+ signing_key:
109
+ specification_version: 4
110
+ summary: ActiveRecord and ActiveController extensions for cursor pagination
111
+ test_files: []