# 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