activerecord_cursor_pagination 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
+ 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