rails_cursor_pagination 0.2.0 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 92ff9fc0d88af01bbcee5b6bc749790494b476fe425492f799a1ebd0e4a35cf7
4
- data.tar.gz: 421e730132cc775c717e10bfb05309a98419d6096166f24db631390cfb4792cb
3
+ metadata.gz: 43aad1bc560c58779869951edd254017bd3d15c0909022df0aeecbdc5c48bcef
4
+ data.tar.gz: 3ea26d6133869375d61644eebda2878dd6ccc65b906cbdc5bfa0294bfecd7a0c
5
5
  SHA512:
6
- metadata.gz: 653cbdf05a63e16adab8f991dcf9fa90b0799424174c8b8838795f2f8cccdb89cd823e7a5708e485a8113e2af3ea6362c8a2696bfdac783ac30b8108795df841
7
- data.tar.gz: 6307cfb673d4d18ed4dff27efdc5de2f042cfeb09332d23cb7813ff7bf79ed761b219b7486330ff924dcbe624325b41a75a71955312dcd30992a84299ba5193c
6
+ metadata.gz: dadfee2340eafb20eb0cd5d2e93f76544386554f7e0bf09935915816af72185a2eec6930270883fdd9870b90270a32677e364811b3914c809ffd38a4a839a296
7
+ data.tar.gz: ba8d85b9cbc57f546bf234f4d4653df7cfe1181cf78f05581c2f0fc4d6ed2a000d8adf39960346fec2b1074d66ab268b45c733d5c0b7e63d3830a54f12352a54
data/CHANGELOG.md CHANGED
@@ -14,6 +14,21 @@ These are the latest changes on the project's `master` branch that have not yet
14
14
  Follow the same format as previous releases by categorizing your feature into "Added", "Changed", "Deprecated", "Removed", "Fixed", or "Security".
15
15
  --->
16
16
 
17
+ ## [0.3.0] - 2022-07-08
18
+
19
+ ### Added
20
+ - Add a `limit` param to paginator that can be used instead either `first` or `last`
21
+ - Add a `max_page_size` to the configuration, allowing to set a global limit to the page size (non overridable): Default `nil`
22
+ - Support explicitly requesting all columns via `.select(*)` without re-including the requested column
23
+
24
+ ### Removed
25
+ - **Breaking change:** Drop support for Ruby 2.5 (EOL 2021-03-31)
26
+
27
+ ### Changed
28
+ - **Breaking change:** Remove nesting of `ParameterError` and `InvalidCursorError` errors, they are now directly nested under the main gem module. So they're now `RailsCursorPagination::ParameterError` and `RailsCursorPagination::InvalidCursorError`.
29
+ - Refactor paginator cursor interactions into exposed `RailsCursorPagination::Cursor` class
30
+ - Require multi-factor-authentication to publish the gem on Rubygems
31
+
17
32
  ## [0.2.0] - 2021-04-19
18
33
 
19
34
  ### Changed
data/README.md CHANGED
@@ -120,6 +120,21 @@ RailsCursorPagination::Paginator
120
120
  .fetch
121
121
  ```
122
122
 
123
+ Alternatively, you can use the `limit` column with either `after` or `before`.
124
+ This will behave like either `first` or `last` respectively and fetch X records.
125
+
126
+ ```ruby
127
+ RailsCursorPagination::Paginator
128
+ .new(posts, limit: 2, after: 'MTA=')
129
+ .fetch
130
+ ```
131
+
132
+ ```ruby
133
+ RailsCursorPagination::Paginator
134
+ .new(posts, limit: 2, before: 'MTA=')
135
+ .fetch
136
+ ```
137
+
123
138
  ### Ordering
124
139
 
125
140
  As said, this gem ignores any previous ordering added to the passed relation.
@@ -216,7 +231,15 @@ end
216
231
  ```
217
232
 
218
233
  This would set the default page size to 50.
219
-
234
+
235
+ You can also select a global `max_page_size` to prevent a client from requesting too large a page.
236
+
237
+ ```ruby
238
+ RailsCursorPagination.configure do |config|
239
+ config.max_page_size = 100
240
+ end
241
+ ```
242
+
220
243
  ### The passed relation
221
244
 
222
245
  The relation passed to the `RailsCursorPagination::Paginator` needs to be an instance of an `ActiveRecord::Relation`.
@@ -404,6 +427,20 @@ You can also run `bin/console` for an interactive prompt that will allow you to
404
427
  To install this gem onto your local machine, run `bundle exec rake install`.
405
428
  To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
406
429
 
430
+ ## Supported environments
431
+
432
+ This gem should run in any project that uses:
433
+ * Ruby
434
+ * `ActiveRecord`
435
+ * Postgres or MySQL
436
+
437
+ We aim to support all versions that are still actively maintained and extend support until one year past the version's EOL.
438
+ While we think it's important to stay up-to-date with versions and update as soon as an EOL is reached, we know that this is not always immediately possible.
439
+ This way, we hope to strike a balance between being usable by most projects without forcing them to upgrade, but also keeping the supported version combinations manageable.
440
+
441
+ This project is tested against different permutations of Ruby versions and DB versions, both Postgres and MySQL.
442
+ Please check the [test automation file under `./.github/workflows/test.yml`](.github/workflows/test.yml) to see all officially supported combinations.
443
+
407
444
  ## Contributing
408
445
 
409
446
  Bug reports and pull requests are welcome on GitHub at https://github.com/xing/rails_cursor_pagination.
@@ -10,12 +10,13 @@ module RailsCursorPagination
10
10
  #
11
11
  # RailsCursorPagination.configure do |config|
12
12
  # config.default_page_size = 42
13
+ # config.max_page_size = 100
13
14
  # end
14
15
  #
15
16
  class Configuration
16
17
  include Singleton
17
18
 
18
- attr_accessor :default_page_size
19
+ attr_accessor :default_page_size, :max_page_size
19
20
 
20
21
  # Ensure the default values are set on first initialization
21
22
  def initialize
@@ -25,6 +26,7 @@ module RailsCursorPagination
25
26
  # Reset all values to their defaults
26
27
  def reset!
27
28
  @default_page_size = 10
29
+ @max_page_size = nil
28
30
  end
29
31
  end
30
32
  end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsCursorPagination
4
+ # Cursor class that's used to uniquely identify a record and serialize and
5
+ # deserialize this cursor so that it can be used for pagination.
6
+ class Cursor
7
+ attr_reader :id, :order_field_value
8
+
9
+ class << self
10
+ # Generate a cursor for the given record and ordering field. The cursor
11
+ # encodes all the data required to then paginate based on it with the
12
+ # given ordering field.
13
+ #
14
+ # @param record [ActiveRecord]
15
+ # Model instance for which we want the cursor
16
+ # @param order_field [Symbol]
17
+ # Column or virtual column of the record that the relation is ordered by
18
+ # @return [Cursor]
19
+ def from_record(record:, order_field: :id)
20
+ new(id: record.id, order_field: order_field,
21
+ order_field_value: record[order_field])
22
+ end
23
+
24
+ # Decode the provided encoded cursor. Returns an instance of this
25
+ # +RailsCursorPagination::Cursor+ class containing either just the
26
+ # cursor's ID or in case of pagination on any other field, containing
27
+ # both the ID and the ordering field value.
28
+ #
29
+ # @param encoded_string [String]
30
+ # The encoded cursor
31
+ # @param order_field [Symbol]
32
+ # Optional. The column that is being ordered on in case it's not the ID
33
+ # column
34
+ # @return [RailsCursorPagination::Cursor]
35
+ def decode(encoded_string:, order_field: :id)
36
+ decoded = JSON.parse(Base64.strict_decode64(encoded_string))
37
+ if order_field == :id
38
+ if decoded.is_a?(Array)
39
+ raise InvalidCursorError,
40
+ "The given cursor `#{encoded_string}` was decoded as " \
41
+ "`#{decoded}` but could not be parsed"
42
+ end
43
+ new(id: decoded, order_field: :id)
44
+ else
45
+ unless decoded.is_a?(Array) && decoded.size == 2
46
+ raise InvalidCursorError,
47
+ "The given cursor `#{encoded_string}` was decoded as " \
48
+ "`#{decoded}` but could not be parsed"
49
+ end
50
+ new(id: decoded[1], order_field: order_field,
51
+ order_field_value: decoded[0])
52
+ end
53
+ rescue ArgumentError, JSON::ParserError
54
+ raise InvalidCursorError,
55
+ "The given cursor `#{encoded_string}` could not be decoded"
56
+ end
57
+ end
58
+
59
+ # Initializes the record
60
+ #
61
+ # @param id [Integer]
62
+ # The ID of the cursor record
63
+ # @param order_field [Symbol]
64
+ # The column or virtual column for ordering
65
+ # @param order_field_value [Object]
66
+ # Optional. The value that the +order_field+ of the record contains in
67
+ # case that the order field is not the ID
68
+ def initialize(id:, order_field: :id, order_field_value: nil)
69
+ @id = id
70
+ @order_field = order_field
71
+ @order_field_value = order_field_value
72
+
73
+ return if !custom_order_field? || !order_field_value.nil?
74
+
75
+ raise ParameterError, 'The `order_field` was set to ' \
76
+ "`#{@order_field.inspect}` but " \
77
+ 'no `order_field_value` was set'
78
+ end
79
+
80
+ # Generate an encoded string for this cursor. The cursor encodes all the
81
+ # data required to then paginate based on it with the given ordering field.
82
+ #
83
+ # If we only order by ID, the cursor doesn't need to include any other data.
84
+ # But if we order by any other field, the cursor needs to include both the
85
+ # value from this other field as well as the records ID to resolve the order
86
+ # of duplicates in the non-ID field.
87
+ #
88
+ # @return [String]
89
+ def encode
90
+ unencoded_cursor =
91
+ if custom_order_field?
92
+ [@order_field_value, @id]
93
+ else
94
+ @id
95
+ end
96
+ Base64.strict_encode64(unencoded_cursor.to_json)
97
+ end
98
+
99
+ private
100
+
101
+ # Returns true when the order has been overridden from the default (ID)
102
+ #
103
+ # @return [Boolean]
104
+ def custom_order_field?
105
+ @order_field != :id
106
+ end
107
+ end
108
+ end
@@ -11,18 +11,13 @@ module RailsCursorPagination
11
11
  # .fetch
12
12
  #
13
13
  class Paginator
14
- # Generic error that gets raised when invalid parameters are passed to the
15
- # Paginator initializer
16
- class ParameterError < Error; end
17
-
18
- # Error that gets raised if a cursor given as `before` or `after` parameter
19
- # cannot be properly parsed
20
- class InvalidCursorError < ParameterError; end
21
-
22
14
  # Create a new instance of the `RailsCursorPagination::Paginator`
23
15
  #
24
16
  # @param relation [ActiveRecord::Relation]
25
17
  # Relation that will be paginated.
18
+ # @param limit [Integer, nil]
19
+ # Number of records to return in pagination. Can be combined with either
20
+ # `after` or `before` as an alternative to `first` or `last`.
26
21
  # @param first [Integer, nil]
27
22
  # Number of records to return in a forward pagination. Can be combined
28
23
  # with `after`.
@@ -43,14 +38,15 @@ module RailsCursorPagination
43
38
  # @param order [Symbol, nil]
44
39
  # Ordering to apply, either `:asc` or `:desc`. Defaults to `:asc`.
45
40
  #
46
- # @raise [RailsCursorPagination::Paginator::ParameterError]
41
+ # @raise [RailsCursorPagination::ParameterError]
47
42
  # If any parameter is not valid
48
- def initialize(relation, first: nil, after: nil, last: nil, before: nil,
49
- order_by: nil, order: nil)
43
+ def initialize(relation, limit: nil, first: nil, after: nil, last: nil,
44
+ before: nil, order_by: nil, order: nil)
50
45
  order_by ||= :id
51
46
  order ||= :asc
52
47
 
53
- ensure_valid_params!(relation, first, after, last, before, order)
48
+ ensure_valid_params_values!(relation, order, limit, first, last)
49
+ ensure_valid_params_combinations!(first, last, limit, before, after)
54
50
 
55
51
  @order_field = order_by
56
52
  @order_direction = order
@@ -62,8 +58,14 @@ module RailsCursorPagination
62
58
  @page_size =
63
59
  first ||
64
60
  last ||
61
+ limit ||
65
62
  RailsCursorPagination::Configuration.instance.default_page_size
66
63
 
64
+ if Configuration.instance.max_page_size &&
65
+ Configuration.instance.max_page_size < @page_size
66
+ @page_size = Configuration.instance.max_page_size
67
+ end
68
+
67
69
  @memos = {}
68
70
  end
69
71
 
@@ -83,50 +85,79 @@ module RailsCursorPagination
83
85
 
84
86
  private
85
87
 
86
- # Ensure that the parameters of this service are valid. Otherwise raise
87
- # a `RailsCursorPagination::Paginator::ParameterError`.
88
+ # Ensure that the parameters of this service have valid values, otherwise
89
+ # raise a `RailsCursorPagination::ParameterError`.
88
90
  #
89
91
  # @param relation [ActiveRecord::Relation]
90
92
  # Relation that will be paginated.
93
+ # @param order [Symbol]
94
+ # Must be :asc or :desc
95
+ # @param limit [Integer, nil]
96
+ # Optional, must be positive
91
97
  # @param first [Integer, nil]
92
- # Optional, must be positive, cannot be combined with `last`
93
- # @param after [String, nil]
94
- # Optional, cannot be combined with `before`
98
+ # Optional, must be positive
95
99
  # @param last [Integer, nil]
96
- # Optional, must be positive, requires `before`, cannot be combined
97
- # with `first`
98
- # @param before [String, nil]
99
- # Optional, cannot be combined with `after`
100
- # @param order [Symbol]
101
- # Optional, must be :asc or :desc
100
+ # Optional, must be positive
101
+ # with `first` or `limit`
102
102
  #
103
- # @raise [RailsCursorPagination::Paginator::ParameterError]
103
+ # @raise [RailsCursorPagination::ParameterError]
104
104
  # If any parameter is not valid
105
- def ensure_valid_params!(relation, first, after, last, before, order)
105
+ def ensure_valid_params_values!(relation, order, limit, first, last)
106
106
  unless relation.is_a?(ActiveRecord::Relation)
107
107
  raise ParameterError,
108
- 'The first argument must be an ActiveRecord::Relation, but was '\
108
+ 'The first argument must be an ActiveRecord::Relation, but was ' \
109
109
  "the #{relation.class} `#{relation.inspect}`"
110
110
  end
111
111
  unless %i[asc desc].include?(order)
112
112
  raise ParameterError,
113
113
  "`order` must be either :asc or :desc, but was `#{order}`"
114
114
  end
115
+ if first.present? && first.negative?
116
+ raise ParameterError, "`first` cannot be negative, but was `#{first}`"
117
+ end
118
+ if last.present? && last.negative?
119
+ raise ParameterError, "`last` cannot be negative, but was `#{last}`"
120
+ end
121
+ if limit.present? && limit.negative?
122
+ raise ParameterError, "`limit` cannot be negative, but was `#{limit}`"
123
+ end
124
+
125
+ true
126
+ end
127
+
128
+ # Ensure that the parameters of this service are combined in a valid way.
129
+ # Otherwise raise a +RailsCursorPagination::ParameterError+.
130
+ #
131
+ # @param limit [Integer, nil]
132
+ # Optional, cannot be combined with `last` or `first`
133
+ # @param first [Integer, nil]
134
+ # Optional, cannot be combined with `last` or `limit`
135
+ # @param after [String, nil]
136
+ # Optional, cannot be combined with `before`
137
+ # @param last [Integer, nil]
138
+ # Optional, requires `before`, cannot be combined
139
+ # with `first` or `limit`
140
+ # @param before [String, nil]
141
+ # Optional, cannot be combined with `after`
142
+ #
143
+ # @raise [RailsCursorPagination::ParameterError]
144
+ # If parameters are combined in an invalid way
145
+ def ensure_valid_params_combinations!(first, last, limit, before, after)
115
146
  if first.present? && last.present?
116
147
  raise ParameterError, '`first` cannot be combined with `last`'
117
148
  end
149
+ if first.present? && limit.present?
150
+ raise ParameterError, '`limit` cannot be combined with `first`'
151
+ end
152
+ if last.present? && limit.present?
153
+ raise ParameterError, '`limit` cannot be combined with `last`'
154
+ end
118
155
  if before.present? && after.present?
119
156
  raise ParameterError, '`before` cannot be combined with `after`'
120
157
  end
121
158
  if last.present? && before.blank?
122
159
  raise ParameterError, '`last` must be combined with `before`'
123
160
  end
124
- if first.present? && first.negative?
125
- raise ParameterError, "`first` cannot be negative, but was `#{first}`"
126
- end
127
- if last.present? && last.negative?
128
- raise ParameterError, "`last` cannot be negative, but was `#{last}`"
129
- end
130
161
 
131
162
  true
132
163
  end
@@ -317,9 +348,9 @@ module RailsCursorPagination
317
348
  #
318
349
  # @return [Integer, String]
319
350
  def filter_value
320
- return decoded_cursor_id unless custom_order_field?
351
+ return decoded_cursor.id unless custom_order_field?
321
352
 
322
- "#{decoded_cursor_field}-#{decoded_cursor_id}"
353
+ "#{decoded_cursor.order_field_value}-#{decoded_cursor.id}"
323
354
  end
324
355
 
325
356
  # Generate a cursor for the given record and ordering field. The cursor
@@ -334,14 +365,7 @@ module RailsCursorPagination
334
365
  # @param record [ActiveRecord] Model instance for which we want the cursor
335
366
  # @return [String]
336
367
  def cursor_for_record(record)
337
- unencoded_cursor =
338
- if custom_order_field?
339
- [record[@order_field], record.id]
340
- else
341
- record.id
342
- end
343
-
344
- Base64.strict_encode64(unencoded_cursor.to_json)
368
+ Cursor.from_record(record: record, order_field: @order_field).encode
345
369
  end
346
370
 
347
371
  # Decode the provided cursor. Either just returns the cursor's ID or in case
@@ -350,37 +374,9 @@ module RailsCursorPagination
350
374
  #
351
375
  # @return [Integer, Array]
352
376
  def decoded_cursor
353
- memoize(:decoded_cursor) { JSON.parse(Base64.strict_decode64(@cursor)) }
354
- rescue ArgumentError, JSON::ParserError
355
- raise InvalidCursorError,
356
- "The given cursor `#{@cursor.inspect}` could not be decoded"
357
- end
358
-
359
- # Return the ID of the cursor's record. In case we use an ordering by ID,
360
- # this is all the data the cursor encodes. Otherwise, it's the second
361
- # element of the tuple encoded by the cursor.
362
- #
363
- # @return [Integer]
364
- def decoded_cursor_id
365
- return decoded_cursor unless decoded_cursor.is_a? Array
366
-
367
- decoded_cursor.last
368
- end
369
-
370
- # Return the value of the cursor's record's custom order field. Only exists
371
- # if the cursor was generated by a query with a custom order field.
372
- # Otherwise the cursor would only encode the ID and not be an array.
373
-
374
- # @raise [InvalidCursorError] in case the cursor is not a tuple
375
- # @return [Object]
376
- def decoded_cursor_field
377
- unless decoded_cursor.is_a? Array
378
- raise InvalidCursorError,
379
- "The given cursor `#{@cursor}` was decoded as "\
380
- "`#{decoded_cursor.inspect}` but could not be parsed"
377
+ memoize(:decoded_cursor) do
378
+ Cursor.decode(encoded_string: @cursor, order_field: @order_field)
381
379
  end
382
-
383
- decoded_cursor.first
384
380
  end
385
381
 
386
382
  # Ensure that the relation has the ID column and any potential `order_by`
@@ -389,7 +385,8 @@ module RailsCursorPagination
389
385
  #
390
386
  # @return [ActiveRecord::Relation]
391
387
  def relation_with_cursor_fields
392
- return @relation if @relation.select_values.blank?
388
+ return @relation if @relation.select_values.blank? ||
389
+ @relation.select_values.include?('*')
393
390
 
394
391
  relation = @relation
395
392
 
@@ -453,15 +450,16 @@ module RailsCursorPagination
453
450
 
454
451
  unless custom_order_field?
455
452
  next sorted_relation.where "#{id_column} #{filter_operator} ?",
456
- decoded_cursor_id
453
+ decoded_cursor.id
457
454
  end
458
455
 
459
456
  sorted_relation
460
- .where("#{@order_field} #{filter_operator} ?", decoded_cursor_field)
457
+ .where("#{@order_field} #{filter_operator} ?",
458
+ decoded_cursor.order_field_value)
461
459
  .or(
462
460
  sorted_relation
463
- .where("#{@order_field} = ?", decoded_cursor_field)
464
- .where("#{id_column} #{filter_operator} ?", decoded_cursor_id)
461
+ .where("#{@order_field} = ?", decoded_cursor.order_field_value)
462
+ .where("#{id_column} #{filter_operator} ?", decoded_cursor.id)
465
463
  )
466
464
  end
467
465
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsCursorPagination
4
- VERSION = '0.2.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -148,12 +148,22 @@
148
148
  module RailsCursorPagination
149
149
  class Error < StandardError; end
150
150
 
151
+ # Generic error that gets raised when invalid parameters are passed to the
152
+ # pagination
153
+ class ParameterError < Error; end
154
+
155
+ # Error that gets raised if a cursor given as `before` or `after` cannot be
156
+ # properly parsed
157
+ class InvalidCursorError < ParameterError; end
158
+
151
159
  require_relative 'rails_cursor_pagination/version'
152
160
 
153
161
  require_relative 'rails_cursor_pagination/configuration'
154
162
 
155
163
  require_relative 'rails_cursor_pagination/paginator'
156
164
 
165
+ require_relative 'rails_cursor_pagination/cursor'
166
+
157
167
  class << self
158
168
  # Allows to configure this gem. Currently supported configuration values
159
169
  # are:
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails_cursor_pagination
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nicolas Fricke
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-04-19 00:00:00.000000000 Z
11
+ date: 2022-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -40,6 +40,7 @@ files:
40
40
  - README.md
41
41
  - lib/rails_cursor_pagination.rb
42
42
  - lib/rails_cursor_pagination/configuration.rb
43
+ - lib/rails_cursor_pagination/cursor.rb
43
44
  - lib/rails_cursor_pagination/paginator.rb
44
45
  - lib/rails_cursor_pagination/version.rb
45
46
  homepage: https://github.com/xing/rails_cursor_pagination
@@ -49,6 +50,7 @@ metadata:
49
50
  homepage_uri: https://github.com/xing/rails_cursor_pagination
50
51
  source_code_uri: https://github.com/xing/rails_cursor_pagination
51
52
  changelog_uri: https://github.com/xing/rails_cursor_pagination/blob/master/CHANGELOG.md
53
+ rubygems_mfa_required: 'true'
52
54
  post_install_message:
53
55
  rdoc_options: []
54
56
  require_paths:
@@ -57,14 +59,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
57
59
  requirements:
58
60
  - - ">="
59
61
  - !ruby/object:Gem::Version
60
- version: 2.5.0
62
+ version: 2.6.0
61
63
  required_rubygems_version: !ruby/object:Gem::Requirement
62
64
  requirements:
63
65
  - - ">="
64
66
  - !ruby/object:Gem::Version
65
67
  version: '0'
66
68
  requirements: []
67
- rubygems_version: 3.1.4
69
+ rubygems_version: 3.1.6
68
70
  signing_key:
69
71
  specification_version: 4
70
72
  summary: Add cursor pagination to your ActiveRecord backed application.