2018-08-03 13:22:24 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2019-07-22 03:47:29 -04:00
|
|
|
# This module makes it possible to handle items as a list, where the order of items can be easily altered
|
|
|
|
# Requirements:
|
|
|
|
#
|
2020-08-19 08:10:17 -04:00
|
|
|
# The model must have the following named columns:
|
|
|
|
# - id: integer
|
|
|
|
# - relative_position: integer
|
2019-07-22 03:47:29 -04:00
|
|
|
#
|
2020-08-19 08:10:17 -04:00
|
|
|
# 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:
|
2019-07-22 03:47:29 -04:00
|
|
|
#
|
|
|
|
# include RelativePositioning
|
|
|
|
#
|
|
|
|
# # base query used for the position calculation
|
|
|
|
# def self.relative_positioning_query_base(issue)
|
|
|
|
# where(deleted: false)
|
|
|
|
# end
|
|
|
|
#
|
|
|
|
# # column that should be used in GROUP BY
|
|
|
|
# def self.relative_positioning_parent_column
|
|
|
|
# :project_id
|
|
|
|
# end
|
|
|
|
#
|
2017-02-01 13:41:01 -05:00
|
|
|
module RelativePositioning
|
|
|
|
extend ActiveSupport::Concern
|
2020-09-16 14:09:47 -04:00
|
|
|
include ::Gitlab::RelativePositioning
|
2020-08-19 05:09:58 -04:00
|
|
|
|
2020-08-19 08:10:17 -04:00
|
|
|
class_methods do
|
|
|
|
def move_nulls_to_end(objects)
|
|
|
|
move_nulls(objects, at_end: true)
|
2020-08-05 20:09:53 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
def move_nulls_to_start(objects)
|
2020-08-19 08:10:17 -04:00
|
|
|
move_nulls(objects, at_end: false)
|
2018-09-10 13:26:33 -04:00
|
|
|
end
|
|
|
|
|
2020-08-19 08:10:17 -04:00
|
|
|
private
|
|
|
|
|
|
|
|
# @api private
|
2020-09-16 14:09:47 -04:00
|
|
|
def gap_size(context, gaps:, at_end:, starting_from:)
|
2020-08-19 08:10:17 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
return [size, starting_from] if size >= MIN_GAP
|
|
|
|
|
2021-05-03 14:10:17 -04:00
|
|
|
terminus = context.at_position(starting_from)
|
2021-04-20 17:09:07 -04:00
|
|
|
|
2020-08-19 08:10:17 -04:00
|
|
|
if at_end
|
2020-09-16 14:09:47 -04:00
|
|
|
terminus.shift_left
|
|
|
|
max_relative_position = terminus.relative_position
|
2020-08-19 08:10:17 -04:00
|
|
|
[[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position]
|
|
|
|
else
|
2020-09-16 14:09:47 -04:00
|
|
|
terminus.shift_right
|
|
|
|
min_relative_position = terminus.relative_position
|
2020-08-19 08:10:17 -04:00
|
|
|
[[(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.
|
2020-08-19 14:10:34 -04:00
|
|
|
# @returns [Number] The number of moved records.
|
2020-08-19 08:10:17 -04:00
|
|
|
def move_nulls(objects, at_end:)
|
|
|
|
objects = objects.reject(&:relative_position)
|
2020-08-19 14:10:34 -04:00
|
|
|
return 0 if objects.empty?
|
2020-08-19 08:10:17 -04:00
|
|
|
|
2021-05-17 14:10:42 -04:00
|
|
|
objects.first.check_repositioning_allowed!
|
|
|
|
|
2020-08-27 11:10:21 -04:00
|
|
|
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
|
2020-09-16 14:09:47 -04:00
|
|
|
representative = RelativePositioning.mover.context(objects.first)
|
|
|
|
|
2020-08-19 08:10:17 -04:00
|
|
|
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
|
|
|
|
|
2020-08-27 11:10:21 -04:00
|
|
|
gap = 0
|
|
|
|
attempts = 10 # consolidate up to 10 gaps to find enough space
|
|
|
|
while gap < 1 && attempts > 0
|
|
|
|
gap, position = gap_size(representative, gaps: number_of_gaps, at_end: at_end, starting_from: position)
|
|
|
|
attempts -= 1
|
|
|
|
end
|
2020-08-19 08:10:17 -04:00
|
|
|
|
2020-08-27 11:10:21 -04:00
|
|
|
# Allow placing items next to each other, if we have to.
|
|
|
|
gap = 1 if gap < MIN_GAP
|
|
|
|
delta = at_end ? gap : -gap
|
|
|
|
indexed = (at_end ? objects : objects.reverse).each_with_index
|
2020-08-19 08:10:17 -04:00
|
|
|
|
2020-08-27 11:10:21 -04:00
|
|
|
lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position]
|
2020-08-19 08:10:17 -04:00
|
|
|
|
2020-10-12 02:08:53 -04:00
|
|
|
representative.model_class.transaction do
|
|
|
|
indexed.each_slice(100) do |batch|
|
|
|
|
mapping = batch.to_h.transform_values! do |i|
|
|
|
|
desired_pos = position + delta * (i + 1)
|
|
|
|
{ relative_position: desired_pos.clamp(lower_bound, upper_bound) }
|
2020-08-19 08:10:17 -04:00
|
|
|
end
|
2020-10-12 02:08:53 -04:00
|
|
|
|
|
|
|
::Gitlab::Database::BulkUpdate.execute([:relative_position], mapping, &:model_class)
|
2018-09-10 13:26:33 -04:00
|
|
|
end
|
|
|
|
end
|
2020-08-19 14:10:34 -04:00
|
|
|
|
|
|
|
objects.size
|
2018-09-10 13:26:33 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
def self.mover
|
|
|
|
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
|
2017-03-07 12:57:24 -05:00
|
|
|
end
|
|
|
|
|
2021-05-17 14:10:42 -04:00
|
|
|
# To be overriden on child classes whenever
|
|
|
|
# blocking position updates is necessary.
|
|
|
|
def check_repositioning_allowed!
|
|
|
|
nil
|
|
|
|
end
|
|
|
|
|
2017-02-01 13:41:01 -05:00
|
|
|
def move_between(before, after)
|
2020-09-16 14:09:47 -04:00
|
|
|
before, after = [before, after].sort_by(&:relative_position) if before && after
|
2020-08-19 08:10:17 -04:00
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
RelativePositioning.mover.move(self, before, after)
|
2021-09-17 08:12:06 -04:00
|
|
|
rescue NoSpaceLeft => e
|
2020-09-16 14:09:47 -04:00
|
|
|
could_not_move(e)
|
|
|
|
raise e
|
2017-03-10 12:04:37 -05:00
|
|
|
end
|
|
|
|
|
|
|
|
def move_after(before = self)
|
2020-09-16 14:09:47 -04:00
|
|
|
RelativePositioning.mover.move(self, before, nil)
|
2021-09-17 08:12:06 -04:00
|
|
|
rescue NoSpaceLeft => e
|
2020-09-16 14:09:47 -04:00
|
|
|
could_not_move(e)
|
|
|
|
raise e
|
2017-02-01 13:41:01 -05:00
|
|
|
end
|
|
|
|
|
2017-03-10 12:04:37 -05:00
|
|
|
def move_before(after = self)
|
2020-09-16 14:09:47 -04:00
|
|
|
RelativePositioning.mover.move(self, nil, after)
|
2021-09-17 08:12:06 -04:00
|
|
|
rescue NoSpaceLeft => e
|
2020-09-16 14:09:47 -04:00
|
|
|
could_not_move(e)
|
|
|
|
raise e
|
2017-02-01 13:41:01 -05:00
|
|
|
end
|
|
|
|
|
2017-03-07 12:57:24 -05:00
|
|
|
def move_to_end
|
2020-09-16 14:09:47 -04:00
|
|
|
RelativePositioning.mover.move_to_end(self)
|
|
|
|
rescue NoSpaceLeft => e
|
|
|
|
could_not_move(e)
|
|
|
|
self.relative_position = MAX_POSITION
|
2017-02-01 13:41:01 -05:00
|
|
|
end
|
|
|
|
|
2018-01-04 07:29:48 -05:00
|
|
|
def move_to_start
|
2020-09-16 14:09:47 -04:00
|
|
|
RelativePositioning.mover.move_to_start(self)
|
|
|
|
rescue NoSpaceLeft => e
|
|
|
|
could_not_move(e)
|
|
|
|
self.relative_position = MIN_POSITION
|
|
|
|
end
|
|
|
|
|
2021-12-09 07:15:43 -05:00
|
|
|
def next_object_by_relative_position(ignoring: nil, order: :asc)
|
|
|
|
relation = relative_positioning_scoped_items(ignoring: ignoring).reorder(relative_position: order)
|
|
|
|
|
|
|
|
relation = if order == :asc
|
|
|
|
relation.where(self.class.arel_table[:relative_position].gt(relative_position))
|
|
|
|
else
|
|
|
|
relation.where(self.class.arel_table[:relative_position].lt(relative_position))
|
|
|
|
end
|
|
|
|
|
|
|
|
relation.first
|
|
|
|
end
|
|
|
|
|
|
|
|
def relative_positioning_scoped_items(ignoring: nil)
|
|
|
|
relation = self.class.relative_positioning_query_base(self)
|
|
|
|
relation = exclude_self(relation, excluded: ignoring) if ignoring.present?
|
|
|
|
relation
|
|
|
|
end
|
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
# This method is used during rebalancing - override it to customise the update
|
|
|
|
# logic:
|
|
|
|
def update_relative_siblings(relation, range, delta)
|
2020-08-19 08:10:17 -04:00
|
|
|
relation
|
2020-09-16 14:09:47 -04:00
|
|
|
.where(relative_position: range)
|
2019-07-24 10:36:39 -04:00
|
|
|
.update_all("relative_position = relative_position + #{delta}")
|
|
|
|
end
|
2018-11-23 08:06:04 -05:00
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
# This method is used to exclude the current self (or another object)
|
|
|
|
# from a relation. Customize this if `id <> :id` is not sufficient
|
|
|
|
def exclude_self(relation, excluded: self)
|
|
|
|
relation.id_not_in(excluded.id)
|
2020-08-19 08:10:17 -04:00
|
|
|
end
|
|
|
|
|
2020-09-16 14:09:47 -04:00
|
|
|
# Override if you want to be notified of failures to move
|
|
|
|
def could_not_move(exception)
|
2019-07-19 04:16:41 -04:00
|
|
|
end
|
2020-09-24 02:09:43 -04:00
|
|
|
|
|
|
|
# Override if the implementing class is not a simple application record, for
|
|
|
|
# example if the record is loaded from a union.
|
|
|
|
def reset_relative_position
|
|
|
|
reset.relative_position
|
|
|
|
end
|
2020-10-12 02:08:53 -04:00
|
|
|
|
|
|
|
# Override if the model class needs a more complicated computation (e.g. the
|
|
|
|
# object is a member of a union).
|
|
|
|
def model_class
|
|
|
|
self.class
|
|
|
|
end
|
2017-02-01 13:41:01 -05:00
|
|
|
end
|