1# frozen_string_literal: true 2 3module Gitlab 4 module ImportExport 5 module Base 6 class RelationFactory 7 include Gitlab::Utils::StrongMemoize 8 9 IMPORTED_OBJECT_MAX_RETRIES = 5 10 11 OVERRIDES = {}.freeze 12 EXISTING_OBJECT_RELATIONS = %i[].freeze 13 14 # This represents all relations that have unique key on `project_id` or `group_id` 15 UNIQUE_RELATIONS = %i[].freeze 16 17 USER_REFERENCES = %w[ 18 author_id 19 assignee_id 20 updated_by_id 21 merged_by_id 22 latest_closed_by_id 23 user_id 24 created_by_id 25 last_edited_by_id 26 merge_user_id 27 resolved_by_id 28 closed_by_id 29 owner_id 30 ].freeze 31 32 TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook ErrorTracking::ProjectErrorTrackingSetting].freeze 33 34 def self.create(*args, **kwargs) 35 new(*args, **kwargs).create 36 end 37 38 def self.relation_class(relation_name) 39 # There are scenarios where the model is pluralized (e.g. 40 # MergeRequest::Metrics), and we don't want to force it to singular 41 # with #classify. 42 relation_name.to_s.classify.constantize 43 rescue NameError 44 relation_name.to_s.constantize 45 end 46 47 def initialize(relation_sym:, relation_index:, relation_hash:, members_mapper:, object_builder:, user:, importable:, excluded_keys: []) 48 @relation_sym = relation_sym 49 @relation_name = self.class.overrides[relation_sym]&.to_sym || relation_sym 50 @relation_index = relation_index 51 @relation_hash = relation_hash.except('noteable_id') 52 @members_mapper = members_mapper 53 @object_builder = object_builder 54 @user = user 55 @importable = importable 56 @imported_object_retries = 0 57 @relation_hash[importable_column_name] = @importable.id 58 @original_user = {} 59 60 # Remove excluded keys from relation_hash 61 # We don't do this in the parsed_relation_hash because of the 'transformed attributes' 62 # For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then, 63 # in the create method that attribute is renamed to diff. And because diff is an excluded key, 64 # if we clean the excluded keys in the parsed_relation_hash, it will be removed 65 # from the object attributes and the export will fail. 66 @relation_hash.except!(*excluded_keys) 67 end 68 69 # Creates an object from an actual model with name "relation_sym" with params from 70 # the relation_hash, updating references with new object IDs, mapping users using 71 # the "members_mapper" object, also updating notes if required. 72 def create 73 return @relation_hash if author_relation? 74 return if invalid_relation? || predefined_relation? 75 76 setup_base_models 77 setup_models 78 79 generate_imported_object 80 end 81 82 def self.overrides 83 self::OVERRIDES 84 end 85 86 def self.existing_object_relations 87 self::EXISTING_OBJECT_RELATIONS 88 end 89 90 private 91 92 def invalid_relation? 93 false 94 end 95 96 def predefined_relation? 97 relation_class.try(:predefined_id?, @relation_hash['id']) 98 end 99 100 def author_relation? 101 @relation_name == :author 102 end 103 104 def setup_models 105 raise NotImplementedError 106 end 107 108 def unique_relations 109 # define in sub-class if any 110 self.class::UNIQUE_RELATIONS 111 end 112 113 def setup_base_models 114 update_user_references 115 remove_duplicate_assignees 116 reset_tokens! 117 remove_encrypted_attributes! 118 end 119 120 def update_user_references 121 self.class::USER_REFERENCES.each do |reference| 122 if @relation_hash[reference] 123 @original_user[reference] = @relation_hash[reference] 124 @relation_hash[reference] = @members_mapper.map[@relation_hash[reference]] 125 end 126 end 127 end 128 129 def remove_duplicate_assignees 130 return unless @relation_hash['issue_assignees'] 131 132 # When an assignee did not exist in the members mapper, the importer is 133 # assigned. We only need to assign each user once. 134 @relation_hash['issue_assignees'].uniq!(&:user_id) 135 end 136 137 def generate_imported_object 138 imported_object 139 end 140 141 def reset_tokens! 142 return unless Gitlab::ImportExport.reset_tokens? && self.class::TOKEN_RESET_MODELS.include?(@relation_name) 143 144 # If we import/export to the same instance, tokens will have to be reset. 145 # We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across. 146 relation_class.attribute_names.select { |name| name.include?('token') }.each do |token| 147 @relation_hash[token] = nil 148 end 149 end 150 151 def remove_encrypted_attributes! 152 return unless relation_class.respond_to?(:encrypted_attributes) && relation_class.encrypted_attributes.any? 153 154 relation_class.encrypted_attributes.each_key do |key| 155 @relation_hash[key.to_s] = nil 156 end 157 end 158 159 def relation_class 160 @relation_class ||= self.class.relation_class(@relation_name) 161 end 162 163 def importable_column_name 164 importable_class_name.concat('_id') 165 end 166 167 def importable_class_name 168 @importable.class.to_s.downcase 169 end 170 171 def imported_object 172 if existing_or_new_object.respond_to?(:importing) 173 existing_or_new_object.importing = true 174 end 175 176 existing_or_new_object 177 rescue ActiveRecord::RecordNotUnique 178 # as the operation is not atomic, retry in the unlikely scenario an INSERT is 179 # performed on the same object between the SELECT and the INSERT 180 @imported_object_retries += 1 181 retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES 182 end 183 184 def parsed_relation_hash 185 strong_memoize(:parsed_relation_hash) do 186 if use_attributes_permitter? && attributes_permitter.permitted_attributes_defined?(@relation_sym) 187 attributes_permitter.permit(@relation_sym, @relation_hash) 188 else 189 Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash, relation_class: relation_class) 190 end 191 end 192 end 193 194 def attributes_permitter 195 @attributes_permitter ||= Gitlab::ImportExport::AttributesPermitter.new 196 end 197 198 def use_attributes_permitter? 199 Feature.enabled?(:permitted_attributes_for_import_export, default_enabled: :yaml) 200 end 201 202 def existing_or_new_object 203 # Only find existing records to avoid mapping tables such as milestones 204 # Otherwise always create the record, skipping the extra SELECT clause. 205 @existing_or_new_object ||= begin 206 if existing_object? 207 attribute_hash = attribute_hash_for(['events']) 208 209 existing_object.assign_attributes(attribute_hash) if attribute_hash.any? 210 211 existing_object 212 else 213 # Because of single-type inheritance, we need to be careful to use the `type` field 214 # See https://gitlab.com/gitlab-org/gitlab/issues/34860#note_235321497 215 inheritance_column = relation_class.try(:inheritance_column) 216 inheritance_attributes = parsed_relation_hash.slice(inheritance_column) 217 object = relation_class.new(inheritance_attributes) 218 object.assign_attributes(parsed_relation_hash) 219 object 220 end 221 end 222 end 223 224 def attribute_hash_for(attributes) 225 attributes.each_with_object({}) do |hash, value| 226 hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value] 227 hash 228 end 229 end 230 231 def existing_object 232 @existing_object ||= find_or_create_object! 233 end 234 235 def unique_relation_object 236 unique_relation_object = relation_class.find_or_create_by(importable_column_name => @importable.id) 237 unique_relation_object.assign_attributes(parsed_relation_hash) 238 unique_relation_object 239 end 240 241 def find_or_create_object! 242 return unique_relation_object if unique_relation? 243 244 # Can't use IDs as validation exists calling `group` or `project` attributes 245 finder_hash = parsed_relation_hash.tap do |hash| 246 if relation_class.attribute_method?('group_id') && @importable.is_a?(::Project) 247 hash['group'] = @importable.group 248 end 249 250 hash[importable_class_name] = @importable if relation_class.reflect_on_association(importable_class_name.to_sym) 251 hash.delete(importable_column_name) 252 end 253 254 @object_builder.build(relation_class, finder_hash) 255 end 256 257 def setup_note 258 set_note_author 259 # attachment is deprecated and note uploads are handled by Markdown uploader 260 @relation_hash['attachment'] = nil 261 end 262 263 # Sets the author for a note. If the user importing the project 264 # has admin access, an actual mapping with new project members 265 # will be used. Otherwise, a note stating the original author name 266 # is left. 267 def set_note_author 268 old_author_id = @original_user['author_id'] 269 author = @relation_hash.delete('author') 270 271 unless @members_mapper.include?(old_author_id) 272 @relation_hash['note'] = "%{note}\n\n %{missing_author_note}" % { 273 note: @relation_hash['note'].presence || '*Blank note*', 274 missing_author_note: missing_author_note(@relation_hash['updated_at'], author['name']) 275 } 276 end 277 end 278 279 def missing_author_note(updated_at, author_name) 280 timestamp = updated_at.split('.').first 281 "*By #{author_name} on #{timestamp} (imported from GitLab)*" 282 end 283 284 def existing_object? 285 strong_memoize(:_existing_object) do 286 self.class.existing_object_relations.include?(@relation_name) || unique_relation? 287 end 288 end 289 290 def unique_relation? 291 strong_memoize(:unique_relation) do 292 importable_foreign_key.present? && 293 (has_unique_index_on_importable_fk? || uses_importable_fk_as_primary_key?) 294 end 295 end 296 297 def has_unique_index_on_importable_fk? 298 cache = cached_has_unique_index_on_importable_fk 299 table_name = relation_class.table_name 300 return cache[table_name] if cache.has_key?(table_name) 301 302 index_exists = 303 ActiveRecord::Base.connection.index_exists?( 304 relation_class.table_name, 305 importable_foreign_key, 306 unique: true) 307 308 cache[table_name] = index_exists 309 end 310 311 # Avoid unnecessary DB requests 312 def cached_has_unique_index_on_importable_fk 313 Thread.current[:cached_has_unique_index_on_importable_fk] ||= {} 314 end 315 316 def uses_importable_fk_as_primary_key? 317 relation_class.primary_key == importable_foreign_key 318 end 319 320 def importable_foreign_key 321 relation_class.reflect_on_association(importable_class_name.to_sym)&.foreign_key 322 end 323 end 324 end 325 end 326end 327