activerecord_cursor_paginate 0.2.0 → 0.3.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: 198c2d0c2e71f89e73fca64d7ab8711949cf25faa20af6297d1d6045587ec7cf
4
+ data.tar.gz: 6f60b98579b2dcd132c09d4f75105b5cab7219ae8841c6970df9e77df6b59722
5
5
  SHA512:
6
- metadata.gz: a671a9f299684a25cc158f38b0c7e23eb1eb03c36141bea92f435a0857dd6b35db371f4cfcf9e6a0ddb2ac10da0a88b7efb101e44a7da319a143e6da3320e3ed
7
- data.tar.gz: 0f2e04a9dbb66a10253ea6f6309aacd1634a4a20cbde2f212ed1b3bf4d270931664cb6ad25e9d84c266b6bf9b815abda732a4fc4bacbe3d2f92cbb25dc7c1b40
6
+ metadata.gz: 85fd85af08637f1c22e52430f51bae57c5b116af2f843703dd2ab528d36616bf323d686c53c7b059fb04aa176a01bc0ec2a3c5a2b40acb6f54cf5412e2770e10
7
+ data.tar.gz: be0771bacd8fbd5f110c9f90927a12792ef85d8f4e4278e46321e96d82f93f87b848ad71b86cd9150c9465c39e5d8dd3a4e2a71672e51d7d7c04f00cbb561990
data/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  ## master (unreleased)
2
2
 
3
+ ## 0.3.0 (2025-01-06)
4
+
5
+ - Allow paginating over nullable columns
6
+
7
+ Previously, the error was raised when cursor values contained `nil`s. Now, it is possible to paginate
8
+ over columns containing `nil` values. You need to explicitly configure which columns are nullable,
9
+ otherwise columns are considered as non-nullable by the gem.
10
+
11
+ ```ruby
12
+ paginator = users.cursor_paginate(order: [:name, :id], nullable_columns: [:name])
13
+ ```
14
+
15
+ Note that it is not recommended to use this feature, because the complexity of produced SQL queries can have
16
+ a very negative impact on the database performance. It is better to paginate using only non-nullable columns.
17
+
18
+ - Fix paginating over relations with joins, includes and custom ordering
19
+ - Add ability to incrementally configure a paginator
20
+
21
+ - Add ability to get the total number of records
22
+
23
+ ```ruby
24
+ paginator = posts.cursor_paginate
25
+ paginator.total_count # => 145
26
+ ```
27
+
3
28
  ## 0.2.0 (2024-05-23)
4
29
 
5
30
  - 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,8 @@ module ActiveRecordCursorPaginate
19
19
  # end
20
20
  #
21
21
  class Paginator
22
+ attr_reader :relation, :before, :after, :limit, :order, :append_primary_key
23
+
22
24
  # Create a new instance of the `ActiveRecordCursorPaginate::Paginator`
23
25
  #
24
26
  # @param relation [ActiveRecord::Relation] Relation that will be paginated.
@@ -38,30 +40,87 @@ module ActiveRecordCursorPaginate
38
40
  # should be implicitly appended to the list of sorting columns. It may be useful
39
41
  # to disable it for the table with a UUID primary key or when the sorting is done by a
40
42
  # combination of columns that are already unique.
43
+ # @param nullable_columns [Symbol, String, nil, Array] Columns which are nullable.
44
+ # By default, all columns are considered as non-nullable, if not in this list.
45
+ # It is not recommended to use this feature, because the complexity of produced SQL
46
+ # queries can have a very negative impact on the database performance. It is better
47
+ # to paginate using only non-nullable columns.
48
+ #
41
49
  # @raise [ArgumentError] If any parameter is not valid
42
50
  #
43
- def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true)
51
+ def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true, nullable_columns: nil)
44
52
  unless relation.is_a?(ActiveRecord::Relation)
45
53
  raise ArgumentError, "relation is not an ActiveRecord::Relation"
46
54
  end
47
55
 
48
- if before.present? && after.present?
56
+ @relation = relation
57
+ @primary_key = @relation.primary_key
58
+ @append_primary_key = append_primary_key
59
+
60
+ @cursor = @current_cursor = nil
61
+ @is_forward_pagination = true
62
+ @before = @after = nil
63
+ @page_size = nil
64
+ @limit = nil
65
+ @columns = []
66
+ @directions = []
67
+ @order = nil
68
+
69
+ self.before = before
70
+ self.after = after
71
+ self.limit = limit
72
+ self.order = order
73
+ self.nullable_columns = nullable_columns
74
+ end
75
+
76
+ def before=(value)
77
+ if value.present? && after.present?
78
+ raise ArgumentError, "Only one of :before and :after can be provided"
79
+ end
80
+
81
+ @cursor = value || after
82
+ @current_cursor = @cursor
83
+ @is_forward_pagination = value.blank?
84
+ @before = value
85
+ end
86
+
87
+ def after=(value)
88
+ if before.present? && value.present?
49
89
  raise ArgumentError, "Only one of :before and :after can be provided"
50
90
  end
51
91
 
52
- @relation = relation
53
- @primary_key = @relation.primary_key
54
- @cursor = before || after
55
- @is_forward_pagination = before.blank?
92
+ @cursor = before || value
93
+ @current_cursor = @cursor
94
+ @after = value
95
+ end
56
96
 
97
+ def limit=(value)
57
98
  config = ActiveRecordCursorPaginate.config
58
- @page_size = limit || config.default_page_size
99
+ @page_size = value || config.default_page_size
59
100
  @page_size = [@page_size, config.max_page_size].min if config.max_page_size
101
+ @limit = value
102
+ end
60
103
 
61
- @append_primary_key = append_primary_key
62
- order = normalize_order(order)
104
+ def order=(value)
105
+ order = normalize_order(value)
63
106
  @columns = order.keys
64
107
  @directions = order.values
108
+ @order = value
109
+ end
110
+
111
+ def nullable_columns=(value)
112
+ value = Array.wrap(value)
113
+ value = value.map { |column| column.is_a?(Symbol) ? column.to_s : column }
114
+
115
+ if (value - @columns).any?
116
+ raise ArgumentError, ":nullable_columns should include only column names from the :order option"
117
+ end
118
+
119
+ if value.include?(@columns.last)
120
+ raise ArgumentError, "Last order column can not be nullable"
121
+ end
122
+
123
+ @nullable_columns = value
65
124
  end
66
125
 
67
126
  # Get the paginated result.
@@ -70,32 +129,7 @@ module ActiveRecordCursorPaginate
70
129
  # @note Calling this method advances the paginator.
71
130
  #
72
131
  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
132
+ relation = build_cursor_relation(@current_cursor)
99
133
 
100
134
  relation = relation.limit(@page_size + 1)
101
135
  records_plus_one = relation.to_a
@@ -106,9 +140,9 @@ module ActiveRecordCursorPaginate
106
140
 
107
141
  if @is_forward_pagination
108
142
  has_next_page = has_additional
109
- has_previous_page = @cursor.present?
143
+ has_previous_page = @current_cursor.present?
110
144
  else
111
- has_next_page = @cursor.present?
145
+ has_next_page = @current_cursor.present?
112
146
  has_previous_page = has_additional
113
147
  end
114
148
 
@@ -116,7 +150,8 @@ module ActiveRecordCursorPaginate
116
150
  records,
117
151
  order_columns: cursor_column_names,
118
152
  has_next: has_next_page,
119
- has_previous: has_previous_page
153
+ has_previous: has_previous_page,
154
+ nullable_columns: nullable_cursor_column_names
120
155
  )
121
156
 
122
157
  advance_by_page(page) unless page.empty?
@@ -139,6 +174,13 @@ module ActiveRecordCursorPaginate
139
174
  end
140
175
  end
141
176
 
177
+ # Total number of records to iterate by this paginator.
178
+ # @return [Integer]
179
+ #
180
+ def total_count
181
+ @total_count ||= build_cursor_relation(@cursor).count(:all)
182
+ end
183
+
142
184
  private
143
185
  def normalize_order(order)
144
186
  order ||= {}
@@ -168,20 +210,81 @@ module ActiveRecordCursorPaginate
168
210
  result
169
211
  end
170
212
 
213
+ def build_cursor_relation(cursor)
214
+ relation = @relation
215
+
216
+ # Non trivial columns (expressions or joined tables columns).
217
+ if @columns.any?(/\W/)
218
+ arel_columns = @columns.map.with_index do |column, i|
219
+ arel_column(column).as("cursor_column_#{i + 1}")
220
+ end
221
+
222
+ relation =
223
+ if relation.select_values.empty?
224
+ relation.select(relation.arel_table[Arel.star], arel_columns)
225
+ else
226
+ relation.select(arel_columns)
227
+ end
228
+ end
229
+
230
+ pagination_directions = @directions.map { |direction| pagination_direction(direction) }
231
+ relation = relation.reorder(@columns.zip(pagination_directions).to_h)
232
+
233
+ if cursor
234
+ decoded_cursor = Cursor.decode(cursor_string: cursor, columns: cursor_column_names, nullable_columns: nullable_cursor_column_names)
235
+ relation = apply_cursor(relation, decoded_cursor)
236
+ end
237
+
238
+ relation
239
+ end
240
+
241
+ def nullable_cursor_column_names
242
+ @nullable_columns.map do |column|
243
+ cursor_column_names[@columns.index(column)]
244
+ end
245
+ end
246
+
247
+ def cursor_column_names
248
+ if @columns.any?(/\W/)
249
+ @columns.size.times.map { |i| "cursor_column_#{i + 1}" }
250
+ else
251
+ @columns
252
+ end
253
+ end
254
+
171
255
  def apply_cursor(relation, cursor)
172
- operators = @directions.map { |direction| pagination_operator(direction) }
173
- cursor_positions = cursor.columns.zip(cursor.values, operators)
256
+ cursor_positions = @columns.zip(cursor.values, @directions)
174
257
 
175
258
  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)
259
+
260
+ cursor_positions.reverse_each.with_index do |(column, value, direction), index|
261
+ previous_where_clause = where_clause
262
+
263
+ operator = pagination_operator(direction)
264
+ arel_column = arel_column(column)
265
+
266
+ # The last column can't be nil.
267
+ if index == 0
268
+ where_clause = arel_column.public_send(operator, value)
269
+ elsif value.nil?
270
+ if nulls_at_end?(direction)
271
+ # We are at the section with nulls, which is at the end ([x, x, null, null, null])
272
+ where_clause = arel_column.eq(nil).and(previous_where_clause)
180
273
  else
181
- arel_column(column).public_send(operator, value).or(
182
- arel_column(column).eq(value).and(where_clause)
183
- )
274
+ # We are at the section with nulls, which is at the beginning ([null, null, null, x, x])
275
+ where_clause = arel_column.not_eq(nil)
276
+ where_clause = arel_column.eq(nil).and(previous_where_clause).or(where_clause)
184
277
  end
278
+ else
279
+ where_clause = arel_column.public_send(operator, value).or(
280
+ arel_column.eq(value).and(previous_where_clause)
281
+ )
282
+
283
+ if nullable_column?(column) && nulls_at_end?(direction)
284
+ # Since column's value is not null, nulls can only be at the end.
285
+ where_clause = arel_column.eq(nil).or(where_clause)
286
+ end
287
+ end
185
288
  end
186
289
 
187
290
  relation.where(where_clause)
@@ -214,12 +317,27 @@ module ActiveRecordCursorPaginate
214
317
  end
215
318
 
216
319
  def advance_by_page(page)
217
- @cursor =
320
+ @current_cursor =
218
321
  if @is_forward_pagination
219
322
  page.next_cursor
220
323
  else
221
324
  page.previous_cursor
222
325
  end
223
326
  end
327
+
328
+ def nulls_at_end?(direction)
329
+ (direction == :asc && !small_nulls?) || (direction == :desc && small_nulls?)
330
+ end
331
+
332
+ def small_nulls?
333
+ # PostgreSQL considers NULLs larger than any value,
334
+ # opposite for SQLite and MySQL.
335
+ db_config = @relation.klass.connection_pool.db_config
336
+ db_config.adapter !~ /postg/ # postgres and postgis
337
+ end
338
+
339
+ def nullable_column?(column)
340
+ @nullable_columns.include?(column)
341
+ end
224
342
  end
225
343
  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.3.0"
5
5
  end
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.3.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-01-06 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: