Normalize import_export structure

This brings a significant refactor to how
we handle `import_export.yml`, merge it with EE
and how we handle that for reader and saver.

This is meant to simplify the code, and remove
a ton of conditions to handle different models
of the structure.

This is also meant to prepare the structure
to extend it much easier, like adding `preload:`
or additional object types when needed.

This does not change the behavior of import/export,
rather unifies and simplifies the current implementation.
This commit is contained in:
Kamil Trzciński 2019-09-05 15:51:27 +02:00
parent 5857f0e57a
commit 0eeadb2dd2
14 changed files with 596 additions and 603 deletions

View File

@ -3,35 +3,19 @@
module Gitlab
module ImportExport
class AttributesFinder
def initialize(included_attributes:, excluded_attributes:, methods:)
@included_attributes = included_attributes || {}
@excluded_attributes = excluded_attributes || {}
@methods = methods || {}
def initialize(config:)
@tree = config[:tree] || {}
@included_attributes = config[:included_attributes] || {}
@excluded_attributes = config[:excluded_attributes] || {}
@methods = config[:methods] || {}
end
def find(model_object)
parsed_hash = find_attributes_only(model_object)
parsed_hash.empty? ? model_object : { model_object => parsed_hash }
def find_root(model_key)
find(model_key, @tree[model_key])
end
def parse(model_object)
parsed_hash = find_attributes_only(model_object)
yield parsed_hash unless parsed_hash.empty?
end
def find_included(value)
key = key_from_hash(value)
@included_attributes[key].nil? ? {} : { only: @included_attributes[key] }
end
def find_excluded(value)
key = key_from_hash(value)
@excluded_attributes[key].nil? ? {} : { except: @excluded_attributes[key] }
end
def find_method(value)
key = key_from_hash(value)
@methods[key].nil? ? {} : { methods: @methods[key] }
def find_relations_tree(model_key)
@tree[model_key]
end
def find_excluded_keys(klass_name)
@ -40,12 +24,24 @@ module Gitlab
private
def find_attributes_only(value)
find_included(value).merge(find_excluded(value)).merge(find_method(value))
def find(model_key, model_tree)
{
only: @included_attributes[model_key],
except: @excluded_attributes[model_key],
methods: @methods[model_key],
include: resolve_model_tree(model_tree)
}.compact
end
def key_from_hash(value)
value.is_a?(Hash) ? value.first.first : value
def resolve_model_tree(model_tree)
return unless model_tree
model_tree
.map(&method(:resolve_model))
end
def resolve_model(model_key, model_tree)
{ model_key => find(model_key, model_tree) }
end
end
end

View File

@ -3,70 +3,49 @@
module Gitlab
module ImportExport
class Config
def initialize
@hash = parse_yaml
@hash.deep_symbolize_keys!
@ee_hash = @hash.delete(:ee) || {}
@hash[:tree] = normalize_tree(@hash[:tree])
@ee_hash[:tree] = normalize_tree(@ee_hash[:tree] || {})
end
# Returns a Hash of the YAML file, including EE specific data if EE is
# used.
def to_h
hash = parse_yaml
ee_hash = hash['ee']
if merge_ee?
deep_merge(@hash, @ee_hash)
else
@hash
end
end
if merge? && ee_hash
ee_hash.each do |key, value|
if key == 'project_tree'
merge_project_tree(value, hash[key])
else
merge_attributes_list(value, hash[key])
end
end
end
# We don't want to expose this section after this point, as it is no
# longer needed.
hash.delete('ee')
hash
end
# Merges a project relationships tree into the target tree.
#
# @param [Array<Hash|Symbol>] source_values
# @param [Array<Hash|Symbol>] target_values
def merge_project_tree(source_values, target_values)
source_values.each do |value|
if value.is_a?(Hash)
# Examples:
#
# { 'project_tree' => [{ 'labels' => [...] }] }
# { 'notes' => [:author, { 'events' => [:push_event_payload] }] }
value.each do |key, val|
target = target_values
.find { |h| h.is_a?(Hash) && h[key] }
if target
merge_project_tree(val, target[key])
else
target_values << { key => val.dup }
end
end
else
# Example: :priorities, :author, etc
target_values << value
private
def deep_merge(hash_a, hash_b)
hash_a.deep_merge(hash_b) do |_, this_val, other_val|
this_val.to_a + other_val.to_a
end
end
def normalize_tree(item)
case item
when Array
item.reduce({}) do |hash, subitem|
hash.merge!(normalize_tree(subitem))
end
when Hash
item.transform_values(&method(:normalize_tree))
when Symbol
{ item => {} }
else
raise ArgumentError, "#{item} needs to be Array, Hash, Symbol or NilClass"
end
end
# Merges a Hash containing a flat list of attributes, such as the entries
# in a `excluded_attributes` section.
#
# @param [Hash] source_values
# @param [Hash] target_values
def merge_attributes_list(source_values, target_values)
source_values.each do |key, values|
target_values[key] ||= []
target_values[key].concat(values)
end
end
def merge?
def merge_ee?
Gitlab.ee?
end

View File

@ -3,89 +3,92 @@
# This list _must_ only contain relationships that are available to both CE and
# EE. EE specific relationships must be defined in the `ee` section further
# down below.
project_tree:
- labels:
- :priorities
- milestones:
- events:
- :push_event_payload
- issues:
- events:
- :push_event_payload
- :timelogs
- notes:
- :author
tree:
project:
- labels:
- :priorities
- milestones:
- events:
- :push_event_payload
- label_links:
- label:
- :priorities
- milestone:
- issues:
- events:
- :push_event_payload
- resource_label_events:
- label:
- :priorities
- :issue_assignees
- snippets:
- :award_emoji
- notes:
- :author
- releases:
- :links
- project_members:
- :timelogs
- notes:
- :author
- events:
- :push_event_payload
- label_links:
- label:
- :priorities
- milestone:
- events:
- :push_event_payload
- resource_label_events:
- label:
- :priorities
- :issue_assignees
- snippets:
- :award_emoji
- notes:
- :author
- releases:
- :links
- project_members:
- :user
- merge_requests:
- :metrics
- notes:
- :author
- events:
- :push_event_payload
- :suggestions
- merge_request_diff:
- :merge_request_diff_commits
- :merge_request_diff_files
- events:
- :push_event_payload
- :timelogs
- label_links:
- label:
- :priorities
- milestone:
- events:
- :push_event_payload
- resource_label_events:
- label:
- :priorities
- ci_pipelines:
- notes:
- :author
- events:
- :push_event_payload
- stages:
- :statuses
- :external_pull_request
- :external_pull_requests
- :auto_devops
- :triggers
- :pipeline_schedules
- :services
- protected_branches:
- :merge_access_levels
- :push_access_levels
- protected_tags:
- :create_access_levels
- :project_feature
- :custom_attributes
- :prometheus_metrics
- :project_badges
- :ci_cd_settings
- :error_tracking_setting
- :metrics_setting
- boards:
- lists:
- label:
- :priorities
group_members:
- :user
- merge_requests:
- :metrics
- notes:
- :author
- events:
- :push_event_payload
- :suggestions
- merge_request_diff:
- :merge_request_diff_commits
- :merge_request_diff_files
- events:
- :push_event_payload
- :timelogs
- label_links:
- label:
- :priorities
- milestone:
- events:
- :push_event_payload
- resource_label_events:
- label:
- :priorities
- ci_pipelines:
- notes:
- :author
- events:
- :push_event_payload
- stages:
- :statuses
- :external_pull_request
- :external_pull_requests
- :auto_devops
- :triggers
- :pipeline_schedules
- :services
- protected_branches:
- :merge_access_levels
- :push_access_levels
- protected_tags:
- :create_access_levels
- :project_feature
- :custom_attributes
- :prometheus_metrics
- :project_badges
- :ci_cd_settings
- :error_tracking_setting
- :metrics_setting
- boards:
- lists:
- label:
- :priorities
# Only include the following attributes for the models specified.
included_attributes:
@ -225,12 +228,15 @@ methods:
- :type
lists:
- :list_type
ci_pipelines:
- :notes
# EE specific relationships and settings to include. All of this will be merged
# into the previous structures if EE is used.
ee:
project_tree:
- protected_branches:
- :unprotect_access_levels
- protected_environments:
- :deploy_access_levels
tree:
project:
protected_branches:
- :unprotect_access_levels
protected_environments:
- :deploy_access_levels

View File

@ -1,117 +0,0 @@
# frozen_string_literal: true
module Gitlab
module ImportExport
# Generates a hash that conforms with http://apidock.com/rails/Hash/to_json
# and its peculiar options.
class JsonHashBuilder
def self.build(model_objects, attributes_finder)
new(model_objects, attributes_finder).build
end
def initialize(model_objects, attributes_finder)
@model_objects = model_objects
@attributes_finder = attributes_finder
end
def build
process_model_objects(@model_objects)
end
private
# Called when the model is actually a hash containing other relations (more models)
# Returns the config in the right format for calling +to_json+
#
# +model_object_hash+ - A model relationship such as:
# {:merge_requests=>[:merge_request_diff, :notes]}
def process_model_objects(model_object_hash)
json_config_hash = {}
current_key = model_object_hash.first.first
model_object_hash.values.flatten.each do |model_object|
@attributes_finder.parse(current_key) { |hash| json_config_hash[current_key] ||= hash }
handle_model_object(current_key, model_object, json_config_hash)
end
json_config_hash
end
# Creates or adds to an existing hash an individual model or list
#
# +current_key+ main model that will be a key in the hash
# +model_object+ model or list of models to include in the hash
# +json_config_hash+ the original hash containing the root model
def handle_model_object(current_key, model_object, json_config_hash)
model_or_sub_model = model_object.is_a?(Hash) ? process_model_objects(model_object) : model_object
if json_config_hash[current_key]
add_model_value(current_key, model_or_sub_model, json_config_hash)
else
create_model_value(current_key, model_or_sub_model, json_config_hash)
end
end
# Constructs a new hash that will hold the configuration for that particular object
# It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+
#
# +current_key+ main model that will be a key in the hash
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def create_model_value(current_key, value, json_config_hash)
json_config_hash[current_key] = parse_hash(value) || { include: value }
end
# Calls attributes finder to parse the hash and add any attributes to it
#
# +value+ existing model to be included in the hash
# +parsed_hash+ the original hash
def parse_hash(value)
return if already_contains_methods?(value)
@attributes_finder.parse(value) do |hash|
{ include: hash_or_merge(value, hash) }
end
end
def already_contains_methods?(value)
value.is_a?(Hash) && value.values.detect { |val| val[:methods]}
end
# Adds new model configuration to an existing hash with key +current_key+
# It may include exceptions or other attribute detail configuration, parsed by +@attributes_finder+
#
# +current_key+ main model that will be a key in the hash
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def add_model_value(current_key, value, json_config_hash)
@attributes_finder.parse(value) do |hash|
value = { value => hash } unless value.is_a?(Hash)
end
add_to_array(current_key, json_config_hash, value)
end
# Adds new model configuration to an existing hash with key +current_key+
# it creates a new array if it was previously a single value
#
# +current_key+ main model that will be a key in the hash
# +value+ existing model to be included in the hash
# +json_config_hash+ the original hash containing the root model
def add_to_array(current_key, json_config_hash, value)
old_values = json_config_hash[current_key][:include]
json_config_hash[current_key][:include] = ([old_values] + [value]).compact.flatten
end
# Construct a new hash or merge with an existing one a model configuration
# This is to fulfil +to_json+ requirements.
#
# +hash+ hash containing configuration generated mainly from +@attributes_finder+
# +value+ existing model to be included in the hash
def hash_or_merge(value, hash)
value.is_a?(Hash) ? value.merge(hash) : { value => hash }
end
end
end
end

View File

@ -58,11 +58,13 @@ module Gitlab
# the configuration yaml file too.
# Finally, it updates each attribute in the newly imported project.
def create_relations
default_relation_list.each do |relation|
if relation.is_a?(Hash)
create_sub_relations(relation, @tree_hash)
elsif @tree_hash[relation.to_s].present?
save_relation_hash(@tree_hash[relation.to_s], relation)
project_relations_without_project_members.each do |relation_key, relation_definition|
relation_key_s = relation_key.to_s
if relation_definition.present?
create_sub_relations(relation_key_s, relation_definition, @tree_hash)
elsif @tree_hash[relation_key_s].present?
save_relation_hash(relation_key_s, @tree_hash[relation_key_s])
end
end
@ -71,7 +73,7 @@ module Gitlab
@saved
end
def save_relation_hash(relation_hash_batch, relation_key)
def save_relation_hash(relation_key, relation_hash_batch)
relation_hash = create_relation(relation_key, relation_hash_batch)
remove_group_models(relation_hash) if relation_hash.is_a?(Array)
@ -91,10 +93,13 @@ module Gitlab
end
end
def default_relation_list
reader.tree.reject do |model|
model.is_a?(Hash) && model[:project_members]
end
def project_relations_without_project_members
# We remove `project_members` as they are deserialized separately
project_relations.except(:project_members)
end
def project_relations
reader.attributes_finder.find_relations_tree(:project)
end
def restore_project
@ -150,8 +155,7 @@ module Gitlab
# issue, finds any subrelations such as notes, creates them and assign them back to the hash
#
# Recursively calls this method if the sub-relation is a hash containing more sub-relations
def create_sub_relations(relation, tree_hash, save: true)
relation_key = relation.keys.first.to_s
def create_sub_relations(relation_key, relation_definition, tree_hash, save: true)
return if tree_hash[relation_key].blank?
tree_array = [tree_hash[relation_key]].flatten
@ -171,13 +175,13 @@ module Gitlab
# But we can't have it in the upper level or GC won't get rid of the AR objects
# after we save the batch.
Project.transaction do
process_sub_relation(relation, relation_item)
process_sub_relation(relation_key, relation_definition, relation_item)
# For every subrelation that hangs from Project, save the associated records altogether
# This effectively batches all records per subrelation item, only keeping those in memory
# We have to keep in mind that more batch granularity << Memory, but >> Slowness
if save
save_relation_hash([relation_item], relation_key)
save_relation_hash(relation_key, [relation_item])
tree_hash[relation_key].delete(relation_item)
end
end
@ -186,37 +190,35 @@ module Gitlab
tree_hash.delete(relation_key) if save
end
def process_sub_relation(relation, relation_item)
relation.values.flatten.each do |sub_relation|
def process_sub_relation(relation_key, relation_definition, relation_item)
relation_definition.each do |sub_relation_key, sub_relation_definition|
# We just use author to get the user ID, do not attempt to create an instance.
next if sub_relation == :author
next if sub_relation_key == :author
create_sub_relations(sub_relation, relation_item, save: false) if sub_relation.is_a?(Hash)
sub_relation_key_s = sub_relation_key.to_s
relation_hash, sub_relation = assign_relation_hash(relation_item, sub_relation)
relation_item[sub_relation.to_s] = create_relation(sub_relation, relation_hash) unless relation_hash.blank?
# create dependent relations if present
if sub_relation_definition.present?
create_sub_relations(sub_relation_key_s, sub_relation_definition, relation_item, save: false)
end
# transform relation hash to actual object
sub_relation_hash = relation_item[sub_relation_key_s]
if sub_relation_hash.present?
relation_item[sub_relation_key_s] = create_relation(sub_relation_key, sub_relation_hash)
end
end
end
def assign_relation_hash(relation_item, sub_relation)
if sub_relation.is_a?(Hash)
relation_hash = relation_item[sub_relation.keys.first.to_s]
sub_relation = sub_relation.keys.first
else
relation_hash = relation_item[sub_relation.to_s]
end
[relation_hash, sub_relation]
end
def create_relation(relation, relation_hash_list)
def create_relation(relation_key, relation_hash_list)
relation_array = [relation_hash_list].flatten.map do |relation_hash|
Gitlab::ImportExport::RelationFactory.create(relation_sym: relation.to_sym,
relation_hash: relation_hash,
members_mapper: members_mapper,
user: @user,
project: @restored_project,
excluded_keys: excluded_keys_for_relation(relation))
Gitlab::ImportExport::RelationFactory.create(
relation_sym: relation_key.to_sym,
relation_hash: relation_hash,
members_mapper: members_mapper,
user: @user,
project: @restored_project,
excluded_keys: excluded_keys_for_relation(relation_key))
end.compact
relation_hash_list.is_a?(Array) ? relation_array : relation_array.first

View File

@ -18,7 +18,10 @@ module Gitlab
def save
mkdir_p(@shared.export_path)
File.write(full_path, project_json_tree)
project_tree = serialize_project_tree
fix_project_tree(project_tree)
File.write(full_path, project_tree.to_json)
true
rescue => e
@shared.error(e)
@ -27,27 +30,25 @@ module Gitlab
private
def project_json_tree
def fix_project_tree(project_tree)
if @params[:description].present?
project_json['description'] = @params[:description]
project_tree['description'] = @params[:description]
end
project_json['project_members'] += group_members_json
project_tree['project_members'] += group_members_array
RelationRenameService.add_new_associations(project_json)
project_json.to_json
RelationRenameService.add_new_associations(project_tree)
end
def project_json
@project_json ||= @project.as_json(reader.project_tree)
def serialize_project_tree
@project.as_json(reader.project_tree)
end
def reader
@reader ||= Gitlab::ImportExport::Reader.new(shared: @shared)
end
def group_members_json
def group_members_array
group_members.as_json(reader.group_members_tree).each do |group_member|
group_member['source_type'] = 'Project' # Make group members project members of the future import
end

View File

@ -7,42 +7,22 @@ module Gitlab
def initialize(shared:)
@shared = shared
config_hash = ImportExport::Config.new.to_h.deep_symbolize_keys
@tree = config_hash[:project_tree]
@attributes_finder = Gitlab::ImportExport::AttributesFinder.new(included_attributes: config_hash[:included_attributes],
excluded_attributes: config_hash[:excluded_attributes],
methods: config_hash[:methods])
@attributes_finder = Gitlab::ImportExport::AttributesFinder.new(
config: ImportExport::Config.new.to_h)
end
# Outputs a hash in the format described here: http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html
# for outputting a project in JSON format, including its relations and sub relations.
def project_tree
attributes = @attributes_finder.find(:project)
project_attributes = attributes.is_a?(Hash) ? attributes[:project] : {}
project_attributes.merge(include: build_hash(@tree))
attributes_finder.find_root(:project)
rescue => e
@shared.error(e)
false
end
def group_members_tree
@attributes_finder.find_included(:project_members).merge(include: @attributes_finder.find(:user))
end
private
# Builds a hash in the format described here: http://api.rubyonrails.org/classes/ActiveModel/Serializers/JSON.html
#
# +model_list+ - List of models as a relation tree to be included in the generated JSON, from the _import_export.yml_ file
def build_hash(model_list)
model_list.map do |model_objects|
if model_objects.is_a?(Hash)
Gitlab::ImportExport::JsonHashBuilder.build(model_objects, @attributes_finder)
else
@attributes_finder.find(model_objects)
end
end
attributes_finder.find_root(:group_members)
end
end
end

View File

@ -12,7 +12,7 @@ describe 'Import/Export attribute configuration' do
let(:config_hash) { Gitlab::ImportExport::Config.new.to_h.deep_stringify_keys }
let(:relation_names) do
names = names_from_tree(config_hash['project_tree'])
names = names_from_tree(config_hash.dig('tree', 'project'))
# Remove duplicated or add missing models
# - project is not part of the tree, so it has to be added manually.

View File

@ -0,0 +1,195 @@
# frozen_string_literal: true
require 'fast_spec_helper'
describe Gitlab::ImportExport::AttributesFinder do
describe '#find_root' do
subject { described_class.new(config: config).find_root(model_key) }
let(:test_config) { 'spec/support/import_export/import_export.yml' }
let(:config) { Gitlab::ImportExport::Config.new.to_h }
let(:model_key) { :project }
let(:project_tree_hash) do
{
except: [:id, :created_at],
include: [
{ issues: { include: [] } },
{ labels: { include: [] } },
{ merge_requests: {
except: [:iid],
include: [
{ merge_request_diff: {
include: []
} },
{ merge_request_test: { include: [] } }
],
only: [:id]
} },
{ commit_statuses: {
include: [{ commit: { include: [] } }]
} },
{ project_members: {
include: [{ user: { include: [],
only: [:email] } }]
} }
]
}
end
before do
allow_any_instance_of(Gitlab::ImportExport).to receive(:config_file).and_return(test_config)
end
it 'generates hash from project tree config' do
is_expected.to match(project_tree_hash)
end
context 'individual scenarios' do
it 'generates the correct hash for a single project relation' do
setup_yaml(tree: { project: [:issues] })
is_expected.to match(
include: [{ issues: { include: [] } }]
)
end
it 'generates the correct hash for a single project feature relation' do
setup_yaml(tree: { project: [:project_feature] })
is_expected.to match(
include: [{ project_feature: { include: [] } }]
)
end
it 'generates the correct hash for a multiple project relation' do
setup_yaml(tree: { project: [:issues, :snippets] })
is_expected.to match(
include: [{ issues: { include: [] } },
{ snippets: { include: [] } }]
)
end
it 'generates the correct hash for a single sub-relation' do
setup_yaml(tree: { project: [issues: [:notes]] })
is_expected.to match(
include: [{ issues: { include: [{ notes: { include: [] } }] } }]
)
end
it 'generates the correct hash for a multiple sub-relation' do
setup_yaml(tree: { project: [merge_requests: [:notes, :merge_request_diff]] })
is_expected.to match(
include: [{ merge_requests:
{ include: [{ notes: { include: [] } },
{ merge_request_diff: { include: [] } }] } }]
)
end
it 'generates the correct hash for a sub-relation with another sub-relation' do
setup_yaml(tree: { project: [merge_requests: [notes: [:author]]] })
is_expected.to match(
include: [{ merge_requests: {
include: [{ notes: { include: [{ author: { include: [] } }] } }]
} }]
)
end
it 'generates the correct hash for a relation with included attributes' do
setup_yaml(tree: { project: [:issues] },
included_attributes: { issues: [:name, :description] })
is_expected.to match(
include: [{ issues: { include: [],
only: [:name, :description] } }]
)
end
it 'generates the correct hash for a relation with excluded attributes' do
setup_yaml(tree: { project: [:issues] },
excluded_attributes: { issues: [:name] })
is_expected.to match(
include: [{ issues: { except: [:name],
include: [] } }]
)
end
it 'generates the correct hash for a relation with both excluded and included attributes' do
setup_yaml(tree: { project: [:issues] },
excluded_attributes: { issues: [:name] },
included_attributes: { issues: [:description] })
is_expected.to match(
include: [{ issues: { except: [:name],
include: [],
only: [:description] } }]
)
end
it 'generates the correct hash for a relation with custom methods' do
setup_yaml(tree: { project: [:issues] },
methods: { issues: [:name] })
is_expected.to match(
include: [{ issues: { include: [],
methods: [:name] } }]
)
end
def setup_yaml(hash)
allow(YAML).to receive(:load_file).with(test_config).and_return(hash)
end
end
end
describe '#find_relations_tree' do
subject { described_class.new(config: config).find_relations_tree(model_key) }
let(:tree) { { project: { issues: {} } } }
let(:model_key) { :project }
context 'when initialized with config including tree' do
let(:config) { { tree: tree } }
context 'when relation is in top-level keys of the tree' do
it { is_expected.to eq({ issues: {} }) }
end
context 'when the relation is not in top-level keys' do
let(:model_key) { :issues }
it { is_expected.to be_nil }
end
end
context 'when tree is not present in config' do
let(:config) { {} }
it { is_expected.to be_nil }
end
end
describe '#find_excluded_keys' do
subject { described_class.new(config: config).find_excluded_keys(klass_name) }
let(:klass_name) { 'project' }
context 'when initialized with excluded_attributes' do
let(:config) { { excluded_attributes: excluded_attributes } }
let(:excluded_attributes) { { project: [:name, :path], issues: [:milestone_id] } }
it { is_expected.to eq(%w[name path]) }
end
context 'when excluded_attributes are not present in config' do
let(:config) { {} }
it { is_expected.to eq([]) }
end
end
end

View File

@ -1,163 +1,159 @@
# frozen_string_literal: true
require 'spec_helper'
require 'fast_spec_helper'
require 'rspec-parameterized'
describe Gitlab::ImportExport::Config do
let(:yaml_file) { described_class.new }
describe '#to_h' do
context 'when using CE' do
before do
allow(yaml_file)
.to receive(:merge?)
.and_return(false)
subject { yaml_file.to_h }
context 'when using default config' do
using RSpec::Parameterized::TableSyntax
where(:ee) do
[true, false]
end
it 'just returns the parsed Hash without the EE section' do
expected = YAML.load_file(Gitlab::ImportExport.config_file)
expected.delete('ee')
with_them do
before do
allow(Gitlab).to receive(:ee?) { ee }
end
expect(yaml_file.to_h).to eq(expected)
it 'parses default config' do
expect { subject }.not_to raise_error
expect(subject).to be_a(Hash)
expect(subject.keys).to contain_exactly(
:tree, :excluded_attributes, :included_attributes, :methods)
end
end
end
context 'when using EE' do
context 'when using custom config' do
let(:config) do
<<-EOF.strip_heredoc
tree:
project:
- labels:
- :priorities
- milestones:
- events:
- :push_event_payload
included_attributes:
user:
- :id
excluded_attributes:
project:
- :name
methods:
labels:
- :type
events:
- :action
ee:
tree:
project:
protected_branches:
- :unprotect_access_levels
included_attributes:
user:
- :name_ee
excluded_attributes:
project:
- :name_without_ee
methods:
labels:
- :type_ee
events_ee:
- :action_ee
EOF
end
let(:config_hash) { YAML.safe_load(config, [Symbol]) }
before do
allow(yaml_file)
.to receive(:merge?)
.and_return(true)
allow_any_instance_of(described_class).to receive(:parse_yaml) do
config_hash.deep_dup
end
end
it 'merges the EE project tree into the CE project tree' do
allow(yaml_file)
.to receive(:parse_yaml)
.and_return({
'project_tree' => [
{
'issues' => [
:id,
:title,
{ 'notes' => [:id, :note, { 'author' => [:name] }] }
]
}
],
'ee' => {
'project_tree' => [
{
'issues' => [
:description,
{ 'notes' => [:date, { 'author' => [:email] }] }
]
},
{ 'foo' => [{ 'bar' => %i[baz] }] }
]
}
})
context 'when using CE' do
before do
allow(Gitlab).to receive(:ee?) { false }
end
expect(yaml_file.to_h).to eq({
'project_tree' => [
it 'just returns the normalized Hash' do
is_expected.to eq(
{
'issues' => [
:id,
:title,
{
'notes' => [
:id,
:note,
{ 'author' => [:name, :email] },
:date
]
},
:description
]
},
{ 'foo' => [{ 'bar' => %i[baz] }] }
]
})
end
it 'merges the excluded attributes list' do
allow(yaml_file)
.to receive(:parse_yaml)
.and_return({
'project_tree' => [],
'excluded_attributes' => {
'project' => %i[id title],
'notes' => %i[id]
},
'ee' => {
'project_tree' => [],
'excluded_attributes' => {
'project' => %i[date],
'foo' => %i[bar baz]
tree: {
project: {
labels: {
priorities: {}
},
milestones: {
events: {
push_event_payload: {}
}
}
}
},
included_attributes: {
user: [:id]
},
excluded_attributes: {
project: [:name]
},
methods: {
labels: [:type],
events: [:action]
}
}
})
expect(yaml_file.to_h).to eq({
'project_tree' => [],
'excluded_attributes' => {
'project' => %i[id title date],
'notes' => %i[id],
'foo' => %i[bar baz]
}
})
)
end
end
it 'merges the included attributes list' do
allow(yaml_file)
.to receive(:parse_yaml)
.and_return({
'project_tree' => [],
'included_attributes' => {
'project' => %i[id title],
'notes' => %i[id]
},
'ee' => {
'project_tree' => [],
'included_attributes' => {
'project' => %i[date],
'foo' => %i[bar baz]
context 'when using EE' do
before do
allow(Gitlab).to receive(:ee?) { true }
end
it 'just returns the normalized Hash' do
is_expected.to eq(
{
tree: {
project: {
labels: {
priorities: {}
},
milestones: {
events: {
push_event_payload: {}
}
},
protected_branches: {
unprotect_access_levels: {}
}
}
},
included_attributes: {
user: [:id, :name_ee]
},
excluded_attributes: {
project: [:name, :name_without_ee]
},
methods: {
labels: [:type, :type_ee],
events: [:action],
events_ee: [:action_ee]
}
}
})
expect(yaml_file.to_h).to eq({
'project_tree' => [],
'included_attributes' => {
'project' => %i[id title date],
'notes' => %i[id],
'foo' => %i[bar baz]
}
})
end
it 'merges the methods list' do
allow(yaml_file)
.to receive(:parse_yaml)
.and_return({
'project_tree' => [],
'methods' => {
'project' => %i[id title],
'notes' => %i[id]
},
'ee' => {
'project_tree' => [],
'methods' => {
'project' => %i[date],
'foo' => %i[bar baz]
}
}
})
expect(yaml_file.to_h).to eq({
'project_tree' => [],
'methods' => {
'project' => %i[id title date],
'notes' => %i[id],
'foo' => %i[bar baz]
}
})
)
end
end
end
end

View File

@ -8,7 +8,7 @@ describe 'Import/Export model configuration' do
let(:config_hash) { Gitlab::ImportExport::Config.new.to_h.deep_stringify_keys }
let(:model_names) do
names = names_from_tree(config_hash['project_tree'])
names = names_from_tree(config_hash.dig('tree', 'project'))
# Remove duplicated or add missing models
# - project is not part of the tree, so it has to be added manually.

View File

@ -2,96 +2,45 @@ require 'spec_helper'
describe Gitlab::ImportExport::Reader do
let(:shared) { Gitlab::ImportExport::Shared.new(nil) }
let(:test_config) { 'spec/support/import_export/import_export.yml' }
let(:project_tree_hash) do
{
except: [:id, :created_at],
include: [:issues, :labels,
{ merge_requests: {
only: [:id],
except: [:iid],
include: [:merge_request_diff, :merge_request_test]
} },
{ commit_statuses: { include: :commit } },
{ project_members: { include: { user: { only: [:email] } } } }]
}
describe '#project_tree' do
subject { described_class.new(shared: shared).project_tree }
it 'delegates to AttributesFinder#find_root' do
expect_any_instance_of(Gitlab::ImportExport::AttributesFinder)
.to receive(:find_root)
.with(:project)
subject
end
context 'when exception raised' do
before do
expect_any_instance_of(Gitlab::ImportExport::AttributesFinder)
.to receive(:find_root)
.with(:project)
.and_raise(StandardError)
end
it { is_expected.to be false }
it 'logs the error' do
expect(shared).to receive(:error).with(instance_of(StandardError))
subject
end
end
end
before do
allow_any_instance_of(Gitlab::ImportExport).to receive(:config_file).and_return(test_config)
end
describe '#group_members_tree' do
subject { described_class.new(shared: shared).group_members_tree }
it 'generates hash from project tree config' do
expect(described_class.new(shared: shared).project_tree).to match(project_tree_hash)
end
it 'delegates to AttributesFinder#find_root' do
expect_any_instance_of(Gitlab::ImportExport::AttributesFinder)
.to receive(:find_root)
.with(:group_members)
context 'individual scenarios' do
it 'generates the correct hash for a single project relation' do
setup_yaml(project_tree: [:issues])
expect(described_class.new(shared: shared).project_tree).to match(include: [:issues])
end
it 'generates the correct hash for a single project feature relation' do
setup_yaml(project_tree: [:project_feature])
expect(described_class.new(shared: shared).project_tree).to match(include: [:project_feature])
end
it 'generates the correct hash for a multiple project relation' do
setup_yaml(project_tree: [:issues, :snippets])
expect(described_class.new(shared: shared).project_tree).to match(include: [:issues, :snippets])
end
it 'generates the correct hash for a single sub-relation' do
setup_yaml(project_tree: [issues: [:notes]])
expect(described_class.new(shared: shared).project_tree).to match(include: [{ issues: { include: :notes } }])
end
it 'generates the correct hash for a multiple sub-relation' do
setup_yaml(project_tree: [merge_requests: [:notes, :merge_request_diff]])
expect(described_class.new(shared: shared).project_tree).to match(include: [{ merge_requests: { include: [:notes, :merge_request_diff] } }])
end
it 'generates the correct hash for a sub-relation with another sub-relation' do
setup_yaml(project_tree: [merge_requests: [notes: :author]])
expect(described_class.new(shared: shared).project_tree).to match(include: [{ merge_requests: { include: { notes: { include: :author } } } }])
end
it 'generates the correct hash for a relation with included attributes' do
setup_yaml(project_tree: [:issues], included_attributes: { issues: [:name, :description] })
expect(described_class.new(shared: shared).project_tree).to match(include: [{ issues: { only: [:name, :description] } }])
end
it 'generates the correct hash for a relation with excluded attributes' do
setup_yaml(project_tree: [:issues], excluded_attributes: { issues: [:name] })
expect(described_class.new(shared: shared).project_tree).to match(include: [{ issues: { except: [:name] } }])
end
it 'generates the correct hash for a relation with both excluded and included attributes' do
setup_yaml(project_tree: [:issues], excluded_attributes: { issues: [:name] }, included_attributes: { issues: [:description] })
expect(described_class.new(shared: shared).project_tree).to match(include: [{ issues: { except: [:name], only: [:description] } }])
end
it 'generates the correct hash for a relation with custom methods' do
setup_yaml(project_tree: [:issues], methods: { issues: [:name] })
expect(described_class.new(shared: shared).project_tree).to match(include: [{ issues: { methods: [:name] } }])
end
it 'generates the correct hash for group members' do
expect(described_class.new(shared: shared).group_members_tree).to match({ include: { user: { only: [:email] } } })
end
def setup_yaml(hash)
allow(YAML).to receive(:load_file).with(test_config).and_return(hash)
subject
end
end
end

View File

@ -12,7 +12,7 @@ describe Gitlab::ImportExport::RelationRenameService do
let(:user) { create(:admin) }
let(:group) { create(:group, :nested) }
let!(:project) { create(:project, :builds_disabled, :issues_disabled, name: 'project', path: 'project') }
let!(:project) { create(:project, :builds_disabled, :issues_disabled, group: group, name: 'project', path: 'project') }
let(:shared) { project.import_export_shared }
before do
@ -24,7 +24,6 @@ describe Gitlab::ImportExport::RelationRenameService do
let(:import_path) { 'spec/lib/gitlab/import_export' }
let(:file_content) { IO.read("#{import_path}/project.json") }
let!(:json_file) { ActiveSupport::JSON.decode(file_content) }
let(:tree_hash) { project_tree_restorer.instance_variable_get(:@tree_hash) }
before do
allow(shared).to receive(:export_path).and_return(import_path)
@ -92,21 +91,25 @@ describe Gitlab::ImportExport::RelationRenameService do
end
context 'when exporting' do
let(:project_tree_saver) { Gitlab::ImportExport::ProjectTreeSaver.new(project: project, current_user: user, shared: shared) }
let(:project_tree) { project_tree_saver.send(:project_json) }
let(:export_content_path) { project_tree_saver.full_path }
let(:export_content_hash) { ActiveSupport::JSON.decode(File.read(export_content_path)) }
let(:injected_hash) { renames.values.product([{}]).to_h }
let(:project_tree_saver) do
Gitlab::ImportExport::ProjectTreeSaver.new(
project: project, current_user: user, shared: shared)
end
it 'adds old relationships to the exported file' do
project_tree.merge!(renames.values.map { |new_name| [new_name, []] }.to_h)
allow(project_tree_saver).to receive(:save) do |arg|
project_tree_saver.send(:project_json_tree)
# we inject relations with new names that should be rewritten
expect(project_tree_saver).to receive(:serialize_project_tree).and_wrap_original do |method, *args|
method.call(*args).merge(injected_hash)
end
result = project_tree_saver.save
expect(project_tree_saver.save).to eq(true)
saved_data = ActiveSupport::JSON.decode(result)
expect(saved_data.keys).to include(*(renames.keys + renames.values))
expect(export_content_hash.keys).to include(*renames.keys)
expect(export_content_hash.keys).to include(*renames.values)
end
end
end

View File

@ -1,13 +1,16 @@
# Class relationships to be included in the project import/export
project_tree:
- :issues
- :labels
- merge_requests:
- :merge_request_diff
- :merge_request_test
- commit_statuses:
- :commit
- project_members:
tree:
project:
- :issues
- :labels
- merge_requests:
- :merge_request_diff
- :merge_request_test
- commit_statuses:
- :commit
- project_members:
- :user
group_members:
- :user
included_attributes: