acts_as_list 0.7.2 → 0.7.7

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