/
list.rb
566 lines (480 loc) · 20.7 KB
/
list.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
# frozen_string_literal: true
require "with_advisory_lock"
module ActiveRecord
module Acts #:nodoc:
module List #:nodoc:
module ClassMethods
# Configuration options are:
#
# * +column+ - specifies the column name to use for keeping the position integer (default: +position+)
# * +scope+ - restricts what is to be considered a list. Given a symbol, it'll attach <tt>_id</tt>
# (if it hasn't already been added) and use that as the foreign key restriction. It's also possible
# to give it an entire string that is interpolated if you need a tighter scope than just a foreign key.
# Example: <tt>acts_as_list scope: 'todo_list_id = #{todo_list_id} AND completed = 0'</tt>
# * +top_of_list+ - defines the integer used for the top of the list. Defaults to 1. Use 0 to make the collection
# act more like an array in its indexing.
# * +add_new_at+ - specifies whether objects get added to the :top or :bottom of the list. (default: +bottom+)
# `nil` will result in new items not being added to the list on create.
# * +sequential_updates+ - specifies whether insert_at should update objects positions during shuffling
# one by one to respect position column unique not null constraint.
# Defaults to true if position column has unique index, otherwise false.
# If constraint is <tt>deferrable initially deferred<tt>, overriding it with false will speed up insert_at.
def acts_as_list(options = {})
configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom }
configuration.update(options) if options.is_a?(Hash)
caller_class = self
ActiveRecord::Acts::List::PositionColumnMethodDefiner.call(caller_class, configuration[:column])
ActiveRecord::Acts::List::ScopeMethodDefiner.call(caller_class, configuration[:scope])
ActiveRecord::Acts::List::TopOfListMethodDefiner.call(caller_class, configuration[:top_of_list])
ActiveRecord::Acts::List::AddNewAtMethodDefiner.call(caller_class, configuration[:add_new_at])
ActiveRecord::Acts::List::AuxMethodDefiner.call(caller_class)
ActiveRecord::Acts::List::CallbackDefiner.call(caller_class, configuration[:add_new_at])
ActiveRecord::Acts::List::SequentialUpdatesMethodDefiner.call(caller_class, configuration[:column], configuration[:sequential_updates])
include ActiveRecord::Acts::List::InstanceMethods
include ActiveRecord::Acts::List::NoUpdate
end
# This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
# The class that has this specified needs to have a +position+ column defined as an integer on
# the mapped database table.
#
# Todo list example:
#
# class TodoList < ActiveRecord::Base
# has_many :todo_items, order: "position"
# end
#
# class TodoItem < ActiveRecord::Base
# belongs_to :todo_list
# acts_as_list scope: :todo_list
# end
#
# todo_list.first.move_to_bottom
# todo_list.last.move_higher
# All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
# by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
# lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
# the first in the list of all chapters.
end
module InstanceMethods
# Insert the item at the given position (defaults to the top position of 1).
def insert_at(position = acts_as_list_top)
with_smart_lock do
insert_at_position(position)
end
end
def insert_at!(position = acts_as_list_top)
with_smart_lock do
insert_at_position(position, true)
end
end
# Swap positions with the next lower item, if one exists.
def move_lower
with_smart_lock do
return unless lower_item
acts_as_list_class.transaction do
if lower_item.send(position_column) != self.send(position_column)
swap_positions(lower_item, self)
else
lower_item.decrement_position
increment_position
end
end
end
end
# Swap positions with the next higher item, if one exists.
def move_higher
with_smart_lock do
return unless higher_item
acts_as_list_class.transaction do
if higher_item.send(position_column) != self.send(position_column)
swap_positions(higher_item, self)
else
higher_item.increment_position
decrement_position
end
end
end
end
# Move to the bottom of the list. If the item is already in the list, the items below it have their
# position adjusted accordingly.
def move_to_bottom
with_smart_lock do
return unless in_list?
insert_at_position bottom_position_in_list.to_i
end
end
# Move to the top of the list. If the item is already in the list, the items above it have their
# position adjusted accordingly.
def move_to_top
with_smart_lock do
return unless in_list?
insert_at_position acts_as_list_top
end
end
# Removes the item from the list.
def remove_from_list
with_smart_lock do
if in_list?
decrement_positions_on_lower_items
set_list_position(nil)
end
end
end
# Move the item within scope. If a position within the new scope isn't supplied, the item will
# be appended to the end of the list.
def move_within_scope(scope_id)
with_smart_lock do
send("#{scope_name}=", scope_id)
save!
end
end
# Increase the position of this item without adjusting the rest of the list.
def increment_position
with_smart_lock do
return unless in_list?
set_list_position(self.send(position_column).to_i + 1)
end
end
# Decrease the position of this item without adjusting the rest of the list.
def decrement_position
with_smart_lock do
return unless in_list?
set_list_position(self.send(position_column).to_i - 1)
end
end
def first?
with_smart_lock do
return false unless in_list?
!higher_items(1).exists?
end
end
def last?
with_smart_lock do
return false unless in_list?
!lower_items(1).exists?
end
end
# Return the next higher item in the list.
def higher_item
with_smart_lock do
return nil unless in_list?
higher_items(1).first
end
end
# Return the next n higher items in the list
# selects all higher items by default
def higher_items(limit=nil)
with_smart_lock do
limit ||= acts_as_list_list.count
position_value = send(position_column)
acts_as_list_list.
where("#{quoted_position_column_with_table_name} <= ?", position_value).
where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
reorder(acts_as_list_order_argument(:desc)).
limit(limit)
end
end
# Return the next lower item in the list.
def lower_item
with_smart_lock do
return nil unless in_list?
lower_items(1).first
end
end
# Return the next n lower items in the list
# selects all lower items by default
def lower_items(limit=nil)
with_smart_lock do
limit ||= acts_as_list_list.count
position_value = send(position_column)
acts_as_list_list.
where("#{quoted_position_column_with_table_name} >= ?", position_value).
where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
reorder(acts_as_list_order_argument(:asc)).
limit(limit)
end
end
# Test if this record is in a list
def in_list?
with_smart_lock do
!not_in_list?
end
end
def not_in_list?
with_smart_lock do
send(position_column).nil?
end
end
def default_position
with_smart_lock do
acts_as_list_class.columns_hash[position_column.to_s].default
end
end
def default_position?
with_smart_lock do
default_position && default_position.to_i == send(position_column)
end
end
# Sets the new position and saves it
def set_list_position(new_position, raise_exception_if_save_fails=false)
with_smart_lock do
write_attribute position_column, new_position
raise_exception_if_save_fails ? save! : save
end
end
private
def advisory_lock_name
scope = case scope_name
when Symbol
send(scope_name)
when Array
scope_name.reject{ |e| e.is_a?(Hash) }.map do |e|
read_attribute(e.to_sym)
end.join('-')
else
eval("%{#{scope_name}}")
end
format('lock-%s-%s', acts_as_list_class.to_s.downcase, scope)
end
def with_smart_lock
acts_as_list_class.with_advisory_lock(advisory_lock_name) do
yield
end
end
def swap_positions(item1, item2)
item1_position = item1.send(position_column)
item1.set_list_position(item2.send(position_column))
item2.set_list_position(item1_position)
end
def acts_as_list_list
if ActiveRecord::VERSION::MAJOR < 4
acts_as_list_class.unscoped do
acts_as_list_class.where(scope_condition)
end
else
acts_as_list_class.unscope(:select, :where).where(scope_condition)
end
end
# Poorly named methods. They will insert the item at the desired position if the position
# has been set manually using position=, not necessarily the top or bottom of the list:
def add_to_list_top
if assume_default_position?
increment_positions_on_all_items
self[position_column] = acts_as_list_top
else
increment_positions_on_lower_items(self[position_column], id)
end
# Make sure we know that we've processed this scope change already
@scope_changed = false
# Don't halt the callback chain
true
end
def add_to_list_bottom
if assume_default_position?
self[position_column] = bottom_position_in_list.to_i + 1
else
increment_positions_on_lower_items(self[position_column], id)
end
# Make sure we know that we've processed this scope change already
@scope_changed = false
# Don't halt the callback chain
true
end
def assume_default_position?
not_in_list? ||
persisted? && internal_scope_changed? && !position_changed ||
default_position?
end
# Overwrite this method to define the scope of the list changes
def scope_condition() {} end
# Returns the bottom position number in the list.
# bottom_position_in_list # => 2
def bottom_position_in_list(except = nil)
item = bottom_item(except)
item ? item.send(position_column) : acts_as_list_top - 1
end
# Returns the bottom item
def bottom_item(except = nil)
scope = acts_as_list_list
if except
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", except.id)
end
scope.in_list.reorder(acts_as_list_order_argument(:desc)).first
end
# Forces item to assume the bottom position in the list.
def assume_bottom_position
set_list_position(bottom_position_in_list(self).to_i + 1)
end
# Forces item to assume the top position in the list.
def assume_top_position
set_list_position(acts_as_list_top)
end
# This has the effect of moving all the higher items down one.
def increment_positions_on_higher_items
return unless in_list?
acts_as_list_list.where("#{quoted_position_column_with_table_name} < ?", send(position_column).to_i).increment_all
end
# This has the effect of moving all the lower items down one.
def increment_positions_on_lower_items(position, avoid_id = nil)
scope = acts_as_list_list
if avoid_id
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", avoid_id)
end
scope.where("#{quoted_position_column_with_table_name} >= ?", position).increment_all
end
# This has the effect of moving all the higher items up one.
def decrement_positions_on_higher_items(position)
acts_as_list_list.where("#{quoted_position_column_with_table_name} <= ?", position).decrement_all
end
# This has the effect of moving all the lower items up one.
def decrement_positions_on_lower_items(position=nil)
return unless in_list?
position ||= send(position_column).to_i
if sequential_updates?
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).reorder(acts_as_list_order_argument(:asc)).each do |item|
item.decrement!(position_column)
end
else
acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
end
end
# Increments position (<tt>position_column</tt>) of all items in the list.
def increment_positions_on_all_items
acts_as_list_list.increment_all
end
# Reorders intermediate items to support moving an item from old_position to new_position.
# unique constraint prevents regular increment_all and forces to do increments one by one
# http://stackoverflow.com/questions/7703196/sqlite-increment-unique-integer-field
# both SQLite and PostgreSQL (and most probably MySQL too) has same issue
# that's why *sequential_updates?* check alters implementation behavior
def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
return if old_position == new_position
scope = acts_as_list_list
if avoid_id
scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", avoid_id)
end
if old_position < new_position
# Decrement position of intermediate items
#
# e.g., if moving an item from 2 to 5,
# move [3, 4, 5] to [2, 3, 4]
items = scope.where(
"#{quoted_position_column_with_table_name} > ?", old_position
).where(
"#{quoted_position_column_with_table_name} <= ?", new_position
)
if sequential_updates?
items.reorder(acts_as_list_order_argument(:asc)).each do |item|
item.decrement!(position_column)
end
else
items.decrement_all
end
else
# Increment position of intermediate items
#
# e.g., if moving an item from 5 to 2,
# move [2, 3, 4] to [3, 4, 5]
items = scope.where(
"#{quoted_position_column_with_table_name} >= ?", new_position
).where(
"#{quoted_position_column_with_table_name} < ?", old_position
)
if sequential_updates?
items.reorder(acts_as_list_order_argument(:desc)).each do |item|
item.increment!(position_column)
end
else
items.increment_all
end
end
end
def insert_at_position(position, raise_exception_if_save_fails=false)
raise ArgumentError.new("position cannot be lower than top") if position < acts_as_list_top
return set_list_position(position, raise_exception_if_save_fails) if new_record?
with_lock do
if in_list?
old_position = send(position_column).to_i
return if position == old_position
# temporary move after bottom with gap, avoiding duplicate values
# gap is required to leave room for position increments
# positive number will be valid with unique not null check (>= 0) db constraint
temporary_position = bottom_position_in_list + 2
set_list_position(temporary_position, raise_exception_if_save_fails)
shuffle_positions_on_intermediate_items(old_position, position, id)
else
increment_positions_on_lower_items(position)
end
set_list_position(position, raise_exception_if_save_fails)
end
end
def update_positions
return unless position_before_save_changed?
old_position = position_before_save || bottom_position_in_list + 1
new_position = send(position_column).to_i
return unless acts_as_list_list.where(
"#{quoted_position_column_with_table_name} = #{new_position}"
).count > 1
shuffle_positions_on_intermediate_items old_position, new_position, id
end
def position_before_save_changed?
if ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1 ||
ActiveRecord::VERSION::MAJOR > 5
saved_change_to_attribute? position_column
else
send "#{position_column}_changed?"
end
end
def position_before_save
if ActiveRecord::VERSION::MAJOR == 5 && ActiveRecord::VERSION::MINOR >= 1 ||
ActiveRecord::VERSION::MAJOR > 5
attribute_before_last_save position_column
else
send "#{position_column}_was"
end
end
def internal_scope_changed?
return @scope_changed if defined?(@scope_changed)
@scope_changed = scope_changed?
end
def clear_scope_changed
remove_instance_variable(:@scope_changed) if defined?(@scope_changed)
end
def check_scope
if internal_scope_changed?
cached_changes = changes
cached_changes.each { |attribute, values| send("#{attribute}=", values[0]) }
send('decrement_positions_on_lower_items') if lower_item
cached_changes.each { |attribute, values| send("#{attribute}=", values[1]) }
send("add_to_list_#{add_new_at}") if add_new_at.present?
end
end
# This check is skipped if the position is currently the default position from the table
# as modifying the default position on creation is handled elsewhere
def check_top_position
if send(position_column) && !default_position? && send(position_column) < acts_as_list_top
self[position_column] = acts_as_list_top
end
end
# When using raw column name it must be quoted otherwise it can raise syntax errors with SQL keywords (e.g. order)
def quoted_position_column
@_quoted_position_column ||= self.class.connection.quote_column_name(position_column)
end
# Used in order clauses
def quoted_table_name
@_quoted_table_name ||= acts_as_list_class.quoted_table_name
end
def quoted_position_column_with_table_name
@_quoted_position_column_with_table_name ||= "#{quoted_table_name}.#{quoted_position_column}"
end
def acts_as_list_order_argument(direction = :asc)
if ActiveRecord::VERSION::MAJOR >= 4
{ position_column => direction }
else
"#{quoted_position_column_with_table_name} #{direction.to_s.upcase}"
end
end
end
end
end
end