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 +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
|
-
[![Build Status](https://github.com/
|
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/
|
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:
|