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