# frozen_string_literal: true # Include atomic internal id generation scheme for a model # # This allows us to atomically generate internal ids that are # unique within a given scope. # # For example, let's generate internal ids for Issue per Project: # ``` # class Issue < ApplicationRecord # has_internal_id :iid, scope: :project, init: ->(s) { s.project.issues.maximum(:iid) } # end # ``` # # This generates unique internal ids per project for newly created issues. # The generated internal id is saved in the `iid` attribute of `Issue`. # # This concern uses InternalId records to facilitate atomicity. # In the absence of a record for the given scope, one will be created automatically. # In this situation, the `init` block is called to calculate the initial value. # In the example above, we calculate the maximum `iid` of all issues # within the given project. # # Note that a model may have more than one internal id associated with possibly # different scopes. module AtomicInternalId extend ActiveSupport::Concern MissingValueError = Class.new(StandardError) class_methods do def has_internal_id( # rubocop:disable Naming/PredicateName column, scope:, init: :not_given, ensure_if: nil, track_if: nil, presence: true, hook_names: :create) raise "has_internal_id init must not be nil if given." if init.nil? raise "has_internal_id needs to be defined on association." unless self.reflect_on_association(scope) init = infer_init(scope) if init == :not_given callback_names = Array.wrap(hook_names).map { |hook_name| :"before_#{hook_name}" } callback_names.each do |callback_name| # rubocop:disable GitlabSecurity/PublicSend public_send(callback_name, :"track_#{scope}_#{column}!", if: track_if) public_send(callback_name, :"ensure_#{scope}_#{column}!", if: ensure_if) # rubocop:enable GitlabSecurity/PublicSend end after_rollback :"clear_#{scope}_#{column}!", on: hook_names, if: ensure_if if presence before_create :"validate_#{column}_exists!" before_update :"validate_#{column}_exists!" end define_singleton_internal_id_methods(scope, column, init) define_instance_internal_id_methods(scope, column, init) end private def infer_init(scope) case scope when :project AtomicInternalId.project_init(self) when :group AtomicInternalId.group_init(self) when :namespace AtomicInternalId.namespace_init(self) else # We require init here to retain the ability to recalculate in the absence of a # InternalId record (we may delete records in `internal_ids` for example). raise "has_internal_id - cannot infer init for scope: #{scope}" end end # Defines instance methods: # - ensure_{scope}_{column}! # - track_{scope}_{column}! # - reset_{scope}_{column} # - {column}= def define_instance_internal_id_methods(scope, column, init) define_method("ensure_#{scope}_#{column}!") do scope_value = internal_id_read_scope(scope) value = read_attribute(column) return value unless scope_value if value.nil? # We don't have a value yet and use a InternalId record to generate # the next value. value = InternalId.generate_next( self, internal_id_scope_attrs(scope), internal_id_scope_usage, init) write_attribute(column, value) @internal_id_set_manually = false end value end define_method("track_#{scope}_#{column}!") do return unless @internal_id_needs_tracking scope_value = internal_id_read_scope(scope) return unless scope_value value = read_attribute(column) if value.present? # The value was set externally, e.g. by the user # We update the InternalId record to keep track of the greatest value. InternalId.track_greatest( self, internal_id_scope_attrs(scope), internal_id_scope_usage, value, init) @internal_id_needs_tracking = false end end define_method("#{column}=") do |value| super(value).tap do |v| # Indicate the iid was set from externally @internal_id_needs_tracking = true @internal_id_set_manually = true end end define_method("reset_#{scope}_#{column}") do if value = read_attribute(column) did_reset = InternalId.reset( self, internal_id_scope_attrs(scope), internal_id_scope_usage, value) if did_reset write_attribute(column, nil) end end read_attribute(column) end define_method("clear_#{scope}_#{column}!") do return if @internal_id_set_manually return unless public_send(:"#{column}_previously_changed?") # rubocop:disable GitlabSecurity/PublicSend write_attribute(column, nil) end define_method("validate_#{column}_exists!") do value = read_attribute(column) raise MissingValueError, "#{column} was unexpectedly blank!" if value.blank? end end # Defines class methods: # # - with_{scope}_{column}_supply # This method can be used to allocate a stream of IID values during # bulk operations (importing/copying, etc). # # Pass in a block that receives a `Supply` instance. To allocate a new # IID value, call `Supply#next_value`. # # Example: # # MyClass.with_project_iid_supply(project) do |supply| # attributes = MyClass.where(project: project).find_each do |record| # record.attributes.merge(iid: supply.next_value) # end # # bulk_insert(attributes) # end # # - track_#{scope}_#{column}! # This method can be used to set a new greatest IID value during import operations. # # Example: # # MyClass.track_project_iid!(project, value) def define_singleton_internal_id_methods(scope, column, init) define_singleton_method("with_#{scope}_#{column}_supply") do |scope_value, &block| subject = find_by(scope => scope_value) || self scope_attrs = ::AtomicInternalId.scope_attrs(scope_value) usage = ::AtomicInternalId.scope_usage(self) supply = Supply.new(-> { InternalId.generate_next(subject, scope_attrs, usage, init) }) block.call(supply) end define_singleton_method("track_#{scope}_#{column}!") do |scope_value, value| InternalId.track_greatest( self, ::AtomicInternalId.scope_attrs(scope_value), ::AtomicInternalId.scope_usage(self), value, init ) end end end def self.scope_attrs(scope_value) { scope_value.class.table_name.singularize.to_sym => scope_value } if scope_value end def internal_id_scope_attrs(scope) scope_value = internal_id_read_scope(scope) ::AtomicInternalId.scope_attrs(scope_value) end def internal_id_scope_usage ::AtomicInternalId.scope_usage(self.class) end def self.scope_usage(including_class) including_class.table_name.to_sym end def self.project_init(klass, column_name = :iid) ->(instance, scope) do if instance klass.default_scoped.where(project_id: instance.project_id).maximum(column_name) elsif scope.present? klass.default_scoped.where(**scope).maximum(column_name) end end end def self.group_init(klass, column_name = :iid) ->(instance, scope) do if instance klass.where(group_id: instance.group_id).maximum(column_name) elsif scope.present? klass.where(group: scope[:namespace]).maximum(column_name) end end end def self.namespace_init(klass, column_name = :iid) ->(instance, scope) do if instance klass.where(namespace_id: instance.namespace_id).maximum(column_name) elsif scope.present? klass.where(**scope).maximum(column_name) end end end def internal_id_read_scope(scope) association(scope).reader end class Supply attr_reader :generator def initialize(generator) @generator = generator end def next_value @generator.call end end end