2018-08-03 13:22:24 -04:00
# frozen_string_literal: true
2019-07-22 03:47:29 -04:00
# This module makes it possible to handle items as a list, where the order of items can be easily altered
# Requirements:
#
# - Only works for ActiveRecord models
# - relative_position integer field must present on the model
# - This module uses GROUP BY: the model should have a parent relation, example: project -> issues, project is the parent relation (issues table has a parent_id column)
#
# Setup like this in the body of your class:
#
# include RelativePositioning
#
# # base query used for the position calculation
# def self.relative_positioning_query_base(issue)
# where(deleted: false)
# end
#
# # column that should be used in GROUP BY
# def self.relative_positioning_parent_column
# :project_id
# end
#
2017-02-01 13:41:01 -05:00
module RelativePositioning
extend ActiveSupport :: Concern
2017-02-27 06:40:26 -05:00
MIN_POSITION = 0
2017-03-10 10:12:31 -05:00
START_POSITION = Gitlab :: Database :: MAX_INT_VALUE / 2
2017-02-27 06:40:26 -05:00
MAX_POSITION = Gitlab :: Database :: MAX_INT_VALUE
2017-03-10 12:04:37 -05:00
IDEAL_DISTANCE = 500
2017-03-07 12:57:24 -05:00
2018-09-10 13:26:33 -04:00
class_methods do
2019-07-31 13:57:04 -04:00
def move_nulls_to_end ( objects )
2018-09-10 13:26:33 -04:00
objects = objects . reject ( & :relative_position )
2018-11-23 08:06:04 -05:00
return if objects . empty?
max_relative_position = objects . first . max_relative_position
2018-09-10 13:26:33 -04:00
self . transaction do
objects . each do | object |
2019-07-31 13:57:04 -04:00
relative_position = position_between ( max_relative_position || START_POSITION , MAX_POSITION )
2018-09-10 13:26:33 -04:00
object . relative_position = relative_position
max_relative_position = relative_position
2019-06-28 05:47:54 -04:00
object . save ( touch : false )
2018-09-10 13:26:33 -04:00
end
end
end
# This method takes two integer values (positions) and
# calculates the position between them. The range is huge as
# the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time
2020-06-04 11:08:21 -04:00
# when we have enough space. If distance is less than IDEAL_DISTANCE, we are calculating an average number.
2018-09-10 13:26:33 -04:00
def position_between ( pos_before , pos_after )
pos_before || = MIN_POSITION
pos_after || = MAX_POSITION
pos_before , pos_after = [ pos_before , pos_after ] . sort
halfway = ( pos_after + pos_before ) / 2
distance_to_halfway = pos_after - halfway
if distance_to_halfway < IDEAL_DISTANCE
halfway
else
if pos_before == MIN_POSITION
pos_after - IDEAL_DISTANCE
elsif pos_after == MAX_POSITION
pos_before + IDEAL_DISTANCE
else
halfway
end
end
end
end
2018-11-23 08:06:04 -05:00
def min_relative_position ( & block )
calculate_relative_position ( 'MIN' , & block )
2017-08-28 17:56:49 -04:00
end
2018-11-23 08:06:04 -05:00
def max_relative_position ( & block )
calculate_relative_position ( 'MAX' , & block )
2017-02-01 13:41:01 -05:00
end
2017-03-07 12:57:24 -05:00
def prev_relative_position
prev_pos = nil
if self . relative_position
2018-11-23 08:06:04 -05:00
prev_pos = max_relative_position do | relation |
relation . where ( 'relative_position < ?' , self . relative_position )
end
2017-03-07 12:57:24 -05:00
end
2017-03-10 10:12:31 -05:00
prev_pos
2017-03-07 12:57:24 -05:00
end
def next_relative_position
next_pos = nil
if self . relative_position
2018-11-23 08:06:04 -05:00
next_pos = min_relative_position do | relation |
relation . where ( 'relative_position > ?' , self . relative_position )
end
2017-03-07 12:57:24 -05:00
end
2017-03-10 10:12:31 -05:00
next_pos
2017-03-07 12:57:24 -05:00
end
2017-02-01 13:41:01 -05:00
def move_between ( before , after )
2017-03-07 12:57:24 -05:00
return move_after ( before ) unless after
return move_before ( after ) unless before
2017-02-01 13:41:01 -05:00
2019-07-24 10:36:39 -04:00
# If there is no place to insert an item we need to create one by moving the item
# before this and all preceding items until there is a gap
2019-07-19 04:16:41 -04:00
before , after = after , before if after . relative_position < before . relative_position
2017-03-10 12:04:37 -05:00
if ( after . relative_position - before . relative_position ) < 2
2019-07-19 04:16:41 -04:00
after . move_sequence_before
before . reset
2017-03-10 12:04:37 -05:00
end
2018-09-10 13:26:33 -04:00
self . relative_position = self . class . position_between ( before . relative_position , after . relative_position )
2017-03-10 12:04:37 -05:00
end
def move_after ( before = self )
2017-02-01 13:41:01 -05:00
pos_before = before . relative_position
2017-03-10 12:04:37 -05:00
pos_after = before . next_relative_position
2017-02-01 13:41:01 -05:00
2019-07-24 10:36:39 -04:00
if pos_after && ( pos_after - pos_before ) < 2
2019-07-19 04:16:41 -04:00
before . move_sequence_after
2019-10-08 05:06:09 -04:00
pos_after = before . next_relative_position
2017-02-01 13:41:01 -05:00
end
2018-09-10 13:26:33 -04:00
self . relative_position = self . class . position_between ( pos_before , pos_after )
2017-02-01 13:41:01 -05:00
end
2017-03-10 12:04:37 -05:00
def move_before ( after = self )
pos_after = after . relative_position
pos_before = after . prev_relative_position
2019-07-24 10:36:39 -04:00
if pos_before && ( pos_after - pos_before ) < 2
2019-07-19 04:16:41 -04:00
after . move_sequence_before
2019-10-08 05:06:09 -04:00
pos_before = after . prev_relative_position
2017-03-10 12:04:37 -05:00
end
2018-09-10 13:26:33 -04:00
self . relative_position = self . class . position_between ( pos_before , pos_after )
2017-02-01 13:41:01 -05:00
end
2017-03-07 12:57:24 -05:00
def move_to_end
2018-09-10 13:26:33 -04:00
self . relative_position = self . class . position_between ( max_relative_position || START_POSITION , MAX_POSITION )
2017-02-01 13:41:01 -05:00
end
2018-01-04 07:29:48 -05:00
def move_to_start
2018-09-10 13:26:33 -04:00
self . relative_position = self . class . position_between ( min_relative_position || START_POSITION , MIN_POSITION )
2018-01-04 07:29:48 -05:00
end
2019-07-24 10:36:39 -04:00
# Moves the sequence before the current item to the middle of the next gap
# For example, we have 5 11 12 13 14 15 and the current item is 15
# This moves the sequence 11 12 13 14 to 8 9 10 11
def move_sequence_before
next_gap = find_next_gap_before
delta = optimum_delta_for_gap ( next_gap )
move_sequence ( next_gap [ :start ] , relative_position , - delta )
2017-03-10 12:04:37 -05:00
end
2019-07-24 10:36:39 -04:00
# Moves the sequence after the current item to the middle of the next gap
# For example, we have 11 12 13 14 15 21 and the current item is 11
# This moves the sequence 12 13 14 15 to 15 16 17 18
def move_sequence_after
next_gap = find_next_gap_after
delta = optimum_delta_for_gap ( next_gap )
move_sequence ( relative_position , next_gap [ :start ] , delta )
2017-03-10 12:04:37 -05:00
end
2019-07-24 10:36:39 -04:00
private
# Supposing that we have a sequence of items: 1 5 11 12 13 and the current item is 13
# This would return: `{ start: 11, end: 5 }`
def find_next_gap_before
items_with_next_pos = scoped_items
. select ( 'relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos' )
. where ( 'relative_position <= ?' , relative_position )
. order ( relative_position : :desc )
find_next_gap ( items_with_next_pos ) . tap do | gap |
gap [ :end ] || = MIN_POSITION
end
2019-07-19 04:16:41 -04:00
end
2017-03-07 12:57:24 -05:00
2019-07-24 10:36:39 -04:00
# Supposing that we have a sequence of items: 13 14 15 20 24 and the current item is 13
# This would return: `{ start: 15, end: 20 }`
def find_next_gap_after
items_with_next_pos = scoped_items
. select ( 'relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos' )
. where ( 'relative_position >= ?' , relative_position )
. order ( :relative_position )
find_next_gap ( items_with_next_pos ) . tap do | gap |
gap [ :end ] || = MAX_POSITION
end
2017-03-07 12:57:24 -05:00
end
2019-07-19 04:16:41 -04:00
2019-07-24 10:36:39 -04:00
def find_next_gap ( items_with_next_pos )
gap = self . class . from ( items_with_next_pos , :items_with_next_pos )
. where ( 'ABS(pos - next_pos) > 1 OR next_pos IS NULL' )
. limit ( 1 )
. pluck ( :pos , :next_pos )
. first
{ start : gap [ 0 ] , end : gap [ 1 ] }
end
def optimum_delta_for_gap ( gap )
delta = ( ( gap [ :start ] - gap [ :end ] ) / 2 . 0 ) . abs . ceil
[ delta , IDEAL_DISTANCE ] . min
end
def move_sequence ( start_pos , end_pos , delta )
scoped_items
. where . not ( id : self . id )
. where ( 'relative_position BETWEEN ? AND ?' , start_pos , end_pos )
. update_all ( " relative_position = relative_position + #{ delta } " )
end
2018-11-23 08:06:04 -05:00
def calculate_relative_position ( calculation )
# When calculating across projects, this is much more efficient than
# MAX(relative_position) without the GROUP BY, due to index usage:
2019-09-18 10:02:45 -04:00
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54276#note_119340977
2019-07-19 04:16:41 -04:00
relation = scoped_items
2018-11-23 08:06:04 -05:00
. order ( Gitlab :: Database . nulls_last_order ( 'position' , 'DESC' ) )
2019-07-22 03:47:29 -04:00
. group ( self . class . relative_positioning_parent_column )
2018-11-23 08:06:04 -05:00
. limit ( 1 )
relation = yield relation if block_given?
relation
2019-07-22 03:47:29 -04:00
. pluck ( self . class . relative_positioning_parent_column , Arel . sql ( " #{ calculation } (relative_position) AS position " ) )
2020-01-27 16:08:47 -05:00
. first & . last
2018-11-23 08:06:04 -05:00
end
2019-07-19 04:16:41 -04:00
def scoped_items
self . class . relative_positioning_query_base ( self )
end
2017-02-01 13:41:01 -05:00
end