1# frozen_string_literal: true
2
3# Include atomic internal id generation scheme for a model
4#
5# This allows us to atomically generate internal ids that are
6# unique within a given scope.
7#
8# For example, let's generate internal ids for Issue per Project:
9# ```
10# class Issue < ApplicationRecord
11#   has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) }
12# end
13# ```
14#
15# This generates unique internal ids per project for newly created issues.
16# The generated internal id is saved in the `iid` attribute of `Issue`.
17#
18# This concern uses InternalId records to facilitate atomicity.
19# In the absence of a record for the given scope, one will be created automatically.
20# In this situation, the `init` block is called to calculate the initial value.
21# In the example above, we calculate the maximum `iid` of all issues
22# within the given project.
23#
24# Note that a model may have more than one internal id associated with possibly
25# different scopes.
26module AtomicInternalId
27  extend ActiveSupport::Concern
28
29  MissingValueError = Class.new(StandardError)
30
31  class_methods do
32    def has_internal_id( # rubocop:disable Naming/PredicateName
33      column, scope:, init: :not_given, ensure_if: nil, track_if: nil, presence: true, hook_names: :create)
34      raise "has_internal_id init must not be nil if given." if init.nil?
35      raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope)
36
37      init = infer_init(scope) if init == :not_given
38      callback_names = Array.wrap(hook_names).map { |hook_name| :"before_#{hook_name}" }
39      callback_names.each do |callback_name|
40        # rubocop:disable GitlabSecurity/PublicSend
41        public_send(callback_name, :"track_#{scope}_#{column}!", if: track_if)
42        public_send(callback_name, :"ensure_#{scope}_#{column}!", if: ensure_if)
43        # rubocop:enable GitlabSecurity/PublicSend
44      end
45      after_rollback :"clear_#{scope}_#{column}!", on: hook_names, if: ensure_if
46
47      if presence
48        before_create :"validate_#{column}_exists!"
49        before_update :"validate_#{column}_exists!"
50      end
51
52      define_singleton_internal_id_methods(scope, column, init)
53      define_instance_internal_id_methods(scope, column, init)
54    end
55
56    private
57
58    def infer_init(scope)
59      case scope
60      when :project
61        AtomicInternalId.project_init(self)
62      when :group
63        AtomicInternalId.group_init(self)
64      else
65        # We require init here to retain the ability to recalculate in the absence of a
66        # InternalId record (we may delete records in `internal_ids` for example).
67        raise "has_internal_id - cannot infer init for scope: #{scope}"
68      end
69    end
70
71    # Defines instance methods:
72    #   - ensure_{scope}_{column}!
73    #   - track_{scope}_{column}!
74    #   - reset_{scope}_{column}
75    #   - {column}=
76    def define_instance_internal_id_methods(scope, column, init)
77      define_method("ensure_#{scope}_#{column}!") do
78        scope_value = internal_id_read_scope(scope)
79        value = read_attribute(column)
80        return value unless scope_value
81
82        if value.nil?
83          # We don't have a value yet and use a InternalId record to generate
84          # the next value.
85          value = InternalId.generate_next(
86            self,
87            internal_id_scope_attrs(scope),
88            internal_id_scope_usage,
89            init)
90          write_attribute(column, value)
91
92          @internal_id_set_manually = false
93        end
94
95        value
96      end
97
98      define_method("track_#{scope}_#{column}!") do
99        return unless @internal_id_needs_tracking
100
101        scope_value = internal_id_read_scope(scope)
102        return unless scope_value
103
104        value = read_attribute(column)
105
106        if value.present?
107          # The value was set externally, e.g. by the user
108          # We update the InternalId record to keep track of the greatest value.
109          InternalId.track_greatest(
110            self,
111            internal_id_scope_attrs(scope),
112            internal_id_scope_usage,
113            value,
114            init)
115
116          @internal_id_needs_tracking = false
117        end
118      end
119
120      define_method("#{column}=") do |value|
121        super(value).tap do |v|
122          # Indicate the iid was set from externally
123          @internal_id_needs_tracking = true
124          @internal_id_set_manually = true
125        end
126      end
127
128      define_method("reset_#{scope}_#{column}") do
129        if value = read_attribute(column)
130          did_reset = InternalId.reset(
131            self,
132            internal_id_scope_attrs(scope),
133            internal_id_scope_usage,
134            value)
135
136          if did_reset
137            write_attribute(column, nil)
138          end
139        end
140
141        read_attribute(column)
142      end
143
144      define_method("clear_#{scope}_#{column}!") do
145        return if @internal_id_set_manually
146
147        return unless public_send(:"#{column}_previously_changed?") # rubocop:disable GitlabSecurity/PublicSend
148
149        write_attribute(column, nil)
150      end
151
152      define_method("validate_#{column}_exists!") do
153        value = read_attribute(column)
154
155        raise MissingValueError, "#{column} was unexpectedly blank!" if value.blank?
156      end
157    end
158
159    # Defines class methods:
160    #
161    # - with_{scope}_{column}_supply
162    #   This method can be used to allocate a stream of IID values during
163    #   bulk operations (importing/copying, etc).
164    #
165    #   Pass in a block that receives a `Supply` instance. To allocate a new
166    #   IID value, call `Supply#next_value`.
167    #
168    #   Example:
169    #
170    #   MyClass.with_project_iid_supply(project) do |supply|
171    #     attributes = MyClass.where(project: project).find_each do |record|
172    #       record.attributes.merge(iid: supply.next_value)
173    #     end
174    #
175    #     bulk_insert(attributes)
176    #   end
177    def define_singleton_internal_id_methods(scope, column, init)
178      define_singleton_method("with_#{scope}_#{column}_supply") do |scope_value, &block|
179        subject = find_by(scope => scope_value) || self
180        scope_attrs = ::AtomicInternalId.scope_attrs(scope_value)
181        usage = ::AtomicInternalId.scope_usage(self)
182
183        supply = Supply.new(-> { InternalId.generate_next(subject, scope_attrs, usage, init) })
184        block.call(supply)
185      end
186    end
187  end
188
189  def self.scope_attrs(scope_value)
190    { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value
191  end
192
193  def internal_id_scope_attrs(scope)
194    scope_value = internal_id_read_scope(scope)
195
196    ::AtomicInternalId.scope_attrs(scope_value)
197  end
198
199  def internal_id_scope_usage
200    ::AtomicInternalId.scope_usage(self.class)
201  end
202
203  def self.scope_usage(including_class)
204    including_class.table_name.to_sym
205  end
206
207  def self.project_init(klass, column_name = :iid)
208    ->(instance, scope) do
209      if instance
210        klass.default_scoped.where(project_id: instance.project_id).maximum(column_name)
211      elsif scope.present?
212        klass.default_scoped.where(**scope).maximum(column_name)
213      end
214    end
215  end
216
217  def self.group_init(klass, column_name = :iid)
218    ->(instance, scope) do
219      if instance
220        klass.where(group_id: instance.group_id).maximum(column_name)
221      elsif scope.present?
222        klass.where(group: scope[:namespace]).maximum(column_name)
223      end
224    end
225  end
226
227  def internal_id_read_scope(scope)
228    association(scope).reader
229  end
230
231  class Supply
232    attr_reader :generator
233
234    def initialize(generator)
235      @generator = generator
236    end
237
238    def next_value
239      @generator.call
240    end
241  end
242end
243