2020-09-16 14:09:47 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
|
|
|
module Gitlab
|
|
|
|
module RelativePositioning
|
|
|
|
# This class is API private - it should not be explicitly instantiated
|
|
|
|
# outside of tests
|
|
|
|
# rubocop: disable CodeReuse/ActiveRecord
|
|
|
|
class ItemContext
|
|
|
|
include Gitlab::Utils::StrongMemoize
|
|
|
|
|
|
|
|
attr_reader :object, :model_class, :range
|
|
|
|
attr_accessor :ignoring
|
|
|
|
|
|
|
|
def initialize(object, range, ignoring: nil)
|
|
|
|
@object = object
|
|
|
|
@range = range
|
|
|
|
@model_class = object.class
|
|
|
|
@ignoring = ignoring
|
|
|
|
end
|
|
|
|
|
|
|
|
def ==(other)
|
|
|
|
other.is_a?(self.class) && other.object == object && other.range == range && other.ignoring == ignoring
|
|
|
|
end
|
|
|
|
|
|
|
|
def positioned?
|
|
|
|
relative_position.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
def min_relative_position
|
|
|
|
strong_memoize(:min_relative_position) { calculate_relative_position('MIN') }
|
|
|
|
end
|
|
|
|
|
|
|
|
def max_relative_position
|
|
|
|
strong_memoize(:max_relative_position) { calculate_relative_position('MAX') }
|
|
|
|
end
|
|
|
|
|
|
|
|
def prev_relative_position
|
|
|
|
calculate_relative_position('MAX') { |r| nextify(r, false) } if object.relative_position
|
|
|
|
end
|
|
|
|
|
|
|
|
def next_relative_position
|
|
|
|
calculate_relative_position('MIN') { |r| nextify(r) } if object.relative_position
|
|
|
|
end
|
|
|
|
|
|
|
|
def nextify(relation, gt = true)
|
|
|
|
if gt
|
|
|
|
relation.where("relative_position > ?", relative_position)
|
|
|
|
else
|
|
|
|
relation.where("relative_position < ?", relative_position)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def relative_siblings(relation = scoped_items)
|
|
|
|
object.exclude_self(relation)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Handles the possibility that the position is already occupied by a sibling
|
|
|
|
def place_at_position(position, lhs)
|
|
|
|
current_occupant = relative_siblings.find_by(relative_position: position)
|
|
|
|
|
|
|
|
if current_occupant.present?
|
|
|
|
Mover.new(position, range).move(object, lhs.object, current_occupant)
|
|
|
|
else
|
|
|
|
object.relative_position = position
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def lhs_neighbour
|
2021-12-09 07:15:43 -05:00
|
|
|
neighbour(object.next_object_by_relative_position(ignoring: ignoring, order: :desc))
|
2020-09-16 14:09:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def rhs_neighbour
|
2021-12-09 07:15:43 -05:00
|
|
|
neighbour(object.next_object_by_relative_position(ignoring: ignoring, order: :asc))
|
2020-09-16 14:09:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def neighbour(item)
|
|
|
|
return unless item.present?
|
|
|
|
|
|
|
|
self.class.new(item, range, ignoring: ignoring)
|
|
|
|
end
|
|
|
|
|
|
|
|
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:
|
|
|
|
# https://gitlab.com/gitlab-org/gitlab-foss/issues/54276#note_119340977
|
|
|
|
relation = scoped_items
|
2022-04-19 05:08:55 -04:00
|
|
|
.order(Arel.sql('position').desc.nulls_last)
|
2020-09-16 14:09:47 -04:00
|
|
|
.group(grouping_column)
|
|
|
|
.limit(1)
|
|
|
|
|
|
|
|
relation = yield relation if block_given?
|
|
|
|
|
|
|
|
relation
|
2022-08-04 05:10:47 -04:00
|
|
|
.pick(grouping_column, Arel.sql("#{calculation}(relative_position) AS position"))&.last
|
2020-09-16 14:09:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def grouping_column
|
|
|
|
model_class.relative_positioning_parent_column
|
|
|
|
end
|
|
|
|
|
|
|
|
def max_sibling
|
|
|
|
sib = relative_siblings
|
2022-04-19 05:08:55 -04:00
|
|
|
.order(model_class.arel_table[:relative_position].desc.nulls_last)
|
2020-09-16 14:09:47 -04:00
|
|
|
.first
|
|
|
|
|
|
|
|
neighbour(sib)
|
|
|
|
end
|
|
|
|
|
|
|
|
def min_sibling
|
|
|
|
sib = relative_siblings
|
2022-04-19 05:08:55 -04:00
|
|
|
.order(model_class.arel_table[:relative_position].asc.nulls_last)
|
2020-09-16 14:09:47 -04:00
|
|
|
.first
|
|
|
|
|
|
|
|
neighbour(sib)
|
|
|
|
end
|
|
|
|
|
2021-04-20 17:09:07 -04:00
|
|
|
def at_position(position)
|
|
|
|
item = scoped_items.find_by(relative_position: position)
|
|
|
|
|
|
|
|
raise InvalidPosition, 'No item found at the specified position' if item.nil?
|
|
|
|
|
|
|
|
neighbour(item)
|
|
|
|
end
|
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
def shift_left
|
|
|
|
move_sequence_before(true)
|
2020-09-24 02:09:43 -04:00
|
|
|
object.reset_relative_position
|
2020-09-16 14:09:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def shift_right
|
|
|
|
move_sequence_after(true)
|
2020-09-24 02:09:43 -04:00
|
|
|
object.reset_relative_position
|
2020-09-16 14:09:47 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def create_space_left
|
|
|
|
find_next_gap_before.tap { |gap| move_sequence_before(false, next_gap: gap) }
|
|
|
|
end
|
|
|
|
|
|
|
|
def create_space_right
|
|
|
|
find_next_gap_after.tap { |gap| move_sequence_after(false, next_gap: gap) }
|
|
|
|
end
|
|
|
|
|
|
|
|
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, range.first)
|
|
|
|
end
|
|
|
|
|
|
|
|
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, range.last)
|
|
|
|
end
|
|
|
|
|
|
|
|
def find_next_gap(items_with_next_pos, default_end)
|
|
|
|
gap = model_class
|
|
|
|
.from(items_with_next_pos, :items)
|
|
|
|
.where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP)
|
2022-08-04 05:10:47 -04:00
|
|
|
.pick(:pos, :next_pos)
|
2020-09-16 14:09:47 -04:00
|
|
|
|
|
|
|
return if gap.nil? || gap.first == default_end
|
|
|
|
|
|
|
|
Gap.new(gap.first, gap.second || default_end)
|
|
|
|
end
|
|
|
|
|
2021-12-09 07:15:43 -05:00
|
|
|
def scoped_items
|
|
|
|
object.relative_positioning_scoped_items(ignoring: ignoring)
|
|
|
|
end
|
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
def relative_position
|
|
|
|
object.relative_position
|
|
|
|
end
|
|
|
|
|
|
|
|
private
|
|
|
|
|
|
|
|
# Moves the sequence before the current item to the middle of the next gap
|
|
|
|
# For example, we have
|
|
|
|
#
|
|
|
|
# 5 . . . . . 11 12 13 14 [15] 16 . 17
|
|
|
|
# -----------
|
|
|
|
#
|
|
|
|
# This moves the sequence [11 12 13 14] to [8 9 10 11], so we have:
|
|
|
|
#
|
|
|
|
# 5 . . 8 9 10 11 . . . [15] 16 . 17
|
|
|
|
# ---------
|
|
|
|
#
|
|
|
|
# Creating a gap to the left of the current item. We can understand this as
|
|
|
|
# dividing the 5 spaces between 5 and 11 into two smaller gaps of 2 and 3.
|
|
|
|
#
|
|
|
|
# If `include_self` is true, the current item will also be moved, creating a
|
|
|
|
# gap to the right of the current item:
|
|
|
|
#
|
|
|
|
# 5 . . 8 9 10 11 [14] . . . 16 . 17
|
|
|
|
# --------------
|
|
|
|
#
|
|
|
|
# As an optimization, the gap can be precalculated and passed to this method.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
# @raises NoSpaceLeft if the sequence cannot be moved
|
|
|
|
def move_sequence_before(include_self = false, next_gap: find_next_gap_before)
|
|
|
|
raise NoSpaceLeft unless next_gap.present?
|
|
|
|
|
|
|
|
delta = next_gap.delta
|
|
|
|
|
|
|
|
move_sequence(next_gap.start_pos, relative_position, -delta, include_self)
|
|
|
|
end
|
|
|
|
|
|
|
|
# Moves the sequence after the current item to the middle of the next gap
|
|
|
|
# For example, we have:
|
|
|
|
#
|
|
|
|
# 8 . 10 [11] 12 13 14 15 . . . . . 21
|
|
|
|
# -----------
|
|
|
|
#
|
|
|
|
# This moves the sequence [12 13 14 15] to [15 16 17 18], so we have:
|
|
|
|
#
|
|
|
|
# 8 . 10 [11] . . . 15 16 17 18 . . 21
|
|
|
|
# -----------
|
|
|
|
#
|
|
|
|
# Creating a gap to the right of the current item. We can understand this as
|
|
|
|
# dividing the 5 spaces between 15 and 21 into two smaller gaps of 3 and 2.
|
|
|
|
#
|
|
|
|
# If `include_self` is true, the current item will also be moved, creating a
|
|
|
|
# gap to the left of the current item:
|
|
|
|
#
|
|
|
|
# 8 . 10 . . . [14] 15 16 17 18 . . 21
|
|
|
|
# ----------------
|
|
|
|
#
|
|
|
|
# As an optimization, the gap can be precalculated and passed to this method.
|
|
|
|
#
|
|
|
|
# @api private
|
|
|
|
# @raises NoSpaceLeft if the sequence cannot be moved
|
|
|
|
def move_sequence_after(include_self = false, next_gap: find_next_gap_after)
|
|
|
|
raise NoSpaceLeft unless next_gap.present?
|
|
|
|
|
|
|
|
delta = next_gap.delta
|
|
|
|
|
|
|
|
move_sequence(relative_position, next_gap.start_pos, delta, include_self)
|
|
|
|
end
|
|
|
|
|
|
|
|
def move_sequence(start_pos, end_pos, delta, include_self = false)
|
|
|
|
relation = include_self ? scoped_items : relative_siblings
|
|
|
|
|
|
|
|
object.update_relative_siblings(relation, (start_pos..end_pos), delta)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
# rubocop: enable CodeReuse/ActiveRecord
|
|
|
|
end
|
|
|
|
end
|