activerecord_cursor_pagination 0.1.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.
@@ -0,0 +1,426 @@
1
+ module ActiverecordCursorPagination
2
+ class CursorScope
3
+ attr_reader :per_page
4
+
5
+ ##
6
+ # @example Empty cursor
7
+ # Posts.where(published: true).order(created_at: :desc).cursor(nil, per: 100)
8
+ # Posts.where(published: true).order(created_at: :desc).cursor("", per: 100)
9
+ # Posts.where(published: true).order(created_at: :desc).cursor(EmptyCursor.new, per: 100)
10
+ #
11
+ # @example Serialized cursor
12
+ # Posts.where(published: true).order(created_at: :desc).cursor("SerializedCursorString", per: 100)
13
+ #
14
+ # @example Record cursor
15
+ # Posts.where(published: true).order(created_at: :desc).cursor(Post.find!(6), per: 100)
16
+ #
17
+ # @example Cursor
18
+ # cursor = ...deserialized cursor...
19
+ # Posts.where(published: true).order(created_at: :desc).cursor(cursor, per: 100)
20
+ #
21
+ # @param [Class] klass Model class
22
+ # @param [ActiveRecord::Relation] scope The database query.
23
+ # @param [String, Cursor, EmptyCursor, ActiveRecord::Base, nil] cursor The current page cursor.
24
+ # @option [Integer] per The number of records per page.
25
+ #
26
+ # @raise [InvalidCursorError] When the cursor is does not match the query or the cursor is not a valid type.
27
+ def initialize(klass, scope, cursor, per: 15)
28
+ @scope = scope.except :offset, :limit
29
+ @klass = klass
30
+ @per_page = per
31
+ @table = @scope.table_name
32
+ @id_column = "#{@table}.id"
33
+
34
+ initialize_order_columns
35
+ initialize_cursor cursor
36
+ initialize_order_column_values
37
+ end
38
+
39
+ ##
40
+ # Get if the query is for single records
41
+ #
42
+ # @return [Boolean] True if only one record per page
43
+ def single_record?
44
+ @per_page === 1
45
+ end
46
+
47
+ ##
48
+ # Get the total count of records from the query scope.
49
+ #
50
+ # @return [Integer] The number of total records.
51
+ def scope_size
52
+ @scope.except(:select).size
53
+ end
54
+
55
+ alias_method :scope_count, :scope_size
56
+ alias_method :total_count, :scope_size
57
+ alias_method :total, :scope_size
58
+
59
+ ##
60
+ # Get if there are not records from the query scope.
61
+ #
62
+ # @return [Boolean] True if the query scope is empty.
63
+ def scope_empty?
64
+ total_count.zero?
65
+ end
66
+
67
+ alias_method :scope_none?, :scope_empty?
68
+
69
+ ##
70
+ # Get if there are records from the query scope.
71
+ #
72
+ # @return [Boolean] True if the query scope is not empty.
73
+ def scope_any?
74
+ !scope_empty?
75
+ end
76
+
77
+ ##
78
+ # Get if there is only one record from the query scope.
79
+ #
80
+ # @return [Boolean] True if there is only one record.
81
+ def scope_one?
82
+ total_count == 1
83
+ end
84
+
85
+ ##
86
+ # Get if there are many records from the query scope.
87
+ #
88
+ # @return [Boolean] True if there is more than one record.
89
+ def scope_many?
90
+ total_count > 1
91
+ end
92
+
93
+ ##
94
+ # Get the number of records in the current page
95
+ #
96
+ # @return [Integer] The number of records
97
+ def size
98
+ current_page_scope.except(:select).size
99
+ end
100
+
101
+ alias_method :count, :size
102
+ alias_method :length, :size
103
+
104
+ ##
105
+ # Get if there no records in the current page.
106
+ #
107
+ # @return [Boolean] True if there are no records.
108
+ def empty?
109
+ size.zero?
110
+ end
111
+
112
+ alias_method :none?, :empty?
113
+
114
+ ##
115
+ # Get if there are records in the current page.
116
+ #
117
+ # @return [Boolean] True if not empty.
118
+ def any?
119
+ !empty?
120
+ end
121
+
122
+ ##
123
+ # Get if there are many records in the current page.
124
+ #
125
+ # @return [Boolean] True if there is more than one record.
126
+ def many?
127
+ size > 1
128
+ end
129
+
130
+ ##
131
+ # Get if there is only one in the current page.
132
+ #
133
+ # @return [Boolean] True if there is only one record.
134
+ def one?
135
+ size == 1
136
+ end
137
+
138
+ ##
139
+ # Get if there is a previous page from the cursor
140
+ #
141
+ # @return [Boolean] True if there is previous page
142
+ def previous_page?
143
+ return false if scope_empty?
144
+ previous_page_scope.any?
145
+ end
146
+
147
+ ##
148
+ # Get if there is another page
149
+ #
150
+ # @return [Boolean] True if there is a next page
151
+ def next_page?
152
+ return false if scope_empty?
153
+ next_page_scope.any?
154
+ end
155
+
156
+ ##
157
+ # Get if the cursor is the first page
158
+ #
159
+ # @return [Boolean] True if first page
160
+ def first_page?
161
+ scope_empty? || !previous_page?
162
+ end
163
+
164
+ ##
165
+ # Get if the cursor is the last page
166
+ #
167
+ # @return [Boolean] True if last page
168
+ def last_page?
169
+ scope_empty? || !next_page?
170
+ end
171
+
172
+ ##
173
+ # Get the current cursor
174
+ #
175
+ # @return [String] The current cursor
176
+ def current_cursor
177
+ @cursor.to_param
178
+ end
179
+
180
+ ##
181
+ # Get the next page cursor
182
+ #
183
+ # @return [String]
184
+ def next_cursor
185
+ return EmptyCursor.to_param unless next_page?
186
+ ids = next_page_scope.pluck(:id)
187
+ Cursor.to_param @klass, @scope, @per_page, ids.first, ids.last
188
+ end
189
+
190
+ ##
191
+ # Get the next record
192
+ #
193
+ # @raise [NotSingleRecordError] If the number of records per page is not one.
194
+ #
195
+ # @return [ActiveRecord::Base]
196
+ def next_cursor_record
197
+ raise NotSingleRecordError unless single_record?
198
+ next_page_scope.first if next_page?
199
+ end
200
+
201
+ ##
202
+ # Get the previous page cursor.
203
+ #
204
+ # @return [String]
205
+ def previous_cursor
206
+ return EmptyCursor.to_param unless previous_page?
207
+ ids = previous_page_scope.pluck(:id)
208
+ Cursor.to_param @klass, @scope, @per_page, ids.last, ids.first
209
+ end
210
+
211
+ ##
212
+ # Get the previous record.
213
+ #
214
+ # @raise [NotSingleRecordError] If the number of records per page is not one.
215
+ #
216
+ # @return [ActiveRecord::Base]
217
+ def previous_cursor_record
218
+ raise NotSingleRecordError unless single_record?
219
+ previous_page_scope.first if previous_page?
220
+ end
221
+
222
+ ##
223
+ # Iterate each record in the current page.
224
+ #
225
+ # @yield [ActiveRecord::Base] Invokes the block with each active record.
226
+ def each(&block)
227
+ current_page_scope.each &block
228
+ end
229
+
230
+ ##
231
+ # Iterate each record in the current page.
232
+ #
233
+ # @yield [ActiveRecord::Base, Integer] Invokes the block with each active record and page row index.
234
+ def each_with_index(&block)
235
+ i = 0
236
+ current_page_scope.each do |r|
237
+ block.call r, i
238
+ i += 1
239
+ end
240
+ end
241
+
242
+ ##
243
+ # Map each record in the current page.
244
+ #
245
+ # @yield [ActiveRecord::Base] Invokes the block with each active record.
246
+ def map(&block)
247
+ current_page_scope.map &block
248
+ end
249
+
250
+ ##
251
+ # Map each record in the current page.
252
+ #
253
+ # @yield [ActiveRecord::Base, Integer] Invokes the block with each active record and page row index.
254
+ def map_with_index(&block)
255
+ current_page_scope.map.with_index do |r, i|
256
+ block.call r, i
257
+ end
258
+ end
259
+
260
+ private
261
+
262
+ def initialize_order_columns
263
+ # FIXME Limitation / Code Smell - Invoking Private Method
264
+ # As of right now, there is no public method to get the values from the .order() method.
265
+ # The private .order_values method is undocumented and future releases of ActiveRecord can
266
+ # remove/change this method.
267
+ # See .order! for reference to the method.
268
+ # https://apidock.com/rails/v5.1.7/ActiveRecord/QueryMethods/order%21
269
+ @order_columns = @scope.send(:order_values).map.with_index do |node, index|
270
+ order = OrderBase.parse node, index
271
+ order.base_id = order.table === @table && order.name == 'id'
272
+ order
273
+ end
274
+
275
+ unless @order_columns.any?(&:base_id?)
276
+ order_column = AscendingOrder.new @table, 'id', @order_columns.size
277
+ order_column.base_id = true
278
+ @order_columns << order_column
279
+ @scope = @scope.order order_column.order_sql
280
+ end
281
+
282
+ @id_column_index = @order_columns.find_index &:base_id?
283
+ end
284
+
285
+ def initialize_cursor(cursor)
286
+ @cursor = EmptyCursor.new
287
+
288
+ if cursor.nil? || cursor.try(:empty?)
289
+ ids = build_sql_order(@scope, false).limit(@per_page).pluck(:id)
290
+
291
+ @cursor = Cursor.new @klass, @scope, @per_page, ids.first, ids.last unless ids.empty?
292
+ elsif cursor.is_a? Cursor
293
+ @cursor = cursor
294
+ elsif cursor.is_a? String
295
+ @cursor = Cursor.parse cursor
296
+ elsif cursor.is_a? ActiveRecord::Base
297
+ if single_record?
298
+ @cursor = Cursor.new @klass, @scope, @per_page, cursor.id, cursor.id
299
+ else
300
+ calculate_cursor_from_record cursor
301
+ end
302
+ else
303
+ raise InvalidCursorError.new("Invalid cursor type #{cursor.class.name}", cursor)
304
+ end
305
+
306
+ @cursor.validate! @klass, @scope, @per_page unless @cursor.empty?
307
+ end
308
+
309
+ def initialize_order_column_values
310
+ if @cursor.empty?
311
+ @start_column_values = @end_column_values = []
312
+ elsif single_record?
313
+ @start_column_values = @end_column_values = ensure_array @scope.only(:from, :joins)
314
+ .where(id: @cursor.start_id).limit(1)
315
+ .pluck(*@order_columns.map(&:quote_full_name))
316
+ .first
317
+ else
318
+ query = @scope.only :from, :joins
319
+
320
+ @start_column_values = ensure_array query.where(id: @cursor.start_id)
321
+ .limit(1)
322
+ .pluck(*@order_columns.map(&:quote_full_name))
323
+ .first
324
+
325
+ @end_column_values = ensure_array query.where(id: @cursor.end_id)
326
+ .limit(1)
327
+ .pluck(*@order_columns.map(&:quote_full_name))
328
+ .first
329
+ end
330
+ end
331
+
332
+ def build_sql_order(query, reverse)
333
+ order_array = @order_columns.map { |c| reverse ? c.reverse.order_sql : c.order_sql }
334
+ query.unscope(:order).order(*order_array)
335
+ end
336
+
337
+ def build_sql_from_columns(query, column_values, id_direction: :start)
338
+ conditions = []
339
+
340
+ values_hash = 0.upto(column_values.size - 1).inject({}) do |hash, i|
341
+ hash.merge @order_columns[i].statement_key => column_values[i]
342
+ end
343
+
344
+ @order_columns.each_with_index do |column, i|
345
+ sql = []
346
+
347
+ 0.upto(i - 1) { |p| sql << @order_columns[p].equals_sql }
348
+
349
+ if column.base_id?
350
+ if id_direction == :previous
351
+ sql << column.reverse.than_sql
352
+ elsif id_direction == :next
353
+ sql << column.than_sql
354
+ elsif id_direction == :end
355
+ sql << column.reverse.than_or_equal_sql
356
+ else
357
+ sql << column.than_or_equal_sql
358
+ end
359
+ else
360
+ if id_direction == :previous || id_direction == :end
361
+ sql << column.reverse.than_sql
362
+ else
363
+ sql << column.than_sql
364
+ end
365
+ end
366
+
367
+ conditions << "(#{sql.join ' AND '})"
368
+ end
369
+
370
+ query.where conditions.join(' OR '), values_hash
371
+ end
372
+
373
+ def calculate_cursor_from_record(record)
374
+ column_values = @scope.only(:from, :joins)
375
+ .where(id: record.id)
376
+ .limit(1)
377
+ .pluck(*@order_columns.map(&:full_name))
378
+ .first
379
+
380
+ column_values = ensure_array column_values
381
+ query = build_sql_order @scope.only(:from, :joins, :where), false
382
+ count_query = build_sql_from_columns query, column_values, id_direction: :previous
383
+ count_query = build_sql_order count_query, true
384
+ page = (count_query.count / @per_page).floor
385
+
386
+ page_values = query.offset(page * @per_page)
387
+ .limit(@per_page)
388
+ .pluck(*@order_columns.map(&:full_name))
389
+
390
+ @start_column_values = ensure_array page_values.first
391
+ @end_column_values = ensure_array page_values.last
392
+
393
+ @cursor = Cursor.new @klass,
394
+ @scope,
395
+ @per_page,
396
+ @start_column_values[@id_column_index],
397
+ @end_column_values[@id_column_index]
398
+ end
399
+
400
+ def previous_page_scope
401
+ query = build_sql_from_columns @scope, @start_column_values, id_direction: :previous
402
+ query = build_sql_order query, true
403
+ query.limit @per_page
404
+ end
405
+
406
+ def current_page_scope
407
+ if @cursor.empty?
408
+ build_sql_order(@scope, false).limit(@per_page)
409
+ else
410
+ query = build_sql_from_columns @scope, @start_column_values, id_direction: :start
411
+ query = build_sql_from_columns query, @end_column_values, id_direction: :end
412
+ build_sql_order query, false
413
+ end
414
+ end
415
+
416
+ def next_page_scope
417
+ query = build_sql_from_columns @scope, @end_column_values, id_direction: :next
418
+ query = build_sql_order query, false
419
+ query.limit @per_page
420
+ end
421
+
422
+ def ensure_array(value)
423
+ value.is_a?(Array) ? value : [value]
424
+ end
425
+ end
426
+ end
@@ -0,0 +1,21 @@
1
+ module ActiverecordCursorPagination
2
+ class DescendingOrder < OrderBase
3
+ def direction
4
+ :desc
5
+ end
6
+
7
+ def reverse
8
+ order = AscendingOrder.new table, name, index
9
+ order.base_id = base_id
10
+ order
11
+ end
12
+
13
+ def than_op
14
+ '<'
15
+ end
16
+
17
+ def than_or_equal_op
18
+ '<='
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,35 @@
1
+ module ActiverecordCursorPagination
2
+ class EmptyCursor
3
+ ##
4
+ # Is the cursor not empty
5
+ #
6
+ # @return [Boolean]
7
+ def present?
8
+ false
9
+ end
10
+
11
+ ##
12
+ # Is the cursor empty
13
+ #
14
+ # @return [Boolean]
15
+ def empty?
16
+ true
17
+ end
18
+
19
+ ##
20
+ # Get the string representation of the cursor
21
+ #
22
+ # @return [String] The serialized cursor
23
+ def to_s
24
+ ''
25
+ end
26
+
27
+ alias_method :to_param, :to_s
28
+
29
+ class << self
30
+ def to_param
31
+ EmptyCursor.new.to_param
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,18 @@
1
+ module ActiverecordCursorPagination
2
+ module Extension
3
+ extend ActiveSupport::Concern
4
+
5
+ module ClassMethods
6
+ def inherited(kls)
7
+ super
8
+ kls.send :include, ModelExtension if kls.superclass == ActiveRecord::Base
9
+ end
10
+ end
11
+
12
+ included do
13
+ descendants.each do |kls|
14
+ kls.send :include, ModelExtension if kls.superclass == ActiveRecord::Base
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,94 @@
1
+ module ActiverecordCursorPagination
2
+ module ModelExtension
3
+ extend ActiveSupport::Concern
4
+
5
+ class_methods do
6
+ ##
7
+ # Get the paginated cursor for the current query
8
+ #
9
+ # @param [String, Cursor, EmptyCursor, ActiveRecord::Base, nil] cursor
10
+ #
11
+ # The current page cursor.
12
+ #
13
+ # If an ActiveRecord::Base is passed, the current page will be calculated based on the record id.
14
+ #
15
+ # @option [Integer] per
16
+ #
17
+ # Limit the number of records per page.
18
+ #
19
+ # @return [CursorScope] The current page scope
20
+ def cursor(cursor, per: 15)
21
+ klass = all.instance_variable_get :@klass
22
+ CursorScope.new klass, self, cursor, per: per
23
+ end
24
+
25
+ ##
26
+ # Page batching using a cursor
27
+ #
28
+ # @option [Integer] batch_size
29
+ #
30
+ # Limits the size of each batch
31
+ #
32
+ # @yield [cursor_scope] Invokes the block with the cursor_scope for each result.
33
+ def cursor_batch(batch_size: 1000, &block)
34
+ current_cursor = nil
35
+
36
+ begin
37
+ cursor = cursor current_cursor, per: batch_size
38
+ block.call cursor
39
+ current_cursor = cursor.next_cursor
40
+ end until cursor.last_page?
41
+ end
42
+
43
+ ##
44
+ # Page batching using a cursor
45
+ #
46
+ # @option [Integer] batch_size
47
+ #
48
+ # Limits the size of each batch
49
+ #
50
+ # @yield [cursor_scope] Invokes the block with the cursor scope and the index for each result.
51
+ def cursor_batch_with_index(batch_size: 1000, &block)
52
+ i = 0
53
+
54
+ self.cursor_batch batch_size: batch_size do |c|
55
+ block.call c, i
56
+ i += 1
57
+ end
58
+ end
59
+
60
+ ##
61
+ # Find each record using the cursor batch
62
+ #
63
+ # @option [Integer] batch_size
64
+ #
65
+ # Limits the size of each batch
66
+ #
67
+ # @yield [record] Invokes the block with a record for each result.
68
+ def cursor_find_each(batch_size: 1000, &block)
69
+ self.cursor_batch batch_size: batch_size do |c|
70
+ c.each &block
71
+ end
72
+ end
73
+
74
+ ##
75
+ # Find each record using the cursor batch
76
+ #
77
+ # @option [Integer] batch_size
78
+ #
79
+ # Limits the size of each batch
80
+ #
81
+ # @yield [record] Invokes the block with a record and the index for each result.
82
+ def cursor_find_each_with_index(batch_size: 1000, &block)
83
+ i = 0
84
+
85
+ self.cursor_batch batch_size: batch_size do |c|
86
+ c.each do |r|
87
+ block.call r, i
88
+ i += 1
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end