relative_positioning.rb 8.2 KB
Newer Older
1 2
# frozen_string_literal: true

A
Adam Hegyi 已提交
3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
# This module makes it possible to handle items as a list, where the order of items can be easily altered
# Requirements:
#
# - Only works for ActiveRecord models
# - relative_position integer field must present on the model
# - This module uses GROUP BY: the model should have a parent relation, example: project -> issues, project is the parent relation (issues table has a parent_id column)
#
# Setup like this in the body of your class:
#
#     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
#
24 25 26
module RelativePositioning
  extend ActiveSupport::Concern

27
  MIN_POSITION = 0
28
  START_POSITION = Gitlab::Database::MAX_INT_VALUE / 2
29
  MAX_POSITION = Gitlab::Database::MAX_INT_VALUE
30
  IDEAL_DISTANCE = 500
31
  MAX_SEQUENCE_LIMIT = 1000
32

33 34 35 36
  class GapNotFound < StandardError
    def message
      'Could not find a gap in the sequence of relative positions.'
    end
37 38
  end

39
  class_methods do
40
    def move_nulls_to_end(objects)
41 42
      objects = objects.reject(&:relative_position)

43 44 45 46
      return if objects.empty?

      max_relative_position = objects.first.max_relative_position

47 48
      self.transaction do
        objects.each do |object|
49
          relative_position = position_between(max_relative_position || START_POSITION, MAX_POSITION)
50 51
          object.relative_position = relative_position
          max_relative_position = relative_position
52
          object.save(touch: false)
53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83
        end
      end
    end

    # This method takes two integer values (positions) and
    # calculates the position between them. The range is huge as
    # the maximum integer value is 2147483647. We are incrementing position by IDEAL_DISTANCE * 2 every time
    # when we have enough space. If distance is less then IDEAL_DISTANCE we are calculating an average number
    def position_between(pos_before, pos_after)
      pos_before ||= MIN_POSITION
      pos_after ||= MAX_POSITION

      pos_before, pos_after = [pos_before, pos_after].sort

      halfway = (pos_after + pos_before) / 2
      distance_to_halfway = pos_after - halfway

      if distance_to_halfway < IDEAL_DISTANCE
        halfway
      else
        if pos_before == MIN_POSITION
          pos_after - IDEAL_DISTANCE
        elsif pos_after == MAX_POSITION
          pos_before + IDEAL_DISTANCE
        else
          halfway
        end
      end
    end
  end

84 85
  def min_relative_position(&block)
    calculate_relative_position('MIN', &block)
F
Felipe Artur 已提交
86 87
  end

88 89
  def max_relative_position(&block)
    calculate_relative_position('MAX', &block)
90 91
  end

92 93 94 95
  def prev_relative_position
    prev_pos = nil

    if self.relative_position
96 97 98
      prev_pos = max_relative_position do |relation|
        relation.where('relative_position < ?', self.relative_position)
      end
99 100
    end

101
    prev_pos
102 103 104 105 106 107
  end

  def next_relative_position
    next_pos = nil

    if self.relative_position
108 109 110
      next_pos = min_relative_position do |relation|
        relation.where('relative_position > ?', self.relative_position)
      end
111 112
    end

113
    next_pos
114 115
  end

116
  def move_between(before, after)
117 118
    return move_after(before) unless after
    return move_before(after) unless before
119

A
Adam Hegyi 已提交
120
    # If there is no place to insert an item we need to create one by moving the before item closer
121
    # to its predecessor. This process will recursively move all the predecessors until we have a place
122
    before, after = after, before if after.relative_position < before.relative_position
123
    if (after.relative_position - before.relative_position) < 2
124 125
      after.move_sequence_before
      before.reset
126 127
    end

128
    self.relative_position = self.class.position_between(before.relative_position, after.relative_position)
129 130 131
  end

  def move_after(before = self)
132
    pos_before = before.relative_position
133
    pos_after = before.next_relative_position
134

135
    if before.shift_after?
136
      before.move_sequence_after
137 138
    end

139
    self.relative_position = self.class.position_between(pos_before, pos_after)
140 141
  end

142 143 144 145 146
  def move_before(after = self)
    pos_after = after.relative_position
    pos_before = after.prev_relative_position

    if after.shift_before?
147
      after.move_sequence_before
148 149
    end

150
    self.relative_position = self.class.position_between(pos_before, pos_after)
151 152
  end

153
  def move_to_end
154
    self.relative_position = self.class.position_between(max_relative_position || START_POSITION, MAX_POSITION)
155 156
  end

157
  def move_to_start
158
    self.relative_position = self.class.position_between(min_relative_position || START_POSITION, MIN_POSITION)
159 160
  end

A
Adam Hegyi 已提交
161
  # Indicates if there is an item that should be shifted to free the place
162 163 164 165 166
  def shift_after?
    next_pos = next_relative_position
    next_pos && (next_pos - relative_position) == 1
  end

A
Adam Hegyi 已提交
167
  # Indicates if there is an item that should be shifted to free the place
168 169 170 171 172
  def shift_before?
    prev_pos = prev_relative_position
    prev_pos && (relative_position - prev_pos) == 1
  end

173 174 175 176
  def move_sequence_before
    items_to_move = scoped_items_batch.where('relative_position <= ?', relative_position).order('relative_position DESC')
    move_nearest_sequence(items_to_move, MIN_POSITION)
  end
177

178 179 180
  def move_sequence_after
    items_to_move = scoped_items_batch.where('relative_position >= ?', relative_position).order(:relative_position)
    move_nearest_sequence(items_to_move, MAX_POSITION)
181
  end
182 183

  private
184 185 186 187 188

  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-ce/issues/54276#note_119340977
189
    relation = scoped_items
190
                 .order(Gitlab::Database.nulls_last_order('position', 'DESC'))
A
Adam Hegyi 已提交
191
                 .group(self.class.relative_positioning_parent_column)
192 193 194 195 196
                 .limit(1)

    relation = yield relation if block_given?

    relation
A
Adam Hegyi 已提交
197
      .pluck(self.class.relative_positioning_parent_column, Arel.sql("#{calculation}(relative_position) AS position"))
198 199 200
      .first&.
      last
  end
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269

  def scoped_items
    self.class.relative_positioning_query_base(self)
  end

  def scoped_items_batch
    scoped_items.limit(MAX_SEQUENCE_LIMIT).select(:id, :relative_position).where.not(id: self.id)
  end

  # Supposing that we have a sequence of positions: 5 11 12 13 14 15,
  # and we want to move another item between 14 and 15, then
  # we shift previous positions at least by one item, but ideally to the middle
  # of the nearest gap. In this case gap is between 5 and 11 so
  # this would move 11 12 13 14 to 8 9 10 11.
  def move_nearest_sequence(items, end_position)
    gap_idx, gap_size = find_gap_in_sequence(items)

    # If we didn't find a gap in the sequence, it's still possible that there
    # are some free positions before the first item
    if gap_idx.nil? && !items.empty? && items.size < MAX_SEQUENCE_LIMIT &&
        items.last.relative_position != end_position

      gap_idx = items.size
      gap_size = end_position - items.last.relative_position
    end

    # The chance that there is a sequence of 1000 positions w/o gap is really
    # low, but it would be good to rebalance all positions in the scope instead
    # of raising an exception:
    # https://gitlab.com/gitlab-org/gitlab-ce/issues/64514#note_192657097
    raise GapNotFound if gap_idx.nil?
    # No shift is needed if gap is next to the item being moved
    return true if gap_idx == 0

    delta = max_delta_for_sequence(gap_size)
    sequence_ids = items.first(gap_idx).map(&:id)
    move_ids_by_delta(sequence_ids, delta)
  end

  def max_delta_for_sequence(gap_size)
    delta = gap_size / 2

    if delta.abs > IDEAL_DISTANCE
      delta = delta < 0 ? -IDEAL_DISTANCE : IDEAL_DISTANCE
    end

    delta
  end

  def move_ids_by_delta(ids, delta)
    self.class.where(id: ids).update_all("relative_position=relative_position+#{delta}")
  end

  def find_gap_in_sequence(items)
    prev = relative_position
    gap = nil

    items.each_with_index do |rec, idx|
      size = rec.relative_position - prev
      if size.abs > 1
        gap = [idx, size]
        break
      end

      prev = rec.relative_position
    end

    gap
  end
270
end