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: ...@@ -1324,7 +1324,6 @@ Rails/SaveBang:
- 'spec/support/shared_examples/models/members_notifications_shared_example.rb' - 'spec/support/shared_examples/models/members_notifications_shared_example.rb'
- 'spec/support/shared_examples/models/mentionable_shared_examples.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/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/slack_mattermost_notifications_shared_examples.rb'
- 'spec/support/shared_examples/models/update_project_statistics_shared_examples.rb' - 'spec/support/shared_examples/models/update_project_statistics_shared_examples.rb'
- 'spec/support/shared_examples/models/with_uploads_shared_examples.rb' - 'spec/support/shared_examples/models/with_uploads_shared_examples.rb'
......
...@@ -3,11 +3,15 @@ ...@@ -3,11 +3,15 @@
# This module makes it possible to handle items as a list, where the order of items can be easily altered # This module makes it possible to handle items as a list, where the order of items can be easily altered
# Requirements: # Requirements:
# #
# - Only works for ActiveRecord models # The model must have the following named columns:
# - relative_position integer field must present on the model # - id: integer
# - 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) # - 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 # include RelativePositioning
# #
...@@ -24,66 +28,162 @@ ...@@ -24,66 +28,162 @@
module RelativePositioning module RelativePositioning
extend ActiveSupport::Concern extend ActiveSupport::Concern
MIN_POSITION = 0 STEPS = 10
START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2 IDEAL_DISTANCE = 2**(STEPS - 1) + 1
MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
IDEAL_DISTANCE = 500
class_methods do MIN_POSITION = Gitlab::Database::MIN_INT_VALUE
def move_nulls_to_end(objects) START_POSITION = 0
objects = objects.reject(&:relative_position) MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
return if objects.empty?
self.transaction do MAX_GAP = IDEAL_DISTANCE * 2
max_relative_position = objects.first.max_relative_position MIN_GAP = 2
objects.each do |object| NoSpaceLeft = Class.new(StandardError)
relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION)
object.update_column(:relative_position, relative_position)
max_relative_position = relative_position class_methods do
end def move_nulls_to_end(objects)
end move_nulls(objects, at_end: true)
end end
def move_nulls_to_start(objects) def move_nulls_to_start(objects)
objects = objects.reject(&:relative_position) move_nulls(objects, at_end: false)
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
end end
# This method takes two integer values (positions) and # This method takes two integer values (positions) and
# calculates the position between them. The range is huge as # 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 # the maximum integer value is 2147483647.
# when we have enough space. If distance is less than IDEAL_DISTANCE, we are calculating an average number. #
# 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) def position_between(pos_before, pos_after)
pos_before ||= MIN_POSITION pos_before ||= MIN_POSITION
pos_after ||= MAX_POSITION pos_after ||= MAX_POSITION
pos_before, pos_after = [pos_before, pos_after].sort pos_before, pos_after = [pos_before, pos_after].sort
halfway = (pos_after + pos_before) / 2 gap_width = pos_after - pos_before
distance_to_halfway = pos_after - halfway midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min
if distance_to_halfway < IDEAL_DISTANCE if gap_width < MIN_GAP
halfway raise NoSpaceLeft
else elsif gap_width > MAX_GAP
if pos_before == MIN_POSITION if pos_before == MIN_POSITION
pos_after - IDEAL_DISTANCE pos_after - IDEAL_DISTANCE
elsif pos_after == MAX_POSITION elsif pos_after == MAX_POSITION
pos_before + IDEAL_DISTANCE pos_before + IDEAL_DISTANCE
else 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 end
end end
...@@ -97,11 +197,12 @@ module RelativePositioning ...@@ -97,11 +197,12 @@ module RelativePositioning
calculate_relative_position('MAX', &block) calculate_relative_position('MAX', &block)
end end
def prev_relative_position def prev_relative_position(ignoring: nil)
prev_pos = nil prev_pos = nil
if self.relative_position if self.relative_position
prev_pos = max_relative_position do |relation| prev_pos = max_relative_position do |relation|
relation = relation.id_not_in(ignoring.id) if ignoring.present?
relation.where('relative_position < ?', self.relative_position) relation.where('relative_position < ?', self.relative_position)
end end
end end
...@@ -109,11 +210,12 @@ module RelativePositioning ...@@ -109,11 +210,12 @@ module RelativePositioning
prev_pos prev_pos
end end
def next_relative_position def next_relative_position(ignoring: nil)
next_pos = nil next_pos = nil
if self.relative_position if self.relative_position
next_pos = min_relative_position do |relation| next_pos = min_relative_position do |relation|
relation = relation.id_not_in(ignoring.id) if ignoring.present?
relation.where('relative_position > ?', self.relative_position) relation.where('relative_position > ?', self.relative_position)
end end
end end
...@@ -125,24 +227,44 @@ module RelativePositioning ...@@ -125,24 +227,44 @@ module RelativePositioning
return move_after(before) unless after return move_after(before) unless after
return move_before(after) unless before 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 before, after = after, before if after.relative_position < before.relative_position
if (after.relative_position - before.relative_position) < 2
after.move_sequence_before pos_left = before.relative_position
before.reset 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 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 end
def move_after(before = self) def move_after(before = self)
pos_before = before.relative_position 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 if pos_before == MAX_POSITION || gap_too_small?(pos_after, pos_before)
before.move_sequence_after gap = before.send(:find_next_gap_after) # rubocop:disable GitlabSecurity/PublicSend
pos_after = before.next_relative_position
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 end
self.relative_position = self.class.position_between(pos_before, pos_after) self.relative_position = self.class.position_between(pos_before, pos_after)
...@@ -150,80 +272,168 @@ module RelativePositioning ...@@ -150,80 +272,168 @@ module RelativePositioning
def move_before(after = self) def move_before(after = self)
pos_after = after.relative_position 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 if pos_after == MIN_POSITION || gap_too_small?(pos_before, pos_after)
after.move_sequence_before gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend
pos_before = after.prev_relative_position
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 end
self.relative_position = self.class.position_between(pos_before, pos_after) self.relative_position = self.class.position_between(pos_before, pos_after)
end end
def move_to_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 end
def move_to_start 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 end
# Moves the sequence before the current item to the middle of the next gap # 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 # For example, we have
# This moves the sequence 11 12 13 14 to 8 9 10 11 #
def move_sequence_before # 5 . . . . . 11 12 13 14 [15] 16 . 17
next_gap = find_next_gap_before # -----------
#
# 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) 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 end
# Moves the sequence after the current item to the middle of the next gap # 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 # For example, we have:
# This moves the sequence 12 13 14 15 to 15 16 17 18 #
def move_sequence_after # 8 . 10 [11] 12 13 14 15 . . . . . 21
next_gap = find_next_gap_after # -----------
#
# 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) 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 end
private private
# Supposing that we have a sequence of items: 1 5 11 12 13 and the current item is 13 def gap_too_small?(pos_a, pos_b)
# This would return: `{ start: 11, end: 5 }` 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 def find_next_gap_before
items_with_next_pos = scoped_items items_with_next_pos = scoped_items
.select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos') .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos')
.where('relative_position <= ?', relative_position) .where('relative_position <= ?', relative_position)
.order(relative_position: :desc) .order(relative_position: :desc)
find_next_gap(items_with_next_pos).tap do |gap| find_next_gap(items_with_next_pos, MIN_POSITION)
gap[:end] ||= MIN_POSITION end
end
end # Find the first suitable gap to the right of the current position.
#
# Supposing that we have a sequence of items: 13 14 15 20 24 and the current item is 13 # Satisfies the relations:
# This would return: `{ start: 15, end: 20 }` # - 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 def find_next_gap_after
items_with_next_pos = scoped_items items_with_next_pos = scoped_items
.select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos') .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos')
.where('relative_position >= ?', relative_position) .where('relative_position >= ?', relative_position)
.order(:relative_position) .order(:relative_position)
find_next_gap(items_with_next_pos).tap do |gap| find_next_gap(items_with_next_pos, MAX_POSITION)
gap[:end] ||= MAX_POSITION
end
end end
def find_next_gap(items_with_next_pos) def find_next_gap(items_with_next_pos, end_is_nil)
gap = self.class.from(items_with_next_pos, :items_with_next_pos) gap = self.class
.where('ABS(pos - next_pos) > 1 OR next_pos IS NULL') .from(items_with_next_pos, :items)
.where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP)
.limit(1) .limit(1)
.pluck(:pos, :next_pos) .pluck(:pos, :next_pos)
.first .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 end
def optimum_delta_for_gap(gap) def optimum_delta_for_gap(gap)
...@@ -232,9 +442,10 @@ module RelativePositioning ...@@ -232,9 +442,10 @@ module RelativePositioning
[delta, IDEAL_DISTANCE].min [delta, IDEAL_DISTANCE].min
end end
def move_sequence(start_pos, end_pos, delta) def move_sequence(start_pos, end_pos, delta, include_self = false)
scoped_items relation = include_self ? scoped_items : relative_siblings
.where.not(id: self.id)
relation
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos) .where('relative_position BETWEEN ? AND ?', start_pos, end_pos)
.update_all("relative_position = relative_position + #{delta}") .update_all("relative_position = relative_position + #{delta}")
end end
...@@ -255,6 +466,10 @@ module RelativePositioning ...@@ -255,6 +466,10 @@ module RelativePositioning
.first&.last .first&.last
end end
def relative_siblings(relation = scoped_items)
relation.id_not_in(id)
end
def scoped_items def scoped_items
self.class.relative_positioning_query_base(self) self.class.relative_positioning_query_base(self)
end end
......
---
title: Performance and robustness improvements for relative positioning
merge_request: 37724
author:
type: performance
...@@ -8,8 +8,8 @@ module EpicTreeSorting ...@@ -8,8 +8,8 @@ module EpicTreeSorting
class_methods do class_methods do
def relative_positioning_query_base(object) def relative_positioning_query_base(object)
from_union([ from_union([
EpicIssue.select("id, relative_position, epic_id, 'epic_issue' as object_type").in_epic(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 as epic_id, 'epic' as object_type").where(parent_id: 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 end
...@@ -19,11 +19,14 @@ module EpicTreeSorting ...@@ -19,11 +19,14 @@ module EpicTreeSorting
end end
included do 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 items_to_update = scoped_items
.select(:id, :object_type) .select(:id, :object_type)
.where('relative_position BETWEEN ? AND ?', start_pos, end_pos) .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| items_to_update.group_by { |item| item.object_type }.each do |type, group_items|
ids = group_items.map(&:id) ids = group_items.map(&:id)
......
...@@ -28,9 +28,49 @@ RSpec.describe EpicIssue do ...@@ -28,9 +28,49 @@ RSpec.describe EpicIssue do
context "relative positioning" do context "relative positioning" do
it_behaves_like "a class that supports 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(:factory) { :epic_issue }
let(:default_params) { { epic: epic } } let(:default_params) { { epic: epic } }
end 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
end end
...@@ -616,12 +616,22 @@ RSpec.describe Epic do ...@@ -616,12 +616,22 @@ RSpec.describe Epic do
end end
context "relative positioning" do context "relative positioning" do
context 'there is no parent' do
it_behaves_like "a class that supports relative positioning" do it_behaves_like "a class that supports relative positioning" do
let(:factory) { :epic } let(:factory) { :epic }
let(:default_params) { {} } let(:default_params) { {} }
end end
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 describe '.related_issues' do
it 'returns epic issues ordered by relative position' do it 'returns epic issues ordered by relative position' do
epic1 = create(:epic, group: group) epic1 = create(:epic, group: group)
......
...@@ -454,20 +454,14 @@ RSpec.describe Issue do ...@@ -454,20 +454,14 @@ RSpec.describe Issue do
end end
describe 'relative positioning with group boards' do describe 'relative positioning with group boards' do
let(:group) { create(:group) } let_it_be(:group) { create(:group) }
let!(:board) { create(:board, group: group) } let_it_be(:board) { create(:board, group: group) }
let(:project) { create(:project, namespace: group) } let_it_be(:project) { create(:project, namespace: group) }
let(:project1) { create(:project, namespace: group) } let_it_be(:project1) { create(:project, namespace: group) }
let(:issue) { build(:issue, project: project) } let_it_be_with_reload(:issue) { create(:issue, project: project) }
let(:issue1) { build(:issue, project: project1) } 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) } 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 describe '#max_relative_position' do
it 'returns maximum position' do it 'returns maximum position' do
expect(issue.max_relative_position).to eq issue1.relative_position expect(issue.max_relative_position).to eq issue1.relative_position
...@@ -546,18 +540,20 @@ RSpec.describe Issue do ...@@ -546,18 +540,20 @@ RSpec.describe Issue do
issue1.update relative_position: issue.relative_position issue1.update relative_position: issue.relative_position
new_issue.move_between(issue, issue1) new_issue.move_between(issue, issue1)
[issue, issue1].each(&:reset)
expect(new_issue.relative_position).to be > issue.relative_position expect(new_issue.relative_position)
expect(issue.relative_position).to be < issue1.relative_position .to be_between(issue.relative_position, issue1.relative_position).exclusive
end end
it 'positions issues between other two if distance is 1' do it 'positions issues between other two if distance is 1' do
issue1.update relative_position: issue.relative_position + 1 issue1.update relative_position: issue.relative_position + 1
new_issue.move_between(issue, issue1) new_issue.move_between(issue, issue1)
[issue, issue1].each(&:reset)
expect(new_issue.relative_position).to be > issue.relative_position expect(new_issue.relative_position)
expect(issue.relative_position).to be < issue1.relative_position .to be_between(issue.relative_position, issue1.relative_position).exclusive
end end
it 'positions issue in the middle of other two if distance is big enough' do it 'positions issue in the middle of other two if distance is big enough' do
...@@ -566,24 +562,20 @@ RSpec.describe Issue do ...@@ -566,24 +562,20 @@ RSpec.describe Issue do
new_issue.move_between(issue, issue1) 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 end
it 'positions issue closer to the middle if we are at the very top' do 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, issue)
new_issue.move_between(nil, issue1)
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 end
it 'positions issue closer to the middle if we are at the very bottom' do it 'positions issue closer to the middle if we are at the very bottom' do
issue.update relative_position: 6000 new_issue.move_between(issue1, nil)
issue1.update relative_position: nil
new_issue.move_between(issue, 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 end
it 'positions issue in the middle of other two if distance is not big enough' do it 'positions issue in the middle of other two if distance is not big enough' do
...@@ -600,8 +592,10 @@ RSpec.describe Issue do ...@@ -600,8 +592,10 @@ RSpec.describe Issue do
issue1.update relative_position: 101 issue1.update relative_position: 101
new_issue.move_between(issue, issue1) 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 end
it 'uses rebalancing if there is no place' do it 'uses rebalancing if there is no place' do
...@@ -612,12 +606,15 @@ RSpec.describe Issue do ...@@ -612,12 +606,15 @@ RSpec.describe Issue do
new_issue.move_between(issue1, issue2) new_issue.move_between(issue1, issue2)
new_issue.save! 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, issue1, issue2, new_issue].map(&:relative_position).uniq).to have_attributes(size: 4)
expect(issue.reload.relative_position).not_to eq(100)
end 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 issue.update relative_position: 99
issue1.update relative_position: 101 issue1.update relative_position: 101
issue2 = create(:issue, relative_position: 102, project: project) issue2 = create(:issue, relative_position: 102, project: project)
......
...@@ -169,7 +169,9 @@ RSpec.describe EpicIssues::CreateService do ...@@ -169,7 +169,9 @@ RSpec.describe EpicIssues::CreateService do
end end
context 'when multiple valid issues are given' do 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_link1) { EpicIssue.find_by!(issue_id: issue.id) }
let(:created_link2) { EpicIssue.find_by!(issue_id: issue2.id) } let(:created_link2) { EpicIssue.find_by!(issue_id: issue2.id) }
...@@ -181,13 +183,18 @@ RSpec.describe EpicIssues::CreateService do ...@@ -181,13 +183,18 @@ RSpec.describe EpicIssues::CreateService do
expect(created_link2).to have_attributes(epic: epic) expect(created_link2).to have_attributes(epic: epic)
end 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 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) existing_link = create(:epic_issue, epic: epic, issue: issue3)
subject subject
expect(created_link2.relative_position).to be < created_link1.relative_position expect([created_link1, created_link2].map(&:relative_position))
expect(created_link1.relative_position).to be < existing_link.reload.relative_position .to all(be < existing_link.reset.relative_position)
end end
it 'returns success status' do it 'returns success status' do
......
...@@ -22,6 +22,7 @@ module Gitlab ...@@ -22,6 +22,7 @@ module Gitlab
# https://www.postgresql.org/docs/9.2/static/datatype-numeric.html # https://www.postgresql.org/docs/9.2/static/datatype-numeric.html
MAX_INT_VALUE = 2147483647 MAX_INT_VALUE = 2147483647
MIN_INT_VALUE = -2147483648
# The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz: # The max value between MySQL's TIMESTAMP and PostgreSQL's timestampz:
# https://www.postgresql.org/docs/9.1/static/datatype-datetime.html # https://www.postgresql.org/docs/9.1/static/datatype-datetime.html
......
...@@ -21,7 +21,7 @@ RSpec.describe 'Issue Boards', :js do ...@@ -21,7 +21,7 @@ RSpec.describe 'Issue Boards', :js do
end end
context 'un-ordered issues' do 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 before do
visit project_board_path(project, board) visit project_board_path(project, board)
......
...@@ -23,7 +23,7 @@ RSpec.describe Analytics::CycleAnalytics::ProjectStage do ...@@ -23,7 +23,7 @@ RSpec.describe Analytics::CycleAnalytics::ProjectStage do
context 'relative positioning' do context 'relative positioning' do
it_behaves_like 'a class that supports 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(:factory) { :cycle_analytics_project_stage }
let(:default_params) { { project: project } } let(:default_params) { { project: project } }
end end
......
...@@ -6,6 +6,7 @@ RSpec.describe Issue do ...@@ -6,6 +6,7 @@ RSpec.describe Issue do
include ExternalAuthorizationServiceHelpers include ExternalAuthorizationServiceHelpers
let_it_be(:user) { create(:user) } let_it_be(:user) { create(:user) }
let_it_be(:reusable_project) { create(:project) }
describe "Associations" do describe "Associations" do
it { is_expected.to belong_to(:milestone) } it { is_expected.to belong_to(:milestone) }
...@@ -145,13 +146,13 @@ RSpec.describe Issue do ...@@ -145,13 +146,13 @@ RSpec.describe Issue do
end end
describe '#order_by_position_and_priority' do 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(:p1) { create(:label, title: 'P1', project: project, priority: 1) }
let(:p2) { create(:label, title: 'P2', project: project, priority: 2) } let(:p2) { create(:label, title: 'P2', project: project, priority: 2) }
let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) } let!(:issue1) { create(:labeled_issue, project: project, labels: [p1]) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) } let!(:issue2) { create(:labeled_issue, project: project, labels: [p2]) }
let!(:issue3) { create(:issue, project: project, relative_position: 100) } let!(:issue3) { create(:issue, project: project, relative_position: -200) }
let!(:issue4) { create(:issue, project: project, relative_position: 200) } let!(:issue4) { create(:issue, project: project, relative_position: -100) }
it 'returns ordered list' do it 'returns ordered list' do
expect(project.issues.order_by_position_and_priority) expect(project.issues.order_by_position_and_priority)
...@@ -160,10 +161,10 @@ RSpec.describe Issue do ...@@ -160,10 +161,10 @@ RSpec.describe Issue do
end end
describe '#sort' do describe '#sort' do
let(:project) { create(:project) } let(:project) { reusable_project }
context "by relative_position" do 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!(:issue2) { create(:issue, project: project, relative_position: 2) }
let!(:issue3) { create(:issue, project: project, relative_position: 1) } let!(:issue3) { create(:issue, project: project, relative_position: 1) }
...@@ -1027,7 +1028,7 @@ RSpec.describe Issue do ...@@ -1027,7 +1028,7 @@ RSpec.describe Issue do
context "relative positioning" do context "relative positioning" do
it_behaves_like "a class that supports 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(:factory) { :issue }
let(:default_params) { { project: project } } let(:default_params) { { project: project } }
end end
......
...@@ -25,7 +25,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -25,7 +25,6 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items = [item1, item2, item3] items = [item1, item2, item3]
described_class.move_nulls_to_end(items) described_class.move_nulls_to_end(items)
items.map(&:reload)
expect(items.sort_by(&:relative_position)).to eq(items) expect(items.sort_by(&:relative_position)).to eq(items)
expect(item1.relative_position).to be(1000) expect(item1.relative_position).to be(1000)
...@@ -35,22 +34,57 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -35,22 +34,57 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(item3.next_relative_position).to be_nil expect(item3.next_relative_position).to be_nil
end 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 it 'moves the item near the start position when there are no existing positions' do
item1.update!(relative_position: nil) item1.update!(relative_position: nil)
described_class.move_nulls_to_end([item1]) described_class.move_nulls_to_end([item1])
item1.reload expect(item1.reset.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE)
expect(item1.relative_position).to eq(described_class::START_POSITION + described_class::IDEAL_DISTANCE)
end end
it 'does not perform any moves if all items have their relative_position set' do it 'does not perform any moves if all items have their relative_position set' do
item1.update!(relative_position: 1) item1.update!(relative_position: 1)
expect do
described_class.move_nulls_to_end([item1]) 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
end end
...@@ -78,11 +112,19 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -78,11 +112,19 @@ RSpec.shared_examples 'a class that supports relative positioning' do
item1.update!(relative_position: nil) item1.update!(relative_position: nil)
described_class.move_nulls_to_start([item1]) described_class.move_nulls_to_start([item1])
item1.reload
expect(item1.relative_position).to eq(described_class::START_POSITION - described_class::IDEAL_DISTANCE) expect(item1.relative_position).to eq(described_class::START_POSITION - described_class::IDEAL_DISTANCE)
end 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 it 'does not perform any moves if all items have their relative_position set' do
item1.update!(relative_position: 1) item1.update!(relative_position: 1)
...@@ -101,8 +143,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -101,8 +143,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '#prev_relative_position' do describe '#prev_relative_position' do
it 'returns previous position if there is an item above' do it 'returns previous position if there is an item above' do
item1.update(relative_position: 5) item1.update!(relative_position: 5)
item2.update(relative_position: 15) item2.update!(relative_position: 15)
expect(item2.prev_relative_position).to eq item1.relative_position expect(item2.prev_relative_position).to eq item1.relative_position
end end
...@@ -114,8 +156,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -114,8 +156,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '#next_relative_position' do describe '#next_relative_position' do
it 'returns next position if there is an item below' do it 'returns next position if there is an item below' do
item1.update(relative_position: 5) item1.update!(relative_position: 5)
item2.update(relative_position: 15) item2.update!(relative_position: 15)
expect(item1.next_relative_position).to eq item2.relative_position expect(item1.next_relative_position).to eq item2.relative_position
end end
...@@ -125,9 +167,172 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -125,9 +167,172 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end end
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 describe '#move_before' do
let(:item3) { create(factory, default_params) }
it 'moves item before' do 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) item1.move_before(item2)
...@@ -135,12 +340,10 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -135,12 +340,10 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end end
context 'when there is no space' do context 'when there is no space' do
let(:item3) { create(factory, default_params) }
before do before do
item1.update(relative_position: 1000) item1.update!(relative_position: 1000)
item2.update(relative_position: 1001) item2.update!(relative_position: 1001)
item3.update(relative_position: 1002) item3.update!(relative_position: 1002)
end end
it 'moves items correctly' do it 'moves items correctly' do
...@@ -149,6 +352,73 @@ RSpec.shared_examples 'a class that supports relative positioning' 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 expect(item3.relative_position).to be_between(item1.reload.relative_position, item2.reload.relative_position).exclusive
end end
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 end
describe '#move_after' do describe '#move_after' do
...@@ -164,9 +434,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -164,9 +434,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do
let(:item3) { create(factory, default_params) } let(:item3) { create(factory, default_params) }
before do before do
item1.update(relative_position: 1000) item1.update!(relative_position: 1000)
item2.update(relative_position: 1001) item2.update!(relative_position: 1001)
item3.update(relative_position: 1002) 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 end
it 'moves items correctly' do it 'moves items correctly' do
...@@ -175,6 +453,53 @@ RSpec.shared_examples 'a class that supports relative positioning' 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 expect(item1.relative_position).to be_between(item2.reload.relative_position, item3.reload.relative_position).exclusive
end end
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 end
describe '#move_to_end' do describe '#move_to_end' do
...@@ -193,8 +518,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -193,8 +518,17 @@ RSpec.shared_examples 'a class that supports relative positioning' do
describe '#move_between' do describe '#move_between' do
before do before do
[item1, item2].each do |item1| [item1, item2].each do |item|
item1.move_to_end && item1.save 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
end end
...@@ -218,26 +552,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -218,26 +552,26 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end end
it 'positions items even when after and before positions are the same' do 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) new_item.move_between(item1, item2)
[item1, item2].each(&:reset)
expect(new_item.relative_position).to be > item1.relative_position expect(new_item.relative_position).to be > item1.relative_position
expect(item1.relative_position).to be < item2.relative_position expect(item1.relative_position).to be < item2.relative_position
end end
it 'positions items between other two if distance is 1' do context 'the two items are next to each other' do
item2.update relative_position: item1.relative_position + 1 let(:left) { item1 }
let(:middle) { new_item }
new_item.move_between(item1, item2) let(:right) { create_item(relative_position: item1.relative_position + 1) }
expect(new_item.relative_position).to be > item1.relative_position it_behaves_like 'moves item between'
expect(item1.relative_position).to be < item2.relative_position
end end
it 'positions item in the middle of other two if distance is big enough' do it 'positions item in the middle of other two if distance is big enough' do
item1.update relative_position: 6000 item1.update! relative_position: 6000
item2.update relative_position: 10000 item2.update! relative_position: 10000
new_item.move_between(item1, item2) new_item.move_between(item1, item2)
...@@ -245,7 +579,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -245,7 +579,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end end
it 'positions item closer to the middle if we are at the very top' do 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) new_item.move_between(nil, item2)
...@@ -253,51 +588,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -253,51 +588,53 @@ RSpec.shared_examples 'a class that supports relative positioning' do
end end
it 'positions item closer to the middle if we are at the very bottom' do it 'positions item closer to the middle if we are at the very bottom' do
new_item.update relative_position: 1 new_item.update!(relative_position: 1)
item1.update relative_position: 6000 item1.update!(relative_position: 6000)
item2.destroy item2.update!(relative_position: 5999)
new_item.move_between(item1, nil) new_item.move_between(item1, nil)
expect(new_item.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE) expect(new_item.relative_position).to eq(6000 + RelativePositioning::IDEAL_DISTANCE)
end end
it 'positions item in the middle of other two if distance is not big enough' do it 'positions item in the middle of other two' do
item1.update relative_position: 100 item1.update! relative_position: 100
item2.update relative_position: 400 item2.update! relative_position: 400
new_item.move_between(item1, item2) new_item.move_between(item1, item2)
expect(new_item.relative_position).to eq(250) expect(new_item.relative_position).to eq(250)
end end
it 'positions item in the middle of other two is there is no place' do context 'there is no space' do
item1.update relative_position: 100 let(:middle) { new_item }
item2.update relative_position: 101 let(:left) { create_item(relative_position: 100) }
let(:right) { create_item(relative_position: 101) }
new_item.move_between(item1, item2)
expect(new_item.relative_position).to be_between(item1.relative_position, item2.relative_position).exclusive it_behaves_like 'moves item between'
end end
it 'uses rebalancing if there is no place' do context 'there is a bunch of items' do
item1.update relative_position: 100 let(:items) { create_items_with_positions(100..104) }
item2.update relative_position: 101 let(:left) { items[1] }
item3 = create_item(relative_position: 102) let(:middle) { items[3] }
new_item.update relative_position: 103 let(:right) { items[2] }
new_item.move_between(item2, item3) it_behaves_like 'moves item between'
new_item.save!
expect(new_item.relative_position).to be_between(item2.relative_position, item3.relative_position).exclusive it 'handles bunches correctly' do
expect(item1.reload.relative_position).not_to eq(100) middle.move_between(left, right)
middle.save!
expect(items.first.reset.relative_position).to be < middle.relative_position
end
end end
it 'positions item right if we pass none-sequential parameters' do it 'positions item right if we pass non-sequential parameters' do
item1.update relative_position: 99 item1.update! relative_position: 99
item2.update relative_position: 101 item2.update! relative_position: 101
item3 = create_item(relative_position: 102) 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.move_between(item1, item3)
new_item.save! new_item.save!
...@@ -329,6 +666,12 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -329,6 +666,12 @@ RSpec.shared_examples 'a class that supports relative positioning' do
expect(positions).to eq([90, 95, 96, 102]) expect(positions).to eq([90, 95, 96, 102])
end 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 it 'finds a gap if there are unused positions' do
items = create_items_with_positions([100, 101, 102]) items = create_items_with_positions([100, 101, 102])
...@@ -336,7 +679,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -336,7 +679,8 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.last.save! items.last.save!
positions = items.map { |item| item.reload.relative_position } 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
end end
...@@ -358,7 +702,33 @@ RSpec.shared_examples 'a class that supports relative positioning' do ...@@ -358,7 +702,33 @@ RSpec.shared_examples 'a class that supports relative positioning' do
items.first.save! items.first.save!
positions = items.map { |item| item.reload.relative_position } 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 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
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