debian-mirror-gitlab/lib/gitlab/import_export/base/relation_factory.rb
2022-03-02 08:16:31 +05:30

327 lines
11 KiB
Ruby

# frozen_string_literal: true
module Gitlab
module ImportExport
module Base
class RelationFactory
include Gitlab::Utils::StrongMemoize
IMPORTED_OBJECT_MAX_RETRIES = 5
OVERRIDES = {}.freeze
EXISTING_OBJECT_RELATIONS = %i[].freeze
# This represents all relations that have unique key on `project_id` or `group_id`
UNIQUE_RELATIONS = %i[].freeze
USER_REFERENCES = %w[
author_id
assignee_id
updated_by_id
merged_by_id
latest_closed_by_id
user_id
created_by_id
last_edited_by_id
merge_user_id
resolved_by_id
closed_by_id
owner_id
].freeze
TOKEN_RESET_MODELS = %i[Project Namespace Group Ci::Trigger Ci::Build Ci::Runner ProjectHook ErrorTracking::ProjectErrorTrackingSetting].freeze
def self.create(*args, **kwargs)
new(*args, **kwargs).create
end
def self.relation_class(relation_name)
# There are scenarios where the model is pluralized (e.g.
# MergeRequest::Metrics), and we don't want to force it to singular
# with #classify.
relation_name.to_s.classify.constantize
rescue NameError
relation_name.to_s.constantize
end
def initialize(relation_sym:, relation_index:, relation_hash:, members_mapper:, object_builder:, user:, importable:, excluded_keys: [])
@relation_sym = relation_sym
@relation_name = self.class.overrides[relation_sym]&.to_sym || relation_sym
@relation_index = relation_index
@relation_hash = relation_hash.except('noteable_id')
@members_mapper = members_mapper
@object_builder = object_builder
@user = user
@importable = importable
@imported_object_retries = 0
@relation_hash[importable_column_name] = @importable.id
@original_user = {}
# Remove excluded keys from relation_hash
# We don't do this in the parsed_relation_hash because of the 'transformed attributes'
# For example, MergeRequestDiffFiles exports its diff attribute as utf8_diff. Then,
# in the create method that attribute is renamed to diff. And because diff is an excluded key,
# if we clean the excluded keys in the parsed_relation_hash, it will be removed
# from the object attributes and the export will fail.
@relation_hash.except!(*excluded_keys)
end
# Creates an object from an actual model with name "relation_sym" with params from
# the relation_hash, updating references with new object IDs, mapping users using
# the "members_mapper" object, also updating notes if required.
def create
return @relation_hash if author_relation?
return if invalid_relation? || predefined_relation?
setup_base_models
setup_models
generate_imported_object
end
def self.overrides
self::OVERRIDES
end
def self.existing_object_relations
self::EXISTING_OBJECT_RELATIONS
end
private
def invalid_relation?
false
end
def predefined_relation?
relation_class.try(:predefined_id?, @relation_hash['id'])
end
def author_relation?
@relation_name == :author
end
def setup_models
raise NotImplementedError
end
def unique_relations
# define in sub-class if any
self.class::UNIQUE_RELATIONS
end
def setup_base_models
update_user_references
remove_duplicate_assignees
reset_tokens!
remove_encrypted_attributes!
end
def update_user_references
self.class::USER_REFERENCES.each do |reference|
if @relation_hash[reference]
@original_user[reference] = @relation_hash[reference]
@relation_hash[reference] = @members_mapper.map[@relation_hash[reference]]
end
end
end
def remove_duplicate_assignees
return unless @relation_hash['issue_assignees']
# When an assignee did not exist in the members mapper, the importer is
# assigned. We only need to assign each user once.
@relation_hash['issue_assignees'].uniq!(&:user_id)
end
def generate_imported_object
imported_object
end
def reset_tokens!
return unless Gitlab::ImportExport.reset_tokens? && self.class::TOKEN_RESET_MODELS.include?(@relation_name)
# If we import/export to the same instance, tokens will have to be reset.
# We also have to reset them to avoid issues when the gitlab secrets file cannot be copied across.
relation_class.attribute_names.select { |name| name.include?('token') }.each do |token|
@relation_hash[token] = nil
end
end
def remove_encrypted_attributes!
return unless relation_class.respond_to?(:encrypted_attributes) && relation_class.encrypted_attributes.any?
relation_class.encrypted_attributes.each_key do |key|
@relation_hash[key.to_s] = nil
end
end
def relation_class
@relation_class ||= self.class.relation_class(@relation_name)
end
def importable_column_name
importable_class_name.concat('_id')
end
def importable_class_name
@importable.class.to_s.downcase
end
def imported_object
if existing_or_new_object.respond_to?(:importing)
existing_or_new_object.importing = true
end
existing_or_new_object
rescue ActiveRecord::RecordNotUnique
# as the operation is not atomic, retry in the unlikely scenario an INSERT is
# performed on the same object between the SELECT and the INSERT
@imported_object_retries += 1
retry if @imported_object_retries < IMPORTED_OBJECT_MAX_RETRIES
end
def parsed_relation_hash
strong_memoize(:parsed_relation_hash) do
if use_attributes_permitter? && attributes_permitter.permitted_attributes_defined?(@relation_sym)
attributes_permitter.permit(@relation_sym, @relation_hash)
else
Gitlab::ImportExport::AttributeCleaner.clean(relation_hash: @relation_hash, relation_class: relation_class)
end
end
end
def attributes_permitter
@attributes_permitter ||= Gitlab::ImportExport::AttributesPermitter.new
end
def use_attributes_permitter?
true
end
def existing_or_new_object
# Only find existing records to avoid mapping tables such as milestones
# Otherwise always create the record, skipping the extra SELECT clause.
@existing_or_new_object ||= begin
if existing_object?
attribute_hash = attribute_hash_for(['events'])
existing_object.assign_attributes(attribute_hash) if attribute_hash.any?
existing_object
else
# Because of single-type inheritance, we need to be careful to use the `type` field
# See https://gitlab.com/gitlab-org/gitlab/issues/34860#note_235321497
inheritance_column = relation_class.try(:inheritance_column)
inheritance_attributes = parsed_relation_hash.slice(inheritance_column)
object = relation_class.new(inheritance_attributes)
object.assign_attributes(parsed_relation_hash)
object
end
end
end
def attribute_hash_for(attributes)
attributes.each_with_object({}) do |hash, value|
hash[value] = parsed_relation_hash.delete(value) if parsed_relation_hash[value]
hash
end
end
def existing_object
@existing_object ||= find_or_create_object!
end
def unique_relation_object
unique_relation_object = relation_class.find_or_create_by(importable_column_name => @importable.id)
unique_relation_object.assign_attributes(parsed_relation_hash)
unique_relation_object
end
def find_or_create_object!
return unique_relation_object if unique_relation?
# Can't use IDs as validation exists calling `group` or `project` attributes
finder_hash = parsed_relation_hash.tap do |hash|
if relation_class.attribute_method?('group_id') && @importable.is_a?(::Project)
hash['group'] = @importable.group
end
hash[importable_class_name] = @importable if relation_class.reflect_on_association(importable_class_name.to_sym)
hash.delete(importable_column_name)
end
@object_builder.build(relation_class, finder_hash)
end
def setup_note
set_note_author
# attachment is deprecated and note uploads are handled by Markdown uploader
@relation_hash['attachment'] = nil
end
# Sets the author for a note. If the user importing the project
# has admin access, an actual mapping with new project members
# will be used. Otherwise, a note stating the original author name
# is left.
def set_note_author
old_author_id = @original_user['author_id']
author = @relation_hash.delete('author')
unless @members_mapper.include?(old_author_id)
@relation_hash['note'] = "%{note}\n\n %{missing_author_note}" % {
note: @relation_hash['note'].presence || '*Blank note*',
missing_author_note: missing_author_note(@relation_hash['updated_at'], author['name'])
}
end
end
def missing_author_note(updated_at, author_name)
timestamp = updated_at.split('.').first
"*By #{author_name} on #{timestamp} (imported from GitLab)*"
end
def existing_object?
strong_memoize(:_existing_object) do
self.class.existing_object_relations.include?(@relation_name) || unique_relation?
end
end
def unique_relation?
strong_memoize(:unique_relation) do
importable_foreign_key.present? &&
(has_unique_index_on_importable_fk? || uses_importable_fk_as_primary_key?)
end
end
def has_unique_index_on_importable_fk?
cache = cached_has_unique_index_on_importable_fk
table_name = relation_class.table_name
return cache[table_name] if cache.has_key?(table_name)
index_exists =
ActiveRecord::Base.connection.index_exists?(
relation_class.table_name,
importable_foreign_key,
unique: true)
cache[table_name] = index_exists
end
# Avoid unnecessary DB requests
def cached_has_unique_index_on_importable_fk
Thread.current[:cached_has_unique_index_on_importable_fk] ||= {}
end
def uses_importable_fk_as_primary_key?
relation_class.primary_key == importable_foreign_key
end
def importable_foreign_key
relation_class.reflect_on_association(importable_class_name.to_sym)&.foreign_key
end
end
end
end
end