activerecord_cursor_paginate 0.2.0 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ea74d80632e02c285a03b087219a635b30b678225dfbe7c17b949a83d74d2444
4
- data.tar.gz: 06b461f0429a9527dd0180fc94ff5d984649aa412e78c641321cb463c63a40e3
3
+ metadata.gz: c62461c4c0310350845f94c41ac762fc39e55892471c3b3f27482000ce2573fb
4
+ data.tar.gz: 0cc0e0d561bedeac454bcec204b56e7b2cb85bf84e0785ed50862753604c60f3
5
5
  SHA512:
6
- metadata.gz: a671a9f299684a25cc158f38b0c7e23eb1eb03c36141bea92f435a0857dd6b35db371f4cfcf9e6a0ddb2ac10da0a88b7efb101e44a7da319a143e6da3320e3ed
7
- data.tar.gz: 0f2e04a9dbb66a10253ea6f6309aacd1634a4a20cbde2f212ed1b3bf4d270931664cb6ad25e9d84c266b6bf9b815abda732a4fc4bacbe3d2f92cbb25dc7c1b40
6
+ metadata.gz: 4a2b046f6ee4792539aea7b84fc3a0e65994c39d0f1d14f58cb2d3e57b39a9f4181b61c66dc5bd90462a16fbd6f6b351fbef653189d6c264c543d16c53ee7ca1
7
+ data.tar.gz: 74ab23173240984e9296544ad760172878d14b4d22cd4d01857f362d36220e90e530fe488933ba0fe29fd46ccff9ae909bac35d28323cbc615964492ea3e6096
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  ## master (unreleased)
2
2
 
3
+ ## 0.4.0 (2025-03-10)
4
+
5
+ - Add ability to paginate backward from the end of the collection
6
+
7
+ ```ruby
8
+ paginator = users.cursor_paginate(forward_pagination: false)
9
+ ```
10
+
11
+ ## 0.3.0 (2025-01-06)
12
+
13
+ - Allow paginating over nullable columns
14
+
15
+ Previously, the error was raised when cursor values contained `nil`s. Now, it is possible to paginate
16
+ over columns containing `nil` values. You need to explicitly configure which columns are nullable,
17
+ otherwise columns are considered as non-nullable by the gem.
18
+
19
+ ```ruby
20
+ paginator = users.cursor_paginate(order: [:name, :id], nullable_columns: [:name])
21
+ ```
22
+
23
+ Note that it is not recommended to use this feature, because the complexity of produced SQL queries can have
24
+ a very negative impact on the database performance. It is better to paginate using only non-nullable columns.
25
+
26
+ - Fix paginating over relations with joins, includes and custom ordering
27
+ - Add ability to incrementally configure a paginator
28
+
29
+ - Add ability to get the total number of records
30
+
31
+ ```ruby
32
+ paginator = posts.cursor_paginate
33
+ paginator.total_count # => 145
34
+ ```
35
+
3
36
  ## 0.2.0 (2024-05-23)
4
37
 
5
38
  - Fix prefixing selected columns when iterating over joined tables
data/README.md CHANGED
@@ -8,7 +8,7 @@ Where a regular limit / offset pagination would jump in results if a record on a
8
8
 
9
9
  To learn more about cursor pagination, check out the _"How does it work?"_ section below.
10
10
 
11
- [![Build Status](https://github.com/fatkodima/activerecord_cursor_paginate/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/fatkodima/activerecord_cursor_paginate/actions/workflows/test.yml)
11
+ [![Build Status](https://github.com/healthie/activerecord_cursor_paginate/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/healthie/activerecord_cursor_paginate/actions/workflows/test.yml)
12
12
 
13
13
  ## Requirements
14
14
 
@@ -44,9 +44,16 @@ And then we create our paginator to fetch the first response page:
44
44
 
45
45
  ```ruby
46
46
  paginator = posts.cursor_paginate
47
+
48
+ # Total number of records to iterate by this paginator
49
+ paginator.total_count # => 145
50
+
47
51
  page = paginator.fetch
48
52
  page.records # => [#<Post:0x00007fd7071b2ea8 @id=1>, #<Post:0x00007fd7071bb738 @id=2>, ..., #<Post:0x00007fd707238260 @id=10>]
53
+
54
+ # Number of records in this page
49
55
  page.count # => 10
56
+
50
57
  page.empty? # => false
51
58
  page.cursors # => ["MQ", "Mg", ..., "MTA"]
52
59
  | |
@@ -375,7 +382,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
375
382
 
376
383
  ## Contributing
377
384
 
378
- Bug reports and pull requests are welcome on GitHub at https://github.com/fatkodima/activerecord_cursor_paginate.
385
+ Bug reports and pull requests are welcome on GitHub at https://github.com/healthie/activerecord_cursor_paginate.
379
386
 
380
387
  ## License
381
388
 
@@ -7,12 +7,12 @@ module ActiveRecordCursorPaginate
7
7
  # @private
8
8
  class Cursor
9
9
  class << self
10
- def from_record(record, columns:)
10
+ def from_record(record, columns:, nullable_columns: nil)
11
11
  values = columns.map { |column| record[column] }
12
- new(columns: columns, values: values)
12
+ new(columns: columns, values: values, nullable_columns: nullable_columns)
13
13
  end
14
14
 
15
- def decode(cursor_string:, columns:)
15
+ def decode(cursor_string:, columns:, nullable_columns: nil)
16
16
  decoded = JSON.parse(Base64.urlsafe_decode64(cursor_string))
17
17
 
18
18
  if (columns.size == 1 && decoded.is_a?(Array)) ||
@@ -28,7 +28,7 @@ module ActiveRecordCursorPaginate
28
28
  deserialize_time_if_needed(decoded)
29
29
  end
30
30
 
31
- new(columns: columns, values: decoded)
31
+ new(columns: columns, values: decoded, nullable_columns: nullable_columns)
32
32
  rescue ArgumentError, JSON::ParserError # ArgumentError is raised by strict_decode64
33
33
  raise InvalidCursorError, "The given cursor `#{cursor_string}` could not be decoded"
34
34
  end
@@ -46,11 +46,16 @@ module ActiveRecordCursorPaginate
46
46
 
47
47
  attr_reader :columns, :values
48
48
 
49
- def initialize(columns:, values:)
49
+ def initialize(columns:, values:, nullable_columns: nil)
50
50
  @columns = Array.wrap(columns)
51
51
  @values = Array.wrap(values)
52
+ @nullable_columns = Array.wrap(nullable_columns)
53
+
54
+ nil_index = @values.index(nil)
55
+ if nil_index && !@nullable_columns.include?(@columns[nil_index])
56
+ raise ArgumentError, "Cursor value is nil for a column that is not in the :nullable_columns list"
57
+ end
52
58
 
53
- raise ArgumentError, "Cursor values can not be nil" if @values.any?(nil)
54
59
  raise ArgumentError, ":columns and :values have different sizes" if @columns.size != @values.size
55
60
  end
56
61
 
@@ -10,9 +10,8 @@ module ActiveRecordCursorPaginate
10
10
  # paginator = Post.cursor_paginate(limit: 2, after: "Mg")
11
11
  # page = paginator.fetch
12
12
  #
13
- def cursor_paginate(after: nil, before: nil, limit: nil, order: nil, append_primary_key: true)
14
- relation = (is_a?(ActiveRecord::Relation) ? self : all)
15
- Paginator.new(relation, after: after, before: before, limit: limit, order: order, append_primary_key: append_primary_key)
13
+ def cursor_paginate(**options)
14
+ Paginator.new(all, **options)
16
15
  end
17
16
  alias cursor_pagination cursor_paginate
18
17
  end
@@ -6,15 +6,16 @@ module ActiveRecordCursorPaginate
6
6
  #
7
7
  class Page
8
8
  # Records this page contains.
9
- # @return [ActiveRecord::Base]
9
+ # @return [Array<ActiveRecord::Base>]
10
10
  #
11
11
  attr_reader :records
12
12
 
13
- def initialize(records, order_columns:, has_previous: false, has_next: false)
13
+ def initialize(records, order_columns:, has_previous: false, has_next: false, nullable_columns: nil)
14
14
  @records = records
15
15
  @order_columns = order_columns
16
16
  @has_previous = has_previous
17
17
  @has_next = has_next
18
+ @nullable_columns = nullable_columns
18
19
  end
19
20
 
20
21
  # Number of records in this page.
@@ -79,7 +80,7 @@ module ActiveRecordCursorPaginate
79
80
  private
80
81
  def cursor_for_record(record)
81
82
  if record
82
- cursor = Cursor.from_record(record, columns: @order_columns)
83
+ cursor = Cursor.from_record(record, columns: @order_columns, nullable_columns: @nullable_columns)
83
84
  cursor.encode
84
85
  end
85
86
  end
@@ -19,6 +19,9 @@ module ActiveRecordCursorPaginate
19
19
  # end
20
20
  #
21
21
  class Paginator
22
+ attr_reader :relation, :before, :after, :limit, :order, :append_primary_key
23
+ attr_accessor :forward_pagination
24
+
22
25
  # Create a new instance of the `ActiveRecordCursorPaginate::Paginator`
23
26
  #
24
27
  # @param relation [ActiveRecord::Relation] Relation that will be paginated.
@@ -38,30 +41,92 @@ module ActiveRecordCursorPaginate
38
41
  # should be implicitly appended to the list of sorting columns. It may be useful
39
42
  # to disable it for the table with a UUID primary key or when the sorting is done by a
40
43
  # combination of columns that are already unique.
44
+ # @param nullable_columns [Symbol, String, nil, Array] Columns which are nullable.
45
+ # By default, all columns are considered as non-nullable, if not in this list.
46
+ # It is not recommended to use this feature, because the complexity of produced SQL
47
+ # queries can have a very negative impact on the database performance. It is better
48
+ # to paginate using only non-nullable columns.
49
+ # @param forward_pagination [Boolean] Whether this is a forward or backward pagination.
50
+ # Optional, defaults to `true` if `:before` is not provided, `false` otherwise.
51
+ # Useful when paginating backward from the end of the collection.
52
+ #
41
53
  # @raise [ArgumentError] If any parameter is not valid
42
54
  #
43
- def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true)
55
+ def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true,
56
+ nullable_columns: nil, forward_pagination: before.nil?)
44
57
  unless relation.is_a?(ActiveRecord::Relation)
45
58
  raise ArgumentError, "relation is not an ActiveRecord::Relation"
46
59
  end
47
60
 
48
- if before.present? && after.present?
61
+ @relation = relation
62
+ @primary_key = @relation.primary_key
63
+ @append_primary_key = append_primary_key
64
+
65
+ @cursor = @current_cursor = nil
66
+ @forward_pagination = forward_pagination
67
+ @before = @after = nil
68
+ @page_size = nil
69
+ @limit = nil
70
+ @columns = []
71
+ @directions = []
72
+ @order = nil
73
+
74
+ self.before = before
75
+ self.after = after
76
+ self.limit = limit
77
+ self.order = order
78
+ self.nullable_columns = nullable_columns
79
+ end
80
+
81
+ def before=(value)
82
+ if value.present? && after.present?
83
+ raise ArgumentError, "Only one of :before and :after can be provided"
84
+ end
85
+
86
+ @cursor = value || after
87
+ @current_cursor = @cursor
88
+ @forward_pagination = false if value
89
+ @before = value
90
+ end
91
+
92
+ def after=(value)
93
+ if value.present? && before.present?
49
94
  raise ArgumentError, "Only one of :before and :after can be provided"
50
95
  end
51
96
 
52
- @relation = relation
53
- @primary_key = @relation.primary_key
54
- @cursor = before || after
55
- @is_forward_pagination = before.blank?
97
+ @cursor = value || before
98
+ @current_cursor = @cursor
99
+ @forward_pagination = true if value
100
+ @after = value
101
+ end
56
102
 
103
+ def limit=(value)
57
104
  config = ActiveRecordCursorPaginate.config
58
- @page_size = limit || config.default_page_size
105
+ @page_size = value || config.default_page_size
59
106
  @page_size = [@page_size, config.max_page_size].min if config.max_page_size
107
+ @limit = value
108
+ end
60
109
 
61
- @append_primary_key = append_primary_key
62
- order = normalize_order(order)
110
+ def order=(value)
111
+ order = normalize_order(value)
63
112
  @columns = order.keys
64
113
  @directions = order.values
114
+ @order = value
115
+ end
116
+
117
+ def nullable_columns=(value)
118
+ value = Array.wrap(value)
119
+ value = value.map { |column| column.is_a?(Symbol) ? column.to_s : column }
120
+
121
+ if (value - @columns).any?
122
+ raise ArgumentError, ":nullable_columns should include only column names from the :order option"
123
+ end
124
+
125
+ if value.include?(@columns.last)
126
+ raise ArgumentError, "Last order column can not be nullable"
127
+ end
128
+
129
+ @nullable_columns = value
65
130
  end
66
131
 
67
132
  # Get the paginated result.
@@ -70,45 +135,20 @@ module ActiveRecordCursorPaginate
70
135
  # @note Calling this method advances the paginator.
71
136
  #
72
137
  def fetch
73
- relation = @relation
74
-
75
- # Non trivial columns (expressions or joined tables columns).
76
- if @columns.any?(/\W/)
77
- arel_columns = @columns.map.with_index do |column, i|
78
- arel_column(column).as("cursor_column_#{i + 1}")
79
- end
80
- cursor_column_names = arel_columns.map { |column| column.right.to_s }
81
-
82
- relation =
83
- if relation.select_values.empty?
84
- relation.select(relation.arel_table[Arel.star], arel_columns)
85
- else
86
- relation.select(arel_columns)
87
- end
88
- else
89
- cursor_column_names = @columns
90
- end
91
-
92
- pagination_directions = @directions.map { |direction| pagination_direction(direction) }
93
- relation = relation.reorder(cursor_column_names.zip(pagination_directions).to_h)
94
-
95
- if @cursor
96
- decoded_cursor = Cursor.decode(cursor_string: @cursor, columns: @columns)
97
- relation = apply_cursor(relation, decoded_cursor)
98
- end
138
+ relation = build_cursor_relation(@current_cursor)
99
139
 
100
140
  relation = relation.limit(@page_size + 1)
101
141
  records_plus_one = relation.to_a
102
142
  has_additional = records_plus_one.size > @page_size
103
143
 
104
144
  records = records_plus_one.take(@page_size)
105
- records.reverse! unless @is_forward_pagination
145
+ records.reverse! unless @forward_pagination
106
146
 
107
- if @is_forward_pagination
147
+ if @forward_pagination
108
148
  has_next_page = has_additional
109
- has_previous_page = @cursor.present?
149
+ has_previous_page = @current_cursor.present?
110
150
  else
111
- has_next_page = @cursor.present?
151
+ has_next_page = @current_cursor.present?
112
152
  has_previous_page = has_additional
113
153
  end
114
154
 
@@ -116,7 +156,8 @@ module ActiveRecordCursorPaginate
116
156
  records,
117
157
  order_columns: cursor_column_names,
118
158
  has_next: has_next_page,
119
- has_previous: has_previous_page
159
+ has_previous: has_previous_page,
160
+ nullable_columns: nullable_cursor_column_names
120
161
  )
121
162
 
122
163
  advance_by_page(page) unless page.empty?
@@ -139,6 +180,13 @@ module ActiveRecordCursorPaginate
139
180
  end
140
181
  end
141
182
 
183
+ # Total number of records to iterate by this paginator.
184
+ # @return [Integer]
185
+ #
186
+ def total_count
187
+ @total_count ||= build_cursor_relation(@cursor).count(:all)
188
+ end
189
+
142
190
  private
143
191
  def normalize_order(order)
144
192
  order ||= {}
@@ -168,20 +216,81 @@ module ActiveRecordCursorPaginate
168
216
  result
169
217
  end
170
218
 
219
+ def build_cursor_relation(cursor)
220
+ relation = @relation
221
+
222
+ # Non trivial columns (expressions or joined tables columns).
223
+ if @columns.any?(/\W/)
224
+ arel_columns = @columns.map.with_index do |column, i|
225
+ arel_column(column).as("cursor_column_#{i + 1}")
226
+ end
227
+
228
+ relation =
229
+ if relation.select_values.empty?
230
+ relation.select(relation.arel_table[Arel.star], arel_columns)
231
+ else
232
+ relation.select(arel_columns)
233
+ end
234
+ end
235
+
236
+ pagination_directions = @directions.map { |direction| pagination_direction(direction) }
237
+ relation = relation.reorder(@columns.zip(pagination_directions).to_h)
238
+
239
+ if cursor
240
+ decoded_cursor = Cursor.decode(cursor_string: cursor, columns: cursor_column_names, nullable_columns: nullable_cursor_column_names)
241
+ relation = apply_cursor(relation, decoded_cursor)
242
+ end
243
+
244
+ relation
245
+ end
246
+
247
+ def nullable_cursor_column_names
248
+ @nullable_columns.map do |column|
249
+ cursor_column_names[@columns.index(column)]
250
+ end
251
+ end
252
+
253
+ def cursor_column_names
254
+ if @columns.any?(/\W/)
255
+ @columns.size.times.map { |i| "cursor_column_#{i + 1}" }
256
+ else
257
+ @columns
258
+ end
259
+ end
260
+
171
261
  def apply_cursor(relation, cursor)
172
- operators = @directions.map { |direction| pagination_operator(direction) }
173
- cursor_positions = cursor.columns.zip(cursor.values, operators)
262
+ cursor_positions = @columns.zip(cursor.values, @directions)
174
263
 
175
264
  where_clause = nil
176
- cursor_positions.reverse_each.with_index do |(column, value, operator), index|
177
- where_clause =
178
- if index == 0
179
- arel_column(column).public_send(operator, value)
265
+
266
+ cursor_positions.reverse_each.with_index do |(column, value, direction), index|
267
+ previous_where_clause = where_clause
268
+
269
+ operator = pagination_operator(direction)
270
+ arel_column = arel_column(column)
271
+
272
+ # The last column can't be nil.
273
+ if index == 0
274
+ where_clause = arel_column.public_send(operator, value)
275
+ elsif value.nil?
276
+ if nulls_at_end?(direction)
277
+ # We are at the section with nulls, which is at the end ([x, x, null, null, null])
278
+ where_clause = arel_column.eq(nil).and(previous_where_clause)
180
279
  else
181
- arel_column(column).public_send(operator, value).or(
182
- arel_column(column).eq(value).and(where_clause)
183
- )
280
+ # We are at the section with nulls, which is at the beginning ([null, null, null, x, x])
281
+ where_clause = arel_column.not_eq(nil)
282
+ where_clause = arel_column.eq(nil).and(previous_where_clause).or(where_clause)
184
283
  end
284
+ else
285
+ where_clause = arel_column.public_send(operator, value).or(
286
+ arel_column.eq(value).and(previous_where_clause)
287
+ )
288
+
289
+ if nullable_column?(column) && nulls_at_end?(direction)
290
+ # Since column's value is not null, nulls can only be at the end.
291
+ where_clause = arel_column.eq(nil).or(where_clause)
292
+ end
293
+ end
185
294
  end
186
295
 
187
296
  relation.where(where_clause)
@@ -198,7 +307,7 @@ module ActiveRecordCursorPaginate
198
307
  end
199
308
 
200
309
  def pagination_direction(direction)
201
- if @is_forward_pagination
310
+ if @forward_pagination
202
311
  direction
203
312
  else
204
313
  direction == :asc ? :desc : :asc
@@ -206,7 +315,7 @@ module ActiveRecordCursorPaginate
206
315
  end
207
316
 
208
317
  def pagination_operator(direction)
209
- if @is_forward_pagination
318
+ if @forward_pagination
210
319
  direction == :asc ? :gt : :lt
211
320
  else
212
321
  direction == :asc ? :lt : :gt
@@ -214,12 +323,27 @@ module ActiveRecordCursorPaginate
214
323
  end
215
324
 
216
325
  def advance_by_page(page)
217
- @cursor =
218
- if @is_forward_pagination
326
+ @current_cursor =
327
+ if @forward_pagination
219
328
  page.next_cursor
220
329
  else
221
330
  page.previous_cursor
222
331
  end
223
332
  end
333
+
334
+ def nulls_at_end?(direction)
335
+ (direction == :asc && !small_nulls?) || (direction == :desc && small_nulls?)
336
+ end
337
+
338
+ def small_nulls?
339
+ # PostgreSQL considers NULLs larger than any value,
340
+ # opposite for SQLite and MySQL.
341
+ db_config = @relation.klass.connection_pool.db_config
342
+ db_config.adapter !~ /postg/ # postgres and postgis
343
+ end
344
+
345
+ def nullable_column?(column)
346
+ @nullable_columns.include?(column)
347
+ end
224
348
  end
225
349
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCursorPaginate
4
- VERSION = "0.2.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # https://github.com/rails/rails/issues/54260
4
+ require "logger"
5
+
3
6
  require "active_record"
4
7
 
5
8
  require_relative "activerecord_cursor_paginate/version"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord_cursor_paginate
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - fatkodima
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-05-23 00:00:00.000000000 Z
11
+ date: 2025-03-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -41,13 +41,13 @@ files:
41
41
  - lib/activerecord_cursor_paginate/page.rb
42
42
  - lib/activerecord_cursor_paginate/paginator.rb
43
43
  - lib/activerecord_cursor_paginate/version.rb
44
- homepage: https://github.com/fatkodima/activerecord_cursor_paginate
44
+ homepage: https://github.com/healthie/activerecord_cursor_paginate
45
45
  licenses:
46
46
  - MIT
47
47
  metadata:
48
- homepage_uri: https://github.com/fatkodima/activerecord_cursor_paginate
49
- source_code_uri: https://github.com/fatkodima/activerecord_cursor_paginate
50
- changelog_uri: https://github.com/fatkodima/activerecord_cursor_paginate/blob/master/CHANGELOG.md
48
+ homepage_uri: https://github.com/healthie/activerecord_cursor_paginate
49
+ source_code_uri: https://github.com/healthie/activerecord_cursor_paginate
50
+ changelog_uri: https://github.com/healthie/activerecord_cursor_paginate/blob/master/CHANGELOG.md
51
51
  post_install_message:
52
52
  rdoc_options: []
53
53
  require_paths: