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