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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +9 -2
- data/lib/activerecord_cursor_paginate/cursor.rb +11 -6
- data/lib/activerecord_cursor_paginate/extension.rb +2 -3
- data/lib/activerecord_cursor_paginate/page.rb +4 -3
- data/lib/activerecord_cursor_paginate/paginator.rb +166 -48
- data/lib/activerecord_cursor_paginate/version.rb +1 -1
- metadata +6 -6
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 198c2d0c2e71f89e73fca64d7ab8711949cf25faa20af6297d1d6045587ec7cf
|
4
|
+
data.tar.gz: 6f60b98579b2dcd132c09d4f75105b5cab7219ae8841c6970df9e77df6b59722
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
[](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/
|
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(
|
14
|
-
|
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
|
-
|
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
|
-
@
|
53
|
-
@
|
54
|
-
@
|
55
|
-
|
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 =
|
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
|
-
|
62
|
-
order = normalize_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 = @
|
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 = @
|
143
|
+
has_previous_page = @current_cursor.present?
|
110
144
|
else
|
111
|
-
has_next_page = @
|
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
|
-
|
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
|
-
|
177
|
-
|
178
|
-
|
179
|
-
|
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
|
-
|
182
|
-
|
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
|
-
@
|
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
|
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.
|
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:
|
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/
|
44
|
+
homepage: https://github.com/healthie/activerecord_cursor_paginate
|
45
45
|
licenses:
|
46
46
|
- MIT
|
47
47
|
metadata:
|
48
|
-
homepage_uri: https://github.com/
|
49
|
-
source_code_uri: https://github.com/
|
50
|
-
changelog_uri: https://github.com/
|
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:
|