acts_as_list 0.7.4 → 1.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.
Files changed (52) hide show
  1. checksums.yaml +5 -13
  2. data/.github/FUNDING.yml +3 -0
  3. data/.github/dependabot.yml +6 -0
  4. data/.github/workflows/ci.yml +123 -0
  5. data/.gitignore +1 -0
  6. data/.travis.yml +50 -12
  7. data/Appraisals +39 -6
  8. data/CHANGELOG.md +565 -148
  9. data/Gemfile +19 -14
  10. data/README.md +206 -19
  11. data/Rakefile +4 -4
  12. data/acts_as_list.gemspec +16 -11
  13. data/gemfiles/rails_4_2.gemfile +18 -9
  14. data/gemfiles/rails_5_0.gemfile +31 -0
  15. data/gemfiles/rails_5_1.gemfile +31 -0
  16. data/gemfiles/rails_5_2.gemfile +31 -0
  17. data/gemfiles/rails_6_0.gemfile +31 -0
  18. data/gemfiles/rails_6_1.gemfile +31 -0
  19. data/gemfiles/rails_7_0.gemfile +31 -0
  20. data/init.rb +2 -0
  21. data/lib/acts_as_list/active_record/acts/active_record.rb +5 -0
  22. data/lib/acts_as_list/active_record/acts/add_new_at_method_definer.rb +11 -0
  23. data/lib/acts_as_list/active_record/acts/aux_method_definer.rb +11 -0
  24. data/lib/acts_as_list/active_record/acts/callback_definer.rb +19 -0
  25. data/lib/acts_as_list/active_record/acts/list.rb +299 -306
  26. data/lib/acts_as_list/active_record/acts/no_update.rb +125 -0
  27. data/lib/acts_as_list/active_record/acts/position_column_method_definer.rb +101 -0
  28. data/lib/acts_as_list/active_record/acts/scope_method_definer.rb +77 -0
  29. data/lib/acts_as_list/active_record/acts/sequential_updates_method_definer.rb +28 -0
  30. data/lib/acts_as_list/active_record/acts/top_of_list_method_definer.rb +15 -0
  31. data/lib/acts_as_list/version.rb +3 -1
  32. data/lib/acts_as_list.rb +11 -14
  33. data/test/database.yml +18 -0
  34. data/test/helper.rb +50 -2
  35. data/test/shared.rb +3 -0
  36. data/test/shared_array_scope_list.rb +21 -4
  37. data/test/shared_list.rb +86 -12
  38. data/test/shared_list_sub.rb +63 -2
  39. data/test/shared_no_addition.rb +50 -2
  40. data/test/shared_quoting.rb +23 -0
  41. data/test/shared_top_addition.rb +36 -13
  42. data/test/shared_zero_based.rb +13 -0
  43. data/test/test_default_scope_with_select.rb +33 -0
  44. data/test/test_joined_list.rb +61 -0
  45. data/test/test_list.rb +601 -84
  46. data/test/test_no_update_for_extra_classes.rb +131 -0
  47. data/test/test_no_update_for_scope_destruction.rb +69 -0
  48. data/test/test_no_update_for_subclasses.rb +56 -0
  49. data/test/test_scope_with_user_defined_foreign_key.rb +42 -0
  50. metadata +56 -22
  51. data/gemfiles/rails_3_2.gemfile +0 -24
  52. data/gemfiles/rails_4_1.gemfile +0 -24
@@ -1,27 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module ActiveRecord
2
4
  module Acts #:nodoc:
3
5
  module List #:nodoc:
4
- def self.included(base)
5
- base.extend(ClassMethods)
6
- end
7
6
 
8
- # This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
9
- # The class that has this specified needs to have a +position+ column defined as an integer on
10
- # the mapped database table.
11
- #
12
- # Todo list example:
13
- #
14
- # class TodoList < ActiveRecord::Base
15
- # has_many :todo_items, order: "position"
16
- # end
17
- #
18
- # class TodoItem < ActiveRecord::Base
19
- # belongs_to :todo_list
20
- # acts_as_list scope: :todo_list
21
- # end
22
- #
23
- # todo_list.first.move_to_bottom
24
- # todo_list.last.move_higher
25
7
  module ClassMethods
26
8
  # Configuration options are:
27
9
  #
@@ -33,121 +15,82 @@ module ActiveRecord
33
15
  # * +top_of_list+ - defines the integer used for the top of the list. Defaults to 1. Use 0 to make the collection
34
16
  # act more like an array in its indexing.
35
17
  # * +add_new_at+ - specifies whether objects get added to the :top or :bottom of the list. (default: +bottom+)
36
- # `nil` will result in new items not being added to the list on create
18
+ # `nil` will result in new items not being added to the list on create.
19
+ # * +sequential_updates+ - specifies whether insert_at should update objects positions during shuffling
20
+ # one by one to respect position column unique not null constraint.
21
+ # Defaults to true if position column has unique index, otherwise false.
22
+ # If constraint is <tt>deferrable initially deferred<tt>, overriding it with false will speed up insert_at.
23
+ # * +touch_on_update+ - configuration to disable the update of the model timestamps when the positions are updated.
37
24
  def acts_as_list(options = {})
38
- configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom}
25
+ configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom, touch_on_update: true }
39
26
  configuration.update(options) if options.is_a?(Hash)
40
27
 
41
- if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
42
- configuration[:scope] = :"#{configuration[:scope]}_id"
43
- end
44
-
45
- if configuration[:scope].is_a?(Symbol)
46
- scope_methods = %(
47
- def scope_condition
48
- { #{configuration[:scope]}: send(:#{configuration[:scope]}) }
49
- end
50
-
51
- def scope_changed?
52
- changed.include?(scope_name.to_s)
53
- end
54
- )
55
- elsif configuration[:scope].is_a?(Array)
56
- scope_methods = %(
57
- def scope_condition
58
- #{configuration[:scope]}.inject({}) do |hash, column|
59
- hash.merge!({ column.to_sym => read_attribute(column.to_sym) })
60
- end
61
- end
62
-
63
- def scope_changed?
64
- (scope_condition.keys & changed.map(&:to_sym)).any?
65
- end
66
- )
67
- else
68
- scope_methods = %(
69
- def scope_condition
70
- "#{configuration[:scope]}"
71
- end
72
-
73
- def scope_changed?() false end
74
- )
75
- end
76
-
77
- class_eval <<-EOV, __FILE__, __LINE__ + 1
78
- include ::ActiveRecord::Acts::List::InstanceMethods
79
-
80
- def acts_as_list_top
81
- #{configuration[:top_of_list]}.to_i
82
- end
83
-
84
- def acts_as_list_class
85
- ::#{self.name}
86
- end
87
-
88
- def position_column
89
- '#{configuration[:column]}'
90
- end
28
+ caller_class = self
91
29
 
92
- def scope_name
93
- '#{configuration[:scope]}'
94
- end
30
+ ActiveRecord::Acts::List::PositionColumnMethodDefiner.call(caller_class, configuration[:column], configuration[:touch_on_update])
31
+ ActiveRecord::Acts::List::ScopeMethodDefiner.call(caller_class, configuration[:scope])
32
+ ActiveRecord::Acts::List::TopOfListMethodDefiner.call(caller_class, configuration[:top_of_list])
33
+ ActiveRecord::Acts::List::AddNewAtMethodDefiner.call(caller_class, configuration[:add_new_at])
95
34
 
96
- def add_new_at
97
- '#{configuration[:add_new_at]}'
98
- end
99
-
100
- def #{configuration[:column]}=(position)
101
- write_attribute(:#{configuration[:column]}, position)
102
- @position_changed = true
103
- end
104
-
105
- #{scope_methods}
106
-
107
- # only add to attr_accessible
108
- # if the class has some mass_assignment_protection
109
-
110
- if defined?(accessible_attributes) and !accessible_attributes.blank?
111
- attr_accessible :#{configuration[:column]}
112
- end
35
+ ActiveRecord::Acts::List::AuxMethodDefiner.call(caller_class)
36
+ ActiveRecord::Acts::List::CallbackDefiner.call(caller_class, configuration[:add_new_at])
37
+ ActiveRecord::Acts::List::SequentialUpdatesMethodDefiner.call(caller_class, configuration[:column], configuration[:sequential_updates])
113
38
 
114
- before_validation :check_top_position
115
-
116
- before_destroy :reload_position
117
- after_destroy :decrement_positions_on_lower_items
118
-
119
- before_update :check_scope
120
- after_update :update_positions
121
-
122
- after_commit 'remove_instance_variable(:@scope_changed) if defined?(@scope_changed)'
123
-
124
- scope :in_list, lambda { where("#{table_name}.#{configuration[:column]} IS NOT NULL") }
125
- EOV
39
+ include ActiveRecord::Acts::List::InstanceMethods
40
+ include ActiveRecord::Acts::List::NoUpdate
41
+ end
126
42
 
127
- if configuration[:add_new_at].present?
128
- self.send :before_create, :"add_to_list_#{configuration[:add_new_at]}"
129
- end
43
+ # This +acts_as+ extension provides the capabilities for sorting and reordering a number of objects in a list.
44
+ # The class that has this specified needs to have a +position+ column defined as an integer on
45
+ # the mapped database table.
46
+ #
47
+ # Todo list example:
48
+ #
49
+ # class TodoList < ActiveRecord::Base
50
+ # has_many :todo_items, order: "position"
51
+ # end
52
+ #
53
+ # class TodoItem < ActiveRecord::Base
54
+ # belongs_to :todo_list
55
+ # acts_as_list scope: :todo_list
56
+ # end
57
+ #
58
+ # todo_list.first.move_to_bottom
59
+ # todo_list.last.move_higher
130
60
 
131
- end
61
+ # All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
62
+ # by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
63
+ # lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
64
+ # the first in the list of all chapters.
132
65
  end
133
66
 
134
- # All the methods available to a record that has had <tt>acts_as_list</tt> specified. Each method works
135
- # by assuming the object to be the item in the list, so <tt>chapter.move_lower</tt> would move that chapter
136
- # lower in the list of all chapters. Likewise, <tt>chapter.first?</tt> would return +true+ if that chapter is
137
- # the first in the list of all chapters.
138
67
  module InstanceMethods
68
+ # Get the current position of the item in the list
69
+ def current_position
70
+ position = send(position_column)
71
+ position ? position.to_i : nil
72
+ end
73
+
139
74
  # Insert the item at the given position (defaults to the top position of 1).
140
75
  def insert_at(position = acts_as_list_top)
141
76
  insert_at_position(position)
142
77
  end
143
78
 
79
+ def insert_at!(position = acts_as_list_top)
80
+ insert_at_position(position, true)
81
+ end
82
+
144
83
  # Swap positions with the next lower item, if one exists.
145
84
  def move_lower
146
85
  return unless lower_item
147
86
 
148
87
  acts_as_list_class.transaction do
149
- lower_item.decrement_position
150
- increment_position
88
+ if lower_item.current_position != current_position
89
+ swap_positions_with(lower_item)
90
+ else
91
+ lower_item.decrement_position
92
+ increment_position
93
+ end
151
94
  end
152
95
  end
153
96
 
@@ -156,8 +99,12 @@ module ActiveRecord
156
99
  return unless higher_item
157
100
 
158
101
  acts_as_list_class.transaction do
159
- higher_item.increment_position
160
- decrement_position
102
+ if higher_item.current_position != current_position
103
+ swap_positions_with(higher_item)
104
+ else
105
+ higher_item.increment_position
106
+ decrement_position
107
+ end
161
108
  end
162
109
  end
163
110
 
@@ -165,20 +112,14 @@ module ActiveRecord
165
112
  # position adjusted accordingly.
166
113
  def move_to_bottom
167
114
  return unless in_list?
168
- acts_as_list_class.transaction do
169
- decrement_positions_on_lower_items
170
- assume_bottom_position
171
- end
115
+ insert_at_position bottom_position_in_list.to_i
172
116
  end
173
117
 
174
118
  # Move to the top of the list. If the item is already in the list, the items above it have their
175
119
  # position adjusted accordingly.
176
120
  def move_to_top
177
121
  return unless in_list?
178
- acts_as_list_class.transaction do
179
- increment_positions_on_higher_items
180
- assume_top_position
181
- end
122
+ insert_at_position acts_as_list_top
182
123
  end
183
124
 
184
125
  # Removes the item from the list.
@@ -199,25 +140,23 @@ module ActiveRecord
199
140
  # Increase the position of this item without adjusting the rest of the list.
200
141
  def increment_position
201
142
  return unless in_list?
202
- set_list_position(self.send(position_column).to_i + 1)
143
+ set_list_position(current_position + 1)
203
144
  end
204
145
 
205
146
  # Decrease the position of this item without adjusting the rest of the list.
206
147
  def decrement_position
207
148
  return unless in_list?
208
- set_list_position(self.send(position_column).to_i - 1)
149
+ set_list_position(current_position - 1)
209
150
  end
210
151
 
211
- # Return +true+ if this object is the first in the list.
212
152
  def first?
213
153
  return false unless in_list?
214
- self.send(position_column) == acts_as_list_top
154
+ !higher_items(1).exists?
215
155
  end
216
156
 
217
- # Return +true+ if this object is the last in the list.
218
157
  def last?
219
158
  return false unless in_list?
220
- self.send(position_column) == bottom_position_in_list
159
+ !lower_items(1).exists?
221
160
  end
222
161
 
223
162
  # Return the next higher item in the list.
@@ -230,12 +169,11 @@ module ActiveRecord
230
169
  # selects all higher items by default
231
170
  def higher_items(limit=nil)
232
171
  limit ||= acts_as_list_list.count
233
- position_value = send(position_column)
234
172
  acts_as_list_list.
235
- where("#{position_column} < ?", position_value).
236
- where("#{position_column} >= ?", position_value - limit).
237
- limit(limit).
238
- order("#{acts_as_list_class.table_name}.#{position_column} ASC")
173
+ where("#{quoted_position_column_with_table_name} <= ?", current_position).
174
+ where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
175
+ reorder(acts_as_list_order_argument(:desc)).
176
+ limit(limit)
239
177
  end
240
178
 
241
179
  # Return the next lower item in the list.
@@ -248,12 +186,11 @@ module ActiveRecord
248
186
  # selects all lower items by default
249
187
  def lower_items(limit=nil)
250
188
  limit ||= acts_as_list_list.count
251
- position_value = send(position_column)
252
189
  acts_as_list_list.
253
- where("#{position_column} > ?", position_value).
254
- where("#{position_column} <= ?", position_value + limit).
255
- limit(limit).
256
- order("#{acts_as_list_class.table_name}.#{position_column} ASC")
190
+ where("#{quoted_position_column_with_table_name} >= ?", current_position).
191
+ where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)).
192
+ reorder(acts_as_list_order_argument(:asc)).
193
+ limit(limit)
257
194
  end
258
195
 
259
196
  # Test if this record is in a list
@@ -262,233 +199,289 @@ module ActiveRecord
262
199
  end
263
200
 
264
201
  def not_in_list?
265
- send(position_column).nil?
202
+ current_position.nil?
266
203
  end
267
204
 
268
205
  def default_position
269
- acts_as_list_class.columns_hash[position_column.to_s].default
206
+ acts_as_list_class.column_defaults[position_column.to_s]
270
207
  end
271
208
 
272
209
  def default_position?
273
- default_position && default_position.to_i == send(position_column)
210
+ default_position && default_position == current_position
274
211
  end
275
212
 
276
213
  # Sets the new position and saves it
277
- def set_list_position(new_position)
278
- write_attribute position_column, new_position
279
- save(validate: false)
214
+ def set_list_position(new_position, raise_exception_if_save_fails=false)
215
+ self[position_column] = new_position
216
+ raise_exception_if_save_fails ? save! : save
280
217
  end
281
218
 
282
219
  private
283
- def acts_as_list_list
284
- acts_as_list_class.unscoped do
285
- acts_as_list_class.where(scope_condition)
286
- end
287
- end
288
220
 
289
- def add_to_list_top
290
- increment_positions_on_all_items
291
- self[position_column] = acts_as_list_top
292
- # Make sure we know that we've processed this scope change already
293
- @scope_changed = false
294
- #dont halt the callback chain
295
- true
296
- end
221
+ def swap_positions_with(item)
222
+ item_position = item.current_position
223
+
224
+ item.set_list_position(current_position)
225
+ set_list_position(item_position)
226
+ end
227
+
228
+ def acts_as_list_list
229
+ acts_as_list_class.default_scoped.unscope(:select, :where).where(scope_condition)
230
+ end
297
231
 
298
- # A poorly named method. It will insert the item at the desired position if the position
299
- # has been set manually using position=, not necessarily the bottom of the list
300
- def add_to_list_bottom
301
- if not_in_list? || internal_scope_changed? && !@position_changed || default_position?
232
+ def avoid_collision
233
+ case add_new_at
234
+ when :top
235
+ if assume_default_position?
236
+ increment_positions_on_all_items
237
+ self[position_column] = acts_as_list_top
238
+ else
239
+ increment_positions_on_lower_items(self[position_column], id)
240
+ end
241
+ when :bottom
242
+ if assume_default_position?
302
243
  self[position_column] = bottom_position_in_list.to_i + 1
303
244
  else
304
245
  increment_positions_on_lower_items(self[position_column], id)
305
246
  end
247
+ else
248
+ increment_positions_on_lower_items(self[position_column], id) if position_changed
249
+ end
306
250
 
307
- # Make sure we know that we've processed this scope change already
308
- @scope_changed = false
251
+ @scope_changed = false # Make sure we know that we've processed this scope change already
252
+ return true # Don't halt the callback chain
253
+ end
309
254
 
310
- #dont halt the callback chain
311
- true
312
- end
255
+ def assume_default_position?
256
+ not_in_list? ||
257
+ persisted? && internal_scope_changed? && !position_changed ||
258
+ default_position?
259
+ end
313
260
 
314
- # Overwrite this method to define the scope of the list changes
315
- def scope_condition() {} end
261
+ # Overwrite this method to define the scope of the list changes
262
+ def scope_condition() {} end
316
263
 
317
- # Returns the bottom position number in the list.
318
- # bottom_position_in_list # => 2
319
- def bottom_position_in_list(except = nil)
320
- item = bottom_item(except)
321
- item ? item.send(position_column) : acts_as_list_top - 1
322
- end
264
+ # Returns the bottom position number in the list.
265
+ # bottom_position_in_list # => 2
266
+ def bottom_position_in_list(except = nil)
267
+ item = bottom_item(except)
268
+ item ? item.current_position : acts_as_list_top - 1
269
+ end
323
270
 
324
- # Returns the bottom item
325
- def bottom_item(except = nil)
326
- conditions = scope_condition
327
- conditions = except ? "#{self.class.primary_key} != #{self.class.connection.quote(except.id)}" : {}
328
- acts_as_list_list.in_list.where(
329
- conditions
330
- ).order(
331
- "#{acts_as_list_class.table_name}.#{position_column} DESC"
332
- ).first
333
- end
271
+ # Returns the bottom item
272
+ def bottom_item(except = nil)
273
+ scope = acts_as_list_list
334
274
 
335
- # Forces item to assume the bottom position in the list.
336
- def assume_bottom_position
337
- set_list_position(bottom_position_in_list(self).to_i + 1)
275
+ if except
276
+ scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", except.id)
338
277
  end
339
278
 
340
- # Forces item to assume the top position in the list.
341
- def assume_top_position
342
- set_list_position(acts_as_list_top)
343
- end
279
+ scope.in_list.reorder(acts_as_list_order_argument(:desc)).first
280
+ end
344
281
 
345
- # This has the effect of moving all the higher items up one.
346
- def decrement_positions_on_higher_items(position)
347
- acts_as_list_list.where(
348
- "#{position_column} <= #{position}"
349
- ).update_all(
350
- "#{position_column} = (#{position_column} - 1)"
351
- )
282
+ # Forces item to assume the bottom position in the list.
283
+ def assume_bottom_position
284
+ set_list_position(bottom_position_in_list(self).to_i + 1)
285
+ end
286
+
287
+ # Forces item to assume the top position in the list.
288
+ def assume_top_position
289
+ set_list_position(acts_as_list_top)
290
+ end
291
+
292
+ # This has the effect of moving all the higher items down one.
293
+ def increment_positions_on_higher_items
294
+ return unless in_list?
295
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} < ?", current_position).increment_all
296
+ end
297
+
298
+ # This has the effect of moving all the lower items down one.
299
+ def increment_positions_on_lower_items(position, avoid_id = nil)
300
+ scope = acts_as_list_list
301
+
302
+ if avoid_id
303
+ scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", avoid_id)
352
304
  end
353
305
 
354
- # This has the effect of moving all the lower items up one.
355
- def decrement_positions_on_lower_items(position=nil)
356
- return unless in_list?
357
- position ||= send(position_column).to_i
358
- acts_as_list_list.where(
359
- "#{position_column} > #{position}"
360
- ).update_all(
361
- "#{position_column} = (#{position_column} - 1)"
362
- )
306
+ if sequential_updates?
307
+ scope.where("#{quoted_position_column_with_table_name} >= ?", position).reorder(acts_as_list_order_argument(:desc)).increment_sequentially
308
+ else
309
+ scope.where("#{quoted_position_column_with_table_name} >= ?", position).increment_all
363
310
  end
311
+ end
364
312
 
365
- # This has the effect of moving all the higher items down one.
366
- def increment_positions_on_higher_items
367
- return unless in_list?
368
- acts_as_list_list.where(
369
- "#{position_column} < #{send(position_column).to_i}"
370
- ).update_all(
371
- "#{position_column} = (#{position_column} + 1)"
372
- )
313
+ # This has the effect of moving all the higher items up one.
314
+ def decrement_positions_on_higher_items(position)
315
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} <= ?", position).decrement_all
316
+ end
317
+
318
+ # This has the effect of moving all the lower items up one.
319
+ def decrement_positions_on_lower_items(position=current_position)
320
+ return unless in_list?
321
+
322
+ if sequential_updates?
323
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).reorder(acts_as_list_order_argument(:asc)).decrement_sequentially
324
+ else
325
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
373
326
  end
327
+ end
374
328
 
375
- # This has the effect of moving all the lower items down one.
376
- def increment_positions_on_lower_items(position, avoid_id = nil)
377
- avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
329
+ # Increments position (<tt>position_column</tt>) of all items in the list.
330
+ def increment_positions_on_all_items
331
+ acts_as_list_list.increment_all
332
+ end
378
333
 
379
- acts_as_list_list.where(
380
- "#{position_column} >= #{position}#{avoid_id_condition}"
381
- ).update_all(
382
- "#{position_column} = (#{position_column} + 1)"
383
- )
334
+ # Reorders intermediate items to support moving an item from old_position to new_position.
335
+ # unique constraint prevents regular increment_all and forces to do increments one by one
336
+ # http://stackoverflow.com/questions/7703196/sqlite-increment-unique-integer-field
337
+ # both SQLite and PostgreSQL (and most probably MySQL too) has same issue
338
+ # that's why *sequential_updates?* check alters implementation behavior
339
+ def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
340
+ return if old_position == new_position
341
+ scope = acts_as_list_list
342
+
343
+ if avoid_id
344
+ scope = scope.where("#{quoted_table_name}.#{self.class.primary_key} != ?", avoid_id)
384
345
  end
385
346
 
386
- # Increments position (<tt>position_column</tt>) of all items in the list.
387
- def increment_positions_on_all_items
388
- acts_as_list_list.update_all(
389
- "#{position_column} = (#{position_column} + 1)"
347
+ if old_position < new_position
348
+ # Decrement position of intermediate items
349
+ #
350
+ # e.g., if moving an item from 2 to 5,
351
+ # move [3, 4, 5] to [2, 3, 4]
352
+ items = scope.where(
353
+ "#{quoted_position_column_with_table_name} > ?", old_position
354
+ ).where(
355
+ "#{quoted_position_column_with_table_name} <= ?", new_position
390
356
  )
391
- end
392
357
 
393
- # Reorders intermediate items to support moving an item from old_position to new_position.
394
- def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
395
- return if old_position == new_position
396
- avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
397
-
398
- if old_position < new_position
399
- # Decrement position of intermediate items
400
- #
401
- # e.g., if moving an item from 2 to 5,
402
- # move [3, 4, 5] to [2, 3, 4]
403
- acts_as_list_list.where(
404
- "#{position_column} > #{old_position}"
405
- ).where(
406
- "#{position_column} <= #{new_position}#{avoid_id_condition}"
407
- ).update_all(
408
- "#{position_column} = (#{position_column} - 1)"
409
- )
358
+ if sequential_updates?
359
+ items.reorder(acts_as_list_order_argument(:asc)).decrement_sequentially
410
360
  else
411
- # Increment position of intermediate items
412
- #
413
- # e.g., if moving an item from 5 to 2,
414
- # move [2, 3, 4] to [3, 4, 5]
415
- acts_as_list_list.where(
416
- "#{position_column} >= #{new_position}"
417
- ).where(
418
- "#{position_column} < #{old_position}#{avoid_id_condition}"
419
- ).update_all(
420
- "#{position_column} = (#{position_column} + 1)"
421
- )
361
+ items.decrement_all
362
+ end
363
+ else
364
+ # Increment position of intermediate items
365
+ #
366
+ # e.g., if moving an item from 5 to 2,
367
+ # move [2, 3, 4] to [3, 4, 5]
368
+ items = scope.where(
369
+ "#{quoted_position_column_with_table_name} >= ?", new_position
370
+ ).where(
371
+ "#{quoted_position_column_with_table_name} < ?", old_position
372
+ )
373
+
374
+ if sequential_updates?
375
+ items.reorder(acts_as_list_order_argument(:desc)).increment_sequentially
376
+ else
377
+ items.increment_all
422
378
  end
423
379
  end
380
+ end
424
381
 
425
- def insert_at_position(position)
426
- return set_list_position(position) if new_record?
382
+ def insert_at_position(position, raise_exception_if_save_fails=false)
383
+ raise ArgumentError.new("position cannot be lower than top") if position < acts_as_list_top
384
+ return set_list_position(position, raise_exception_if_save_fails) if new_record?
385
+ with_lock do
427
386
  if in_list?
428
- old_position = send(position_column).to_i
387
+ old_position = current_position
429
388
  return if position == old_position
430
- shuffle_positions_on_intermediate_items(old_position, position)
389
+ # temporary move after bottom with gap, avoiding duplicate values
390
+ # gap is required to leave room for position increments
391
+ # positive number will be valid with unique not null check (>= 0) db constraint
392
+ temporary_position = bottom_position_in_list + 2
393
+ set_list_position(temporary_position, raise_exception_if_save_fails)
394
+ shuffle_positions_on_intermediate_items(old_position, position, id)
431
395
  else
432
396
  increment_positions_on_lower_items(position)
433
397
  end
434
- set_list_position(position)
398
+ set_list_position(position, raise_exception_if_save_fails)
435
399
  end
400
+ end
436
401
 
437
- # used by insert_at_position instead of remove_from_list, as postgresql raises error if position_column has non-null constraint
438
- def store_at_0
439
- if in_list?
440
- old_position = send(position_column).to_i
441
- set_list_position(0)
442
- decrement_positions_on_lower_items(old_position)
443
- end
444
- end
402
+ def update_positions
403
+ return unless position_before_save_changed?
445
404
 
446
- def update_positions
447
- old_position = send("#{position_column}_was").to_i
448
- new_position = send(position_column).to_i
405
+ old_position = position_before_save || bottom_position_in_list + 1
449
406
 
450
- return unless acts_as_list_list.where(
451
- "#{position_column} = #{new_position}"
452
- ).count > 1
453
- shuffle_positions_on_intermediate_items old_position, new_position, id
454
- end
407
+ return unless current_position && acts_as_list_list.where(
408
+ "#{quoted_position_column_with_table_name} = #{current_position}"
409
+ ).count > 1
455
410
 
456
- def internal_scope_changed?
457
- return @scope_changed if defined?(@scope_changed)
458
-
459
- @scope_changed = scope_changed?
460
- end
411
+ shuffle_positions_on_intermediate_items old_position, current_position, id
412
+ end
461
413
 
462
- # Temporarily swap changes attributes with current attributes
463
- def swap_changed_attributes
464
- @changed_attributes.each do |k, _|
465
- if self.class.column_names.include? k
466
- @changed_attributes[k], self[k] = self[k], @changed_attributes[k]
467
- end
468
- end
414
+ def position_before_save_changed?
415
+ if active_record_version_is?('>= 5.1')
416
+ saved_change_to_attribute? position_column
417
+ else
418
+ attribute_changed? position_column
469
419
  end
420
+ end
470
421
 
471
- def check_scope
472
- if internal_scope_changed?
473
- swap_changed_attributes
474
- send('decrement_positions_on_lower_items') if lower_item
475
- swap_changed_attributes
476
- send("add_to_list_#{add_new_at}")
477
- end
422
+ def position_before_save
423
+ if active_record_version_is?('>= 5.1')
424
+ attribute_before_last_save position_column
425
+ else
426
+ attribute_was position_column
478
427
  end
428
+ end
429
+
430
+ def internal_scope_changed?
431
+ return @scope_changed if defined?(@scope_changed)
432
+
433
+ @scope_changed = scope_changed?
434
+ end
435
+
436
+ def clear_scope_changed
437
+ remove_instance_variable(:@scope_changed) if defined?(@scope_changed)
438
+ end
439
+
440
+ def check_scope
441
+ if internal_scope_changed?
442
+ cached_changes = changes
443
+
444
+ cached_changes.each { |attribute, values| send("#{attribute}=", values[0]) }
445
+ send('decrement_positions_on_lower_items') if lower_item
446
+ cached_changes.each { |attribute, values| send("#{attribute}=", values[1]) }
479
447
 
480
- def reload_position
481
- self.reload
448
+ avoid_collision
482
449
  end
450
+ end
483
451
 
484
- # This check is skipped if the position is currently the default position from the table
485
- # as modifying the default position on creation is handled elsewhere
486
- def check_top_position
487
- if send(position_column) && !default_position? && send(position_column) < acts_as_list_top
488
- self[position_column] = acts_as_list_top
489
- end
452
+ # This check is skipped if the position is currently the default position from the table
453
+ # as modifying the default position on creation is handled elsewhere
454
+ def check_top_position
455
+ if current_position && !default_position? && current_position < acts_as_list_top
456
+ self[position_column] = acts_as_list_top
490
457
  end
458
+ end
459
+
460
+ # When using raw column name it must be quoted otherwise it can raise syntax errors with SQL keywords (e.g. order)
461
+ def quoted_position_column
462
+ @_quoted_position_column ||= self.class.connection.quote_column_name(position_column)
463
+ end
464
+
465
+ # Used in order clauses
466
+ def quoted_table_name
467
+ @_quoted_table_name ||= acts_as_list_class.quoted_table_name
468
+ end
469
+
470
+ def quoted_position_column_with_table_name
471
+ @_quoted_position_column_with_table_name ||= "#{quoted_table_name}.#{quoted_position_column}"
472
+ end
473
+
474
+ def acts_as_list_order_argument(direction = :asc)
475
+ { position_column => direction }
476
+ end
477
+
478
+ def active_record_version_is?(version_requirement)
479
+ requirement = Gem::Requirement.new(version_requirement)
480
+ version = Gem.loaded_specs['activerecord'].version
481
+ requirement.satisfied_by?(version)
482
+ end
491
483
  end
484
+
492
485
  end
493
486
  end
494
487
  end