1# frozen_string_literal: true 2 3module Gitlab 4 module RelativePositioning 5 # This class is API private - it should not be explicitly instantiated 6 # outside of tests 7 # rubocop: disable CodeReuse/ActiveRecord 8 class ItemContext 9 include Gitlab::Utils::StrongMemoize 10 11 attr_reader :object, :model_class, :range 12 attr_accessor :ignoring 13 14 def initialize(object, range, ignoring: nil) 15 @object = object 16 @range = range 17 @model_class = object.class 18 @ignoring = ignoring 19 end 20 21 def ==(other) 22 other.is_a?(self.class) && other.object == object && other.range == range && other.ignoring == ignoring 23 end 24 25 def positioned? 26 relative_position.present? 27 end 28 29 def min_relative_position 30 strong_memoize(:min_relative_position) { calculate_relative_position('MIN') } 31 end 32 33 def max_relative_position 34 strong_memoize(:max_relative_position) { calculate_relative_position('MAX') } 35 end 36 37 def prev_relative_position 38 calculate_relative_position('MAX') { |r| nextify(r, false) } if object.relative_position 39 end 40 41 def next_relative_position 42 calculate_relative_position('MIN') { |r| nextify(r) } if object.relative_position 43 end 44 45 def nextify(relation, gt = true) 46 if gt 47 relation.where("relative_position > ?", relative_position) 48 else 49 relation.where("relative_position < ?", relative_position) 50 end 51 end 52 53 def relative_siblings(relation = scoped_items) 54 object.exclude_self(relation) 55 end 56 57 # Handles the possibility that the position is already occupied by a sibling 58 def place_at_position(position, lhs) 59 current_occupant = relative_siblings.find_by(relative_position: position) 60 61 if current_occupant.present? 62 Mover.new(position, range).move(object, lhs.object, current_occupant) 63 else 64 object.relative_position = position 65 end 66 end 67 68 def lhs_neighbour 69 neighbour(object.next_object_by_relative_position(ignoring: ignoring, order: :desc)) 70 end 71 72 def rhs_neighbour 73 neighbour(object.next_object_by_relative_position(ignoring: ignoring, order: :asc)) 74 end 75 76 def neighbour(item) 77 return unless item.present? 78 79 self.class.new(item, range, ignoring: ignoring) 80 end 81 82 def calculate_relative_position(calculation) 83 # When calculating across projects, this is much more efficient than 84 # MAX(relative_position) without the GROUP BY, due to index usage: 85 # https://gitlab.com/gitlab-org/gitlab-foss/issues/54276#note_119340977 86 relation = scoped_items 87 .order(Gitlab::Database.nulls_last_order('position', 'DESC')) 88 .group(grouping_column) 89 .limit(1) 90 91 relation = yield relation if block_given? 92 93 relation 94 .pluck(grouping_column, Arel.sql("#{calculation}(relative_position) AS position")) 95 .first&.last 96 end 97 98 def grouping_column 99 model_class.relative_positioning_parent_column 100 end 101 102 def max_sibling 103 sib = relative_siblings 104 .order(Gitlab::Database.nulls_last_order('relative_position', 'DESC')) 105 .first 106 107 neighbour(sib) 108 end 109 110 def min_sibling 111 sib = relative_siblings 112 .order(Gitlab::Database.nulls_last_order('relative_position', 'ASC')) 113 .first 114 115 neighbour(sib) 116 end 117 118 def at_position(position) 119 item = scoped_items.find_by(relative_position: position) 120 121 raise InvalidPosition, 'No item found at the specified position' if item.nil? 122 123 neighbour(item) 124 end 125 126 def shift_left 127 move_sequence_before(true) 128 object.reset_relative_position 129 end 130 131 def shift_right 132 move_sequence_after(true) 133 object.reset_relative_position 134 end 135 136 def create_space_left 137 find_next_gap_before.tap { |gap| move_sequence_before(false, next_gap: gap) } 138 end 139 140 def create_space_right 141 find_next_gap_after.tap { |gap| move_sequence_after(false, next_gap: gap) } 142 end 143 144 def find_next_gap_before 145 items_with_next_pos = scoped_items 146 .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position DESC) AS next_pos') 147 .where('relative_position <= ?', relative_position) 148 .order(relative_position: :desc) 149 150 find_next_gap(items_with_next_pos, range.first) 151 end 152 153 def find_next_gap_after 154 items_with_next_pos = scoped_items 155 .select('relative_position AS pos, LEAD(relative_position) OVER (ORDER BY relative_position ASC) AS next_pos') 156 .where('relative_position >= ?', relative_position) 157 .order(:relative_position) 158 159 find_next_gap(items_with_next_pos, range.last) 160 end 161 162 def find_next_gap(items_with_next_pos, default_end) 163 gap = model_class 164 .from(items_with_next_pos, :items) 165 .where('next_pos IS NULL OR ABS(pos::bigint - next_pos::bigint) >= ?', MIN_GAP) 166 .limit(1) 167 .pluck(:pos, :next_pos) 168 .first 169 170 return if gap.nil? || gap.first == default_end 171 172 Gap.new(gap.first, gap.second || default_end) 173 end 174 175 def scoped_items 176 object.relative_positioning_scoped_items(ignoring: ignoring) 177 end 178 179 def relative_position 180 object.relative_position 181 end 182 183 private 184 185 # Moves the sequence before the current item to the middle of the next gap 186 # For example, we have 187 # 188 # 5 . . . . . 11 12 13 14 [15] 16 . 17 189 # ----------- 190 # 191 # This moves the sequence [11 12 13 14] to [8 9 10 11], so we have: 192 # 193 # 5 . . 8 9 10 11 . . . [15] 16 . 17 194 # --------- 195 # 196 # Creating a gap to the left of the current item. We can understand this as 197 # dividing the 5 spaces between 5 and 11 into two smaller gaps of 2 and 3. 198 # 199 # If `include_self` is true, the current item will also be moved, creating a 200 # gap to the right of the current item: 201 # 202 # 5 . . 8 9 10 11 [14] . . . 16 . 17 203 # -------------- 204 # 205 # As an optimization, the gap can be precalculated and passed to this method. 206 # 207 # @api private 208 # @raises NoSpaceLeft if the sequence cannot be moved 209 def move_sequence_before(include_self = false, next_gap: find_next_gap_before) 210 raise NoSpaceLeft unless next_gap.present? 211 212 delta = next_gap.delta 213 214 move_sequence(next_gap.start_pos, relative_position, -delta, include_self) 215 end 216 217 # Moves the sequence after the current item to the middle of the next gap 218 # For example, we have: 219 # 220 # 8 . 10 [11] 12 13 14 15 . . . . . 21 221 # ----------- 222 # 223 # This moves the sequence [12 13 14 15] to [15 16 17 18], so we have: 224 # 225 # 8 . 10 [11] . . . 15 16 17 18 . . 21 226 # ----------- 227 # 228 # Creating a gap to the right of the current item. We can understand this as 229 # dividing the 5 spaces between 15 and 21 into two smaller gaps of 3 and 2. 230 # 231 # If `include_self` is true, the current item will also be moved, creating a 232 # gap to the left of the current item: 233 # 234 # 8 . 10 . . . [14] 15 16 17 18 . . 21 235 # ---------------- 236 # 237 # As an optimization, the gap can be precalculated and passed to this method. 238 # 239 # @api private 240 # @raises NoSpaceLeft if the sequence cannot be moved 241 def move_sequence_after(include_self = false, next_gap: find_next_gap_after) 242 raise NoSpaceLeft unless next_gap.present? 243 244 delta = next_gap.delta 245 246 move_sequence(relative_position, next_gap.start_pos, delta, include_self) 247 end 248 249 def move_sequence(start_pos, end_pos, delta, include_self = false) 250 relation = include_self ? scoped_items : relative_siblings 251 252 object.update_relative_siblings(relation, (start_pos..end_pos), delta) 253 end 254 end 255 # rubocop: enable CodeReuse/ActiveRecord 256 end 257end 258