# frozen_string_literal: true 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 _id # (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: acts_as_list scope: 'todo_list_id = #{todo_list_id} AND completed = 0' # * +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 deferrable initially deferred, overriding it with false will speed up insert_at. # * +touch_on_update+ - configuration to disable the update of the model timestamps when the positions are updated. def acts_as_list(options = {}) configuration = { column: "position", scope: "1 = 1", top_of_list: 1, add_new_at: :bottom, touch_on_update: true } configuration.update(options) if options.is_a?(Hash) caller_class = self ActiveRecord::Acts::List::PositionColumnMethodDefiner.call(caller_class, configuration[:column], configuration[:touch_on_update]) 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 acts_as_list specified. Each method works # by assuming the object to be the item in the list, so chapter.move_lower would move that chapter # lower in the list of all chapters. Likewise, chapter.first? would return +true+ if that chapter is # the first in the list of all chapters. end module InstanceMethods # Get the current position of the item in the list def current_position position = send(position_column) position ? position.to_i : nil end # Insert the item at the given position (defaults to the top position of 1). def insert_at(position = acts_as_list_top) insert_at_position(position) end def insert_at!(position = acts_as_list_top) insert_at_position(position, true) end # Swap positions with the next lower item, if one exists. def move_lower return unless lower_item acts_as_list_class.transaction do if lower_item.current_position != current_position swap_positions_with(lower_item) else lower_item.decrement_position increment_position end end end # Swap positions with the next higher item, if one exists. def move_higher return unless higher_item acts_as_list_class.transaction do if higher_item.current_position != current_position swap_positions_with(higher_item) else higher_item.increment_position decrement_position 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 return unless in_list? insert_at_position bottom_position_in_list.to_i 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 return unless in_list? insert_at_position acts_as_list_top end # Removes the item from the list. def remove_from_list if in_list? decrement_positions_on_lower_items set_list_position(nil) 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) send("#{scope_name}=", scope_id) save! end # Increase the position of this item without adjusting the rest of the list. def increment_position return unless in_list? set_list_position(current_position + 1) end # Decrease the position of this item without adjusting the rest of the list. def decrement_position return unless in_list? set_list_position(current_position - 1) end def first? return false unless in_list? !higher_items(1).exists? end def last? return false unless in_list? !lower_items(1).exists? end # Return the next higher item in the list. def higher_item return nil unless in_list? higher_items(1).first end # Return the next n higher items in the list # selects all higher items by default def higher_items(limit=nil) limit ||= acts_as_list_list.count acts_as_list_list. where("#{quoted_position_column_with_table_name} <= ?", current_position). where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)). reorder(acts_as_list_order_argument(:desc)). limit(limit) end # Return the next lower item in the list. def lower_item return nil unless in_list? lower_items(1).first end # Return the next n lower items in the list # selects all lower items by default def lower_items(limit=nil) limit ||= acts_as_list_list.count acts_as_list_list. where("#{quoted_position_column_with_table_name} >= ?", current_position). where("#{quoted_table_name}.#{self.class.primary_key} != ?", self.send(self.class.primary_key)). reorder(acts_as_list_order_argument(:asc)). limit(limit) end # Test if this record is in a list def in_list? !not_in_list? end def not_in_list? current_position.nil? end def default_position acts_as_list_class.column_defaults[position_column.to_s] end def default_position? default_position && default_position == current_position end # Sets the new position and saves it def set_list_position(new_position, raise_exception_if_save_fails=false) self[position_column] = new_position raise_exception_if_save_fails ? save! : save end private def swap_positions_with(item) item_position = item.current_position item.set_list_position(current_position) set_list_position(item_position) end def acts_as_list_list acts_as_list_class.default_scoped.unscope(:select, :where).where(scope_condition) end def avoid_collision case add_new_at when :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 when :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 else increment_positions_on_lower_items(self[position_column], id) if position_changed end @scope_changed = false # Make sure we know that we've processed this scope change already return true # Don't halt the callback chain 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.current_position : 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} < ?", current_position).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 if sequential_updates? scope.where("#{quoted_position_column_with_table_name} >= ?", position).reorder(acts_as_list_order_argument(:desc)).increment_sequentially else scope.where("#{quoted_position_column_with_table_name} >= ?", position).increment_all end 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=current_position) return unless in_list? if sequential_updates? acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).reorder(acts_as_list_order_argument(:asc)).decrement_sequentially else acts_as_list_list.where("#{quoted_position_column_with_table_name} > ?", position).decrement_all end end # Increments position (position_column) 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)).decrement_sequentially 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)).increment_sequentially 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 = current_position 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 return unless current_position && acts_as_list_list.where( "#{quoted_position_column_with_table_name} = #{current_position}" ).count > 1 shuffle_positions_on_intermediate_items old_position, current_position, id end def position_before_save_changed? if active_record_version_is?('>= 5.1') saved_change_to_attribute? position_column else attribute_changed? position_column end end def position_before_save if active_record_version_is?('>= 5.1') attribute_before_last_save position_column else attribute_was position_column 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]) } avoid_collision 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 current_position && !default_position? && current_position < 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) { position_column => direction } end def active_record_version_is?(version_requirement) requirement = Gem::Requirement.new(version_requirement) version = Gem.loaded_specs['activerecord'].version requirement.satisfied_by?(version) end end end end end