activerecord_cursor_paginate 0.1.0 → 0.2.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 +16 -0
- data/README.md +46 -38
- data/lib/activerecord_cursor_paginate/cursor.rb +4 -5
- data/lib/activerecord_cursor_paginate/extension.rb +4 -3
- data/lib/activerecord_cursor_paginate/page.rb +4 -2
- data/lib/activerecord_cursor_paginate/paginator.rb +17 -6
- data/lib/activerecord_cursor_paginate/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: ea74d80632e02c285a03b087219a635b30b678225dfbe7c17b949a83d74d2444
|
4
|
+
data.tar.gz: 06b461f0429a9527dd0180fc94ff5d984649aa412e78c641321cb463c63a40e3
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: a671a9f299684a25cc158f38b0c7e23eb1eb03c36141bea92f435a0857dd6b35db371f4cfcf9e6a0ddb2ac10da0a88b7efb101e44a7da319a143e6da3320e3ed
|
7
|
+
data.tar.gz: 0f2e04a9dbb66a10253ea6f6309aacd1634a4a20cbde2f212ed1b3bf4d270931664cb6ad25e9d84c266b6bf9b815abda732a4fc4bacbe3d2f92cbb25dc7c1b40
|
data/CHANGELOG.md
CHANGED
@@ -1,5 +1,21 @@
|
|
1
1
|
## master (unreleased)
|
2
2
|
|
3
|
+
## 0.2.0 (2024-05-23)
|
4
|
+
|
5
|
+
- Fix prefixing selected columns when iterating over joined tables
|
6
|
+
- Change cursor encoding to url safe base64
|
7
|
+
- Fix `next_cursor`/`previous_cursor` for empty pages
|
8
|
+
- Fix iterating using only a timestamp column
|
9
|
+
|
10
|
+
- Add the ability to skip implicitly appending a primary key to the list of sorting columns.
|
11
|
+
|
12
|
+
It may be useful to disable it for the table with a UUID primary key or when the sorting
|
13
|
+
is done by a combination of columns that are already unique.
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
paginator = UserSettings.cursor_paginate(order: :user_id, append_primary_key: false)
|
17
|
+
```
|
18
|
+
|
3
19
|
## 0.1.0 (2024-03-08)
|
4
20
|
|
5
21
|
- First release
|
data/README.md
CHANGED
@@ -48,11 +48,11 @@ page = paginator.fetch
|
|
48
48
|
page.records # => [#<Post:0x00007fd7071b2ea8 @id=1>, #<Post:0x00007fd7071bb738 @id=2>, ..., #<Post:0x00007fd707238260 @id=10>]
|
49
49
|
page.count # => 10
|
50
50
|
page.empty? # => false
|
51
|
-
page.cursors # => ["MQ
|
52
|
-
|
53
|
-
|
54
|
-
page.previous_cursor # =>
|
55
|
-
page.next_cursor # =>
|
51
|
+
page.cursors # => ["MQ", "Mg", ..., "MTA"]
|
52
|
+
| |
|
53
|
+
| |
|
54
|
+
page.previous_cursor # => "MQ" |
|
55
|
+
page.next_cursor # => "MTA" -------------|
|
56
56
|
page.has_previous? # => false
|
57
57
|
page.has_next? # => true
|
58
58
|
```
|
@@ -63,24 +63,24 @@ Take a look at the next section _"Ordering"_ to see how you can have an order di
|
|
63
63
|
To then get the next result page, you simply need to pass the last cursor of the returned page item via:
|
64
64
|
|
65
65
|
```ruby
|
66
|
-
paginator = posts.cursor_paginate(after: "MTA
|
66
|
+
paginator = posts.cursor_paginate(after: "MTA")
|
67
67
|
```
|
68
68
|
|
69
69
|
This will then fetch the next result page.
|
70
70
|
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
71
|
|
72
72
|
```ruby
|
73
|
-
paginator = posts.cursor_paginate(before: "MQ
|
73
|
+
paginator = posts.cursor_paginate(before: "MQ")
|
74
74
|
```
|
75
75
|
|
76
76
|
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
77
|
|
78
78
|
```ruby
|
79
|
-
paginator = posts.cursor_paginate(after: "MTA
|
79
|
+
paginator = posts.cursor_paginate(after: "MTA", limit: 2)
|
80
80
|
```
|
81
81
|
|
82
82
|
```ruby
|
83
|
-
paginator = posts.cursor_paginate(before: "MQ
|
83
|
+
paginator = posts.cursor_paginate(before: "MQ", limit: 2)
|
84
84
|
```
|
85
85
|
|
86
86
|
You can also easily iterate over the whole relation:
|
@@ -115,6 +115,14 @@ paginator = posts.cursor_paginate(order: [:author, :title])
|
|
115
115
|
paginator = posts.cursor_paginate(order: { author: :asc, title: :desc })
|
116
116
|
```
|
117
117
|
|
118
|
+
The gem implicitly appends a primary key column to the list of sorting columns. It may be useful
|
119
|
+
to disable it for the table with a UUID primary key or when the sorting is done by a combination
|
120
|
+
of columns that are already unique.
|
121
|
+
|
122
|
+
```ruby
|
123
|
+
paginator = UserSettings.cursor_paginate(order: :user_id, append_primary_key: false)
|
124
|
+
```
|
125
|
+
|
118
126
|
**Important:**
|
119
127
|
If your app regularly orders by another column, you might want to add a database index for this.
|
120
128
|
Say that your order column is `author` then you'll want to add a compound index on `(author, id)`.
|
@@ -149,7 +157,7 @@ page = paginator.fetch
|
|
149
157
|
|
150
158
|
#### Order by more complex logic
|
151
159
|
|
152
|
-
Sometimes you might not only want to
|
160
|
+
Sometimes you might not only want to order by a column ascending or descending, but need more complex logic.
|
153
161
|
Imagine you would also store the post's `category` on the `posts` table (as a plain string for simplicity's sake).
|
154
162
|
And the category could be `pinned`, `announcement`, or `general`.
|
155
163
|
Then you might want to show all `pinned` posts first, followed by the `announcement` ones and lastly show the `general` posts.
|
@@ -215,15 +223,15 @@ this cursor, you can request the "n records AFTER the cursor"
|
|
215
223
|
|
216
224
|
As an example, assume we have a table called "posts" with this data:
|
217
225
|
|
218
|
-
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
|
223
|
-
|
224
|
-
|
225
|
-
|
226
|
-
|
226
|
+
| id | author |
|
227
|
+
|----|--------|
|
228
|
+
| 1 | Jane |
|
229
|
+
| 2 | John |
|
230
|
+
| 3 | John |
|
231
|
+
| 4 | Jane |
|
232
|
+
| 5 | Jane |
|
233
|
+
| 6 | John |
|
234
|
+
| 7 | John |
|
227
235
|
|
228
236
|
Now if we make a basic request without any `before`, `after`, custom `order` column,
|
229
237
|
this will just request the first page of this relation.
|
@@ -237,8 +245,8 @@ Assume that our default page size here is 2 and we would get a query like this:
|
|
237
245
|
|
238
246
|
```sql
|
239
247
|
SELECT *
|
240
|
-
FROM
|
241
|
-
ORDER BY
|
248
|
+
FROM posts
|
249
|
+
ORDER BY id ASC
|
242
250
|
LIMIT 2
|
243
251
|
```
|
244
252
|
|
@@ -247,11 +255,11 @@ no custom order is defined, each item in the returned collection will have a
|
|
247
255
|
cursor that only encodes the record's ID.
|
248
256
|
|
249
257
|
If we want to now request the next page, we can pass in the cursor of record
|
250
|
-
#2 which would be `"Mg
|
258
|
+
#2 which would be `"Mg"` (can get via `page.cursor`). So now we can request
|
251
259
|
the next page by calling:
|
252
260
|
|
253
261
|
```ruby
|
254
|
-
paginator = relation.cursor_paginate(limit: 2, after: "Mg
|
262
|
+
paginator = relation.cursor_paginate(limit: 2, after: "Mg")
|
255
263
|
page = paginator.fetch
|
256
264
|
```
|
257
265
|
|
@@ -259,18 +267,18 @@ And this will decode the given cursor and issue a query like:
|
|
259
267
|
|
260
268
|
```sql
|
261
269
|
SELECT *
|
262
|
-
FROM
|
263
|
-
WHERE
|
264
|
-
ORDER BY
|
270
|
+
FROM posts
|
271
|
+
WHERE id > 2
|
272
|
+
ORDER BY id ASC
|
265
273
|
LIMIT 2
|
266
274
|
```
|
267
275
|
|
268
276
|
Which would return posts #3 and #4. If we now want to paginate back, we can
|
269
277
|
request the posts that came before the first post, whose cursor would be
|
270
|
-
`"Mw
|
278
|
+
`"Mw"` (can get via `page.previous_cursor`):
|
271
279
|
|
272
280
|
```ruby
|
273
|
-
paginator = relation.cursor_paginate(limit: 2, before: "Mw
|
281
|
+
paginator = relation.cursor_paginate(limit: 2, before: "Mw")
|
274
282
|
page = paginator.fetch
|
275
283
|
```
|
276
284
|
|
@@ -279,9 +287,9 @@ around to get the last two records that have an ID smaller than the given one:
|
|
279
287
|
|
280
288
|
```sql
|
281
289
|
SELECT *
|
282
|
-
FROM
|
283
|
-
WHERE
|
284
|
-
ORDER BY
|
290
|
+
FROM posts
|
291
|
+
WHERE id < 3
|
292
|
+
ORDER BY id DESC
|
285
293
|
LIMIT 2
|
286
294
|
```
|
287
295
|
|
@@ -301,8 +309,8 @@ This will issue the following SQL query:
|
|
301
309
|
|
302
310
|
```sql
|
303
311
|
SELECT *
|
304
|
-
FROM
|
305
|
-
ORDER BY
|
312
|
+
FROM posts
|
313
|
+
ORDER BY author ASC, id ASC
|
306
314
|
LIMIT 2
|
307
315
|
```
|
308
316
|
|
@@ -323,12 +331,12 @@ data, the first record being the custom order column followed by the
|
|
323
331
|
record's ID.
|
324
332
|
|
325
333
|
Therefore, the cursor of record #4 will encode `['Jane', 4]`, which yields
|
326
|
-
this cursor: `"WyJKYW5lIiw0XQ
|
334
|
+
this cursor: `"WyJKYW5lIiw0XQ"`.
|
327
335
|
|
328
336
|
If we now want to request the next page via:
|
329
337
|
|
330
338
|
```ruby
|
331
|
-
paginator = relation.cursor_paginate(order: :author, limit: 2, after: "WyJKYW5lIiw0XQ
|
339
|
+
paginator = relation.cursor_paginate(order: :author, limit: 2, after: "WyJKYW5lIiw0XQ")
|
332
340
|
page = paginator.fetch
|
333
341
|
```
|
334
342
|
|
@@ -336,9 +344,9 @@ We get this SQL query:
|
|
336
344
|
|
337
345
|
```sql
|
338
346
|
SELECT *
|
339
|
-
FROM
|
340
|
-
WHERE (author > 'Jane' OR (author = 'Jane') AND (
|
341
|
-
ORDER BY
|
347
|
+
FROM posts
|
348
|
+
WHERE (author > 'Jane' OR (author = 'Jane') AND (id > 4))
|
349
|
+
ORDER BY author ASC, id ASC
|
342
350
|
LIMIT 2
|
343
351
|
```
|
344
352
|
|
@@ -8,13 +8,12 @@ module ActiveRecordCursorPaginate
|
|
8
8
|
class Cursor
|
9
9
|
class << self
|
10
10
|
def from_record(record, columns:)
|
11
|
-
columns = columns.map { |column| column.to_s.split(".").last }
|
12
11
|
values = columns.map { |column| record[column] }
|
13
12
|
new(columns: columns, values: values)
|
14
13
|
end
|
15
14
|
|
16
15
|
def decode(cursor_string:, columns:)
|
17
|
-
decoded = JSON.parse(Base64.
|
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)
|
@@ -48,8 +47,8 @@ module ActiveRecordCursorPaginate
|
|
48
47
|
attr_reader :columns, :values
|
49
48
|
|
50
49
|
def initialize(columns:, values:)
|
51
|
-
@columns = Array(columns)
|
52
|
-
@values = Array(values)
|
50
|
+
@columns = Array.wrap(columns)
|
51
|
+
@values = Array.wrap(values)
|
53
52
|
|
54
53
|
raise ArgumentError, "Cursor values can not be nil" if @values.any?(nil)
|
55
54
|
raise ArgumentError, ":columns and :values have different sizes" if @columns.size != @values.size
|
@@ -64,7 +63,7 @@ module ActiveRecordCursorPaginate
|
|
64
63
|
end
|
65
64
|
end
|
66
65
|
unencoded_cursor = (serialized_values.size == 1 ? serialized_values.first : serialized_values)
|
67
|
-
Base64.
|
66
|
+
Base64.urlsafe_encode64(unencoded_cursor.to_json, padding: false)
|
68
67
|
end
|
69
68
|
|
70
69
|
TIMESTAMP_PREFIX = "0aIX2_" # something random
|
@@ -4,14 +4,15 @@ 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(after: nil, before: nil, limit: nil, order: nil)
|
13
|
+
def cursor_paginate(after: nil, before: nil, limit: nil, order: nil, append_primary_key: true)
|
13
14
|
relation = (is_a?(ActiveRecord::Relation) ? self : all)
|
14
|
-
Paginator.new(relation, after: after, before: before, limit: limit, order: order)
|
15
|
+
Paginator.new(relation, after: after, before: before, limit: limit, order: order, append_primary_key: append_primary_key)
|
15
16
|
end
|
16
17
|
alias cursor_pagination cursor_paginate
|
17
18
|
end
|
@@ -78,8 +78,10 @@ module ActiveRecordCursorPaginate
|
|
78
78
|
|
79
79
|
private
|
80
80
|
def cursor_for_record(record)
|
81
|
-
|
82
|
-
|
81
|
+
if record
|
82
|
+
cursor = Cursor.from_record(record, columns: @order_columns)
|
83
|
+
cursor.encode
|
84
|
+
end
|
83
85
|
end
|
84
86
|
end
|
85
87
|
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|
|
@@ -34,9 +34,13 @@ module ActiveRecordCursorPaginate
|
|
34
34
|
# ```sql
|
35
35
|
# CREATE INDEX <index_name> ON <table_name> (<order_fields>..., id)
|
36
36
|
# ```
|
37
|
+
# @param append_primary_key [Boolean] (true). Specifies whether the primary column(s)
|
38
|
+
# should be implicitly appended to the list of sorting columns. It may be useful
|
39
|
+
# to disable it for the table with a UUID primary key or when the sorting is done by a
|
40
|
+
# combination of columns that are already unique.
|
37
41
|
# @raise [ArgumentError] If any parameter is not valid
|
38
42
|
#
|
39
|
-
def initialize(relation, before: nil, after: nil, limit: nil, order: nil)
|
43
|
+
def initialize(relation, before: nil, after: nil, limit: nil, order: nil, append_primary_key: true)
|
40
44
|
unless relation.is_a?(ActiveRecord::Relation)
|
41
45
|
raise ArgumentError, "relation is not an ActiveRecord::Relation"
|
42
46
|
end
|
@@ -54,6 +58,7 @@ module ActiveRecordCursorPaginate
|
|
54
58
|
@page_size = limit || config.default_page_size
|
55
59
|
@page_size = [@page_size, config.max_page_size].min if config.max_page_size
|
56
60
|
|
61
|
+
@append_primary_key = append_primary_key
|
57
62
|
order = normalize_order(order)
|
58
63
|
@columns = order.keys
|
59
64
|
@directions = order.values
|
@@ -72,11 +77,11 @@ module ActiveRecordCursorPaginate
|
|
72
77
|
arel_columns = @columns.map.with_index do |column, i|
|
73
78
|
arel_column(column).as("cursor_column_#{i + 1}")
|
74
79
|
end
|
75
|
-
cursor_column_names =
|
80
|
+
cursor_column_names = arel_columns.map { |column| column.right.to_s }
|
76
81
|
|
77
82
|
relation =
|
78
83
|
if relation.select_values.empty?
|
79
|
-
relation.select(Arel.star, arel_columns)
|
84
|
+
relation.select(relation.arel_table[Arel.star], arel_columns)
|
80
85
|
else
|
81
86
|
relation.select(arel_columns)
|
82
87
|
end
|
@@ -153,7 +158,13 @@ module ActiveRecordCursorPaginate
|
|
153
158
|
|
154
159
|
result = result.with_indifferent_access
|
155
160
|
result.transform_values! { |direction| direction.downcase.to_sym }
|
156
|
-
|
161
|
+
|
162
|
+
if @append_primary_key
|
163
|
+
Array(@primary_key).each { |column| result[column] ||= default_direction }
|
164
|
+
end
|
165
|
+
|
166
|
+
raise ArgumentError, ":order must contain columns to order by" if result.blank?
|
167
|
+
|
157
168
|
result
|
158
169
|
end
|
159
170
|
|
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.2.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-
|
11
|
+
date: 2024-05-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -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.
|