activerecord_cursor_pagination 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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