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