acts_as_list 0.7.2 → 0.7.7

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.
@@ -38,32 +38,30 @@ module ActiveRecord
38
38
  configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom}
39
39
  configuration.update(options) if options.is_a?(Hash)
40
40
 
41
- configuration[:scope] = "#{configuration[:scope]}_id".intern if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
41
+ if configuration[:scope].is_a?(Symbol) && configuration[:scope].to_s !~ /_id$/
42
+ configuration[:scope] = :"#{configuration[:scope]}_id"
43
+ end
42
44
 
43
45
  if configuration[:scope].is_a?(Symbol)
44
46
  scope_methods = %(
45
47
  def scope_condition
46
- { :#{configuration[:scope].to_s} => send(:#{configuration[:scope].to_s}) }
48
+ { #{configuration[:scope]}: send(:#{configuration[:scope]}) }
47
49
  end
48
50
 
49
51
  def scope_changed?
50
- changes.include?(scope_name.to_s)
52
+ changed.include?(scope_name.to_s)
51
53
  end
52
54
  )
53
55
  elsif configuration[:scope].is_a?(Array)
54
56
  scope_methods = %(
55
- def attrs
56
- %w(#{configuration[:scope].join(" ")}).inject({}) do |memo,column|
57
- memo[column.intern] = read_attribute(column.intern); memo
57
+ def scope_condition
58
+ #{configuration[:scope]}.inject({}) do |hash, column|
59
+ hash.merge!({ column.to_sym => read_attribute(column.to_sym) })
58
60
  end
59
61
  end
60
62
 
61
63
  def scope_changed?
62
- (attrs.keys & changes.keys.map(&:to_sym)).any?
63
- end
64
-
65
- def scope_condition
66
- attrs
64
+ (scope_condition.keys & changed.map(&:to_sym)).any?
67
65
  end
68
66
  )
69
67
  else
@@ -76,8 +74,13 @@ module ActiveRecord
76
74
  )
77
75
  end
78
76
 
79
- class_eval <<-EOV
80
- include ::ActiveRecord::Acts::List::InstanceMethods
77
+ quoted_position_column = connection.quote_column_name(configuration[:column])
78
+ quoted_position_column_with_table_name = "#{quoted_table_name}.#{quoted_position_column}"
79
+
80
+ class_eval <<-EOV, __FILE__, __LINE__ + 1
81
+ def self.acts_as_list_top
82
+ #{configuration[:top_of_list]}.to_i
83
+ end
81
84
 
82
85
  def acts_as_list_top
83
86
  #{configuration[:top_of_list]}.to_i
@@ -113,19 +116,46 @@ module ActiveRecord
113
116
  attr_accessible :#{configuration[:column]}
114
117
  end
115
118
 
116
- before_destroy :reload_position
117
- after_destroy :decrement_positions_on_lower_items
118
- before_update :check_scope
119
- after_update :update_positions
120
- before_validation :check_top_position
119
+ scope :in_list, lambda { where(%q{#{quoted_position_column_with_table_name} IS NOT NULL}) }
120
+
121
+ def self.decrement_all
122
+ update_all_with_touch %q(#{quoted_position_column} = (#{quoted_position_column_with_table_name} - 1))
123
+ end
124
+
125
+ def self.increment_all
126
+ update_all_with_touch %q(#{quoted_position_column} = (#{quoted_position_column_with_table_name} + 1))
127
+ end
128
+
129
+ def self.update_all_with_touch(updates)
130
+ record = new
131
+ attrs = record.send(:timestamp_attributes_for_update_in_model)
132
+ now = record.send(:current_time_from_proper_timezone)
133
+
134
+ query = attrs.map { |attr| %(\#{connection.quote_column_name(attr)} = :now) }
135
+ query.push updates
136
+ query = query.join(", ")
121
137
 
122
- scope :in_list, lambda { where("#{table_name}.#{configuration[:column]} IS NOT NULL") }
138
+ update_all([query, now: now])
139
+ end
123
140
  EOV
124
141
 
142
+ attr_reader :position_changed
143
+
144
+ before_validation :check_top_position
145
+
146
+ before_destroy :lock!
147
+ after_destroy :decrement_positions_on_lower_items
148
+
149
+ before_update :check_scope
150
+ after_update :update_positions
151
+
152
+ after_commit :clear_scope_changed
153
+
125
154
  if configuration[:add_new_at].present?
126
- self.send(:before_create, "add_to_list_#{configuration[:add_new_at]}")
155
+ before_create "add_to_list_#{configuration[:add_new_at]}".to_sym
127
156
  end
128
157
 
158
+ include ::ActiveRecord::Acts::List::InstanceMethods
129
159
  end
130
160
  end
131
161
 
@@ -221,10 +251,7 @@ module ActiveRecord
221
251
  # Return the next higher item in the list.
222
252
  def higher_item
223
253
  return nil unless in_list?
224
- acts_as_list_class.unscoped do
225
- acts_as_list_class.where(scope_condition).where("#{position_column} < #{(send(position_column).to_i).to_s}").
226
- order("#{acts_as_list_class.table_name}.#{position_column} DESC").first
227
- end
254
+ higher_items(1).first
228
255
  end
229
256
 
230
257
  # Return the next n higher items in the list
@@ -233,19 +260,16 @@ module ActiveRecord
233
260
  limit ||= acts_as_list_list.count
234
261
  position_value = send(position_column)
235
262
  acts_as_list_list.
236
- where("#{position_column} < ?", position_value).
237
- where("#{position_column} >= ?", position_value - limit).
263
+ where("#{quoted_position_column_with_table_name} < ?", position_value).
264
+ where("#{quoted_position_column_with_table_name} >= ?", position_value - limit).
238
265
  limit(limit).
239
- order("#{acts_as_list_class.table_name}.#{position_column} ASC")
266
+ order("#{quoted_position_column_with_table_name} ASC")
240
267
  end
241
268
 
242
269
  # Return the next lower item in the list.
243
270
  def lower_item
244
271
  return nil unless in_list?
245
- acts_as_list_class.unscoped do
246
- acts_as_list_class.where(scope_condition).where("#{position_column} > #{(send(position_column).to_i).to_s}").
247
- order("#{acts_as_list_class.table_name}.#{position_column} ASC").first
248
- end
272
+ lower_items(1).first
249
273
  end
250
274
 
251
275
  # Return the next n lower items in the list
@@ -254,10 +278,10 @@ module ActiveRecord
254
278
  limit ||= acts_as_list_list.count
255
279
  position_value = send(position_column)
256
280
  acts_as_list_list.
257
- where("#{position_column} > ?", position_value).
258
- where("#{position_column} <= ?", position_value + limit).
281
+ where("#{quoted_position_column_with_table_name} > ?", position_value).
282
+ where("#{quoted_position_column_with_table_name} <= ?", position_value + limit).
259
283
  limit(limit).
260
- order("#{acts_as_list_class.table_name}.#{position_column} ASC")
284
+ order("#{quoted_position_column_with_table_name} ASC")
261
285
  end
262
286
 
263
287
  # Test if this record is in a list
@@ -293,14 +317,26 @@ module ActiveRecord
293
317
  def add_to_list_top
294
318
  increment_positions_on_all_items
295
319
  self[position_column] = acts_as_list_top
320
+ # Make sure we know that we've processed this scope change already
321
+ @scope_changed = false
322
+ #dont halt the callback chain
323
+ true
296
324
  end
297
325
 
326
+ # A poorly named method. It will insert the item at the desired position if the position
327
+ # has been set manually using position=, not necessarily the bottom of the list
298
328
  def add_to_list_bottom
299
- if not_in_list? || scope_changed? && !@position_changed || default_position?
329
+ if not_in_list? || internal_scope_changed? && !position_changed || default_position?
300
330
  self[position_column] = bottom_position_in_list.to_i + 1
301
331
  else
302
332
  increment_positions_on_lower_items(self[position_column], id)
303
333
  end
334
+
335
+ # Make sure we know that we've processed this scope change already
336
+ @scope_changed = false
337
+
338
+ #dont halt the callback chain
339
+ true
304
340
  end
305
341
 
306
342
  # Overwrite this method to define the scope of the list changes
@@ -315,11 +351,12 @@ module ActiveRecord
315
351
 
316
352
  # Returns the bottom item
317
353
  def bottom_item(except = nil)
318
- conditions = scope_condition
319
- conditions = except ? "#{self.class.primary_key} != #{self.class.connection.quote(except.id)}" : {}
320
- acts_as_list_class.unscoped do
321
- acts_as_list_class.in_list.where(scope_condition).where(conditions).order("#{acts_as_list_class.table_name}.#{position_column} DESC").first
322
- end
354
+ conditions = except ? "#{quoted_table_name}.#{self.class.primary_key} != #{self.class.connection.quote(except.id)}" : {}
355
+ acts_as_list_list.in_list.where(
356
+ conditions
357
+ ).order(
358
+ "#{quoted_position_column_with_table_name} DESC"
359
+ ).first
323
360
  end
324
361
 
325
362
  # Forces item to assume the bottom position in the list.
@@ -334,110 +371,74 @@ module ActiveRecord
334
371
 
335
372
  # This has the effect of moving all the higher items up one.
336
373
  def decrement_positions_on_higher_items(position)
337
- acts_as_list_class.unscoped do
338
- acts_as_list_class.where(scope_condition).where(
339
- "#{position_column} <= #{position}"
340
- ).update_all(
341
- "#{position_column} = (#{position_column} - 1)"
342
- )
343
- end
374
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} <= ?", position).decrement_all
344
375
  end
345
376
 
346
377
  # This has the effect of moving all the lower items up one.
347
378
  def decrement_positions_on_lower_items(position=nil)
348
379
  return unless in_list?
349
380
  position ||= send(position_column).to_i
350
- acts_as_list_class.unscoped do
351
- acts_as_list_class.where(scope_condition).where(
352
- "#{position_column} > #{position}"
353
- ).update_all(
354
- "#{position_column} = (#{position_column} - 1)"
355
- )
356
- end
381
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all
357
382
  end
358
383
 
359
384
  # This has the effect of moving all the higher items down one.
360
385
  def increment_positions_on_higher_items
361
386
  return unless in_list?
362
- acts_as_list_class.unscoped do
363
- acts_as_list_class.where(scope_condition).where(
364
- "#{position_column} < #{send(position_column).to_i}"
365
- ).update_all(
366
- "#{position_column} = (#{position_column} + 1)"
367
- )
368
- end
387
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} < #{send(position_column).to_i}").increment_all
369
388
  end
370
389
 
371
390
  # This has the effect of moving all the lower items down one.
372
391
  def increment_positions_on_lower_items(position, avoid_id = nil)
373
- avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
392
+ avoid_id_condition = avoid_id ? " AND #{quoted_table_name}.#{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
374
393
 
375
- acts_as_list_class.unscoped do
376
- acts_as_list_class.where(scope_condition).where(
377
- "#{position_column} >= #{position}#{avoid_id_condition}"
378
- ).update_all(
379
- "#{position_column} = (#{position_column} + 1)"
380
- )
381
- end
394
+ acts_as_list_list.where("#{quoted_position_column_with_table_name} >= #{position}#{avoid_id_condition}").increment_all
382
395
  end
383
396
 
384
397
  # Increments position (<tt>position_column</tt>) of all items in the list.
385
398
  def increment_positions_on_all_items
386
- acts_as_list_class.unscoped do
387
- acts_as_list_class.where(
388
- scope_condition
389
- ).update_all(
390
- "#{position_column} = (#{position_column} + 1)"
391
- )
392
- end
399
+ acts_as_list_list.increment_all
393
400
  end
394
401
 
395
402
  # Reorders intermediate items to support moving an item from old_position to new_position.
396
403
  def shuffle_positions_on_intermediate_items(old_position, new_position, avoid_id = nil)
397
404
  return if old_position == new_position
398
- avoid_id_condition = avoid_id ? " AND #{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
405
+ avoid_id_condition = avoid_id ? " AND #{quoted_table_name}.#{self.class.primary_key} != #{self.class.connection.quote(avoid_id)}" : ''
399
406
 
400
407
  if old_position < new_position
401
408
  # Decrement position of intermediate items
402
409
  #
403
410
  # e.g., if moving an item from 2 to 5,
404
411
  # move [3, 4, 5] to [2, 3, 4]
405
- acts_as_list_class.unscoped do
406
- acts_as_list_class.where(scope_condition).where(
407
- "#{position_column} > #{old_position}"
408
- ).where(
409
- "#{position_column} <= #{new_position}#{avoid_id_condition}"
410
- ).update_all(
411
- "#{position_column} = (#{position_column} - 1)"
412
- )
413
- end
412
+ acts_as_list_list.where(
413
+ "#{quoted_position_column_with_table_name} > ?", old_position
414
+ ).where(
415
+ "#{quoted_position_column_with_table_name} <= #{new_position}#{avoid_id_condition}"
416
+ ).decrement_all
414
417
  else
415
418
  # Increment position of intermediate items
416
419
  #
417
420
  # e.g., if moving an item from 5 to 2,
418
421
  # move [2, 3, 4] to [3, 4, 5]
419
- acts_as_list_class.unscoped do
420
- acts_as_list_class.where(scope_condition).where(
421
- "#{position_column} >= #{new_position}"
422
- ).where(
423
- "#{position_column} < #{old_position}#{avoid_id_condition}"
424
- ).update_all(
425
- "#{position_column} = (#{position_column} + 1)"
426
- )
427
- end
422
+ acts_as_list_list.where(
423
+ "#{quoted_position_column_with_table_name} >= ?", new_position
424
+ ).where(
425
+ "#{quoted_position_column_with_table_name} < #{old_position}#{avoid_id_condition}"
426
+ ).increment_all
428
427
  end
429
428
  end
430
429
 
431
430
  def insert_at_position(position)
432
431
  return set_list_position(position) if new_record?
433
- if in_list?
434
- old_position = send(position_column).to_i
435
- return if position == old_position
436
- shuffle_positions_on_intermediate_items(old_position, position)
437
- else
438
- increment_positions_on_lower_items(position)
432
+ with_lock do
433
+ if in_list?
434
+ old_position = send(position_column).to_i
435
+ return if position == old_position
436
+ shuffle_positions_on_intermediate_items(old_position, position)
437
+ else
438
+ increment_positions_on_lower_items(position)
439
+ end
440
+ set_list_position(position)
439
441
  end
440
- set_list_position(position)
441
442
  end
442
443
 
443
444
  # used by insert_at_position instead of remove_from_list, as postgresql raises error if position_column has non-null constraint
@@ -453,29 +454,32 @@ module ActiveRecord
453
454
  old_position = send("#{position_column}_was").to_i
454
455
  new_position = send(position_column).to_i
455
456
 
456
- return unless acts_as_list_class.unscoped do
457
- acts_as_list_class.where(scope_condition).where("#{position_column} = #{new_position}").count > 1
458
- end
457
+ return unless acts_as_list_list.where(
458
+ "#{quoted_position_column_with_table_name} = #{new_position}"
459
+ ).count > 1
459
460
  shuffle_positions_on_intermediate_items old_position, new_position, id
460
461
  end
461
462
 
462
- # Temporarily swap changes attributes with current attributes
463
- def swap_changed_attributes
464
- @changed_attributes.each { |k, _| @changed_attributes[k], self[k] =
465
- self[k], @changed_attributes[k] }
463
+ def internal_scope_changed?
464
+ return @scope_changed if defined?(@scope_changed)
465
+
466
+ @scope_changed = scope_changed?
467
+ end
468
+
469
+ def clear_scope_changed
470
+ remove_instance_variable(:@scope_changed) if defined?(@scope_changed)
466
471
  end
467
472
 
468
473
  def check_scope
469
- if scope_changed?
470
- swap_changed_attributes
474
+ if internal_scope_changed?
475
+ cached_changes = changes
476
+
477
+ cached_changes.each { |attribute, values| self[attribute] = values[0] }
471
478
  send('decrement_positions_on_lower_items') if lower_item
472
- swap_changed_attributes
473
- send("add_to_list_#{add_new_at}")
474
- end
475
- end
479
+ cached_changes.each { |attribute, values| self[attribute] = values[1] }
476
480
 
477
- def reload_position
478
- self.reload
481
+ send("add_to_list_#{add_new_at}") if add_new_at.present?
482
+ end
479
483
  end
480
484
 
481
485
  # This check is skipped if the position is currently the default position from the table
@@ -485,6 +489,20 @@ module ActiveRecord
485
489
  self[position_column] = acts_as_list_top
486
490
  end
487
491
  end
492
+
493
+ # When using raw column name it must be quoted otherwise it can raise syntax errors with SQL keywords (e.g. order)
494
+ def quoted_position_column
495
+ @_quoted_position_column ||= self.class.connection.quote_column_name(position_column)
496
+ end
497
+
498
+ # Used in order clauses
499
+ def quoted_table_name
500
+ @_quoted_table_name ||= acts_as_list_class.quoted_table_name
501
+ end
502
+
503
+ def quoted_position_column_with_table_name
504
+ @_quoted_position_column_with_table_name ||= "#{quoted_table_name}.#{quoted_position_column}"
505
+ end
488
506
  end
489
507
  end
490
508
  end
@@ -1,7 +1,7 @@
1
1
  module ActiveRecord
2
2
  module Acts
3
3
  module List
4
- VERSION = '0.7.2'
4
+ VERSION = '0.7.7'
5
5
  end
6
6
  end
7
7
  end
data/test/helper.rb CHANGED
@@ -11,4 +11,11 @@ require "active_record"
11
11
  require "minitest/autorun"
12
12
  require "#{File.dirname(__FILE__)}/../init"
13
13
 
14
+ if defined?(ActiveRecord::VERSION) &&
15
+ ActiveRecord::VERSION::MAJOR == 4 && ActiveRecord::VERSION::MINOR >= 2
16
+
17
+ # Was removed in Rails 5 and is effectively true.
18
+ ActiveRecord::Base.raise_in_transactional_callbacks = true
19
+ end
20
+
14
21
  require "shared"
data/test/shared.rb CHANGED
@@ -6,4 +6,5 @@ module Shared
6
6
  autoload :ArrayScopeList, 'shared_array_scope_list'
7
7
  autoload :TopAddition, 'shared_top_addition'
8
8
  autoload :NoAddition, 'shared_no_addition'
9
+ autoload :Quoting, 'shared_quoting'
9
10
  end
data/test/shared_list.rb CHANGED
@@ -104,6 +104,12 @@ module Shared
104
104
 
105
105
  new4.reload
106
106
  assert_equal 5, new4.pos
107
+
108
+ last1 = ListMixin.order('pos').last
109
+ last2 = ListMixin.order('pos').last
110
+ last1.insert_at(1)
111
+ last2.insert_at(1)
112
+ assert_equal [1, 2, 3, 4, 5], ListMixin.where(parent_id: 20).order('pos').map(&:pos)
107
113
  end
108
114
 
109
115
  def test_delete_middle
@@ -142,7 +148,7 @@ module Shared
142
148
 
143
149
  def test_update_position_when_scope_changes
144
150
  assert_equal [1, 2, 3, 4], ListMixin.where(parent_id: 5).order('pos').map(&:id)
145
- parent = ListMixin.create(parent_id: 6)
151
+ ListMixin.create(parent_id: 6)
146
152
 
147
153
  ListMixin.where(id: 2).first.move_within_scope(6)
148
154
 
@@ -246,5 +252,11 @@ module Shared
246
252
 
247
253
  assert_equal [5, 1, 6, 2, 3, 4], ListMixin.where(parent_id: 5).order('pos').map(&:id)
248
254
  end
255
+
256
+ def test_non_persisted_records_dont_get_lock_called
257
+ new = ListMixin.new(parent_id: 5)
258
+
259
+ new.destroy
260
+ end
249
261
  end
250
262
  end
@@ -21,5 +21,16 @@ module Shared
21
21
  assert !new.in_list?
22
22
  end
23
23
 
24
+ def test_update_scope_does_not_add_to_list
25
+ new = NoAdditionMixin.create
26
+
27
+ new.update_attribute(:parent_id, 20)
28
+ new.reload
29
+ assert !new.in_list?
30
+
31
+ new.update_attribute(:parent_id, 5)
32
+ new.reload
33
+ assert !new.in_list?
34
+ end
24
35
  end
25
36
  end
@@ -0,0 +1,21 @@
1
+ module Shared
2
+ module Quoting
3
+
4
+ def setup
5
+ 3.times { |counter| QuotedList.create! order: counter }
6
+ end
7
+
8
+ def test_create
9
+ assert_equal QuotedList.in_list.size, 3
10
+ end
11
+
12
+ # This test execute raw queries involving table name
13
+ def test_moving
14
+ item = QuotedList.first
15
+ item.higher_items
16
+ item.lower_items
17
+ item.send :bottom_item # Part of private api
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ require 'helper'
2
+
3
+ ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
4
+ ActiveRecord::Schema.verbose = false
5
+
6
+ class Section < ActiveRecord::Base
7
+ has_many :items
8
+ acts_as_list
9
+
10
+ scope :visible, -> { where(visible: true) }
11
+ end
12
+
13
+ class Item < ActiveRecord::Base
14
+ belongs_to :section
15
+ acts_as_list scope: :section
16
+
17
+ scope :visible, -> { where(visible: true).joins(:section).merge(Section.visible) }
18
+ end
19
+
20
+ class JoinedTestCase < Minitest::Test
21
+ def setup
22
+ ActiveRecord::Base.connection.create_table :sections do |t|
23
+ t.column :position, :integer
24
+ t.column :visible, :boolean, default: true
25
+ end
26
+
27
+ ActiveRecord::Base.connection.create_table :items do |t|
28
+ t.column :position, :integer
29
+ t.column :section_id, :integer
30
+ t.column :visible, :boolean, default: true
31
+ end
32
+
33
+ ActiveRecord::Base.connection.schema_cache.clear!
34
+ [Section, Item].each(&:reset_column_information)
35
+ super
36
+ end
37
+
38
+ def teardown
39
+ ActiveRecord::Base.connection.tables.each do |table|
40
+ ActiveRecord::Base.connection.drop_table(table)
41
+ end
42
+ super
43
+ end
44
+ end
45
+
46
+ # joining the relation returned by `#higher_items` or `#lower_items` to another table
47
+ # previously could result in ambiguous column names in the query
48
+ class TestHigherLowerItems < JoinedTestCase
49
+ def test_higher_items
50
+ section = Section.create
51
+ item1 = Item.create section: section
52
+ item2 = Item.create section: section
53
+ item3 = Item.create section: section
54
+ assert_equal item3.higher_items.visible, [item1, item2]
55
+ end
56
+
57
+ def test_lower_items
58
+ section = Section.create
59
+ item1 = Item.create section: section
60
+ item2 = Item.create section: section
61
+ item3 = Item.create section: section
62
+ assert_equal item1.lower_items.visible, [item2, item3]
63
+ end
64
+ end