activerecord_cursor_paginate 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: 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: