# frozen_string_literal: true # This module makes it possible to handle items as a list, where the order of items can be easily altered # Requirements: # # The model must have the following named columns: # - id: integer # - relative_position: integer # # 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 # # # 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 # module RelativePositioning extend ActiveSupport::Concern STEPS = 10 IDEAL_DISTANCE = 2**(STEPS - 1) + 1 MIN_POSITION = Gitlab::Database::MIN_INT_VALUE START_POSITION = 0 MAX_POSITION = Gitlab::Database::MAX_INT_VALUE MAX_GAP = IDEAL_DISTANCE * 2 MIN_GAP = 2 NoSpaceLeft = Class.new(StandardError) class_methods do def move_nulls_to_end(objects) move_nulls(objects, at_end: true) end def move_nulls_to_start(objects) move_nulls(objects, at_end: false) end # This method takes two integer values (positions) and # calculates the position between them. The range is huge as # the maximum integer value is 2147483647. # # We avoid open ranges by clamping the range to [MIN_POSITION, MAX_POSITION]. # # Then we handle one of three cases: # - If the gap is too small, we raise NoSpaceLeft # - If the gap is larger than MAX_GAP, we place the new position at most # IDEAL_DISTANCE from the edge of the gap. # - otherwise we place the new position at the midpoint. # # The new position will always satisfy: pos_before <= midpoint <= pos_after # # As a precondition, the gap between pos_before and pos_after MUST be >= 2. # If the gap is too small, NoSpaceLeft is raised. # # This class method should only be called by instance methods of this module, which # include handling for minimum gap size. # # @raises NoSpaceLeft # @api private def position_between(pos_before, pos_after) pos_before ||= MIN_POSITION pos_after ||= MAX_POSITION pos_before, pos_after = [pos_before, pos_after].sort gap_width = pos_after - pos_before midpoint = [pos_after - 1, pos_before + (gap_width / 2)].min if gap_width < MIN_GAP raise NoSpaceLeft elsif gap_width > MAX_GAP if pos_before <= MIN_POSITION pos_after - IDEAL_DISTANCE elsif pos_after >= MAX_POSITION pos_before + IDEAL_DISTANCE else midpoint end else midpoint end end private # @api private def gap_size(object, gaps:, at_end:, starting_from:) 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] 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? representative = objects.first number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each 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 = 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 # 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 # Some classes are polymorphic, and not all siblings are in the same table. by_model = indexed.group_by { |pair| pair.first.class } lower_bound, upper_bound = at_end ? [position, MAX_POSITION] : [MIN_POSITION, position] 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| desired_pos = position + delta * (i + 1) pos = desired_pos.clamp(lower_bound, upper_bound) 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 objects.size end end def min_relative_position(&block) calculate_relative_position('MIN', &block) end def max_relative_position(&block) calculate_relative_position('MAX', &block) end def prev_relative_position(ignoring: nil) prev_pos = nil if self.relative_position prev_pos = max_relative_position do |relation| relation = relation.id_not_in(ignoring.id) if ignoring.present? relation.where('relative_position < ?', self.relative_position) end end prev_pos end def next_relative_position(ignoring: nil) next_pos = nil if self.relative_position next_pos = min_relative_position do |relation| relation = relation.id_not_in(ignoring.id) if ignoring.present? relation.where('relative_position > ?', self.relative_position) end end next_pos end def move_between(before, after) return move_after(before) unless after return move_before(after) unless before before, after = after, before if after.relative_position < before.relative_position pos_left = before.relative_position pos_right = after.relative_position if pos_right - pos_left < MIN_GAP # Not enough room! Make space by shifting all previous elements to the left # if there is enough space, else to the right gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend if gap.present? after.move_sequence_before(next_gap: gap) pos_left -= optimum_delta_for_gap(gap) else before.move_sequence_after pos_right = after.reset.relative_position end end new_position = self.class.position_between(pos_left, pos_right) self.relative_position = new_position end def move_after(before = self) pos_before = before.relative_position pos_after = before.next_relative_position(ignoring: self) if pos_before == MAX_POSITION || gap_too_small?(pos_after, pos_before) gap = before.send(:find_next_gap_after) # rubocop:disable GitlabSecurity/PublicSend if gap.nil? before.move_sequence_before(true) pos_before = before.reset.relative_position else before.move_sequence_after(next_gap: gap) pos_after += optimum_delta_for_gap(gap) end end self.relative_position = self.class.position_between(pos_before, pos_after) end def move_before(after = self) pos_after = after.relative_position pos_before = after.prev_relative_position(ignoring: self) if pos_after == MIN_POSITION || gap_too_small?(pos_before, pos_after) gap = after.send(:find_next_gap_before) # rubocop:disable GitlabSecurity/PublicSend if gap.nil? after.move_sequence_after(true) pos_after = after.reset.relative_position else after.move_sequence_before(next_gap: gap) pos_before -= optimum_delta_for_gap(gap) end end self.relative_position = self.class.position_between(pos_before, pos_after) end def move_to_end max_pos = max_relative_position if max_pos.nil? self.relative_position = START_POSITION elsif gap_too_small?(max_pos, MAX_POSITION + 1) max = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'DESC')).first max.move_sequence_before(true) max.reset self.relative_position = self.class.position_between(max.relative_position, MAX_POSITION + 1) else self.relative_position = self.class.position_between(max_pos, MAX_POSITION + 1) end end def move_to_start min_pos = min_relative_position if min_pos.nil? self.relative_position = START_POSITION elsif gap_too_small?(min_pos, MIN_POSITION - 1) min = relative_siblings.order(Gitlab::Database.nulls_last_order('relative_position', 'ASC')).first min.move_sequence_after(true) min.reset self.relative_position = self.class.position_between(MIN_POSITION - 1, min.relative_position) else self.relative_position = self.class.position_between(MIN_POSITION - 1, min_pos) end end # Moves the sequence before the current item to the middle of the next gap # For example, we have # # 5 . . . . . 11 12 13 14 [15] 16 . 17 # ----------- # # This moves the sequence [11 12 13 14] to [8 9 10 11], so we have: # # 5 . . 8 9 10 11 . . . [15] 16 . 17 # --------- # # Creating a gap to the left of the current item. We can understand this as # dividing the 5 spaces between 5 and 11 into two smaller gaps of 2 and 3. # # If `include_self` is true, the current item will also be moved, creating a # gap to the right of the current item: # # 5 . . 8 9 10 11 [14] . . . 16 . 17 # -------------- # # As an optimization, the gap can be precalculated and passed to this method. # # @api private # @raises NoSpaceLeft if the sequence cannot be moved def move_sequence_before(include_self = false, next_gap: find_next_gap_before) raise NoSpaceLeft unless next_gap.present? delta = optimum_delta_for_gap(next_gap) move_sequence(next_gap[:start], relative_position, -delta, include_self) end # Moves the sequence after the current item to the middle of the next gap # For example, we have: # # 8 . 10 [11] 12 13 14 15 . . . . . 21 # ----------- # # This moves the sequence [12 13 14 15] to [15 16 17 18], so we have: # # 8 . 10 [11] . . . 15 16 17 18 . . 21 # ----------- # # Creating a gap to the right of the current item. We can understand this as # dividing the 5 spaces between 15 and 21 into two smaller gaps of 3 and 2. # # If `include_self` is true, the current item will also be moved, creating a # gap to the left of the current item: # # 8 . 10 . . . [14] 15 16 17 18 . . 21 # ---------------- # # As an optimization, the gap can be precalculated and passed to this method. # # @api private # @raises NoSpaceLeft if the sequence cannot be moved def move_sequence_after(include_self = false, next_gap: find_next_gap_after) raise NoSpaceLeft unless next_gap.present? delta = optimum_delta_for_gap(next_gap) move_sequence(relative_position, next_gap[:start], delta, include_self) end private def gap_too_small?(pos_a, pos_b) return false unless pos_a && pos_b (pos_a - pos_b).abs < MIN_GAP end # Find the first suitable gap to the left of the current position. # # Satisfies the relations: # - gap[:start] <= relative_position # - abs(gap[:start] - gap[:end]) >= MIN_GAP # - MIN_POSITION <= gap[:start] <= MAX_POSITION # - MIN_POSITION <= gap[:end] <= MAX_POSITION # # Supposing that the current item is 13, and we have a sequence of items: # # 1 . . . 5 . . . . 11 12 [13] 14 . . 17 # ^---------^ # # Then we return: `{ start: 11, end: 5 }` # # Here start refers to the end of the gap closest to the current item. def find_next_gap_before items_with_next_pos = scoped_items .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos') .where('relative_position <= ?', relative_position) .order(relative_position: :desc) find_next_gap(items_with_next_pos, MIN_POSITION) end # Find the first suitable gap to the right of the current position. # # Satisfies the relations: # - gap[:start] >= relative_position # - abs(gap[:start] - gap[:end]) >= MIN_GAP # - MIN_POSITION <= gap[:start] <= MAX_POSITION # - MIN_POSITION <= gap[:end] <= MAX_POSITION # # Supposing the current item is 13, and that we have a sequence of items: # # 9 . . . [13] 14 15 . . . . 20 . . . 24 # ^---------^ # # Then we return: `{ start: 15, end: 20 }` # # Here start refers to the end of the gap closest to the current item. def find_next_gap_after items_with_next_pos = scoped_items .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos') .where('relative_position >= ?', relative_position) .order(:relative_position) find_next_gap(items_with_next_pos, MAX_POSITION) end def find_next_gap(items_with_next_pos, end_is_nil) gap = self.class .from(items_with_next_pos, :items) .where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP) .limit(1) .pluck(:pos, :next_pos) .first return if gap.nil? || gap.first == end_is_nil { start: gap.first, end: gap.second || end_is_nil } end def optimum_delta_for_gap(gap) delta = ((gap[:start] - gap[:end]) / 2.0).abs.ceil [delta, IDEAL_DISTANCE].min end def move_sequence(start_pos, end_pos, delta, include_self = false) relation = include_self ? scoped_items : relative_siblings relation .where('relative_position BETWEEN ? AND ?', start_pos, end_pos) .update_all("relative_position = relative_position + #{delta}") end def calculate_relative_position(calculation) # When calculating across projects, this is much more efficient than # MAX(relative_position) without the GROUP BY, due to index usage: # https://gitlab.com/gitlab-org/gitlab-foss/issues/54276#note_119340977 relation = scoped_items .order(Gitlab::Database.nulls_last_order('position', 'DESC')) .group(self.class.relative_positioning_parent_column) .limit(1) relation = yield relation if block_given? relation .pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position")) .first&.last end def relative_siblings(relation = scoped_items) relation.id_not_in(id) end def scoped_items self.class.relative_positioning_query_base(self) end end