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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6a96b7bf3c40469653bf87dcf68cc159b30c389ed734047ef165b12d6ff7ef24
4
- data.tar.gz: 2dea9c8b3847b5a72d6a915e9051e8b61891940e9feaa855edc822d49a393750
3
+ metadata.gz: ea74d80632e02c285a03b087219a635b30b678225dfbe7c17b949a83d74d2444
4
+ data.tar.gz: 06b461f0429a9527dd0180fc94ff5d984649aa412e78c641321cb463c63a40e3
5
5
  SHA512:
6
- metadata.gz: f54a87c366b7cfd08e59530b29fab0e0d5736cc323c4bab81ba0c3873ae5faa1254a2a9c94563e7aa9751debdb8802f28454a5781ddccf6811fe912c6808afca
7
- data.tar.gz: c5689804eb34fa24e4e38f80994c9e3f16f45ab2594fe1fe9482397d2260b7d6db3453168a61fcb5f7e614ad97d8dfb6d8a1a42b4e560d780cebda759fd9f26d
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==", "Mg==", ..., "MTA="]
52
- | |
53
- | |
54
- page.previous_cursor # => "MQ==" |
55
- page.next_cursor # => "MTA=" ------------------|
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=", limit: 2)
79
+ paginator = posts.cursor_paginate(after: "MTA", limit: 2)
80
80
  ```
81
81
 
82
82
  ```ruby
83
- paginator = posts.cursor_paginate(before: "MQ==", limit: 2)
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 oder by a column ascending or descending, but need more complex logic.
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
- | id | author |
219
- |----|--------|
220
- | 1 | Jane |
221
- | 2 | John |
222
- | 3 | John |
223
- | 4 | Jane |
224
- | 5 | Jane |
225
- | 6 | John |
226
- | 7 | John |
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 "posts"
241
- ORDER BY "posts"."id" ASC
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=="` (can get via `page.cursor`). So now we can request
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 "posts"
263
- WHERE "posts"."id" > 2
264
- ORDER BY "posts"."id" ASC
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=="` (can get via `page.previous_cursor`):
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 "posts"
283
- WHERE "posts"."id" < 3
284
- ORDER BY "posts"."id" DESC
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 "posts"
305
- ORDER BY "posts"."author" ASC, "posts"."id" ASC
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 "posts"
340
- WHERE (author > 'Jane' OR (author = 'Jane') AND ("posts"."id" > 4))
341
- ORDER BY "posts"."author" ASC, "posts"."id" ASC
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.strict_decode64(cursor_string))
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.strict_encode64(unencoded_cursor.to_json)
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.all.cursor_paginate(limit: 2, after: "Mg==")
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
- cursor = Cursor.from_record(record, columns: @order_columns)
82
- cursor.encode
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 = 1.upto(@columns.size).map { |i| "cursor_column_#{i}" }
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
- Array(@primary_key).each { |column| result[column] ||= default_direction }
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
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCursorPaginate
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.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.1.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-03-08 00:00:00.000000000 Z
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.5.4
66
+ rubygems_version: 3.4.19
67
67
  signing_key:
68
68
  specification_version: 4
69
69
  summary: Cursor-based pagination for ActiveRecord.