activerecord_cursor_paginate 0.1.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 +41 -0
- data/README.md +55 -40
- data/lib/activerecord_cursor_paginate/cursor.rb +15 -11
- data/lib/activerecord_cursor_paginate/extension.rb +4 -4
- data/lib/activerecord_cursor_paginate/page.rb +7 -4
- data/lib/activerecord_cursor_paginate/paginator.rb +179 -50
- data/lib/activerecord_cursor_paginate/version.rb +1 -1
- metadata +7 -7
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,46 @@
|
|
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
|
+
|
28
|
+
## 0.2.0 (2024-05-23)
|
29
|
+
|
30
|
+
- Fix prefixing selected columns when iterating over joined tables
|
31
|
+
- Change cursor encoding to url safe base64
|
32
|
+
- Fix `next_cursor`/`previous_cursor` for empty pages
|
33
|
+
- Fix iterating using only a timestamp column
|
34
|
+
|
35
|
+
- Add the ability to skip implicitly appending a primary key to the list of sorting columns.
|
36
|
+
|
37
|
+
It may be useful to disable it for the table with a UUID primary key or when the sorting
|
38
|
+
is done by a combination of columns that are already unique.
|
39
|
+
|
40
|
+
```ruby
|
41
|
+
paginator = UserSettings.cursor_paginate(order: :user_id, append_primary_key: false)
|
42
|
+
```
|
43
|
+
|
3
44
|
## 0.1.0 (2024-03-08)
|
4
45
|
|
5
46
|
- First release
|
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,15 +44,22 @@ 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
|
-
page.cursors # => ["MQ
|
52
|
-
|
53
|
-
|
54
|
-
page.previous_cursor # =>
|
55
|
-
page.next_cursor # =>
|
58
|
+
page.cursors # => ["MQ", "Mg", ..., "MTA"]
|
59
|
+
| |
|
60
|
+
| |
|
61
|
+
page.previous_cursor # => "MQ" |
|
62
|
+
page.next_cursor # => "MTA" -------------|
|
56
63
|
page.has_previous? # => false
|
57
64
|
page.has_next? # => true
|
58
65
|
```
|
@@ -63,24 +70,24 @@ Take a look at the next section _"Ordering"_ to see how you can have an order di
|
|
63
70
|
To then get the next result page, you simply need to pass the last cursor of the returned page item via:
|
64
71
|
|
65
72
|
```ruby
|
66
|
-
paginator = posts.cursor_paginate(after: "MTA
|
73
|
+
paginator = posts.cursor_paginate(after: "MTA")
|
67
74
|
```
|
68
75
|
|
69
76
|
This will then fetch the next result page.
|
70
77
|
You can also just as easily paginate to previous pages by using `before` instead of `after` and using the first cursor of the current page.
|
71
78
|
|
72
79
|
```ruby
|
73
|
-
paginator = posts.cursor_paginate(before: "MQ
|
80
|
+
paginator = posts.cursor_paginate(before: "MQ")
|
74
81
|
```
|
75
82
|
|
76
83
|
By default, this will always return up to 10 results. But you can also specify how many records should be returned via `limit` parameter.
|
77
84
|
|
78
85
|
```ruby
|
79
|
-
paginator = posts.cursor_paginate(after: "MTA
|
86
|
+
paginator = posts.cursor_paginate(after: "MTA", limit: 2)
|
80
87
|
```
|
81
88
|
|
82
89
|
```ruby
|
83
|
-
paginator = posts.cursor_paginate(before: "MQ
|
90
|
+
paginator = posts.cursor_paginate(before: "MQ", limit: 2)
|
84
91
|
```
|
85
92
|
|
86
93
|
You can also easily iterate over the whole relation:
|
@@ -115,6 +122,14 @@ paginator = posts.cursor_paginate(order: [:author, :title])
|
|
115
122
|
paginator = posts.cursor_paginate(order: { author: :asc, title: :desc })
|
116
123
|
```
|
117
124
|
|
125
|
+
The gem implicitly appends a primary key column to the list of sorting columns. It may be useful
|
126
|
+
to disable it for the table with a UUID primary key or when the sorting is done by a combination
|
127
|
+
of columns that are already unique.
|
128
|
+
|
129
|
+
```ruby
|
130
|
+
paginator = UserSettings.cursor_paginate(order: :user_id, append_primary_key: false)
|
131
|
+
```
|
132
|
+
|
118
133
|
**Important:**
|
119
134
|
If your app regularly orders by another column, you might want to add a database index for this.
|
120
135
|
Say that your order column is `author` then you'll want to add a compound index on `(author, id)`.
|
@@ -149,7 +164,7 @@ page = paginator.fetch
|
|
149
164
|
|
150
165
|
#### Order by more complex logic
|
151
166
|
|
152
|
-
Sometimes you might not only want to
|
167
|
+
Sometimes you might not only want to order by a column ascending or descending, but need more complex logic.
|
153
168
|
Imagine you would also store the post's `category` on the `posts` table (as a plain string for simplicity's sake).
|
154
169
|
And the category could be `pinned`, `announcement`, or `general`.
|
155
170
|
Then you might want to show all `pinned` posts first, followed by the `announcement` ones and lastly show the `general` posts.
|
@@ -215,15 +230,15 @@ this cursor, you can request the "n records AFTER the cursor"
|
|
215
230
|
|
216
231
|
As an example, assume we have a table called "posts" with this data:
|
217
232
|
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
233
|
+
| id | author |
|
234
|
+
|----|--------|
|
235
|
+
| 1 | Jane |
|
236
|
+
| 2 | John |
|
237
|
+
| 3 | John |
|
238
|
+
| 4 | Jane |
|
239
|
+
| 5 | Jane |
|
240
|
+
| 6 | John |
|
241
|
+
| 7 | John |
|
227
242
|
|
228
243
|
Now if we make a basic request without any `before`, `after`, custom `order` column,
|
229
244
|
this will just request the first page of this relation.
|
@@ -237,8 +252,8 @@ Assume that our default page size here is 2 and we would get a query like this:
|
|
237
252
|
|
238
253
|
```sql
|
239
254
|
SELECT *
|
240
|
-
FROM
|
241
|
-
ORDER BY
|
255
|
+
FROM posts
|
256
|
+
ORDER BY id ASC
|
242
257
|
LIMIT 2
|
243
258
|
```
|
244
259
|
|
@@ -247,11 +262,11 @@ no custom order is defined, each item in the returned collection will have a
|
|
247
262
|
cursor that only encodes the record's ID.
|
248
263
|
|
249
264
|
If we want to now request the next page, we can pass in the cursor of record
|
250
|
-
#2 which would be `"Mg
|
265
|
+
#2 which would be `"Mg"` (can get via `page.cursor`). So now we can request
|
251
266
|
the next page by calling:
|
252
267
|
|
253
268
|
```ruby
|
254
|
-
paginator = relation.cursor_paginate(limit: 2, after: "Mg
|
269
|
+
paginator = relation.cursor_paginate(limit: 2, after: "Mg")
|
255
270
|
page = paginator.fetch
|
256
271
|
```
|
257
272
|
|
@@ -259,18 +274,18 @@ And this will decode the given cursor and issue a query like:
|
|
259
274
|
|
260
275
|
```sql
|
261
276
|
SELECT *
|
262
|
-
FROM
|
263
|
-
WHERE
|
264
|
-
ORDER BY
|
277
|
+
FROM posts
|
278
|
+
WHERE id > 2
|
279
|
+
ORDER BY id ASC
|
265
280
|
LIMIT 2
|
266
281
|
```
|
267
282
|
|
268
283
|
Which would return posts #3 and #4. If we now want to paginate back, we can
|
269
284
|
request the posts that came before the first post, whose cursor would be
|
270
|
-
`"Mw
|
285
|
+
`"Mw"` (can get via `page.previous_cursor`):
|
271
286
|
|
272
287
|
```ruby
|
273
|
-
paginator = relation.cursor_paginate(limit: 2, before: "Mw
|
288
|
+
paginator = relation.cursor_paginate(limit: 2, before: "Mw")
|
274
289
|
page = paginator.fetch
|
275
290
|
```
|
276
291
|
|
@@ -279,9 +294,9 @@ around to get the last two records that have an ID smaller than the given one:
|
|
279
294
|
|
280
295
|
```sql
|
281
296
|
SELECT *
|
282
|
-
FROM
|
283
|
-
WHERE
|
284
|
-
ORDER BY
|
297
|
+
FROM posts
|
298
|
+
WHERE id < 3
|
299
|
+
ORDER BY id DESC
|
285
300
|
LIMIT 2
|
286
301
|
```
|
287
302
|
|
@@ -301,8 +316,8 @@ This will issue the following SQL query:
|
|
301
316
|
|
302
317
|
```sql
|
303
318
|
SELECT *
|
304
|
-
FROM
|
305
|
-
ORDER BY
|
319
|
+
FROM posts
|
320
|
+
ORDER BY author ASC, id ASC
|
306
321
|
LIMIT 2
|
307
322
|
```
|
308
323
|
|
@@ -323,12 +338,12 @@ data, the first record being the custom order column followed by the
|
|
323
338
|
record's ID.
|
324
339
|
|
325
340
|
Therefore, the cursor of record #4 will encode `['Jane', 4]`, which yields
|
326
|
-
this cursor: `"WyJKYW5lIiw0XQ
|
341
|
+
this cursor: `"WyJKYW5lIiw0XQ"`.
|
327
342
|
|
328
343
|
If we now want to request the next page via:
|
329
344
|
|
330
345
|
```ruby
|
331
|
-
paginator = relation.cursor_paginate(order: :author, limit: 2, after: "WyJKYW5lIiw0XQ
|
346
|
+
paginator = relation.cursor_paginate(order: :author, limit: 2, after: "WyJKYW5lIiw0XQ")
|
332
347
|
page = paginator.fetch
|
333
348
|
```
|
334
349
|
|
@@ -336,9 +351,9 @@ We get this SQL query:
|
|
336
351
|
|
337
352
|
```sql
|
338
353
|
SELECT *
|
339
|
-
FROM
|
340
|
-
WHERE (author > 'Jane' OR (author = 'Jane') AND (
|
341
|
-
ORDER BY
|
354
|
+
FROM posts
|
355
|
+
WHERE (author > 'Jane' OR (author = 'Jane') AND (id > 4))
|
356
|
+
ORDER BY author ASC, id ASC
|
342
357
|
LIMIT 2
|
343
358
|
```
|
344
359
|
|
@@ -367,7 +382,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
367
382
|
|
368
383
|
## Contributing
|
369
384
|
|
370
|
-
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.
|
371
386
|
|
372
387
|
## License
|
373
388
|
|
@@ -7,14 +7,13 @@ module ActiveRecordCursorPaginate
|
|
7
7
|
# @private
|
8
8
|
class Cursor
|
9
9
|
class << self
|
10
|
-
def from_record(record, columns:)
|
11
|
-
columns = columns.map { |column| column.to_s.split(".").last }
|
10
|
+
def from_record(record, columns:, nullable_columns: nil)
|
12
11
|
values = columns.map { |column| record[column] }
|
13
|
-
new(columns: columns, values: values)
|
12
|
+
new(columns: columns, values: values, nullable_columns: nullable_columns)
|
14
13
|
end
|
15
14
|
|
16
|
-
def decode(cursor_string:, columns:)
|
17
|
-
decoded = JSON.parse(Base64.
|
15
|
+
def decode(cursor_string:, columns:, nullable_columns: nil)
|
16
|
+
decoded = JSON.parse(Base64.urlsafe_decode64(cursor_string))
|
18
17
|
|
19
18
|
if (columns.size == 1 && decoded.is_a?(Array)) ||
|
20
19
|
(decoded.is_a?(Array) && decoded.size != columns.size)
|
@@ -29,7 +28,7 @@ module ActiveRecordCursorPaginate
|
|
29
28
|
deserialize_time_if_needed(decoded)
|
30
29
|
end
|
31
30
|
|
32
|
-
new(columns: columns, values: decoded)
|
31
|
+
new(columns: columns, values: decoded, nullable_columns: nullable_columns)
|
33
32
|
rescue ArgumentError, JSON::ParserError # ArgumentError is raised by strict_decode64
|
34
33
|
raise InvalidCursorError, "The given cursor `#{cursor_string}` could not be decoded"
|
35
34
|
end
|
@@ -47,11 +46,16 @@ module ActiveRecordCursorPaginate
|
|
47
46
|
|
48
47
|
attr_reader :columns, :values
|
49
48
|
|
50
|
-
def initialize(columns:, values:)
|
51
|
-
@columns = Array(columns)
|
52
|
-
@values = Array(values)
|
49
|
+
def initialize(columns:, values:, nullable_columns: nil)
|
50
|
+
@columns = Array.wrap(columns)
|
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
|
53
58
|
|
54
|
-
raise ArgumentError, "Cursor values can not be nil" if @values.any?(nil)
|
55
59
|
raise ArgumentError, ":columns and :values have different sizes" if @columns.size != @values.size
|
56
60
|
end
|
57
61
|
|
@@ -64,7 +68,7 @@ module ActiveRecordCursorPaginate
|
|
64
68
|
end
|
65
69
|
end
|
66
70
|
unencoded_cursor = (serialized_values.size == 1 ? serialized_values.first : serialized_values)
|
67
|
-
Base64.
|
71
|
+
Base64.urlsafe_encode64(unencoded_cursor.to_json, padding: false)
|
68
72
|
end
|
69
73
|
|
70
74
|
TIMESTAMP_PREFIX = "0aIX2_" # something random
|
@@ -4,14 +4,14 @@ module ActiveRecordCursorPaginate
|
|
4
4
|
module Extension
|
5
5
|
# Convenient method to use on ActiveRecord::Relation to get a paginator.
|
6
6
|
# @return [ActiveRecordCursorPaginate::Paginator]
|
7
|
+
# @see ActiveRecordCursorPaginate::Paginator#initialize
|
7
8
|
#
|
8
9
|
# @example
|
9
|
-
# paginator = Post.
|
10
|
+
# paginator = Post.cursor_paginate(limit: 2, after: "Mg")
|
10
11
|
# page = paginator.fetch
|
11
12
|
#
|
12
|
-
def cursor_paginate(
|
13
|
-
|
14
|
-
Paginator.new(relation, after: after, before: before, limit: limit, order: order)
|
13
|
+
def cursor_paginate(**options)
|
14
|
+
Paginator.new(all, **options)
|
15
15
|
end
|
16
16
|
alias cursor_pagination cursor_paginate
|
17
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.
|
@@ -78,8 +79,10 @@ module ActiveRecordCursorPaginate
|
|
78
79
|
|
79
80
|
private
|
80
81
|
def cursor_for_record(record)
|
81
|
-
|
82
|
-
|
82
|
+
if record
|
83
|
+
cursor = Cursor.from_record(record, columns: @order_columns, nullable_columns: @nullable_columns)
|
84
|
+
cursor.encode
|
85
|
+
end
|
83
86
|
end
|
84
87
|
end
|
85
88
|
end
|
@@ -6,12 +6,12 @@ module ActiveRecordCursorPaginate
|
|
6
6
|
#
|
7
7
|
# @example Iterating one page at a time
|
8
8
|
# ActiveRecordCursorPaginate::Paginator
|
9
|
-
# .new(relation, order: :author, limit: 2, after: "WyJKYW5lIiw0XQ
|
9
|
+
# .new(relation, order: :author, limit: 2, after: "WyJKYW5lIiw0XQ")
|
10
10
|
# .fetch
|
11
11
|
#
|
12
12
|
# @example Iterating over the whole relation
|
13
13
|
# paginator = ActiveRecordCursorPaginate::Paginator
|
14
|
-
# .new(relation, order: :author, limit: 2, after: "WyJKYW5lIiw0XQ
|
14
|
+
# .new(relation, order: :author, limit: 2, after: "WyJKYW5lIiw0XQ")
|
15
15
|
#
|
16
16
|
# # Will lazily iterate over the pages.
|
17
17
|
# paginator.pages.each do |page|
|
@@ -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.
|
@@ -34,29 +36,91 @@ module ActiveRecordCursorPaginate
|
|
34
36
|
# ```sql
|
35
37
|
# CREATE INDEX <index_name> ON <table_name> (<order_fields>..., id)
|
36
38
|
# ```
|
39
|
+
# @param append_primary_key [Boolean] (true). Specifies whether the primary column(s)
|
40
|
+
# should be implicitly appended to the list of sorting columns. It may be useful
|
41
|
+
# to disable it for the table with a UUID primary key or when the sorting is done by a
|
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
|
+
#
|
37
49
|
# @raise [ArgumentError] If any parameter is not valid
|
38
50
|
#
|
39
|
-
def initialize(relation, before: nil, after: nil, limit: nil, order: nil)
|
51
|
+
def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true, nullable_columns: nil)
|
40
52
|
unless relation.is_a?(ActiveRecord::Relation)
|
41
53
|
raise ArgumentError, "relation is not an ActiveRecord::Relation"
|
42
54
|
end
|
43
55
|
|
44
|
-
|
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?
|
45
78
|
raise ArgumentError, "Only one of :before and :after can be provided"
|
46
79
|
end
|
47
80
|
|
48
|
-
@
|
49
|
-
@
|
50
|
-
@
|
51
|
-
@
|
81
|
+
@cursor = value || after
|
82
|
+
@current_cursor = @cursor
|
83
|
+
@is_forward_pagination = value.blank?
|
84
|
+
@before = value
|
85
|
+
end
|
52
86
|
|
87
|
+
def after=(value)
|
88
|
+
if before.present? && value.present?
|
89
|
+
raise ArgumentError, "Only one of :before and :after can be provided"
|
90
|
+
end
|
91
|
+
|
92
|
+
@cursor = before || value
|
93
|
+
@current_cursor = @cursor
|
94
|
+
@after = value
|
95
|
+
end
|
96
|
+
|
97
|
+
def limit=(value)
|
53
98
|
config = ActiveRecordCursorPaginate.config
|
54
|
-
@page_size =
|
99
|
+
@page_size = value || config.default_page_size
|
55
100
|
@page_size = [@page_size, config.max_page_size].min if config.max_page_size
|
101
|
+
@limit = value
|
102
|
+
end
|
56
103
|
|
57
|
-
|
104
|
+
def order=(value)
|
105
|
+
order = normalize_order(value)
|
58
106
|
@columns = order.keys
|
59
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
|
60
124
|
end
|
61
125
|
|
62
126
|
# Get the paginated result.
|
@@ -65,32 +129,7 @@ module ActiveRecordCursorPaginate
|
|
65
129
|
# @note Calling this method advances the paginator.
|
66
130
|
#
|
67
131
|
def fetch
|
68
|
-
relation = @
|
69
|
-
|
70
|
-
# Non trivial columns (expressions or joined tables columns).
|
71
|
-
if @columns.any?(/\W/)
|
72
|
-
arel_columns = @columns.map.with_index do |column, i|
|
73
|
-
arel_column(column).as("cursor_column_#{i + 1}")
|
74
|
-
end
|
75
|
-
cursor_column_names = 1.upto(@columns.size).map { |i| "cursor_column_#{i}" }
|
76
|
-
|
77
|
-
relation =
|
78
|
-
if relation.select_values.empty?
|
79
|
-
relation.select(Arel.star, arel_columns)
|
80
|
-
else
|
81
|
-
relation.select(arel_columns)
|
82
|
-
end
|
83
|
-
else
|
84
|
-
cursor_column_names = @columns
|
85
|
-
end
|
86
|
-
|
87
|
-
pagination_directions = @directions.map { |direction| pagination_direction(direction) }
|
88
|
-
relation = relation.reorder(cursor_column_names.zip(pagination_directions).to_h)
|
89
|
-
|
90
|
-
if @cursor
|
91
|
-
decoded_cursor = Cursor.decode(cursor_string: @cursor, columns: @columns)
|
92
|
-
relation = apply_cursor(relation, decoded_cursor)
|
93
|
-
end
|
132
|
+
relation = build_cursor_relation(@current_cursor)
|
94
133
|
|
95
134
|
relation = relation.limit(@page_size + 1)
|
96
135
|
records_plus_one = relation.to_a
|
@@ -101,9 +140,9 @@ module ActiveRecordCursorPaginate
|
|
101
140
|
|
102
141
|
if @is_forward_pagination
|
103
142
|
has_next_page = has_additional
|
104
|
-
has_previous_page = @
|
143
|
+
has_previous_page = @current_cursor.present?
|
105
144
|
else
|
106
|
-
has_next_page = @
|
145
|
+
has_next_page = @current_cursor.present?
|
107
146
|
has_previous_page = has_additional
|
108
147
|
end
|
109
148
|
|
@@ -111,7 +150,8 @@ module ActiveRecordCursorPaginate
|
|
111
150
|
records,
|
112
151
|
order_columns: cursor_column_names,
|
113
152
|
has_next: has_next_page,
|
114
|
-
has_previous: has_previous_page
|
153
|
+
has_previous: has_previous_page,
|
154
|
+
nullable_columns: nullable_cursor_column_names
|
115
155
|
)
|
116
156
|
|
117
157
|
advance_by_page(page) unless page.empty?
|
@@ -134,6 +174,13 @@ module ActiveRecordCursorPaginate
|
|
134
174
|
end
|
135
175
|
end
|
136
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
|
+
|
137
184
|
private
|
138
185
|
def normalize_order(order)
|
139
186
|
order ||= {}
|
@@ -153,24 +200,91 @@ module ActiveRecordCursorPaginate
|
|
153
200
|
|
154
201
|
result = result.with_indifferent_access
|
155
202
|
result.transform_values! { |direction| direction.downcase.to_sym }
|
156
|
-
|
203
|
+
|
204
|
+
if @append_primary_key
|
205
|
+
Array(@primary_key).each { |column| result[column] ||= default_direction }
|
206
|
+
end
|
207
|
+
|
208
|
+
raise ArgumentError, ":order must contain columns to order by" if result.blank?
|
209
|
+
|
157
210
|
result
|
158
211
|
end
|
159
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
|
+
|
160
255
|
def apply_cursor(relation, cursor)
|
161
|
-
|
162
|
-
cursor_positions = cursor.columns.zip(cursor.values, operators)
|
256
|
+
cursor_positions = @columns.zip(cursor.values, @directions)
|
163
257
|
|
164
258
|
where_clause = nil
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
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)
|
169
273
|
else
|
170
|
-
|
171
|
-
|
172
|
-
)
|
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)
|
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)
|
173
286
|
end
|
287
|
+
end
|
174
288
|
end
|
175
289
|
|
176
290
|
relation.where(where_clause)
|
@@ -203,12 +317,27 @@ module ActiveRecordCursorPaginate
|
|
203
317
|
end
|
204
318
|
|
205
319
|
def advance_by_page(page)
|
206
|
-
@
|
320
|
+
@current_cursor =
|
207
321
|
if @is_forward_pagination
|
208
322
|
page.next_cursor
|
209
323
|
else
|
210
324
|
page.previous_cursor
|
211
325
|
end
|
212
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
|
213
342
|
end
|
214
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:
|
@@ -63,7 +63,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
63
63
|
- !ruby/object:Gem::Version
|
64
64
|
version: '0'
|
65
65
|
requirements: []
|
66
|
-
rubygems_version: 3.
|
66
|
+
rubygems_version: 3.4.19
|
67
67
|
signing_key:
|
68
68
|
specification_version: 4
|
69
69
|
summary: Cursor-based pagination for ActiveRecord.
|