activerecord_cursor_pagination 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ec58ef3cae3354d8ae2bde159b228728f072aa4f79bd57ea7d950cfd7a56dd7c
4
+ data.tar.gz: 1b5d74a9eb2c66cd08219688f97c697f5df984551bee64a83c763a964971357e
5
+ SHA512:
6
+ metadata.gz: 5440057c5689b27043c534e49048579c7a2a21108f4b7c91e39cf3bb1f1ee08bbd7f366ce14e25156a55fcbff3cebd5b8274803c86e16ce952bfd54c40a1991e
7
+ data.tar.gz: '0219a04ac75387cbe73056447dfdbfb6cbf7cf3cc7bf0118f35aab50128910560f72134786c695de93de7834acb5fac6240316db3863c951cf9bce54f9c7a5a7'
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,13 @@
1
+ AllCops:
2
+ TargetRubyVersion: 2.6
3
+
4
+ Style/StringLiterals:
5
+ Enabled: true
6
+ EnforcedStyle: double_quotes
7
+
8
+ Style/StringLiteralsInInterpolation:
9
+ Enabled: true
10
+ EnforcedStyle: double_quotes
11
+
12
+ Layout/LineLength:
13
+ Max: 120
data/Gemfile ADDED
@@ -0,0 +1,5 @@
1
+ source "https://rubygems.org"
2
+
3
+ git_source(:github) { |repo_name| "https://github.com/#{repo_name}" }
4
+
5
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2021 James Fawks
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,220 @@
1
+ ![Tests](https://github.com/jefawks3/activerecord_cursor_pagination/actions/workflows/test.yml/badge.svg)
2
+
3
+ # ActiverecordCursorPagination
4
+
5
+ ActiveRecord plugin for cursor based pagination using a serialized representation of the pages to paginate your content.
6
+
7
+ The main advantage to cursor based pagination over the traditional (`limit` & `offset`) is that the cursors are not
8
+ impacted by changes to the query (i.e. new records or records that no longer fit the query conditions).
9
+
10
+ The advantage of `ActiverecordCursorPagination` over other gems is their is no requirement to define a row key (usually `id`) to sort
11
+ the records. This allows for more complex queries to include joins or subqueries, and for ordering to also include
12
+ table aliases or complex operations.
13
+
14
+ ## Motivation
15
+
16
+ I needed a cursor pagination method that was key agnostic and where I can order the records in any method I wish;
17
+ including using complex queries or table aliases. This was especially important when building out user feeds.
18
+
19
+ ## Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'activerecord_cursor_pagination'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ $ bundle install
30
+
31
+ Or install it yourself as:
32
+
33
+ $ gem install activerecord_cursor_pagination
34
+
35
+ ## The `cursor` Basics
36
+
37
+ By default, `ActiverecordCursorPagination` defaults to 15 results per page.
38
+
39
+ ```ruby
40
+ # Imagine there are 60 *total* posts (at 10 results/page, that is 6 pages)
41
+ cursor = Posts.where(published: true)
42
+ .order(published_at: :desc)
43
+ .cursor(nil)
44
+
45
+ cursor.per_page # => 10
46
+
47
+ # scoped to the whole query
48
+ cursor.scope_size # => 60
49
+ cursor.scope_empty? # => false
50
+ cursor.scope_any? # => true
51
+ cursor.scope_one? # => false
52
+ cursor.scope_many? # => true
53
+
54
+ # scoped to the current page
55
+ cursor.size # => 10
56
+ cursor.empty? # => false
57
+ cursor.any? # => true
58
+ cursor.one? # => false
59
+ cursor.many? # => true
60
+
61
+ # pagination
62
+ cursor.current_page # => "serialized cursor..."
63
+ cursor.first_page? # => true
64
+ cursor.last_page? # => false
65
+ cursor.next_page? # => true
66
+ cursor.next_page # => "serialized cursor..."
67
+ cursor.previous_page? # => false
68
+ cursor.previous_cursor # => ""
69
+ ```
70
+
71
+ To retrieve the next page of results, pass the next page cursor.
72
+
73
+ ```ruby
74
+ cursor = Posts.where(published: true)
75
+ .order(published_at: :desc)
76
+ .cursor("next page serialized cursor...")
77
+
78
+ cursor.per_page # => 10
79
+
80
+ # scoped to the whole query
81
+ cursor.scope_size # => 60
82
+ cursor.scope_empty? # => false
83
+ cursor.scope_any? # => true
84
+ cursor.scope_one? # => false
85
+ cursor.scope_many? # => true
86
+
87
+ # scoped to the current page
88
+ cursor.size # => 10
89
+ cursor.empty? # => false
90
+ cursor.any? # => true
91
+ cursor.one? # => false
92
+ cursor.many? # => true
93
+
94
+ # pagination
95
+ cursor.current_page # => "serialized cursor..."
96
+ cursor.first_page? # => false
97
+ cursor.last_page? # => false
98
+ cursor.next_page? # => true
99
+ cursor.next_page # => "serialized cursor..."
100
+ cursor.previous_page? # => true
101
+ cursor.previous_cursor # => "serialized cursor..."
102
+ ```
103
+
104
+ You can iterate through the current page of results.
105
+
106
+ ```ruby
107
+ cursor.each { |record| /* do something */ }
108
+ cursor.each_with_index { |record, index| /* do something */ }
109
+ mapped = cursor.map { |record| /* do something */ }
110
+ mapped = cursor.map_with_index { |record, index| /* do something */ }
111
+ ```
112
+
113
+ A custom number of results per page can be specified by passing the `per` option.
114
+
115
+ ```ruby
116
+ cursor = Posts.where(published: true)
117
+ .order(published_at: :desc)
118
+ .cursor(nil, per: 50)
119
+ ```
120
+
121
+ ## PagerView Helpers
122
+
123
+ Lets image you have a pager view that displays one `Post` at a time and you have left and right errors to go to
124
+ the next or previous record.
125
+
126
+ ```ruby
127
+ post = Post.find(10)
128
+
129
+ cursor = Posts.where(published: true)
130
+ .order(published_at: :desc)
131
+ .cursor(post, per: 1)
132
+
133
+ cursor.next_cursor_record # => [Post] Next published post
134
+ cursor.previous_cursor_record # => [Post] Previous published post
135
+ ```
136
+
137
+ **Make sure to set `per` to `1` or you will get a `NotSingleRecordError`**
138
+
139
+ If no record can be found, `next_cursor_record` and `previous_cursor_record` will return `nil`.
140
+
141
+ ## Configuration
142
+
143
+ Configure `ActiverecordCursorPagination` using the `setup` method.
144
+
145
+ ```ruby
146
+ ActiverecordCursorPagination.setup do |config|
147
+ config.secret_key = 'your super secret key'
148
+ config.serializer = YourCustomSerializer
149
+ end
150
+ ```
151
+
152
+ ## Custom Cursor Serializer
153
+
154
+ To create a custom cursor serializer, you need to override `ActiverecordCursorPagination::Serializer`.
155
+ Call `secret_key` in your custom class to get the configured cursor key.
156
+
157
+ **If you secure your database with external ids, make sure to encrypt the tokens so you don't
158
+ expose the internal database ids.**
159
+
160
+ For instance, to create a `JWT` serializer:
161
+
162
+ ```ruby
163
+ class JwtCursorSerializer < ActiverecordCursorPagination::Serializer
164
+ def deserialize(str)
165
+ data = JWT.decode str,
166
+ secret_key,
167
+ true,
168
+ { algorithm: 'HS256' }
169
+
170
+ data.first.symbolize_keys
171
+ end
172
+
173
+ def serialize(hash)
174
+ JWT.encode hash, secret_key,'HS256'
175
+ end
176
+ end
177
+ ```
178
+
179
+ Make sure to configure `ActiverecordCursorPagination` by setting the `serializer`
180
+ configuration option with your new serializer.
181
+
182
+ ## Known Issues/Limitations
183
+
184
+ - There is no known public method to call to get the order values. Currently calls `order_values` to get a list of all order values in the current query scope.
185
+ - When using a sub query or `CASE` statement as an order value, you have to use single quote strings.
186
+
187
+ ## Development
188
+
189
+ After checking out the repo, run `bin/setup` to install dependencies.
190
+ Then, run `rake spec` to run the tests.
191
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
192
+
193
+ To install this gem onto your local machine, run `bundle exec rake install`.
194
+
195
+ ## Testing
196
+
197
+ Run `rake rspec` to run all the tests or you can run:
198
+ - `rake rspec [path]` to run all the tests in a given directory,
199
+ - or `rake rspec [file]` to run a specific file.
200
+
201
+ ## Contributing
202
+
203
+ Bug reports and pull requests are welcome on GitHub at https://github.com/jefawks3/activerecord_cursor_pagination.
204
+
205
+ I hope that you will consider contributing to ActiverecordCursorPagination.
206
+ You can contribute in many ways. For example, you might:
207
+ - add documentation and “how-to” articles to the README or Wiki.
208
+ - hack on ActiverecordCursorPagination itself by fixing bugs you've found in the GitHub Issue tracker or adding new features to ActiverecordCursorPagination.
209
+
210
+ When contributing to ActiverecordCursorPagination, we ask that you:
211
+ - let me know what you plan in the GitHub Issue tracker so I can provide feedback.
212
+ - provide tests and documentation whenever possible. It is very unlikely that I will accept new features or functionality into ActiverecordCursorPagination without the proper testing and documentation. When fixing a bug, provide a failing test case that your patch solves.
213
+ - open a GitHub Pull Request with your patches and I will review your contribution and respond as quickly as possible.
214
+
215
+ Keep in mind that this is an open source project, and it may take me some time to get back to you.
216
+ Your patience is very much appreciated.
217
+
218
+ ## License
219
+
220
+ The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "activerecord_cursor_pagination"
6
+
7
+ # You can add fixtures and/or initialization code here to make experimenting
8
+ # with your gem easier. You can also use a different console, if you like.
9
+
10
+ # (If you use this, don't forget to add pry to your Gemfile!)
11
+ # require "pry"
12
+ # Pry.start
13
+
14
+ require "irb"
15
+ IRB.start(__FILE__)
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -0,0 +1,14 @@
1
+ class JwtCursorSerializer < ActiverecordCursorPagination::Serializer
2
+ def deserialize(str)
3
+ data = JWT.decode str,
4
+ secret_key,
5
+ true,
6
+ { algorithm: 'HS256' }
7
+
8
+ data.first.symbolize_keys
9
+ end
10
+
11
+ def serialize(hash)
12
+ JWT.encode hash, secret_key,'HS256'
13
+ end
14
+ end
@@ -0,0 +1,21 @@
1
+ module ActiverecordCursorPagination
2
+ class AscendingOrder < OrderBase
3
+ def direction
4
+ :asc
5
+ end
6
+
7
+ def reverse
8
+ order = DescendingOrder.new table, name, index
9
+ order.base_id = base_id
10
+ order
11
+ end
12
+
13
+ def than_op
14
+ '>'
15
+ end
16
+
17
+ def than_or_equal_op
18
+ '>='
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,19 @@
1
+ module ActiverecordCursorPagination
2
+ class ClassFormatter
3
+ ##
4
+ # Format the class name
5
+ #
6
+ # @param [String, Symbol, Class] klass_or_name
7
+ #
8
+ # @return [String, nil] The formatted class name
9
+ def format(klass_or_name)
10
+ if klass_or_name.nil? || klass_or_name.is_a?(String)
11
+ klass_or_name
12
+ elsif klass_or_name.is_a? Symbol
13
+ klass_or_name.to_s.camelcase
14
+ else
15
+ klass_or_name.name
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,45 @@
1
+ module ActiverecordCursorPagination
2
+ class Configuration
3
+ attr_accessor :serializer, :secret_key
4
+
5
+ def initialize
6
+ setup_defaults
7
+ end
8
+
9
+ ##
10
+ # Gets the secret key base for secure cursor implementations.
11
+ #
12
+ # If Rails is defined, +secret_key+ will try to find the implementation of the default +secret_key_base+
13
+ # in the application.
14
+ #
15
+ # @raise [NoSecretKeyError] If no key is set or found.
16
+ #
17
+ # @return [String] The secret key.
18
+ def secret_key
19
+ raise NoSecretKeyError, 'No secret key is defined' if @secret_key.nil? || @secret_key.empty?
20
+ @secret_key
21
+ end
22
+
23
+ ##
24
+ # Get an instance of the serializer
25
+ #
26
+ # @return [Serializer]
27
+ def serializer_instance
28
+ serializer.new
29
+ end
30
+
31
+ private
32
+
33
+ def setup_defaults
34
+ @secret_key = find_secret_key
35
+ @serializer = SecureCursorSerializer
36
+ end
37
+
38
+ def find_secret_key
39
+ return nil unless defined?(Rails) && Rails.respond_to?(:application)
40
+
41
+ finder = SecretKeyFinder.new
42
+ finder.find_in Rails.application
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,144 @@
1
+ module ActiverecordCursorPagination
2
+ class Cursor
3
+ attr_reader :klass_name, :signed_sql, :per_page, :start_id, :end_id
4
+
5
+ ##
6
+ # Initialize a cursor
7
+ #
8
+ # @param [Class, String] klass_or_name The model class
9
+ # @param [ActiveRecord::Relation, String] sql_or_signed_sql The active record SQL relation
10
+ # @param [Integer] per_page The number of records per page
11
+ # @param [Integer] start_id The ID of the first record in the page
12
+ # @param [Integer] end_id The ID of the last record in the page
13
+ def initialize(klass_or_name, sql_or_signed_sql, per_page, start_id, end_id)
14
+ @signed_sql = sql_or_signed_sql.is_a?(String) ? sql_or_signed_sql : sql_signer.sign(sql_or_signed_sql)
15
+ @klass_name = class_formatter.format klass_or_name
16
+ @per_page = per_page
17
+ @start_id = start_id
18
+ @end_id = end_id
19
+ end
20
+
21
+ ##
22
+ # Is the cursor not empty
23
+ #
24
+ # @return [Boolean]
25
+ def present?
26
+ !empty?
27
+ end
28
+
29
+ ##
30
+ # Is the cursor empty
31
+ #
32
+ # @return [Boolean]
33
+ def empty?
34
+ @start_id.nil? || @end_id.nil?
35
+ end
36
+
37
+ ##
38
+ # Gets the hash representation of the cursor
39
+ #
40
+ # @return [Hash]
41
+ def to_hash
42
+ {
43
+ start: @start_id,
44
+ end: @end_id,
45
+ per_page: @per_page,
46
+ model: @klass_name,
47
+ sql: @signed_sql
48
+ }
49
+ end
50
+
51
+ ##
52
+ # Get the string representation of the cursor
53
+ #
54
+ # @return [String] The serialized cursor
55
+ def to_s
56
+ serializer.serialize to_hash
57
+ end
58
+
59
+ alias_method :to_param, :to_s
60
+
61
+ ##
62
+ # Validates the cursor
63
+ #
64
+ # @param [Class] klass The model class
65
+ # @param [ActiveRecord::Relation] sql The active record SQL relation
66
+ # @param [Integer] per_page The number of records per page
67
+ #
68
+ # @raise [InvalidCursorError] If cursor is not valid
69
+ def validate!(klass, sql, per_page)
70
+ raise InvalidCursorError.new('Invalid cursor', self) unless valid?(klass, sql, per_page)
71
+ end
72
+
73
+ private
74
+
75
+ delegate :class_formatter, :sql_signer, :serializer, to: :class
76
+
77
+ def valid?(klass, sql, per_page)
78
+ formatted_class = class_formatter.format klass
79
+ signed_sql = sql.is_a?(String) ? sql : sql_signer.sign(sql)
80
+
81
+ @klass_name === formatted_class &&
82
+ @signed_sql === signed_sql &&
83
+ @per_page === per_page
84
+ end
85
+
86
+ class << self
87
+ ##
88
+ # Get sql signer instance
89
+ #
90
+ # @return [SqlSigner]
91
+ def sql_signer
92
+ SqlSigner.new
93
+ end
94
+
95
+ ##
96
+ # Get class formatter
97
+ #
98
+ # @return [ClassFormatter]
99
+ def class_formatter
100
+ ClassFormatter.new
101
+ end
102
+
103
+ ##
104
+ # Get cursor serializer instance
105
+ #
106
+ # @return [Serializer]
107
+ def serializer
108
+ ActiverecordCursorPagination.configuration.serializer_instance
109
+ end
110
+
111
+ ##
112
+ # Parse the cursor string
113
+ #
114
+ # @param [String] str Cursor serialized string.
115
+ #
116
+ # @return [Cursor, EmptyCursor] Instance of Cursor.
117
+ def parse(str)
118
+ return EmptyCursor.new if str.nil? || str.empty?
119
+
120
+ hash = serializer.deserialize str
121
+
122
+ new hash[:model],
123
+ hash[:sql],
124
+ hash[:per_page],
125
+ hash[:start],
126
+ hash[:end]
127
+ end
128
+
129
+ ##
130
+ # Serialize the cursor
131
+ #
132
+ # @param [Class, String] klass_or_name The model class
133
+ # @param [ActiveRecord::Relation, String] sql_or_signed_sql The active record SQL relation
134
+ # @param [Integer] per_page The number of records per page
135
+ # @param [Integer] start_id The ID of the first record in the page
136
+ # @param [Integer] end_id The ID of the last record in the page
137
+ #
138
+ # @return [String] The serialized cursor string
139
+ def to_param(klass_or_name, sql_or_signed_sql, per_page, start_id, end_id)
140
+ new(klass_or_name, sql_or_signed_sql, per_page, start_id, end_id).to_param
141
+ end
142
+ end
143
+ end
144
+ end