activerecord_cursor_paginate 0.1.0 → 0.3.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: 198c2d0c2e71f89e73fca64d7ab8711949cf25faa20af6297d1d6045587ec7cf
4
+ data.tar.gz: 6f60b98579b2dcd132c09d4f75105b5cab7219ae8841c6970df9e77df6b59722
5
5
  SHA512:
6
- metadata.gz: f54a87c366b7cfd08e59530b29fab0e0d5736cc323c4bab81ba0c3873ae5faa1254a2a9c94563e7aa9751debdb8802f28454a5781ddccf6811fe912c6808afca
7
- data.tar.gz: c5689804eb34fa24e4e38f80994c9e3f16f45ab2594fe1fe9482397d2260b7d6db3453168a61fcb5f7e614ad97d8dfb6d8a1a42b4e560d780cebda759fd9f26d
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
- [![Build Status](https://github.com/fatkodima/activerecord_cursor_paginate/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/fatkodima/activerecord_cursor_paginate/actions/workflows/test.yml)
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,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==", "Mg==", ..., "MTA="]
52
- | |
53
- | |
54
- page.previous_cursor # => "MQ==" |
55
- page.next_cursor # => "MTA=" ------------------|
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=", limit: 2)
86
+ paginator = posts.cursor_paginate(after: "MTA", limit: 2)
80
87
  ```
81
88
 
82
89
  ```ruby
83
- paginator = posts.cursor_paginate(before: "MQ==", limit: 2)
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 oder by a column ascending or descending, but need more complex logic.
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
- | id | author |
219
- |----|--------|
220
- | 1 | Jane |
221
- | 2 | John |
222
- | 3 | John |
223
- | 4 | Jane |
224
- | 5 | Jane |
225
- | 6 | John |
226
- | 7 | John |
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 "posts"
241
- ORDER BY "posts"."id" ASC
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=="` (can get via `page.cursor`). So now we can request
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 "posts"
263
- WHERE "posts"."id" > 2
264
- ORDER BY "posts"."id" ASC
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=="` (can get via `page.previous_cursor`):
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 "posts"
283
- WHERE "posts"."id" < 3
284
- ORDER BY "posts"."id" DESC
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 "posts"
305
- ORDER BY "posts"."author" ASC, "posts"."id" ASC
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 "posts"
340
- WHERE (author > 'Jane' OR (author = 'Jane') AND ("posts"."id" > 4))
341
- ORDER BY "posts"."author" ASC, "posts"."id" ASC
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/fatkodima/activerecord_cursor_paginate.
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.strict_decode64(cursor_string))
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.strict_encode64(unencoded_cursor.to_json)
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.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
- relation = (is_a?(ActiveRecord::Relation) ? self : all)
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
- cursor = Cursor.from_record(record, columns: @order_columns)
82
- cursor.encode
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
- if before.present? && after.present?
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
- @relation = relation
49
- @primary_key = @relation.primary_key
50
- @cursor = before || after
51
- @is_forward_pagination = before.blank?
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 = limit || config.default_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
- order = normalize_order(order)
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 = @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 = @cursor.present?
143
+ has_previous_page = @current_cursor.present?
105
144
  else
106
- has_next_page = @cursor.present?
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
- Array(@primary_key).each { |column| result[column] ||= default_direction }
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
- operators = @directions.map { |direction| pagination_operator(direction) }
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
- cursor_positions.reverse_each.with_index do |(column, value, operator), index|
166
- where_clause =
167
- if index == 0
168
- arel_column(column).public_send(operator, value)
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
- arel_column(column).public_send(operator, value).or(
171
- arel_column(column).eq(value).and(where_clause)
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
- @cursor =
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordCursorPaginate
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.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.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: 2024-03-08 00:00:00.000000000 Z
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/fatkodima/activerecord_cursor_paginate
44
+ homepage: https://github.com/healthie/activerecord_cursor_paginate
45
45
  licenses:
46
46
  - MIT
47
47
  metadata:
48
- homepage_uri: https://github.com/fatkodima/activerecord_cursor_paginate
49
- source_code_uri: https://github.com/fatkodima/activerecord_cursor_paginate
50
- changelog_uri: https://github.com/fatkodima/activerecord_cursor_paginate/blob/master/CHANGELOG.md
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.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.