debian-mirror-gitlab/app/models/concerns/relative_positioning.rb

207 lines
6.7 KiB
Ruby
Raw Normal View History

2018-11-20 20:47:30 +05:30
# frozen_string_literal: true
2019-10-12 21:52:04 +05:30
# This module makes it possible to handle items as a list, where the order of items can be easily altered
# Requirements:
#
2020-10-24 23:57:45 +05:30
# The model must have the following named columns:
# - id: integer
# - relative_position: integer
2019-10-12 21:52:04 +05:30
#
2020-10-24 23:57:45 +05:30
# 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-10-12 21:52:04 +05:30
#
# 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-08-17 22:00:37 +05:30
module RelativePositioning
extend ActiveSupport::Concern
2020-11-24 15:15:51 +05:30
include ::Gitlab::RelativePositioning
2019-02-15 15:39:39 +05:30
2020-10-24 23:57:45 +05:30
class_methods do
def move_nulls_to_end(objects)
move_nulls(objects, at_end: true)
end
2019-02-15 15:39:39 +05:30
2020-10-24 23:57:45 +05:30
def move_nulls_to_start(objects)
move_nulls(objects, at_end: false)
2018-12-13 13:39:08 +05:30
end
2020-10-24 23:57:45 +05:30
private
# @api private
2020-11-24 15:15:51 +05:30
def gap_size(context, gaps:, at_end:, starting_from:)
2020-10-24 23:57:45 +05:30
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-06-08 01:23:25 +05:30
terminus = context.at_position(starting_from)
2020-10-24 23:57:45 +05:30
if at_end
2020-11-24 15:15:51 +05:30
terminus.shift_left
max_relative_position = terminus.relative_position
2020-10-24 23:57:45 +05:30
[[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position]
else
2020-11-24 15:15:51 +05:30
terminus.shift_right
min_relative_position = terminus.relative_position
2020-10-24 23:57:45 +05:30
[[(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.
# @returns [Number] The number of moved records.
def move_nulls(objects, at_end:)
objects = objects.reject(&:relative_position)
return 0 if objects.empty?
2021-06-08 01:23:25 +05:30
objects.first.check_repositioning_allowed!
2020-11-24 15:15:51 +05:30
number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each
representative = RelativePositioning.mover.context(objects.first)
2020-10-24 23:57:45 +05:30
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-11-24 15:15:51 +05:30
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-10-24 23:57:45 +05:30
2020-11-24 15:15:51 +05:30
# 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-10-24 23:57:45 +05:30
2020-11-24 15:15:51 +05:30
lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position]
2020-10-24 23:57:45 +05:30
2021-01-03 14:25:43 +05:30
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-10-24 23:57:45 +05:30
end
2021-01-03 14:25:43 +05:30
::Gitlab::Database::BulkUpdate.execute([:relative_position], mapping, &:model_class)
2018-12-13 13:39:08 +05:30
end
end
2020-10-24 23:57:45 +05:30
objects.size
2018-12-13 13:39:08 +05:30
end
end
2020-11-24 15:15:51 +05:30
def self.mover
::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION))
2017-08-17 22:00:37 +05:30
end
2021-06-08 01:23:25 +05:30
# To be overriden on child classes whenever
# blocking position updates is necessary.
def check_repositioning_allowed!
nil
end
2017-08-17 22:00:37 +05:30
def move_between(before, after)
2020-11-24 15:15:51 +05:30
before, after = [before, after].sort_by(&:relative_position) if before && after
2020-10-24 23:57:45 +05:30
2020-11-24 15:15:51 +05:30
RelativePositioning.mover.move(self, before, after)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
2017-08-17 22:00:37 +05:30
end
def move_after(before = self)
2020-11-24 15:15:51 +05:30
RelativePositioning.mover.move(self, before, nil)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
2017-08-17 22:00:37 +05:30
end
def move_before(after = self)
2020-11-24 15:15:51 +05:30
RelativePositioning.mover.move(self, nil, after)
rescue ActiveRecord::QueryCanceled, NoSpaceLeft => e
could_not_move(e)
raise e
2017-08-17 22:00:37 +05:30
end
def move_to_end
2020-11-24 15:15:51 +05:30
RelativePositioning.mover.move_to_end(self)
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MAX_POSITION
rescue ActiveRecord::QueryCanceled => e
could_not_move(e)
raise e
2017-08-17 22:00:37 +05:30
end
2018-03-17 18:26:18 +05:30
def move_to_start
2020-11-24 15:15:51 +05:30
RelativePositioning.mover.move_to_start(self)
rescue NoSpaceLeft => e
could_not_move(e)
self.relative_position = MIN_POSITION
rescue ActiveRecord::QueryCanceled => e
could_not_move(e)
raise e
end
# This method is used during rebalancing - override it to customise the update
# logic:
def update_relative_siblings(relation, range, delta)
2020-10-24 23:57:45 +05:30
relation
2020-11-24 15:15:51 +05:30
.where(relative_position: range)
2019-10-12 21:52:04 +05:30
.update_all("relative_position = relative_position + #{delta}")
2017-08-17 22:00:37 +05:30
end
2019-02-15 15:39:39 +05:30
2020-11-24 15:15:51 +05:30
# 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-10-24 23:57:45 +05:30
end
2020-11-24 15:15:51 +05:30
# Override if you want to be notified of failures to move
def could_not_move(exception)
2019-10-12 21:52:04 +05:30
end
2021-01-03 14:25:43 +05:30
# 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
# 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-08-17 22:00:37 +05:30
end