Commit 03e50239 authored by Sean McGivern's avatar Sean McGivern

Merge branch 'ajk-relative-positioning-mover' into 'master'

Exhaustively test relative positioning logic

See merge request gitlab-org/gitlab!41967
parents 3bf6f632 b93304a1
......@@ -27,18 +27,7 @@
#
module RelativePositioning
extend ActiveSupport::Concern
STEPS = 10
IDEAL_DISTANCE = 2**(STEPS - 1) + 1
MIN_POSITION = Gitlab::Database::MIN_INT_VALUE
START_POSITION = 0
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
MAX_GAP = IDEAL_DISTANCE * 2
MIN_GAP = 2
NoSpaceLeft = Class.new(StandardError)
include ::Gitlab::RelativePositioning
class_methods do
def move_nulls_to_end(objects)
......@@ -49,56 +38,10 @@ module RelativePositioning
move_nulls(objects, at_end: false)
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 avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION].
#
# Then we handle one of three cases:
# - If the gap is too small, we raise NoSpaceLeft
# - If the gap is larger than MAX_GAP, we place the new position at most
# IDEAL_DISTANCE from the edge of the gap.
# - otherwise we place the new position at the midpoint.
#
# The new position will always satisfy: pos_before <= midpoint <= pos_after
#
# As a precondition, the gap between pos_before and pos_after MUST be >= 2.
# If the gap is too small, NoSpaceLeft is raised.
#
# This class method should only be called by instance methods of this module, which
# include handling for minimum gap size.
#
# @raises NoSpaceLeft
# @api private
def position_between(pos_before, pos_after)
pos_before ||= MIN_POSITION
pos_after ||= MAX_POSITION
pos_before, pos_after = [pos_before, pos_after].sort
gap_width = pos_after - pos_before
midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min
if gap_width < MIN_GAP
raise NoSpaceLeft
elsif gap_width > MAX_GAP
if pos_before <= MIN_POSITION
pos_after - IDEAL_DISTANCE
elsif pos_after >= MAX_POSITION
pos_before + IDEAL_DISTANCE
else
midpoint
end
else
midpoint
end
end
private
# @api private
def gap_size(object, gaps:, at_end:, starting_from:)
def gap_size(context, gaps:, at_end:, starting_from:)
total_width = IDEAL_DISTANCE * gaps
size = if at_end && starting_from + total_width >= MAX_POSITION
(MAX_POSITION - starting_from) / gaps
......@@ -108,23 +51,17 @@ module RelativePositioning
IDEAL_DISTANCE
end
# Shift max elements leftwards if there isn't enough space
return [size, starting_from] if size >= MIN_GAP
order = at_end ? :desc : :asc
terminus = object
.send(:relative_siblings) # rubocop:disable GitlabSecurity/PublicSend
.where('relative_position IS NOT NULL')
.order(relative_position: order)
.first
if at_end
terminus.move_sequence_before(true)
max_relative_position = terminus.reset.relative_position
terminus = context.max_sibling
terminus.shift_left
max_relative_position = terminus.relative_position
[[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position]
else
terminus.move_sequence_after(true)
min_relative_position = terminus.reset.relative_position
terminus = context.min_sibling
terminus.shift_right
min_relative_position = terminus.relative_position
[[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position]
end
end
......@@ -142,8 +79,9 @@ module RelativePositioning
objects = objects.reject(&:relative_position)
return 0 if objects.empty?
representative = objects.first
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
representative = RelativePositioning.mover.context(objects.first)
position = if at_end
representative.max_relative_position
else
......@@ -198,306 +136,68 @@ module RelativePositioning
end
end
def min_relative_position(&block)
calculate_relative_position('MIN', &block)
end
def max_relative_position(&block)
calculate_relative_position('MAX', &block)
end
def prev_relative_position(ignoring: nil)
prev_pos = nil
if self.relative_position
prev_pos = max_relative_position do |relation|
relation = relation.id_not_in(ignoring.id) if ignoring.present?
relation.where('relative_position < ?', self.relative_position)
end
end
prev_pos
end
def next_relative_position(ignoring: nil)
next_pos = nil
if self.relative_position
next_pos = min_relative_position do |relation|
relation = relation.id_not_in(ignoring.id) if ignoring.present?
relation.where('relative_position > ?', self.relative_position)
end
end
next_pos
def self.mover
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
end
def move_between(before, after)
return move_after(before) unless after
return move_before(after) unless before
before, after = after, before if after.relative_position < before.relative_position
pos_left = before.relative_position
pos_right = after.relative_position
before, after = [before, after].sort_by(&:relative_position) if before && after
if pos_right - pos_left < MIN_GAP
# Not enough room! Make space by shifting all previous elements to the left
# if there is enough space, else to the right
gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend
if gap.present?
after.move_sequence_before(next_gap: gap)
pos_left -= optimum_delta_for_gap(gap)
else
before.move_sequence_after
pos_right = after.reset.relative_position
end
end
new_position = self.class.position_between(pos_left, pos_right)
self.relative_position = new_position
RelativePositioning.mover.move(self, before, after)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
end
def move_after(before = self)
pos_before = before.relative_position
pos_after = before.next_relative_position(ignoring: self)
if pos_before == MAX_POSITION || gap_too_small?(pos_after, pos_before)
gap = before.send(:find_next_gap_after) # rubocop:disable GitlabSecurity/PublicSend
if gap.nil?
before.move_sequence_before(true)
pos_before = before.reset.relative_position
else
before.move_sequence_after(next_gap: gap)
pos_after += optimum_delta_for_gap(gap)
end
end
self.relative_position = self.class.position_between(pos_before, pos_after)
RelativePositioning.mover.move(self, before, nil)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
end
def move_before(after = self)
pos_after = after.relative_position
pos_before = after.prev_relative_position(ignoring: self)
if pos_after == MIN_POSITION || gap_too_small?(pos_before, pos_after)
gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend
if gap.nil?
after.move_sequence_after(true)
pos_after = after.reset.relative_position
else
after.move_sequence_before(next_gap: gap)
pos_before -= optimum_delta_for_gap(gap)
end
end
self.relative_position = self.class.position_between(pos_before, pos_after)
RelativePositioning.mover.move(self, nil, after)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
end
def move_to_end
max_pos = max_relative_position
if max_pos.nil?
self.relative_position = START_POSITION
elsif gap_too_small?(max_pos, MAX_POSITION + 1)
max = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'DESC')).first
max.move_sequence_before(true)
max.reset
self.relative_position = self.class.position_between(max.relative_position, MAX_POSITION + 1)
else
self.relative_position = self.class.position_between(max_pos, MAX_POSITION + 1)
end
RelativePositioning.mover.move_to_end(self)
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MAX_POSITION
rescue ActiveRecord::QueryCanceled => e
could_not_move(e)
raise e
end
def move_to_start
min_pos = min_relative_position
if min_pos.nil?
self.relative_position = START_POSITION
elsif gap_too_small?(min_pos, MIN_POSITION - 1)
min = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'ASC')).first
min.move_sequence_after(true)
min.reset
self.relative_position = self.class.position_between(MIN_POSITION - 1, min.relative_position)
else
self.relative_position = self.class.position_between(MIN_POSITION - 1, min_pos)
end
end
# 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 = optimum_delta_for_gap(next_gap)
move_sequence(next_gap[:start], 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 = optimum_delta_for_gap(next_gap)
move_sequence(relative_position, next_gap[:start], delta, include_self)
end
private
def gap_too_small?(pos_a, pos_b)
return false unless pos_a && pos_b
(pos_a - pos_b).abs < MIN_GAP
end
# Find the first suitable gap to the left of the current position.
#
# Satisfies the relations:
# - gap[:start] <= relative_position
# - abs(gap[:start] - gap[:end]) >= MIN_GAP
# - MIN_POSITION <= gap[:start] <= MAX_POSITION
# - MIN_POSITION <= gap[:end] <= MAX_POSITION
#
# Supposing that the current item is 13, and we have a sequence of items:
#
# 1 . . . 5 . . . . 11 12 [13] 14 . . 17
# ^---------^
#
# Then we return: `{ start: 11, end: 5 }`
#
# Here start refers to the end of the gap closest to the current item.
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, MIN_POSITION)
end
# Find the first suitable gap to the right of the current position.
#
# Satisfies the relations:
# - gap[:start] >= relative_position
# - abs(gap[:start] - gap[:end]) >= MIN_GAP
# - MIN_POSITION <= gap[:start] <= MAX_POSITION
# - MIN_POSITION <= gap[:end] <= MAX_POSITION
#
# Supposing the current item is 13, and that we have a sequence of items:
#
# 9 . . . [13] 14 15 . . . . 20 . . . 24
# ^---------^
#
# Then we return: `{ start: 15, end: 20 }`
#
# Here start refers to the end of the gap closest to the current item.
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, MAX_POSITION)
end
def find_next_gap(items_with_next_pos, end_is_nil)
gap = self.class
.from(items_with_next_pos, :items)
.where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP)
.limit(1)
.pluck(:pos, :next_pos)
.first
return if gap.nil? || gap.first == end_is_nil
{ start: gap.first, end: gap.second || end_is_nil }
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, include_self = false)
relation = include_self ? scoped_items : relative_siblings
RelativePositioning.mover.move_to_start(self)
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MIN_POSITION
rescue ActiveRecord::QueryCanceled => e
could_not_move(e)
raise e
end
# This method is used during rebalancing - override it to customise the update
# logic:
def update_relative_siblings(relation, range, delta)
relation
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
.where(relative_position: range)
.update_all("relative_position = relative_position + #{delta}")
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
.order(Gitlab::Database.nulls_last_order('position', 'DESC'))
.group(self.class.relative_positioning_parent_column)
.limit(1)
relation = yield relation if block_given?
relation
.pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position"))
.first&.last
end
def relative_siblings(relation = scoped_items)
relation.id_not_in(id)
# This method is used to exclude the current self (or another object)
# from a relation. Customize this if `id <> :id` is not sufficient
def exclude_self(relation, excluded: self)
relation.id_not_in(excluded.id)
end
def scoped_items
self.class.relative_positioning_query_base(self)
# Override if you want to be notified of failures to move
def could_not_move(exception)
end
end
......@@ -444,20 +444,9 @@ class Issue < ApplicationRecord
Gitlab::EtagCaching::Store.new.touch(key)
end
def find_next_gap_before
super
rescue ActiveRecord::QueryCanceled => e
# Symptom of running out of space - schedule rebalancing
IssueRebalancingWorker.perform_async(nil, project_id)
raise e
end
def find_next_gap_after
super
rescue ActiveRecord::QueryCanceled => e
def could_not_move(exception)
# Symptom of running out of space - schedule rebalancing
IssueRebalancingWorker.perform_async(nil, project_id)
raise e
end
end
......
---
title: Refactor relative positioning to enable better testing
merge_request: 41967
author:
type: other
......@@ -21,15 +21,11 @@ module EpicTreeSorting
included do
extend ::Gitlab::Utils::Override
override :move_sequence
def move_sequence(start_pos, end_pos, delta, include_self = false)
items_to_update = scoped_items
override :update_relative_siblings
def update_relative_siblings(relation, range, delta)
items_to_update = relation
.select(:id, :object_type)
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
unless include_self
items_to_update = relative_siblings(items_to_update)
end
.where(relative_position: range)
items_to_update.group_by { |item| item.object_type }.each do |type, group_items|
ids = group_items.map(&:id)
......@@ -38,11 +34,13 @@ module EpicTreeSorting
end
end
private
override :exclude_self
def exclude_self(relation, excluded: self)
return relation unless excluded&.id.present?
object_type = excluded.try(:object_type) || excluded.class.table_name.singularize
override :relative_siblings
def relative_siblings(relation = scoped_items)
relation.where.not('object_type = ? AND id = ?', self.class.table_name.singularize, self.id)
relation.where.not('object_type = ? AND id = ?', object_type, excluded.id)
end
end
end
......@@ -14,7 +14,7 @@ RSpec.describe EpicTreeSorting do
describe '#relative_siblings' do
def siblings(obj)
obj.send(:relative_siblings).pluck(:id, :object_type)
RelativePositioning.mover.context(obj).relative_siblings.pluck(:id, :object_type)
end
def polymorphic_ident(obj)
......@@ -106,9 +106,16 @@ RSpec.describe EpicTreeSorting do
let!(:epic2) { create(:epic, parent: base_epic, group: group, relative_position: 1003) }
let!(:epic3) { create(:epic, parent: base_epic, group: group, relative_position: 1005) }
def move_sequence(range)
dx = 500
RelativePositioning.mover.context(item).send(:move_sequence, range.first, range.last, dx)
end
context 'when self is an epic' do
let(:item) { epic1 }
it 'moves all objects correctly' do
epic1.move_sequence(1003, 1005, 500)
move_sequence(1003..1005)
expect(epic_issue1.reload.relative_position).to eq(1000)
expect(epic_issue2.reload.relative_position).to eq(1001)
......@@ -121,8 +128,10 @@ RSpec.describe EpicTreeSorting do
end
context 'when self is an epic_issue' do
let(:item) { epic_issue1 }
it 'moves all objects correctly' do
epic_issue1.move_sequence(1001, 1005, 500)
move_sequence(1001..1005)
expect(epic_issue1.reload.relative_position).to eq(1000)
expect(epic_issue2.reload.relative_position).to eq(1501)
......
......@@ -39,8 +39,8 @@ RSpec.describe EpicIssue do
let_it_be_with_reload(:middle) { create(:epic, group: epic.group, parent: epic, relative_position: 101) }
let_it_be_with_reload(:right) { create(:epic_issue, epic: epic, relative_position: 102) }
it 'can create space by using move_sequence_after' do
left.move_sequence_after
it 'can create space to the right' do
RelativePositioning.mover.context(left).create_space_right
[left, middle, right].each(&:reset)
expect(middle.relative_position - left.relative_position).to be > 1
......@@ -48,8 +48,8 @@ RSpec.describe EpicIssue do
expect(middle.relative_position).to be < right.relative_position
end
it 'can create space by using move_sequence_before' do
right.move_sequence_before
it 'can create space to the left' do
RelativePositioning.mover.context(right).create_space_left
[left, middle, right].each(&:reset)
expect(right.relative_position - middle.relative_position).to be > 1
......
......@@ -419,29 +419,11 @@ RSpec.describe Issue do
let_it_be_with_reload(:issue1) { create(:issue, project: project1, relative_position: issue.relative_position + RelativePositioning::IDEAL_DISTANCE) }
let(:new_issue) { build(:issue, project: project1, relative_position: nil) }
describe '#max_relative_position' do
it 'returns maximum position' do
expect(issue.max_relative_position).to eq issue1.relative_position
end
end
describe '#prev_relative_position' do
it 'returns previous position if there is an issue above' do
expect(issue1.prev_relative_position).to eq issue.relative_position
end
it 'returns nil if there is no issue above' do
expect(issue.prev_relative_position).to eq nil
end
end
describe '#next_relative_position' do
it 'returns next position if there is an issue below' do
expect(issue.next_relative_position).to eq issue1.relative_position
end
describe '.relative_positioning_query_base' do
it 'includes cross project issues in the same group' do
siblings = Issue.relative_positioning_query_base(issue)
it 'returns nil if there is no issue below' do
expect(issue1.next_relative_position).to eq nil
expect(siblings).to include(issue1)
end
end
......
# frozen_string_literal: true
module Gitlab
module RelativePositioning
STEPS = 10
IDEAL_DISTANCE = 2**(STEPS - 1) + 1
MIN_POSITION = Gitlab::Database::MIN_INT_VALUE
START_POSITION = 0
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
MAX_GAP = IDEAL_DISTANCE * 2
MIN_GAP = 2
NoSpaceLeft = Class.new(StandardError)
end
end
# frozen_string_literal: true
#
module Gitlab
module RelativePositioning
class Gap
attr_reader :start_pos, :end_pos
def initialize(start_pos, end_pos)
@start_pos, @end_pos = start_pos, end_pos
end
def ==(other)
other.is_a?(self.class) && other.start_pos == start_pos && other.end_pos == end_pos
end
def delta
((start_pos - end_pos) / 2.0).abs.ceil.clamp(0, RelativePositioning::IDEAL_DISTANCE)
end
end
end
end
# 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
scoped_items
.where('relative_position < ?', relative_position)
.reorder(relative_position: :desc)
.first
.then { |x| neighbour(x) }
end
def rhs_neighbour
scoped_items
.where('relative_position > ?', relative_position)
.reorder(relative_position: :asc)
.first
.then { |x| neighbour(x) }
end
def neighbour(item)
return unless item.present?
self.class.new(item, range, ignoring: ignoring)
end
def scoped_items
r = model_class.relative_positioning_query_base(object)
r = object.exclude_self(r, excluded: ignoring) if ignoring.present?
r
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
.order(Gitlab::Database.nulls_last_order('position', 'DESC'))
.group(grouping_column)
.limit(1)
relation = yield relation if block_given?
relation
.pluck(grouping_column, Arel.sql("#{calculation}(relative_position) AS position"))
.first&.last
end
def grouping_column
model_class.relative_positioning_parent_column
end
def max_sibling
sib = relative_siblings
.order(Gitlab::Database.nulls_last_order('relative_position', 'DESC'))
.first
neighbour(sib)
end
def min_sibling
sib = relative_siblings
.order(Gitlab::Database.nulls_last_order('relative_position', 'ASC'))
.first
neighbour(sib)
end
def shift_left
move_sequence_before(true)
object.reset
end
def shift_right
move_sequence_after(true)
object.reset
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)
.limit(1)
.pluck(:pos, :next_pos)
.first
return if gap.nil? || gap.first == default_end
Gap.new(gap.first, gap.second || default_end)
end
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
# frozen_string_literal: true
module Gitlab
module RelativePositioning
class Mover
attr_reader :range, :start_position
def initialize(start, range)
@range = range
@start_position = start
end
def move_to_end(object)
focus = context(object, ignoring: object)
max_pos = focus.max_relative_position
move_to_range_end(focus, max_pos)
end
def move_to_start(object)
focus = context(object, ignoring: object)
min_pos = focus.min_relative_position
move_to_range_start(focus, min_pos)
end
def move(object, first, last)
raise ArgumentError, 'object is required' unless object
lhs = context(first, ignoring: object)
rhs = context(last, ignoring: object)
focus = context(object)
range = RelativePositioning.range(lhs, rhs)
if range.cover?(focus)
# Moving a object already within a range is a no-op
elsif range.open_on_left?
move_to_range_start(focus, range.rhs.relative_position)
elsif range.open_on_right?
move_to_range_end(focus, range.lhs.relative_position)
else
pos_left, pos_right = create_space_between(range)
desired_position = position_between(pos_left, pos_right)
focus.place_at_position(desired_position, range.lhs)
end
end
def context(object, ignoring: nil)
return unless object
ItemContext.new(object, range, ignoring: ignoring)
end
private
def gap_too_small?(pos_a, pos_b)
return false unless pos_a && pos_b
(pos_a - pos_b).abs < MIN_GAP
end
def move_to_range_end(context, max_pos)
range_end = range.last + 1
new_pos = if max_pos.nil?
start_position
elsif gap_too_small?(max_pos, range_end)
max = context.max_sibling
max.ignoring = context.object
max.shift_left
position_between(max.relative_position, range_end)
else
position_between(max_pos, range_end)
end
context.object.relative_position = new_pos
end
def move_to_range_start(context, min_pos)
range_end = range.first - 1
new_pos = if min_pos.nil?
start_position
elsif gap_too_small?(min_pos, range_end)
sib = context.min_sibling
sib.ignoring = context.object
sib.shift_right
position_between(sib.relative_position, range_end)
else
position_between(min_pos, range_end)
end
context.object.relative_position = new_pos
end
def create_space_between(range)
pos_left = range.lhs&.relative_position
pos_right = range.rhs&.relative_position
return [pos_left, pos_right] unless gap_too_small?(pos_left, pos_right)
gap = range.rhs.create_space_left
[pos_left - gap.delta, pos_right]
rescue NoSpaceLeft
gap = range.lhs.create_space_right
[pos_left, pos_right + gap.delta]
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 avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION].
#
# Then we handle one of three cases:
# - If the gap is too small, we raise NoSpaceLeft
# - If the gap is larger than MAX_GAP, we place the new position at most
# IDEAL_DISTANCE from the edge of the gap.
# - otherwise we place the new position at the midpoint.
#
# The new position will always satisfy: pos_before <= midpoint <= pos_after
#
# As a precondition, the gap between pos_before and pos_after MUST be >= 2.
# If the gap is too small, NoSpaceLeft is raised.
#
# @raises NoSpaceLeft
def position_between(pos_before, pos_after)
pos_before ||= range.first
pos_after ||= range.last
pos_before, pos_after = [pos_before, pos_after].sort
gap_width = pos_after - pos_before
if gap_too_small?(pos_before, pos_after)
raise NoSpaceLeft
elsif gap_width > MAX_GAP
if pos_before <= range.first
pos_after - IDEAL_DISTANCE
elsif pos_after >= range.last
pos_before + IDEAL_DISTANCE
else
midpoint(pos_before, pos_after)
end
else
midpoint(pos_before, pos_after)
end
end
def midpoint(lower_bound, upper_bound)
((lower_bound + upper_bound) / 2.0).ceil.clamp(lower_bound, upper_bound - 1)
end
end
end
end
# frozen_string_literal: true
module Gitlab
module RelativePositioning
IllegalRange = Class.new(ArgumentError)
class Range
attr_reader :lhs, :rhs
def open_on_left?
lhs.nil?
end
def open_on_right?
rhs.nil?
end
def cover?(item_context)
return false unless item_context
return false unless item_context.positioned?
return true if item_context.object == lhs&.object
return true if item_context.object == rhs&.object
pos = item_context.relative_position
return lhs.relative_position < pos if open_on_right?
return pos < rhs.relative_position if open_on_left?
lhs.relative_position < pos && pos < rhs.relative_position
end
def ==(other)
other.is_a?(RelativePositioning::Range) && lhs == other.lhs && rhs == other.rhs
end
end
def self.range(lhs, rhs)
if lhs && rhs
ClosedRange.new(lhs, rhs)
elsif lhs
StartingFrom.new(lhs)
elsif rhs
EndingAt.new(rhs)
else
raise IllegalRange, 'One of rhs or lhs must be provided' unless lhs && rhs
end
end
class ClosedRange < RelativePositioning::Range
def initialize(lhs, rhs)
@lhs, @rhs = lhs, rhs
raise IllegalRange, 'Either lhs or rhs is missing' unless lhs && rhs
raise IllegalRange, 'lhs and rhs cannot be the same object' if lhs == rhs
end
end
class StartingFrom < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(lhs)
@lhs = lhs
raise IllegalRange, 'lhs is required' unless lhs
end
def rhs
strong_memoize(:rhs) { lhs.rhs_neighbour }
end
end
class EndingAt < RelativePositioning::Range
include Gitlab::Utils::StrongMemoize
def initialize(rhs)
@rhs = rhs
raise IllegalRange, 'rhs is required' unless rhs
end
def lhs
strong_memoize(:lhs) { rhs.lhs_neighbour }
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::RelativePositioning::ItemContext do
let_it_be(:default_user) { create_default(:user) }
let_it_be(:project, reload: true) { create(:project) }
def create_issue(pos)
create(:issue, project: project, relative_position: pos)
end
range = (101..107) # A deliberately small range, so we can test everything
indices = (0..).take(range.size)
let(:start) { ((range.first + range.last) / 2.0).floor }
let(:subjects) { issues.map { |i| described_class.new(i.reset, range) } }
# This allows us to refer to range in methods and examples
let_it_be(:full_range) { range }
context 'there are gaps at the start and end' do
let_it_be(:issues) { (range.first.succ..range.last.pred).map { |pos| create_issue(pos) } }
it 'is always possible to find a gap' do
expect(subjects)
.to all(have_attributes(find_next_gap_before: be_present, find_next_gap_after: be_present))
end
where(:index) { indices.reverse.drop(2) }
with_them do
subject { subjects[index] }
let(:positions) { subject.scoped_items.map(&:relative_position) }
it 'is possible to shift_right, which will consume the gap at the end' do
subject.shift_right
expect(subject.find_next_gap_after).not_to be_present
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to eq(positions.uniq)
end
it 'is possible to create_space_right, which will move the gap to immediately after' do
subject.create_space_right
expect(subject.find_next_gap_after).to have_attributes(start_pos: subject.relative_position)
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to eq(positions.uniq)
end
it 'is possible to shift_left, which will consume the gap at the start' do
subject.shift_left
expect(subject.find_next_gap_before).not_to be_present
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to eq(positions.uniq)
end
it 'is possible to create_space_left, which will move the gap to immediately before' do
subject.create_space_left
expect(subject.find_next_gap_before).to have_attributes(start_pos: subject.relative_position)
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to eq(positions.uniq)
end
end
end
context 'there is a gap of multiple spaces' do
let_it_be(:issues) { [range.first, range.last].map { |pos| create_issue(pos) } }
it 'is impossible to move the last element to the right' do
expect { subjects.last.shift_right }.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
end
it 'is impossible to move the first element to the left' do
expect { subjects.first.shift_left }.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
end
it 'is possible to move the last element to the left' do
subject = subjects.last
expect { subject.shift_left }.to change { subject.relative_position }.by(be < 0)
end
it 'is possible to move the first element to the right' do
subject = subjects.first
expect { subject.shift_right }.to change { subject.relative_position }.by(be > 0)
end
it 'is possible to find the gap from the right' do
gap = Gitlab::RelativePositioning::Gap.new(range.last, range.first)
expect(subjects.last).to have_attributes(
find_next_gap_before: eq(gap),
find_next_gap_after: be_nil
)
end
it 'is possible to find the gap from the left' do
gap = Gitlab::RelativePositioning::Gap.new(range.first, range.last)
expect(subjects.first).to have_attributes(
find_next_gap_before: be_nil,
find_next_gap_after: eq(gap)
)
end
end
context 'there are several free spaces' do
let_it_be(:issues) { range.select(&:even?).map { |pos| create_issue(pos) } }
let_it_be(:gaps) do
range.select(&:odd?).map do |pos|
rhs = pos.succ.clamp(range.first, range.last)
lhs = pos.pred.clamp(range.first, range.last)
{
before: Gitlab::RelativePositioning::Gap.new(rhs, lhs),
after: Gitlab::RelativePositioning::Gap.new(lhs, rhs)
}
end
end
def issue_at(position)
issues.find { |i| i.relative_position == position }
end
where(:current_pos) { range.select(&:even?) }
with_them do
let(:subject) { subjects.find { |s| s.relative_position == current_pos } }
let(:siblings) { subjects.reject { |s| s.relative_position == current_pos } }
def covered_by_range(pos)
full_range.cover?(pos) ? pos : nil
end
it 'finds the closest gap' do
closest_gap_before = gaps
.map { |gap| gap[:before] }
.select { |gap| gap.start_pos <= subject.relative_position }
.max_by { |gap| gap.start_pos }
closest_gap_after = gaps
.map { |gap| gap[:after] }
.select { |gap| gap.start_pos >= subject.relative_position }
.min_by { |gap| gap.start_pos }
expect(subject).to have_attributes(
find_next_gap_before: closest_gap_before,
find_next_gap_after: closest_gap_after
)
end
it 'finds the neighbours' do
expect(subject).to have_attributes(
lhs_neighbour: subject.neighbour(issue_at(subject.relative_position - 2)),
rhs_neighbour: subject.neighbour(issue_at(subject.relative_position + 2))
)
end
it 'finds the next relative_positions' do
expect(subject).to have_attributes(
prev_relative_position: covered_by_range(subject.relative_position - 2),
next_relative_position: covered_by_range(subject.relative_position + 2)
)
end
it 'finds the min/max positions' do
expect(subject).to have_attributes(
min_relative_position: issues.first.relative_position,
max_relative_position: issues.last.relative_position
)
end
it 'finds the min/max siblings' do
expect(subject).to have_attributes(
min_sibling: siblings.first,
max_sibling: siblings.last
)
end
end
end
context 'there is at least one free space' do
where(:free_space) { range.to_a }
with_them do
let(:issues) { range.reject { |x| x == free_space }.map { |p| create_issue(p) } }
let(:gap_rhs) { free_space.succ.clamp(range.first, range.last) }
let(:gap_lhs) { free_space.pred.clamp(range.first, range.last) }
it 'can always find a gap before if there is space to the left' do
expected_gap = Gitlab::RelativePositioning::Gap.new(gap_rhs, gap_lhs)
to_the_right_of_gap = subjects.select { |s| free_space < s.relative_position }
expect(to_the_right_of_gap)
.to all(have_attributes(find_next_gap_before: eq(expected_gap), find_next_gap_after: be_nil))
end
it 'can always find a gap after if there is space to the right' do
expected_gap = Gitlab::RelativePositioning::Gap.new(gap_lhs, gap_rhs)
to_the_left_of_gap = subjects.select { |s| s.relative_position < free_space }
expect(to_the_left_of_gap)
.to all(have_attributes(find_next_gap_before: be_nil, find_next_gap_after: eq(expected_gap)))
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe RelativePositioning::Mover do
let_it_be(:user) { create(:user) }
let_it_be(:one_sibling, reload: true) { create(:project, creator: user, namespace: user.namespace) }
let_it_be(:one_free_space, reload: true) { create(:project, creator: user, namespace: user.namespace) }
let_it_be(:fully_occupied, reload: true) { create(:project, creator: user, namespace: user.namespace) }
let_it_be(:no_issues, reload: true) { create(:project, creator: user, namespace: user.namespace) }
let_it_be(:three_sibs, reload: true) { create(:project, creator: user, namespace: user.namespace) }
def create_issue(pos, parent = project)
create(:issue, author: user, project: parent, relative_position: pos)
end
range = (101..105)
indices = (0..).take(range.size)
let(:start) { ((range.first + range.last) / 2.0).floor }
subject { described_class.new(start, range) }
let_it_be(:full_set) do
range.each_with_index.map do |pos, i|
create(:issue, iid: i.succ, project: fully_occupied, relative_position: pos)
end
end
let_it_be(:sole_sibling) { create(:issue, iid: 1, project: one_sibling, relative_position: nil) }
let_it_be(:one_sibling_set) { [sole_sibling] }
let_it_be(:one_free_space_set) do
indices.drop(1).map { |iid| create(:issue, project: one_free_space, iid: iid.succ) }
end
let_it_be(:three_sibs_set) do
[1, 2, 3].map { |iid| create(:issue, iid: iid, project: three_sibs) }
end
def set_positions(positions)
vals = issues.zip(positions).map do |issue, pos|
issue.relative_position = pos
"(#{issue.id}, #{pos})"
end.join(', ')
Issue.connection.exec_query(<<~SQL, 'set-positions')
WITH cte(cte_id, new_pos) AS (
SELECT * FROM (VALUES #{vals}) as t (id, pos)
)
UPDATE issues SET relative_position = new_pos FROM cte WHERE id = cte_id
;
SQL
end
def ids_in_position_order
project.issues.reorder(:relative_position).pluck(:id)
end
def relative_positions
project.issues.pluck(:relative_position)
end
describe '#move_to_end' do
def max_position
project.issues.maximum(:relative_position)
end
def move_to_end(issue)
subject.move_to_end(issue)
issue.save!
end
shared_examples 'able to place a new item at the end' do
it 'can place any new item' do
existing_issues = ids_in_position_order
new_item = create_issue(nil)
expect do
move_to_end(new_item)
end.to change { project.issues.pluck(:id, :relative_position) }
expect(new_item.relative_position).to eq(max_position)
expect(relative_positions).to all(be_between(range.first, range.last))
expect(ids_in_position_order).to eq(existing_issues + [new_item.id])
end
end
shared_examples 'able to move existing items to the end' do
it 'can move any existing item' do
issues = project.issues.reorder(:relative_position).to_a
issue = issues[index]
other_issues = issues.reject { |i| i == issue }
expect(relative_positions).to all(be_between(range.first, range.last))
if issues.last == issue
move_to_end(issue) # May not change the positions
else
expect do
move_to_end(issue)
end.to change { project.issues.pluck(:id, :relative_position) }
end
project.reset
expect(relative_positions).to all(be_between(range.first, range.last))
expect(issue.relative_position).to eq(max_position)
expect(ids_in_position_order).to eq(other_issues.map(&:id) + [issue.id])
end
end
context 'all positions are taken' do
let(:issues) { full_set }
let(:project) { fully_occupied }
it 'raises an error when placing a new item' do
new_item = create_issue(nil)
expect { subject.move_to_end(new_item) }.to raise_error(RelativePositioning::NoSpaceLeft)
end
where(:index) { indices }
with_them do
it_behaves_like 'able to move existing items to the end'
end
end
context 'there are no siblings' do
let(:issues) { [] }
let(:project) { no_issues }
it_behaves_like 'able to place a new item at the end'
end
context 'there is only one sibling' do
where(:pos) { range.to_a }
with_them do
let(:issues) { one_sibling_set }
let(:project) { one_sibling }
let(:index) { 0 }
before do
sole_sibling.reset.update!(relative_position: pos)
end
it_behaves_like 'able to place a new item at the end'
it_behaves_like 'able to move existing items to the end'
end
end
context 'at least one position is free' do
where(:free_space, :index) do
is = indices.take(range.size - 1)
range.to_a.product(is)
end
with_them do
let(:issues) { one_free_space_set }
let(:project) { one_free_space }
before do
positions = range.reject { |x| x == free_space }
set_positions(positions)
end
it_behaves_like 'able to place a new item at the end'
it_behaves_like 'able to move existing items to the end'
end
end
end
describe '#move_to_start' do
def min_position
project.issues.minimum(:relative_position)
end
def move_to_start(issue)
subject.move_to_start(issue)
issue.save!
end
shared_examples 'able to place a new item at the start' do
it 'can place any new item' do
existing_issues = ids_in_position_order
new_item = create_issue(nil)
expect do
move_to_start(new_item)
end.to change { project.issues.pluck(:id, :relative_position) }
expect(relative_positions).to all(be_between(range.first, range.last))
expect(new_item.relative_position).to eq(min_position)
expect(ids_in_position_order).to eq([new_item.id] + existing_issues)
end
end
shared_examples 'able to move existing items to the start' do
it 'can move any existing item' do
issues = project.issues.reorder(:relative_position).to_a
issue = issues[index]
other_issues = issues.reject { |i| i == issue }
expect(relative_positions).to all(be_between(range.first, range.last))
if issues.first == issue
move_to_start(issue) # May not change the positions
else
expect do
move_to_start(issue)
end.to change { project.issues.pluck(:id, :relative_position) }
end
project.reset
expect(relative_positions).to all(be_between(range.first, range.last))
expect(issue.relative_position).to eq(min_position)
expect(ids_in_position_order).to eq([issue.id] + other_issues.map(&:id))
end
end
context 'all positions are taken' do
let(:issues) { full_set }
let(:project) { fully_occupied }
it 'raises an error when placing a new item' do
new_item = create(:issue, project: project, relative_position: nil)
expect { subject.move_to_start(new_item) }.to raise_error(RelativePositioning::NoSpaceLeft)
end
where(:index) { indices }
with_them do
it_behaves_like 'able to move existing items to the start'
end
end
context 'there are no siblings' do
let(:project) { no_issues }
let(:issues) { [] }
it_behaves_like 'able to place a new item at the start'
end
context 'there is only one sibling' do
where(:pos) { range.to_a }
with_them do
let(:issues) { one_sibling_set }
let(:project) { one_sibling }
let(:index) { 0 }
before do
sole_sibling.reset.update!(relative_position: pos)
end
it_behaves_like 'able to place a new item at the start'
it_behaves_like 'able to move existing items to the start'
end
end
context 'at least one position is free' do
where(:free_space, :index) do
range.to_a.product((0..).take(range.size - 1).to_a)
end
with_them do
let(:issues) { one_free_space_set }
let(:project) { one_free_space }
before do
set_positions(range.reject { |x| x == free_space })
end
it_behaves_like 'able to place a new item at the start'
it_behaves_like 'able to move existing items to the start'
end
end
end
describe '#move' do
shared_examples 'able to move a new item' do
let(:other_issues) { project.issues.reorder(relative_position: :asc).to_a }
let!(:previous_order) { other_issues.map(&:id) }
it 'can place any new item betwen two others' do
new_item = create_issue(nil)
subject.move(new_item, lhs, rhs)
new_item.save!
lhs.reset
rhs.reset
expect(new_item.relative_position).to be_between(range.first, range.last)
expect(new_item.relative_position).to be_between(lhs.relative_position, rhs.relative_position)
ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
expect(ids).to eq(previous_order)
end
it 'can place any new item after another' do
new_item = create_issue(nil)
subject.move(new_item, lhs, nil)
new_item.save!
lhs.reset
expect(new_item.relative_position).to be_between(range.first, range.last)
expect(new_item.relative_position).to be > lhs.relative_position
ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
expect(ids).to eq(previous_order)
end
it 'can place any new item before another' do
new_item = create_issue(nil)
subject.move(new_item, nil, rhs)
new_item.save!
rhs.reset
expect(new_item.relative_position).to be_between(range.first, range.last)
expect(new_item.relative_position).to be < rhs.relative_position
ids = project.issues.reorder(:relative_position).pluck(:id).reject { |id| id == new_item.id }
expect(ids).to eq(previous_order)
end
end
shared_examples 'able to move an existing item' do
let(:all_issues) { project.issues.reorder(:relative_position).to_a }
let(:item) { all_issues[index] }
let(:positions) { project.reset.issues.pluck(:relative_position) }
let(:other_issues) { all_issues.reject { |i| i == item } }
let!(:previous_order) { other_issues.map(&:id) }
let(:new_order) do
project.issues.where.not(id: item.id).reorder(:relative_position).pluck(:id)
end
it 'can place any item betwen two others' do
subject.move(item, lhs, rhs)
item.save!
lhs.reset
rhs.reset
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to match_array(positions.uniq)
expect(item.relative_position).to be_between(lhs.relative_position, rhs.relative_position)
expect(new_order).to eq(previous_order)
end
def sequence(expected_sequence)
range = (expected_sequence.first.relative_position..expected_sequence.last.relative_position)
project.issues.reorder(:relative_position).where(relative_position: range)
end
it 'can place any item after another' do
subject.move(item, lhs, nil)
item.save!
lhs.reset
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to match_array(positions.uniq)
expect(item.relative_position).to be >= lhs.relative_position
expected_sequence = [lhs, item].uniq
expect(sequence(expected_sequence)).to eq(expected_sequence)
expect(new_order).to eq(previous_order)
end
it 'can place any item before another' do
subject.move(item, nil, rhs)
item.save!
rhs.reset
expect(positions).to all(be_between(range.first, range.last))
expect(positions).to match_array(positions.uniq)
expect(item.relative_position).to be <= rhs.relative_position
expected_sequence = [item, rhs].uniq
expect(sequence(expected_sequence)).to eq(expected_sequence)
expect(new_order).to eq(previous_order)
end
end
context 'all positions are taken' do
let(:issues) { full_set }
let(:project) { fully_occupied }
where(:idx_a, :idx_b) do
indices.product(indices).select { |a, b| a < b }
end
with_them do
let(:lhs) { issues[idx_a].reset }
let(:rhs) { issues[idx_b].reset }
it 'raises an error when placing a new item anywhere' do
new_item = create_issue(nil)
expect { subject.move(new_item, lhs, rhs) }
.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
expect { subject.move(new_item, nil, rhs) }
.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
expect { subject.move(new_item, lhs, nil) }
.to raise_error(Gitlab::RelativePositioning::NoSpaceLeft)
end
where(:index) { indices }
with_them do
it_behaves_like 'able to move an existing item'
end
end
end
context 'there are no siblings' do
let(:project) { no_issues }
it 'raises an ArgumentError when both first and last are nil' do
new_item = create_issue(nil)
expect { subject.move(new_item, nil, nil) }.to raise_error(ArgumentError)
end
end
context 'there are a couple of siblings' do
where(:pos_movable, :pos_a, :pos_b) do
xs = range.to_a
xs.product(xs).product(xs).map(&:flatten)
.select { |vals| vals == vals.uniq && vals[1] < vals[2] }
end
with_them do
let(:issues) { three_sibs_set }
let(:project) { three_sibs }
let(:index) { 0 }
let(:lhs) { issues[1] }
let(:rhs) { issues[2] }
before do
set_positions([pos_movable, pos_a, pos_b])
end
it_behaves_like 'able to move a new item'
it_behaves_like 'able to move an existing item'
end
end
context 'at least one position is free' do
where(:free_space, :index, :pos_a, :pos_b) do
is = indices.reverse.drop(1)
range.to_a.product(is).product(is).product(is)
.map(&:flatten)
.select { |_, _, a, b| a < b }
end
with_them do
let(:issues) { one_free_space_set }
let(:project) { one_free_space }
let(:lhs) { issues[pos_a] }
let(:rhs) { issues[pos_b] }
before do
set_positions(range.reject { |x| x == free_space })
end
it_behaves_like 'able to move a new item'
it_behaves_like 'able to move an existing item'
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::RelativePositioning::Range do
item_a = OpenStruct.new(relative_position: 100, object: :x, positioned?: true)
item_b = OpenStruct.new(relative_position: 200, object: :y, positioned?: true)
before do
allow(item_a).to receive(:lhs_neighbour) { nil }
allow(item_a).to receive(:rhs_neighbour) { item_b }
allow(item_b).to receive(:lhs_neighbour) { item_a }
allow(item_b).to receive(:rhs_neighbour) { nil }
end
describe 'RelativePositioning.range' do
it 'raises if lhs and rhs are nil' do
expect { Gitlab::RelativePositioning.range(nil, nil) }.to raise_error(ArgumentError)
end
it 'raises an error if there is no extent' do
expect { Gitlab::RelativePositioning.range(item_a, item_a) }.to raise_error(ArgumentError)
end
it 'constructs a closed range when both termini are provided' do
range = Gitlab::RelativePositioning.range(item_a, item_b)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::ClosedRange)
end
it 'constructs a starting-from range when only the LHS is provided' do
range = Gitlab::RelativePositioning.range(item_a, nil)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::StartingFrom)
end
it 'constructs an ending-at range when only the RHS is provided' do
range = Gitlab::RelativePositioning.range(nil, item_b)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::Range)
expect(range).to be_a_kind_of(Gitlab::RelativePositioning::EndingAt)
end
end
it 'infers neighbours correctly' do
starting_at_a = Gitlab::RelativePositioning.range(item_a, nil)
ending_at_b = Gitlab::RelativePositioning.range(nil, item_b)
expect(starting_at_a).to eq(ending_at_b)
end
describe '#open_on_left?' do
where(:lhs, :rhs, :expected_result) do
[
[item_a, item_b, false],
[item_a, nil, false],
[nil, item_b, false],
[item_b, nil, false],
[nil, item_a, true]
]
end
with_them do
it 'is true if there is no LHS terminus' do
range = Gitlab::RelativePositioning.range(lhs, rhs)
expect(range.open_on_left?).to be(expected_result)
end
end
end
describe '#open_on_right?' do
where(:lhs, :rhs, :expected_result) do
[
[item_a, item_b, false],
[item_a, nil, false],
[nil, item_b, false],
[item_b, nil, true],
[nil, item_a, false]
]
end
with_them do
it 'is true if there is no RHS terminus' do
range = Gitlab::RelativePositioning.range(lhs, rhs)
expect(range.open_on_right?).to be(expected_result)
end
end
end
describe '#cover?' do
item_c = OpenStruct.new(relative_position: 150, object: :z, positioned?: true)
item_d = OpenStruct.new(relative_position: 050, object: :w, positioned?: true)
item_e = OpenStruct.new(relative_position: 250, object: :r, positioned?: true)
item_f = OpenStruct.new(positioned?: false)
item_ax = OpenStruct.new(relative_position: 100, object: :not_x, positioned?: true)
item_bx = OpenStruct.new(relative_position: 200, object: :not_y, positioned?: true)
where(:lhs, :rhs, :item, :expected_result) do
[
[item_a, item_b, item_a, true],
[item_a, item_b, item_b, true],
[item_a, item_b, item_c, true],
[item_a, item_b, item_d, false],
[item_a, item_b, item_e, false],
[item_a, item_b, item_ax, false],
[item_a, item_b, item_bx, false],
[item_a, item_b, item_f, false],
[item_a, item_b, nil, false],
[nil, item_b, item_a, true],
[nil, item_b, item_b, true],
[nil, item_b, item_c, true],
[nil, item_b, item_d, false],
[nil, item_b, item_e, false],
[nil, item_b, item_ax, false],
[nil, item_b, item_bx, false],
[nil, item_b, item_f, false],
[nil, item_b, nil, false],
[item_a, nil, item_a, true],
[item_a, nil, item_b, true],
[item_a, nil, item_c, true],
[item_a, nil, item_d, false],
[item_a, nil, item_e, false],
[item_a, nil, item_ax, false],
[item_a, nil, item_bx, false],
[item_a, nil, item_f, false],
[item_a, nil, nil, false],
[nil, item_a, item_a, true],
[nil, item_a, item_b, false],
[nil, item_a, item_c, false],
[nil, item_a, item_d, true],
[nil, item_a, item_e, false],
[nil, item_a, item_ax, false],
[nil, item_a, item_bx, false],
[nil, item_a, item_f, false],
[nil, item_a, nil, false],
[item_b, nil, item_a, false],
[item_b, nil, item_b, true],
[item_b, nil, item_c, false],
[item_b, nil, item_d, false],
[item_b, nil, item_e, true],
[item_b, nil, item_ax, false],
[item_b, nil, item_bx, false],
[item_b, nil, item_f, false],
[item_b, nil, nil, false]
]
end
with_them do
it 'is true when the object is within the bounds of the range' do
range = Gitlab::RelativePositioning.range(lhs, rhs)
expect(range.cover?(item)).to be(expected_result)
end
end
end
end
......@@ -12,8 +12,10 @@ RSpec.describe DesignManagement::Design do
let_it_be(:deleted_design) { create(:design, :with_versions, deleted: true) }
it_behaves_like 'a class that supports relative positioning' do
let_it_be(:relative_parent) { create(:issue) }
let(:factory) { :design }
let(:default_params) { { issue: issue } }
let(:default_params) { { issue: relative_parent } }
end
describe 'relations' do
......
......@@ -1187,29 +1187,20 @@ RSpec.describe Issue do
describe 'scheduling rebalancing' do
before do
allow(issue).to receive(:find_next_gap) { raise ActiveRecord::QueryCanceled }
allow_next_instance_of(RelativePositioning::Mover) do |mover|
allow(mover).to receive(:move) { raise ActiveRecord::QueryCanceled }
end
end
let(:project) { build(:project_empty_repo) }
let(:project) { build_stubbed(:project_empty_repo) }
let(:issue) { build_stubbed(:issue, relative_position: 100, project: project) }
describe '#find_next_gap_before' do
it 'schedules rebalancing if we time-out when finding a gap' do
lhs = build_stubbed(:issue, relative_position: 99, project: project)
to_move = build(:issue, project: project)
expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
it 'schedules rebalancing if we time-out when moving' do
lhs = build_stubbed(:issue, relative_position: 99, project: project)
to_move = build(:issue, project: project)
expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
expect { to_move.move_between(lhs, issue) }.to raise_error(ActiveRecord::QueryCanceled)
end
end
describe '#find_next_gap_after' do
it 'schedules rebalancing if we time-out when finding a gap' do
allow(issue).to receive(:find_next_gap) { raise ActiveRecord::QueryCanceled }
expect(IssueRebalancingWorker).to receive(:perform_async).with(nil, project.id)
expect { issue.move_sequence_after }.to raise_error(ActiveRecord::QueryCanceled)
end
expect { to_move.move_between(lhs, issue) }.to raise_error(ActiveRecord::QueryCanceled)
end
end
......
# frozen_string_literal: true
# Notes for implementing classes:
#
# The following let bindings should be defined:
# - `factory`: A symbol naming a factory to use to create items
# - `default_params`: A HashMap of factory parameters to pass to the factory.
#
# The `default_params` should include the relative parent, so that any item
# created with these parameters passed to the `factory` will be considered in
# the same set of items relative to each other.
#
# For the purposes of efficiency, it is a good idea to bind the parent in
# `let_it_be`, so that it is re-used across examples, but be careful that it
# does not have any other children - it should only be used within this set of
# shared examples.
RSpec.shared_examples 'a class that supports relative positioning' do
let(:item1) { create_item }
let(:item2) { create_item }
let(:new_item) { create_item }
let(:new_item) { create_item(relative_position: nil) }
let(:set_size) { RelativePositioning.mover.context(item1).scoped_items.count }
def create_item(params = {})
create(factory, params.merge(default_params))
......@@ -17,6 +33,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '.move_nulls_to_end' do
let(:item3) { create_item }
let(:sibling_query) { item1.class.relative_positioning_query_base(item1) }
it 'moves items with null relative_position to the end' do
item1.update!(relative_position: 1000)
......@@ -28,10 +45,9 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(items.sort_by(&:relative_position)).to eq(items)
expect(item1.relative_position).to be(1000)
expect(item1.prev_relative_position).to be_nil
expect(item1.next_relative_position).to eq(item2.relative_position)
expect(item2.next_relative_position).to eq(item3.relative_position)
expect(item3.next_relative_position).to be_nil
expect(sibling_query.where(relative_position: nil)).not_to exist
expect(sibling_query.reorder(:relative_position, :id)).to eq([item1, item2, item3])
end
it 'preserves relative position' do
......@@ -120,6 +136,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '.move_nulls_to_start' do
let(:item3) { create_item }
let(:sibling_query) { item1.class.relative_positioning_query_base(item1) }
it 'moves items with null relative_position to the start' do
item1.update!(relative_position: nil)
......@@ -131,10 +148,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.map(&:reload)
expect(items.sort_by(&:relative_position)).to eq(items)
expect(item1.prev_relative_position).to eq nil
expect(item1.next_relative_position).to eq item2.relative_position
expect(item2.next_relative_position).to eq item3.relative_position
expect(item3.next_relative_position).to eq nil
expect(sibling_query.where(relative_position: nil)).not_to exist
expect(sibling_query.reorder(:relative_position, :id)).to eq(items)
expect(item3.relative_position).to be(1000)
end
......@@ -194,194 +209,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
describe '#max_relative_position' do
it 'returns maximum position' do
expect(item1.max_relative_position).to eq item2.relative_position
end
end
describe '#prev_relative_position' do
it 'returns previous position if there is an item above' do
item1.update!(relative_position: 5)
item2.update!(relative_position: 15)
expect(item2.prev_relative_position).to eq item1.relative_position
end
it 'returns nil if there is no item above' do
expect(item1.prev_relative_position).to eq nil
end
end
describe '#next_relative_position' do
it 'returns next position if there is an item below' do
item1.update!(relative_position: 5)
item2.update!(relative_position: 15)
expect(item1.next_relative_position).to eq item2.relative_position
end
it 'returns nil if there is no item below' do
expect(item2.next_relative_position).to eq nil
end
end
describe '#find_next_gap_before' do
context 'there is no gap' do
let(:items) { create_items_with_positions(run_at_start) }
it 'returns nil' do
items.each do |item|
expect(item.send(:find_next_gap_before)).to be_nil
end
end
end
context 'there is a sequence ending at MAX_POSITION' do
let(:items) { create_items_with_positions(run_at_end) }
let(:gaps) do
items.map { |item| item.send(:find_next_gap_before) }
end
it 'can find the gap at the start for any item in the sequence' do
gap = { start: items.first.relative_position, end: RelativePositioning::MIN_POSITION }
expect(gaps).to all(eq(gap))
end
it 'respects lower bounds' do
gap = { start: items.first.relative_position, end: 10 }
new_item.update!(relative_position: 10)
expect(gaps).to all(eq(gap))
end
end
specify do
item1.update!(relative_position: 5)
(0..10).each do |pos|
item2.update!(relative_position: pos)
gap = item2.send(:find_next_gap_before)
expect(gap[:start]).to be <= item2.relative_position
expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP
expect(gap[:start]).to be_valid_position
expect(gap[:end]).to be_valid_position
end
end
it 'deals with there not being any items to the left' do
create_items_with_positions([1, 2, 3])
new_item.update!(relative_position: 0)
expect(new_item.send(:find_next_gap_before)).to eq(start: 0, end: RelativePositioning::MIN_POSITION)
end
it 'finds the next gap to the left, skipping adjacent values' do
create_items_with_positions([1, 9, 10])
new_item.update!(relative_position: 11)
expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 1)
end
it 'finds the next gap to the left' do
create_items_with_positions([2, 10])
new_item.update!(relative_position: 15)
expect(new_item.send(:find_next_gap_before)).to eq(start: 15, end: 10)
new_item.update!(relative_position: 11)
expect(new_item.send(:find_next_gap_before)).to eq(start: 10, end: 2)
new_item.update!(relative_position: 9)
expect(new_item.send(:find_next_gap_before)).to eq(start: 9, end: 2)
new_item.update!(relative_position: 5)
expect(new_item.send(:find_next_gap_before)).to eq(start: 5, end: 2)
end
end
describe '#find_next_gap_after' do
context 'there is no gap' do
let(:items) { create_items_with_positions(run_at_end) }
it 'returns nil' do
items.each do |item|
expect(item.send(:find_next_gap_after)).to be_nil
end
end
end
context 'there is a sequence starting at MIN_POSITION' do
let(:items) { create_items_with_positions(run_at_start) }
let(:gaps) do
items.map { |item| item.send(:find_next_gap_after) }
end
it 'can find the gap at the end for any item in the sequence' do
gap = { start: items.last.relative_position, end: RelativePositioning::MAX_POSITION }
expect(gaps).to all(eq(gap))
end
it 'respects upper bounds' do
gap = { start: items.last.relative_position, end: 10 }
new_item.update!(relative_position: 10)
expect(gaps).to all(eq(gap))
end
end
specify do
item1.update!(relative_position: 5)
(0..10).each do |pos|
item2.update!(relative_position: pos)
gap = item2.send(:find_next_gap_after)
expect(gap[:start]).to be >= item2.relative_position
expect((gap[:end] - gap[:start]).abs).to be >= RelativePositioning::MIN_GAP
expect(gap[:start]).to be_valid_position
expect(gap[:end]).to be_valid_position
end
end
it 'deals with there not being any items to the right' do
create_items_with_positions([1, 2, 3])
new_item.update!(relative_position: 5)
expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: RelativePositioning::MAX_POSITION)
end
it 'finds the next gap to the right, skipping adjacent values' do
create_items_with_positions([1, 2, 10])
new_item.update!(relative_position: 0)
expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10)
end
it 'finds the next gap to the right' do
create_items_with_positions([2, 10])
new_item.update!(relative_position: 0)
expect(new_item.send(:find_next_gap_after)).to eq(start: 0, end: 2)
new_item.update!(relative_position: 1)
expect(new_item.send(:find_next_gap_after)).to eq(start: 2, end: 10)
new_item.update!(relative_position: 3)
expect(new_item.send(:find_next_gap_after)).to eq(start: 3, end: 10)
new_item.update!(relative_position: 5)
expect(new_item.send(:find_next_gap_after)).to eq(start: 5, end: 10)
end
end
describe '#move_before' do
let(:item3) { create(factory, default_params) }
......@@ -446,36 +273,39 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
context 'leap-frogging to the left' do
let(:item3) { create(factory, default_params) }
let(:start) { RelativePositioning::START_POSITION }
before do
start = RelativePositioning::START_POSITION
item1.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 0)
item2.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 1)
item3.update!(relative_position: start - RelativePositioning::IDEAL_DISTANCE * 2)
end
let(:item3) { create(factory, default_params) }
def leap_frog(steps)
a = item1
b = item2
def leap_frog
a, b = [item1.reset, item2.reset].sort_by(&:relative_position)
steps.times do |i|
a.move_before(b)
a.save!
a, b = b, a
end
b.move_before(a)
b.save!
end
it 'can leap-frog STEPS - 1 times before needing to rebalance' do
# This is less efficient than going right, due to the flooring of
# integer division
expect { leap_frog(RelativePositioning::STEPS - 1) }
.not_to change { item3.reload.relative_position }
it 'can leap-frog STEPS times before needing to rebalance' do
expect { RelativePositioning::STEPS.times { leap_frog } }
.to change { item3.reload.relative_position }.by(0)
.and change { item1.reload.relative_position }.by(be < 0)
.and change { item2.reload.relative_position }.by(be < 0)
expect { leap_frog }
.to change { item3.reload.relative_position }.by(be < 0)
end
it 'rebalances after leap-frogging STEPS times' do
expect { leap_frog(RelativePositioning::STEPS) }
.to change { item3.reload.relative_position }
context 'there is no space to the left after moving STEPS times' do
let(:start) { RelativePositioning::MIN_POSITION + (2 * RelativePositioning::IDEAL_DISTANCE) }
it 'rebalances to the right' do
expect { RelativePositioning::STEPS.succ.times { leap_frog } }
.not_to change { item3.reload.relative_position }
end
end
end
end
......@@ -538,25 +368,25 @@ RSpec.shared_examples 'a class that supports relative positioning' do
let(:item3) { create(factory, default_params) }
def leap_frog(steps)
a = item1
b = item2
def leap_frog
a, b = [item1.reset, item2.reset].sort_by(&:relative_position)
steps.times do |i|
a.move_after(b)
a.save!
a, b = b, a
end
a.move_after(b)
a.save!
end
it 'can leap-frog STEPS times before needing to rebalance' do
expect { leap_frog(RelativePositioning::STEPS) }
.not_to change { item3.reload.relative_position }
end
it 'rebalances after STEPS jumps' do
RelativePositioning::STEPS.pred.times do
expect { leap_frog }
.to change { item3.reload.relative_position }.by(0)
.and change { item1.reset.relative_position }.by(be >= 0)
.and change { item2.reset.relative_position }.by(be >= 0)
end
it 'rebalances after leap-frogging STEPS+1 times' do
expect { leap_frog(RelativePositioning::STEPS + 1) }
.to change { item3.reload.relative_position }
expect { leap_frog }
.to change { item3.reload.relative_position }.by(0)
.and change { item1.reset.relative_position }.by(be < 0)
.and change { item2.reset.relative_position }.by(be < 0)
end
end
end
......@@ -569,7 +399,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
it 'places items at most IDEAL_DISTANCE from the start when the range is open' do
n = item1.send(:scoped_items).count
n = set_size
expect([item1, item2].map(&:relative_position)).to all(be >= (RelativePositioning::START_POSITION - (n * RelativePositioning::IDEAL_DISTANCE)))
end
......@@ -620,7 +450,7 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
it 'places items at most IDEAL_DISTANCE from the start when the range is open' do
n = item1.send(:scoped_items).count
n = set_size
expect([item1, item2].map(&:relative_position)).to all(be <= (RelativePositioning::START_POSITION + (n * RelativePositioning::IDEAL_DISTANCE)))
end
......@@ -802,63 +632,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
end
describe '#move_sequence_before' do
it 'moves the whole sequence of items to the middle of the nearest gap' do
items = create_items_with_positions([90, 100, 101, 102])
items.last.move_sequence_before
items.last.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions).to eq([90, 95, 96, 102])
end
it 'raises an error if there is no space' do
items = create_items_with_positions(run_at_start)
expect { items.last.move_sequence_before }.to raise_error(RelativePositioning::NoSpaceLeft)
end
it 'finds a gap if there are unused positions' do
items = create_items_with_positions([100, 101, 102])
items.last.move_sequence_before
items.last.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP
end
end
describe '#move_sequence_after' do
it 'moves the whole sequence of items to the middle of the nearest gap' do
items = create_items_with_positions([100, 101, 102, 110])
items.first.move_sequence_after
items.first.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions).to eq([100, 105, 106, 110])
end
it 'finds a gap if there are unused positions' do
items = create_items_with_positions([100, 101, 102])
items.first.move_sequence_after
items.first.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions.second - positions.first).to be > RelativePositioning::MIN_GAP
end
it 'raises an error if there is no space' do
items = create_items_with_positions(run_at_end)
expect { items.first.move_sequence_after }.to raise_error(RelativePositioning::NoSpaceLeft)
end
end
def be_valid_position
be_between(RelativePositioning::MIN_POSITION, RelativePositioning::MAX_POSITION)
end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment