Commit 890fe7e3 authored by Alex Kalderimis's avatar Alex Kalderimis Committed by Mayra Cabrera

Relative positioning improvements

This expands the usable range of positions to the negative numbers,
uses more precisely chosen gap sizes, and deals more effectively with
edge cases.
parent bbd574e3
......@@ -1324,7 +1324,6 @@ Rails/SaveBang:
- 'spec/support/shared_examples/models/members_notifications_shared_example.rb'
- 'spec/support/shared_examples/models/mentionable_shared_examples.rb'
- 'spec/support/shared_examples/models/project_latest_successful_build_for_shared_examples.rb'
- 'spec/support/shared_examples/models/relative_positioning_shared_examples.rb'
- 'spec/support/shared_examples/models/slack_mattermost_notifications_shared_examples.rb'
- 'spec/support/shared_examples/models/update_project_statistics_shared_examples.rb'
- 'spec/support/shared_examples/models/with_uploads_shared_examples.rb'
......
......@@ -3,11 +3,15 @@
# 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)
# The model must have the following named columns:
# - id: integer
# - relative_position: integer
#
# Setup like this in the body of your class:
# The model must support a concept of siblings via a child->parent relationship,
# to enable rebalancing and `GROUP BY` in queries.
# - example: project -> issues, project is the parent relation (issues table has a parent_id column)
#
# Two class methods must be defined when including this concern:
#
# include RelativePositioning
#
......@@ -24,66 +28,162 @@
module RelativePositioning
extend ActiveSupport::Concern
MIN_POSITION = 0
START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
IDEAL_DISTANCE = 500
STEPS = 10
IDEAL_DISTANCE = 2**(STEPS - 1) + 1
class_methods do
def move_nulls_to_end(objects)
objects = objects.reject(&:relative_position)
return if objects.empty?
MIN_POSITION = Gitlab::Database::MIN_INT_VALUE
START_POSITION = 0
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
self.transaction do
max_relative_position = objects.first.max_relative_position
MAX_GAP = IDEAL_DISTANCE * 2
MIN_GAP = 2
objects.each do |object|
relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION)
object.update_column(:relative_position, relative_position)
NoSpaceLeft = Class.new(StandardError)
max_relative_position = relative_position
end
end
class_methods do
def move_nulls_to_end(objects)
move_nulls(objects, at_end: true)
end
def move_nulls_to_start(objects)
objects = objects.reject(&:relative_position)
return if objects.empty?
self.transaction do
min_relative_position = objects.first.min_relative_position
objects.reverse_each do |object|
relative_position = position_between(MIN_POSITION, min_relative_position || START_POSITION)
object.update_column(:relative_position, relative_position)
min_relative_position = relative_position
end
end
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 are incrementing position by IDEAL_DISTANCE * 2 every time
# when we have enough space. If distance is less than IDEAL_DISTANCE, we are calculating an average number.
# 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
halfway = (pos_after + pos_before) / 2
distance_to_halfway = pos_after - halfway
gap_width = pos_after - pos_before
midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min
if distance_to_halfway < IDEAL_DISTANCE
halfway
else
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
halfway
midpoint
end
else
midpoint
end
end
private
# @api private
def gap_size(object, 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
elsif !at_end && starting_from - total_width <= MIN_POSITION
(starting_from - MIN_POSITION) / gaps
else
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
[[(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
[[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position]
end
end
# @api private
# @param [Array<RelativePositioning>] objects The objects to give positions to. The relative
# order will be preserved (i.e. when this method returns,
# objects.first.relative_position < objects.last.relative_position)
# @param [Boolean] at_end: The placement.
# If `true`, then all objects with `null` positions are placed _after_
# all siblings with positions. If `false`, all objects with `null`
# positions are placed _before_ all siblings with positions.
def move_nulls(objects, at_end:)
objects = objects.reject(&:relative_position)
return if objects.empty?
representative = objects.first
number_of_gaps = objects.size + 1 # 1 at left, one between each, and one at right
position = if at_end
representative.max_relative_position
else
representative.min_relative_position
end
position ||= START_POSITION # If there are no positioned siblings, start from START_POSITION
gap, position = gap_size(representative, gaps: number_of_gaps, at_end: at_end, starting_from: position)
# Raise if we could not make enough space
raise NoSpaceLeft if gap < MIN_GAP
indexed = objects.each_with_index.to_a
starting_from = at_end ? position : position - (gap * number_of_gaps)
# Some classes are polymorphic, and not all siblings are in the same table.
by_model = indexed.group_by { |pair| pair.first.class }
by_model.each do |model, pairs|
model.transaction do
pairs.each_slice(100) do |batch|
# These are known to be integers, one from the DB, and the other
# calculated by us, and thus safe to interpolate
values = batch.map do |obj, i|
pos = starting_from + gap * (i + 1)
obj.relative_position = pos
"(#{obj.id}, #{pos})"
end.join(', ')
model.connection.exec_query(<<~SQL, "UPDATE #{model.table_name} positions")
WITH cte(cte_id, new_pos) AS (
SELECT *
FROM (VALUES #{values}) as t (id, pos)
)
UPDATE #{model.table_name}
SET relative_position = cte.new_pos
FROM cte
WHERE cte_id = id
SQL
end
end
end
end
......@@ -97,11 +197,12 @@ module RelativePositioning
calculate_relative_position('MAX', &block)
end
def prev_relative_position
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
......@@ -109,11 +210,12 @@ module RelativePositioning
prev_pos
end
def next_relative_position
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
......@@ -125,24 +227,44 @@ module RelativePositioning
return move_after(before) unless after
return move_before(after) unless before
# 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
before, after = after, before if after.relative_position < before.relative_position
if (after.relative_position - before.relative_position) < 2
after.move_sequence_before
before.reset
pos_left = before.relative_position
pos_right = after.relative_position
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
self.relative_position = self.class.position_between(before.relative_position, after.relative_position)
new_position = self.class.position_between(pos_left, pos_right)
self.relative_position = new_position
end
def move_after(before = self)
pos_before = before.relative_position
pos_after = before.next_relative_position
pos_after = before.next_relative_position(ignoring: self)
if pos_after && (pos_after - pos_before) < 2
before.move_sequence_after
pos_after = before.next_relative_position
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)
......@@ -150,80 +272,168 @@ module RelativePositioning
def move_before(after = self)
pos_after = after.relative_position
pos_before = after.prev_relative_position
pos_before = after.prev_relative_position(ignoring: self)
if pos_before && (pos_after - pos_before) < 2
after.move_sequence_before
pos_before = after.prev_relative_position
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)
end
def move_to_end
self.relative_position = self.class.position_between(max_relative_position || START_POSITION, MAX_POSITION)
max_pos = max_relative_position
self.relative_position = max_pos.nil? ? START_POSITION : self.class.position_between(max_pos, MAX_POSITION)
end
def move_to_start
self.relative_position = self.class.position_between(min_relative_position || START_POSITION, MIN_POSITION)
min_pos = max_relative_position
self.relative_position = min_pos.nil? ? START_POSITION : self.class.position_between(MIN_POSITION, min_pos)
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 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
# 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)
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 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
# 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)
move_sequence(relative_position, next_gap[:start], delta, include_self)
end
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 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).tap do |gap|
gap[:end] ||= MIN_POSITION
end
end
# 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 }`
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).tap do |gap|
gap[:end] ||= MAX_POSITION
end
find_next_gap(items_with_next_pos, MAX_POSITION)
end
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')
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
{ start: gap[0], end: gap[1] }
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)
......@@ -232,9 +442,10 @@ module RelativePositioning
[delta, IDEAL_DISTANCE].min
end
def move_sequence(start_pos, end_pos, delta)
scoped_items
.where.not(id: self.id)
def move_sequence(start_pos, end_pos, delta, include_self = false)
relation = include_self ? scoped_items : relative_siblings
relation
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
.update_all("relative_position = relative_position + #{delta}")
end
......@@ -255,6 +466,10 @@ module RelativePositioning
.first&.last
end
def relative_siblings(relation = scoped_items)
relation.id_not_in(id)
end
def scoped_items
self.class.relative_positioning_query_base(self)
end
......
---
title: Performance and robustness improvements for relative positioning
merge_request: 37724
author:
type: performance
......@@ -8,8 +8,8 @@ module EpicTreeSorting
class_methods do
def relative_positioning_query_base(object)
from_union([
EpicIssue.select("id, relative_position, epic_id, 'epic_issue' as object_type").in_epic(object.parent_ids),
Epic.select("id, relative_position, parent_id as epic_id, 'epic' as object_type").where(parent_id: object.parent_ids)
EpicIssue.select("id, relative_position, epic_id as parent_id, epic_id, 'epic_issue' as object_type").in_epic(object.parent_ids),
Epic.select("id, relative_position, parent_id, parent_id as epic_id, 'epic' as object_type").where(parent_id: object.parent_ids)
])
end
......@@ -19,11 +19,14 @@ module EpicTreeSorting
end
included do
def move_sequence(start_pos, end_pos, delta)
def move_sequence(start_pos, end_pos, delta, include_self = false)
items_to_update = scoped_items
.select(:id, :object_type)
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
.where.not('object_type = ? AND id = ?', self.class.table_name.singularize, self.id)
unless include_self
items_to_update = items_to_update.where.not('object_type = ? AND id = ?', self.class.table_name.singularize, self.id)
end
items_to_update.group_by { |item| item.object_type }.each do |type, group_items|
ids = group_items.map(&:id)
......
......@@ -28,9 +28,49 @@ RSpec.describe EpicIssue do
context "relative positioning" do
it_behaves_like "a class that supports relative positioning" do
let(:epic) { create(:epic) }
let_it_be(:epic) { create(:epic) }
let(:factory) { :epic_issue }
let(:default_params) { { epic: epic } }
end
context 'with a mixed tree level' do
let_it_be(:epic) { create(:epic) }
let_it_be_with_reload(:left) { create(:epic_issue, epic: epic, relative_position: 100) }
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
[left, middle, right].each(&:reset)
expect(middle.relative_position - left.relative_position).to be > 1
expect(left.relative_position).to be < middle.relative_position
expect(middle.relative_position).to be < right.relative_position
end
it 'can create space by using move_sequence_before' do
right.move_sequence_before
[left, middle, right].each(&:reset)
expect(right.relative_position - middle.relative_position).to be > 1
expect(left.relative_position).to be < middle.relative_position
expect(middle.relative_position).to be < right.relative_position
end
it 'moves nulls to the end' do
leaves = create_list(:epic_issue, 2, epic: epic, relative_position: nil)
nested = create(:epic, group: epic.group, parent: epic, relative_position: nil)
moved = [*leaves, nested]
level = [nested, *leaves, right]
expect do
EpicIssue.move_nulls_to_end(level)
end.not_to change { right.reset.relative_position }
moved.each(&:reset)
expect(moved.map(&:relative_position)).to all(be > right.relative_position)
end
end
end
end
......@@ -616,12 +616,22 @@ RSpec.describe Epic do
end
context "relative positioning" do
context 'there is no parent' do
it_behaves_like "a class that supports relative positioning" do
let(:factory) { :epic }
let(:default_params) { {} }
end
end
context 'there is a parent' do
it_behaves_like "a class that supports relative positioning" do
let_it_be(:parent) { create(:epic) }
let(:factory) { :epic }
let(:default_params) { { parent: parent, group: parent.group } }
end
end
end
describe '.related_issues' do
it 'returns epic issues ordered by relative position' do
epic1 = create(:epic, group: group)
......
......@@ -454,20 +454,14 @@ RSpec.describe Issue do
end
describe 'relative positioning with group boards' do
let(:group) { create(:group) }
let!(:board) { create(:board, group: group) }
let(:project) { create(:project, namespace: group) }
let(:project1) { create(:project, namespace: group) }
let(:issue) { build(:issue, project: project) }
let(:issue1) { build(:issue, project: project1) }
let_it_be(:group) { create(:group) }
let_it_be(:board) { create(:board, group: group) }
let_it_be(:project) { create(:project, namespace: group) }
let_it_be(:project1) { create(:project, namespace: group) }
let_it_be_with_reload(:issue) { create(:issue, project: project) }
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) }
before do
[issue, issue1].each do |issue|
issue.move_to_end && issue.save
end
end
describe '#max_relative_position' do
it 'returns maximum position' do
expect(issue.max_relative_position).to eq issue1.relative_position
......@@ -546,18 +540,20 @@ RSpec.describe Issue do
issue1.update relative_position: issue.relative_position
new_issue.move_between(issue, issue1)
[issue, issue1].each(&:reset)
expect(new_issue.relative_position).to be > issue.relative_position
expect(issue.relative_position).to be < issue1.relative_position
expect(new_issue.relative_position)
.to be_between(issue.relative_position, issue1.relative_position).exclusive
end
it 'positions issues between other two if distance is 1' do
issue1.update relative_position: issue.relative_position + 1
new_issue.move_between(issue, issue1)
[issue, issue1].each(&:reset)
expect(new_issue.relative_position).to be > issue.relative_position
expect(issue.relative_position).to be < issue1.relative_position
expect(new_issue.relative_position)
.to be_between(issue.relative_position, issue1.relative_position).exclusive
end
it 'positions issue in the middle of other two if distance is big enough' do
......@@ -566,24 +562,20 @@ RSpec.describe Issue do
new_issue.move_between(issue, issue1)
expect(new_issue.relative_position).to eq(8000)
expect(new_issue.relative_position)
.to be_between(issue.relative_position, issue1.relative_position).exclusive
end
it 'positions issue closer to the middle if we are at the very top' do
issue1.update relative_position: 6000
new_issue.move_between(nil, issue1)
new_issue.move_between(nil, issue)
expect(new_issue.relative_position).to eq(6000 - RelativePositioning::IDEAL_DISTANCE)
expect(new_issue.relative_position).to eq(issue.relative_position - RelativePositioning::IDEAL_DISTANCE)
end
it 'positions issue closer to the middle if we are at the very bottom' do
issue.update relative_position: 6000
issue1.update relative_position: nil
new_issue.move_between(issue, nil)
new_issue.move_between(issue1, nil)
expect(new_issue.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE)
expect(new_issue.relative_position).to eq(issue1.relative_position + RelativePositioning::IDEAL_DISTANCE)
end
it 'positions issue in the middle of other two if distance is not big enough' do
......@@ -600,8 +592,10 @@ RSpec.describe Issue do
issue1.update relative_position: 101
new_issue.move_between(issue, issue1)
[issue, issue1].each(&:reset)
expect(new_issue.relative_position).to be_between(issue.relative_position, issue1.relative_position)
expect(new_issue.relative_position)
.to be_between(issue.relative_position, issue1.relative_position).exclusive
end
it 'uses rebalancing if there is no place' do
......@@ -612,12 +606,15 @@ RSpec.describe Issue do
new_issue.move_between(issue1, issue2)
new_issue.save!
[issue, issue1, issue2].each(&:reset)
expect(new_issue.relative_position)
.to be_between(issue1.relative_position, issue2.relative_position).exclusive
expect(new_issue.relative_position).to be_between(issue1.relative_position, issue2.relative_position)
expect(issue.reload.relative_position).not_to eq(100)
expect([issue, issue1, issue2, new_issue].map(&:relative_position).uniq).to have_attributes(size: 4)
end
it 'positions issue right if we pass none-sequential parameters' do
it 'positions issue right if we pass non-sequential parameters' do
issue.update relative_position: 99
issue1.update relative_position: 101
issue2 = create(:issue, relative_position: 102, project: project)
......
......@@ -169,7 +169,9 @@ RSpec.describe EpicIssues::CreateService do
end
context 'when multiple valid issues are given' do
subject { assign_issue([issue.to_reference(full: true), issue2.to_reference(full: true)]) }
let(:references) { [issue, issue2].map { |i| i.to_reference(full: true) } }
subject { assign_issue(references) }
let(:created_link1) { EpicIssue.find_by!(issue_id: issue.id) }
let(:created_link2) { EpicIssue.find_by!(issue_id: issue2.id) }
......@@ -181,13 +183,18 @@ RSpec.describe EpicIssues::CreateService do
expect(created_link2).to have_attributes(epic: epic)
end
it 'places each issue at the start' do
subject
expect(created_link2.relative_position).to be < created_link1.relative_position
end
it 'orders the epic issues to the first place and moves the existing ones down' do
existing_link = create(:epic_issue, epic: epic, issue: issue3)
subject
expect(created_link2.relative_position).to be < created_link1.relative_position
expect(created_link1.relative_position).to be < existing_link.reload.relative_position
expect([created_link1, created_link2].map(&:relative_position))
.to all(be < existing_link.reset.relative_position)
end
it 'returns success status' do
......
......@@ -22,6 +22,7 @@ module Gitlab
# https://www.postgresql.org/docs/9.2/static/datatype-numeric.html
MAX_INT_VALUE = 2147483647
MIN_INT_VALUE = -2147483648
# The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz:
# https://www.postgresql.org/docs/9.1/static/datatype-datetime.html
......
......@@ -21,7 +21,7 @@ RSpec.describe 'Issue Boards', :js do
end
context 'un-ordered issues' do
let!(:issue4) { create(:labeled_issue, project: project, labels: [label]) }
let!(:issue4) { create(:labeled_issue, project: project, labels: [label], relative_position: nil) }
before do
visit project_board_path(project, board)
......
......@@ -23,7 +23,7 @@ RSpec.describe Analytics::CycleAnalytics::ProjectStage do
context 'relative positioning' do
it_behaves_like 'a class that supports relative positioning' do
let(:project) { build(:project) }
let_it_be(:project) { create(:project) }
let(:factory) { :cycle_analytics_project_stage }
let(:default_params) { { project: project } }
end
......
......@@ -6,6 +6,7 @@ RSpec.describe Issue do
include ExternalAuthorizationServiceHelpers
let_it_be(:user) { create(:user) }
let_it_be(:reusable_project) { create(:project) }
describe "Associations" do
it { is_expected.to belong_to(:milestone) }
......@@ -145,13 +146,13 @@ RSpec.describe Issue do
end
describe '#order_by_position_and_priority' do
let(:project) { create :project }
let(:project) { reusable_project }
let(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) }
let!(:issue3) { create(:issue, project: project, relative_position: 100) }
let!(:issue4) { create(:issue, project: project, relative_position: 200) }
let!(:issue3) { create(:issue, project: project, relative_position: -200) }
let!(:issue4) { create(:issue, project: project, relative_position: -100) }
it 'returns ordered list' do
expect(project.issues.order_by_position_and_priority)
......@@ -160,10 +161,10 @@ RSpec.describe Issue do
end
describe '#sort' do
let(:project) { create(:project) }
let(:project) { reusable_project }
context "by relative_position" do
let!(:issue) { create(:issue, project: project) }
let!(:issue) { create(:issue, project: project, relative_position: nil) }
let!(:issue2) { create(:issue, project: project, relative_position: 2) }
let!(:issue3) { create(:issue, project: project, relative_position: 1) }
......@@ -1027,7 +1028,7 @@ RSpec.describe Issue do
context "relative positioning" do
it_behaves_like "a class that supports relative positioning" do
let(:project) { create(:project) }
let_it_be(:project) { create(:project) }
let(:factory) { :issue }
let(:default_params) { { project: project } }
end
......
......@@ -25,7 +25,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items = [item1, item2, item3]
described_class.move_nulls_to_end(items)
items.map(&:reload)
expect(items.sort_by(&:relative_position)).to eq(items)
expect(item1.relative_position).to be(1000)
......@@ -35,22 +34,57 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(item3.next_relative_position).to be_nil
end
it 'preserves relative position' do
item1.update!(relative_position: nil)
item2.update!(relative_position: nil)
described_class.move_nulls_to_end([item1, item2])
expect(item1.relative_position).to be < item2.relative_position
end
it 'moves the item near the start position when there are no existing positions' do
item1.update!(relative_position: nil)
described_class.move_nulls_to_end([item1])
item1.reload
expect(item1.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE)
expect(item1.reset.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE)
end
it 'does not perform any moves if all items have their relative_position set' do
item1.update!(relative_position: 1)
expect do
described_class.move_nulls_to_end([item1])
item1.reload
end.not_to change { item1.reset.relative_position }
end
expect(item1.relative_position).to be(1)
it 'manages to move nulls to the end even if there is a sequence at the end' do
bunch = create_items_with_positions(run_at_end)
item1.update!(relative_position: nil)
described_class.move_nulls_to_end([item1])
items = [*bunch, item1]
items.each(&:reset)
expect(items.map(&:relative_position)).to all(be_valid_position)
expect(items.sort_by(&:relative_position)).to eq(items)
end
it 'does not have an N+1 issue' do
create_items_with_positions(10..12)
a, b, c, d, e, f = create_items_with_positions([nil, nil, nil, nil, nil, nil])
baseline = ActiveRecord::QueryRecorder.new do
described_class.move_nulls_to_end([a, e])
end
expect { described_class.move_nulls_to_end([b, c, d]) }
.not_to exceed_query_limit(baseline)
expect { described_class.move_nulls_to_end([f]) }
.not_to exceed_query_limit(baseline.count)
end
end
......@@ -78,11 +112,19 @@ RSpec.shared_examples 'a class that supports relative positioning' do
item1.update!(relative_position: nil)
described_class.move_nulls_to_start([item1])
item1.reload
expect(item1.relative_position).to eq(described_class::START_POSITION - described_class::IDEAL_DISTANCE)
end
it 'preserves relative position' do
item1.update!(relative_position: nil)
item2.update!(relative_position: nil)
described_class.move_nulls_to_end([item1, item2])
expect(item1.relative_position).to be < item2.relative_position
end
it 'does not perform any moves if all items have their relative_position set' do
item1.update!(relative_position: 1)
......@@ -101,8 +143,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
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)
item1.update!(relative_position: 5)
item2.update!(relative_position: 15)
expect(item2.prev_relative_position).to eq item1.relative_position
end
......@@ -114,8 +156,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
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)
item1.update!(relative_position: 5)
item2.update!(relative_position: 15)
expect(item1.next_relative_position).to eq item2.relative_position
end
......@@ -125,9 +167,172 @@ RSpec.shared_examples 'a class that supports relative positioning' do
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) }
it 'moves item before' do
[item2, item1].each(&:move_to_end)
[item2, item1].each do |item|
item.move_to_end
item.save!
end
expect(item1.relative_position).to be > item2.relative_position
item1.move_before(item2)
......@@ -135,12 +340,10 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
context 'when there is no space' do
let(:item3) { create(factory, default_params) }
before do
item1.update(relative_position: 1000)
item2.update(relative_position: 1001)
item3.update(relative_position: 1002)
item1.update!(relative_position: 1000)
item2.update!(relative_position: 1001)
item3.update!(relative_position: 1002)
end
it 'moves items correctly' do
......@@ -149,6 +352,73 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(item3.relative_position).to be_between(item1.reload.relative_position, item2.reload.relative_position).exclusive
end
end
it 'can move the item before an item at the start' do
item1.update!(relative_position: RelativePositioning::START_POSITION)
new_item.move_before(item1)
expect(new_item.relative_position).to be_valid_position
expect(new_item.relative_position).to be < item1.reload.relative_position
end
it 'can move the item before an item at MIN_POSITION' do
item1.update!(relative_position: RelativePositioning::MIN_POSITION)
new_item.move_before(item1)
expect(new_item.relative_position).to be >= RelativePositioning::MIN_POSITION
expect(new_item.relative_position).to be < item1.reload.relative_position
end
it 'can move the item before an item bunched up at MIN_POSITION' do
item1, item2, item3 = create_items_with_positions(run_at_start)
new_item.move_before(item3)
new_item.save!
items = [item1, item2, new_item, item3]
items.each do |item|
expect(item.reset.relative_position).to be_valid_position
end
expect(items.sort_by(&:relative_position)).to eq(items)
end
context 'leap-frogging to the left' do
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
steps.times do |i|
a.move_before(b)
a.save!
a, b = b, a
end
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 }
end
it 'rebalances after leap-frogging STEPS times' do
expect { leap_frog(RelativePositioning::STEPS) }
.to change { item3.reload.relative_position }
end
end
end
describe '#move_after' do
......@@ -164,9 +434,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do
let(:item3) { create(factory, default_params) }
before do
item1.update(relative_position: 1000)
item2.update(relative_position: 1001)
item3.update(relative_position: 1002)
item1.update!(relative_position: 1000)
item2.update!(relative_position: 1001)
item3.update!(relative_position: 1002)
end
it 'can move the item after an item at MAX_POSITION' do
item1.update!(relative_position: RelativePositioning::MAX_POSITION)
new_item.move_after(item1)
expect(new_item.relative_position).to be_valid_position
expect(new_item.relative_position).to be > item1.reset.relative_position
end
it 'moves items correctly' do
......@@ -175,6 +453,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(item1.relative_position).to be_between(item2.reload.relative_position, item3.reload.relative_position).exclusive
end
end
it 'can move the item after an item bunched up at MAX_POSITION' do
item1, item2, item3 = create_items_with_positions(run_at_end)
new_item.move_after(item1)
new_item.save!
items = [item1, new_item, item2, item3]
items.each do |item|
expect(item.reset.relative_position).to be_valid_position
end
expect(items.sort_by(&:relative_position)).to eq(items)
end
context 'leap-frogging' do
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
steps.times do |i|
a.move_after(b)
a.save!
a, b = b, a
end
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 leap-frogging STEPS+1 times' do
expect { leap_frog(RelativePositioning::STEPS + 1) }
.to change { item3.reload.relative_position }
end
end
end
describe '#move_to_end' do
......@@ -193,8 +518,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '#move_between' do
before do
[item1, item2].each do |item1|
item1.move_to_end && item1.save
[item1, item2].each do |item|
item.move_to_end && item.save!
end
end
shared_examples 'moves item between' do
it 'moves the middle item to between left and right' do
expect do
middle.move_between(left, right)
middle.save!
end.to change { between_exclusive?(left, middle, right) }.from(false).to(true)
end
end
......@@ -218,26 +552,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
it 'positions items even when after and before positions are the same' do
item2.update relative_position: item1.relative_position
item2.update! relative_position: item1.relative_position
new_item.move_between(item1, item2)
[item1, item2].each(&:reset)
expect(new_item.relative_position).to be > item1.relative_position
expect(item1.relative_position).to be < item2.relative_position
end
it 'positions items between other two if distance is 1' do
item2.update relative_position: item1.relative_position + 1
new_item.move_between(item1, item2)
context 'the two items are next to each other' do
let(:left) { item1 }
let(:middle) { new_item }
let(:right) { create_item(relative_position: item1.relative_position + 1) }
expect(new_item.relative_position).to be > item1.relative_position
expect(item1.relative_position).to be < item2.relative_position
it_behaves_like 'moves item between'
end
it 'positions item in the middle of other two if distance is big enough' do
item1.update relative_position: 6000
item2.update relative_position: 10000
item1.update! relative_position: 6000
item2.update! relative_position: 10000
new_item.move_between(item1, item2)
......@@ -245,7 +579,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
it 'positions item closer to the middle if we are at the very top' do
item2.update relative_position: 6000
item1.update!(relative_position: 6001)
item2.update!(relative_position: 6000)
new_item.move_between(nil, item2)
......@@ -253,51 +588,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end
it 'positions item closer to the middle if we are at the very bottom' do
new_item.update relative_position: 1
item1.update relative_position: 6000
item2.destroy
new_item.update!(relative_position: 1)
item1.update!(relative_position: 6000)
item2.update!(relative_position: 5999)
new_item.move_between(item1, nil)
expect(new_item.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE)
end
it 'positions item in the middle of other two if distance is not big enough' do
item1.update relative_position: 100
item2.update relative_position: 400
it 'positions item in the middle of other two' do
item1.update! relative_position: 100
item2.update! relative_position: 400
new_item.move_between(item1, item2)
expect(new_item.relative_position).to eq(250)
end
it 'positions item in the middle of other two is there is no place' do
item1.update relative_position: 100
item2.update relative_position: 101
new_item.move_between(item1, item2)
context 'there is no space' do
let(:middle) { new_item }
let(:left) { create_item(relative_position: 100) }
let(:right) { create_item(relative_position: 101) }
expect(new_item.relative_position).to be_between(item1.relative_position, item2.relative_position).exclusive
it_behaves_like 'moves item between'
end
it 'uses rebalancing if there is no place' do
item1.update relative_position: 100
item2.update relative_position: 101
item3 = create_item(relative_position: 102)
new_item.update relative_position: 103
context 'there is a bunch of items' do
let(:items) { create_items_with_positions(100..104) }
let(:left) { items[1] }
let(:middle) { items[3] }
let(:right) { items[2] }
new_item.move_between(item2, item3)
new_item.save!
it_behaves_like 'moves item between'
expect(new_item.relative_position).to be_between(item2.relative_position, item3.relative_position).exclusive
expect(item1.reload.relative_position).not_to eq(100)
it 'handles bunches correctly' do
middle.move_between(left, right)
middle.save!
expect(items.first.reset.relative_position).to be < middle.relative_position
end
end
it 'positions item right if we pass none-sequential parameters' do
item1.update relative_position: 99
item2.update relative_position: 101
it 'positions item right if we pass non-sequential parameters' do
item1.update! relative_position: 99
item2.update! relative_position: 101
item3 = create_item(relative_position: 102)
new_item.update relative_position: 103
new_item.update! relative_position: 103
new_item.move_between(item1, item3)
new_item.save!
......@@ -329,6 +666,12 @@ RSpec.shared_examples 'a class that supports relative positioning' do
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])
......@@ -336,7 +679,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.last.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions).to eq([50, 51, 102])
expect(positions.last - positions.second).to be > RelativePositioning::MIN_GAP
end
end
......@@ -358,7 +702,33 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.first.save!
positions = items.map { |item| item.reload.relative_position }
expect(positions).to eq([100, 601, 602])
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
def between_exclusive?(left, middle, right)
a, b, c = [left, middle, right].map { |item| item.reset.relative_position }
return false if a.nil? || b.nil?
return a < b if c.nil?
a < b && b < c
end
def run_at_end(size = 3)
(RelativePositioning::MAX_POSITION - size)..RelativePositioning::MAX_POSITION
end
def run_at_start(size = 3)
(RelativePositioning::MIN_POSITION..).take(size)
end
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