Welcome to mirror list, hosted at ThFree Co, Russian Federation.

relation_factory.rb « base « import_export « gitlab « lib - gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
blob: d1fd45882d3b33ee5058bcfa30ed9d3e08f07c5f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
# 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

        attr_reader :relation_name, :importable

        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

        # When an assignee (or any other listed association) did not exist in the members mapper, the importer is
        # assigned. We only need to assign each user once.
        def remove_duplicate_assignees
          associations = %w[issue_assignees merge_request_assignees merge_request_reviewers approvals]

          associations.each do |association|
            next unless @relation_hash.key?(association)
            next unless @relation_hash[association].is_a?(Array)
            next if @relation_hash[association].empty?

            @relation_hash[association].select! { |record| record.respond_to?(:user_id) }
            @relation_hash[association].uniq!(&:user_id)
          end
        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 ||= 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

        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 =
            relation_class.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