topographer 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/Gemfile +4 -0
- data/LICENSE +20 -0
- data/LICENSE.txt +22 -0
- data/README.md +38 -0
- data/Rakefile +1 -0
- data/lib/Topographer/exceptions.rb +4 -0
- data/lib/Topographer/importer.rb +57 -0
- data/lib/Topographer/importer/helpers.rb +18 -0
- data/lib/Topographer/importer/helpers/write_log_to_csv.rb +85 -0
- data/lib/Topographer/importer/importable.rb +5 -0
- data/lib/Topographer/importer/input.rb +5 -0
- data/lib/Topographer/importer/input/base.rb +13 -0
- data/lib/Topographer/importer/input/roo.rb +28 -0
- data/lib/Topographer/importer/input/source_data.rb +8 -0
- data/lib/Topographer/importer/logger.rb +8 -0
- data/lib/Topographer/importer/logger/base.rb +69 -0
- data/lib/Topographer/importer/logger/fatal_error_entry.rb +19 -0
- data/lib/Topographer/importer/logger/file.rb +0 -0
- data/lib/Topographer/importer/logger/log_entry.rb +34 -0
- data/lib/Topographer/importer/logger/simple.rb +27 -0
- data/lib/Topographer/importer/mapper.rb +161 -0
- data/lib/Topographer/importer/mapper/default_field_mapping.rb +22 -0
- data/lib/Topographer/importer/mapper/field_mapping.rb +55 -0
- data/lib/Topographer/importer/mapper/ignored_field_mapping.rb +10 -0
- data/lib/Topographer/importer/mapper/result.rb +21 -0
- data/lib/Topographer/importer/mapper/validation_field_mapping.rb +36 -0
- data/lib/Topographer/importer/strategy.rb +7 -0
- data/lib/Topographer/importer/strategy/base.rb +42 -0
- data/lib/Topographer/importer/strategy/create_or_update_record.rb +50 -0
- data/lib/Topographer/importer/strategy/import_new_record.rb +17 -0
- data/lib/Topographer/importer/strategy/import_status.rb +28 -0
- data/lib/Topographer/importer/strategy/update_record.rb +33 -0
- data/lib/Topographer/version.rb +3 -0
- data/lib/topographer.rb +6 -0
- data/spec/Cartographer/importer/helpers/write_log_to_csv_spec.rb +69 -0
- data/spec/Cartographer/importer/helpers_spec.rb +33 -0
- data/spec/Cartographer/importer/importable_spec.rb +13 -0
- data/spec/Cartographer/importer/importer_spec.rb +132 -0
- data/spec/Cartographer/importer/logger/base_spec.rb +12 -0
- data/spec/Cartographer/importer/logger/fatal_error_entry_spec.rb +31 -0
- data/spec/Cartographer/importer/logger/simple_spec.rb +53 -0
- data/spec/Cartographer/importer/mapper/default_field_mapping_spec.rb +41 -0
- data/spec/Cartographer/importer/mapper/field_mapping_spec.rb +126 -0
- data/spec/Cartographer/importer/mapper/validation_field_mapping_spec.rb +42 -0
- data/spec/Cartographer/importer/mapper_spec.rb +318 -0
- data/spec/Cartographer/importer/strategy/base_spec.rb +43 -0
- data/spec/Cartographer/importer/strategy/create_or_update_record_spec.rb +46 -0
- data/spec/Cartographer/importer/strategy/import_new_records_spec.rb +66 -0
- data/spec/Cartographer/importer/strategy/import_status_spec.rb +24 -0
- data/spec/Cartographer/importer/strategy/mapped_model.rb +41 -0
- data/spec/Cartographer/importer/strategy/update_record_spec.rb +45 -0
- data/spec/spec_helper.rb +1 -0
- data/topographer.gemspec +26 -0
- metadata +175 -0
File without changes
|
@@ -0,0 +1,34 @@
|
|
1
|
+
class Importer::Logger::LogEntry
|
2
|
+
attr_reader :input_identifier,
|
3
|
+
:model_name
|
4
|
+
|
5
|
+
def initialize(input_identifier, model_name, import_status)
|
6
|
+
@input_identifier = input_identifier
|
7
|
+
@model_name = model_name
|
8
|
+
@import_status = import_status
|
9
|
+
end
|
10
|
+
|
11
|
+
def source_identifier
|
12
|
+
@import_status.input_identifier
|
13
|
+
end
|
14
|
+
|
15
|
+
def message
|
16
|
+
@import_status.message
|
17
|
+
end
|
18
|
+
|
19
|
+
def timestamp
|
20
|
+
@import_status.timestamp
|
21
|
+
end
|
22
|
+
|
23
|
+
def details
|
24
|
+
@import_status.errors
|
25
|
+
end
|
26
|
+
|
27
|
+
def success?
|
28
|
+
!failure?
|
29
|
+
end
|
30
|
+
|
31
|
+
def failure?
|
32
|
+
@import_status.errors?
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
class Importer::Logger::Simple < Importer::Logger::Base
|
2
|
+
|
3
|
+
attr_reader :successes, :failures
|
4
|
+
|
5
|
+
def initialize
|
6
|
+
@successes = []
|
7
|
+
@failures = []
|
8
|
+
super
|
9
|
+
end
|
10
|
+
|
11
|
+
def log_success(message)
|
12
|
+
@successes << message
|
13
|
+
end
|
14
|
+
|
15
|
+
def log_failure(message)
|
16
|
+
@failures << message
|
17
|
+
end
|
18
|
+
|
19
|
+
def successful_imports
|
20
|
+
@successes.size
|
21
|
+
end
|
22
|
+
|
23
|
+
def failed_imports
|
24
|
+
@failures.size
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
@@ -0,0 +1,161 @@
|
|
1
|
+
class Importer::Mapper
|
2
|
+
require_relative 'mapper/field_mapping'
|
3
|
+
require_relative 'mapper/ignored_field_mapping'
|
4
|
+
require_relative 'mapper/validation_field_mapping'
|
5
|
+
require_relative 'mapper/default_field_mapping'
|
6
|
+
require_relative 'mapper/result'
|
7
|
+
|
8
|
+
attr_reader :bad_columns, :missing_columns, :model_class, :key_fields
|
9
|
+
|
10
|
+
def self.build_mapper(model_class)
|
11
|
+
mapper = self.new(model_class)
|
12
|
+
yield mapper
|
13
|
+
|
14
|
+
mapper
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(model_class)
|
18
|
+
@required_mappings = {}
|
19
|
+
@optional_mappings = {}
|
20
|
+
@ignored_mappings = {}
|
21
|
+
@validation_mappings = {}
|
22
|
+
@default_values = {}
|
23
|
+
@key_fields = []
|
24
|
+
@model_class = model_class
|
25
|
+
end
|
26
|
+
|
27
|
+
def required_columns
|
28
|
+
@required_mappings.values.flat_map(&:input_columns)
|
29
|
+
end
|
30
|
+
|
31
|
+
def optional_columns
|
32
|
+
@optional_mappings.values.flat_map(&:input_columns)
|
33
|
+
end
|
34
|
+
|
35
|
+
def ignored_columns
|
36
|
+
@ignored_mappings.values.flat_map(&:input_columns)
|
37
|
+
end
|
38
|
+
|
39
|
+
def validation_columns
|
40
|
+
@validation_mappings.values.flat_map(&:input_columns)
|
41
|
+
end
|
42
|
+
|
43
|
+
def default_fields
|
44
|
+
@default_values.keys
|
45
|
+
end
|
46
|
+
|
47
|
+
def input_columns
|
48
|
+
required_columns + optional_columns + validation_columns
|
49
|
+
end
|
50
|
+
|
51
|
+
def required_input_columns
|
52
|
+
required_columns + validation_columns
|
53
|
+
end
|
54
|
+
|
55
|
+
def output_fields
|
56
|
+
(@required_mappings.merge(@optional_mappings).merge(@default_values)).values.map(&:output_field)
|
57
|
+
end
|
58
|
+
|
59
|
+
def required_mapping(input_columns, output_field, &mapping_behavior)
|
60
|
+
validate_unique_mapping(input_columns, output_field)
|
61
|
+
@required_mappings[output_field] = FieldMapping.new(true, input_columns, output_field, &mapping_behavior)
|
62
|
+
end
|
63
|
+
|
64
|
+
def optional_mapping(input_columns, output_field, &mapping_behavior)
|
65
|
+
validate_unique_mapping(input_columns, output_field)
|
66
|
+
@optional_mappings[output_field] = FieldMapping.new(false, input_columns, output_field, &mapping_behavior)
|
67
|
+
end
|
68
|
+
|
69
|
+
def validation_field(name, input_columns, &mapping_behavior)
|
70
|
+
validate_unique_validation_name(name)
|
71
|
+
@validation_mappings[name] = ValidationFieldMapping.new(name, input_columns, &mapping_behavior)
|
72
|
+
end
|
73
|
+
|
74
|
+
def default_value(output_field, &mapping_behavior)
|
75
|
+
validate_unique_mapping([], output_field)
|
76
|
+
@default_values[output_field] = DefaultFieldMapping.new(output_field, &mapping_behavior)
|
77
|
+
end
|
78
|
+
|
79
|
+
def key_field(output_field)
|
80
|
+
validate_key_field(output_field)
|
81
|
+
@key_fields << output_field
|
82
|
+
end
|
83
|
+
|
84
|
+
def ignored_column(input_column)
|
85
|
+
validate_unique_column_mapping_type(input_column, ignored: true)
|
86
|
+
@ignored_mappings[input_column] = IgnoredFieldMapping.new(input_column)
|
87
|
+
end
|
88
|
+
|
89
|
+
def input_structure_valid?(input_columns)
|
90
|
+
@bad_columns ||= input_columns - mapped_input_columns
|
91
|
+
@missing_columns ||= required_input_columns - input_columns
|
92
|
+
@bad_columns.empty? && @missing_columns.empty?
|
93
|
+
end
|
94
|
+
|
95
|
+
def map_input(source_data)
|
96
|
+
mapping_result = Result.new(source_data.source_identifier)
|
97
|
+
|
98
|
+
@validation_mappings.values.each do |validation_field_mapping|
|
99
|
+
validation_field_mapping.process_input(source_data.data, mapping_result)
|
100
|
+
end
|
101
|
+
|
102
|
+
output_fields.each do |output_field|
|
103
|
+
field_mapping = mappings[output_field]
|
104
|
+
field_mapping.process_input(source_data.data, mapping_result)
|
105
|
+
end
|
106
|
+
|
107
|
+
mapping_result
|
108
|
+
end
|
109
|
+
|
110
|
+
private
|
111
|
+
def mapped_input_columns
|
112
|
+
required_columns + optional_columns + ignored_columns + validation_columns
|
113
|
+
end
|
114
|
+
|
115
|
+
def mappings
|
116
|
+
@required_mappings.merge(@optional_mappings).merge(@ignored_mappings).merge(@default_values)
|
117
|
+
end
|
118
|
+
|
119
|
+
def non_ignored_columns
|
120
|
+
@required_mappings.merge(@optional_mappings)
|
121
|
+
end
|
122
|
+
|
123
|
+
def validate_key_field(field)
|
124
|
+
if field.is_a?(Array)
|
125
|
+
raise Topographer::InvalidMappingError, 'One to many mapping is not supported'
|
126
|
+
elsif @key_fields.include?(field)
|
127
|
+
raise Topographer::InvalidMappingError, "Field `#{field}` has already been included as a key"
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def validate_unique_mapping(input_columns, output_field)
|
132
|
+
if(output_field.is_a?(Array))
|
133
|
+
raise Topographer::InvalidMappingError, 'One to many mapping is not supported'
|
134
|
+
end
|
135
|
+
validate_unique_column_mapping_type(input_columns)
|
136
|
+
validate_unique_output_mapping(output_field)
|
137
|
+
end
|
138
|
+
|
139
|
+
def validate_unique_column_mapping_type(mapping_input_columns, options = {})
|
140
|
+
ignored = options.fetch(:ignored, false)
|
141
|
+
mapping_input_columns = Array(mapping_input_columns)
|
142
|
+
mapping_input_columns.each do |col|
|
143
|
+
if ignored && ((input_columns + ignored_columns).include?(col))
|
144
|
+
raise Topographer::InvalidMappingError, 'Input column already mapped to an output column.'
|
145
|
+
elsif(ignored_columns.include?(col))
|
146
|
+
raise Topographer::InvalidMappingError, 'Input column already ignored.'
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
def validate_unique_output_mapping(output_field)
|
152
|
+
if output_fields.include?(output_field)
|
153
|
+
raise Topographer::InvalidMappingError, 'Output column already mapped.'
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def validate_unique_validation_name(name)
|
158
|
+
raise Topographer::InvalidMappingError, "A validation already exists with the name `#{name}`" if @validation_mappings.has_key?(name)
|
159
|
+
end
|
160
|
+
|
161
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class Importer::Mapper::DefaultFieldMapping < Importer::Mapper::FieldMapping
|
2
|
+
|
3
|
+
def initialize(output_column, &output_block)
|
4
|
+
unless block_given?
|
5
|
+
raise Topographer::InvalidMappingError, 'Static fields must have an output block'
|
6
|
+
end
|
7
|
+
@output_field = output_column
|
8
|
+
@output_block = output_block
|
9
|
+
end
|
10
|
+
|
11
|
+
def process_input(_, result)
|
12
|
+
@output_data = @output_block.()
|
13
|
+
result.add_data(@output_field, @output_data)
|
14
|
+
rescue => exception
|
15
|
+
result.add_error(@output_field, exception.message)
|
16
|
+
end
|
17
|
+
|
18
|
+
def required?
|
19
|
+
true
|
20
|
+
end
|
21
|
+
|
22
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require 'active_support/core_ext/hash'
|
2
|
+
require 'active_support/core_ext/object/blank'
|
3
|
+
class Importer::Mapper::FieldMapping
|
4
|
+
attr_reader :input_columns, :output_field
|
5
|
+
|
6
|
+
def initialize(required, input_columns, output_field, &mapping_behavior)
|
7
|
+
@required = required
|
8
|
+
@input_columns = Array(input_columns)
|
9
|
+
@output_field = output_field
|
10
|
+
@mapping_behavior = mapping_behavior
|
11
|
+
@invalid_keys = []
|
12
|
+
end
|
13
|
+
|
14
|
+
def process_input(input, result)
|
15
|
+
mapping_input = input.slice(*input_columns)
|
16
|
+
@invalid_keys = get_invalid_keys(mapping_input)
|
17
|
+
data = (@invalid_keys.any?) ? nil : apply_mapping(mapping_input)
|
18
|
+
if !data.nil?
|
19
|
+
result.add_data(output_field, data)
|
20
|
+
elsif required?
|
21
|
+
result.add_error(output_field, invalid_input_error)
|
22
|
+
end
|
23
|
+
|
24
|
+
rescue => exception
|
25
|
+
result.add_error(output_field, exception.message)
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
def required?
|
30
|
+
@required
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def apply_mapping(mapping_input)
|
36
|
+
if @mapping_behavior
|
37
|
+
@mapping_behavior.(mapping_input)
|
38
|
+
else
|
39
|
+
(mapping_input.size > 1) ? mapping_input.values.join(', ') : mapping_input.values.first
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def invalid_input_error
|
44
|
+
"Missing required input(s): `#{@invalid_keys.join(", ")}` for `#{@output_field}`"
|
45
|
+
end
|
46
|
+
|
47
|
+
def get_invalid_keys(input)
|
48
|
+
missing_columns = @input_columns - input.keys
|
49
|
+
#reject input that is not blank or the value `false`
|
50
|
+
#this allows boolean inputs for required fields
|
51
|
+
missing_data = @required ? input.reject{|k,v| !v.blank? || v == false }.keys : []
|
52
|
+
missing_columns + missing_data
|
53
|
+
end
|
54
|
+
|
55
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
class Importer::Mapper::Result
|
2
|
+
attr_reader :data, :errors, :source_identifier
|
3
|
+
|
4
|
+
def initialize(source_identifier)
|
5
|
+
@source_identifier = source_identifier
|
6
|
+
@data = {}
|
7
|
+
@errors = {}
|
8
|
+
end
|
9
|
+
|
10
|
+
def add_data (key, value)
|
11
|
+
@data[key] = value
|
12
|
+
end
|
13
|
+
|
14
|
+
def add_error (key, value)
|
15
|
+
@errors[key] = value
|
16
|
+
end
|
17
|
+
|
18
|
+
def errors?
|
19
|
+
errors.any?
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
class Importer::Mapper::ValidationFieldMapping < Importer::Mapper::FieldMapping
|
2
|
+
attr_reader :name
|
3
|
+
|
4
|
+
def initialize(name, input_columns, &validation_block)
|
5
|
+
unless block_given?
|
6
|
+
raise Topographer::InvalidMappingError, 'Validation fields must have a behavior block'
|
7
|
+
end
|
8
|
+
@name = name
|
9
|
+
@input_columns = Array(input_columns)
|
10
|
+
@validation_block = validation_block
|
11
|
+
@output_field = nil
|
12
|
+
end
|
13
|
+
|
14
|
+
def process_input(input, result)
|
15
|
+
mapping_input = input.slice(*input_columns)
|
16
|
+
@invalid_keys = get_invalid_keys(mapping_input)
|
17
|
+
if @invalid_keys.blank?
|
18
|
+
@validation_block.(mapping_input)
|
19
|
+
else
|
20
|
+
result.add_error(name, invalid_input_error)
|
21
|
+
end
|
22
|
+
|
23
|
+
rescue => exception
|
24
|
+
result.add_error(name, exception.message)
|
25
|
+
|
26
|
+
end
|
27
|
+
|
28
|
+
def required?
|
29
|
+
true
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
def get_invalid_keys(input)
|
34
|
+
@input_columns - input.keys
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
class Importer::Strategy::Base
|
2
|
+
|
3
|
+
attr_reader :mapper
|
4
|
+
attr_accessor :dry_run
|
5
|
+
|
6
|
+
def initialize(mapper)
|
7
|
+
@mapper = mapper
|
8
|
+
@dry_run = false
|
9
|
+
end
|
10
|
+
|
11
|
+
def import_record (record_input)
|
12
|
+
raise NotImplementedError
|
13
|
+
end
|
14
|
+
|
15
|
+
def success_message
|
16
|
+
'Imported'
|
17
|
+
end
|
18
|
+
|
19
|
+
def failure_message
|
20
|
+
'Unable to import'
|
21
|
+
end
|
22
|
+
|
23
|
+
def should_persist_import?(status)
|
24
|
+
(@dry_run || status.errors?) ? false : true
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def get_import_status(mapping_result, new_model_errors)
|
30
|
+
status = Importer::Strategy::ImportStatus.new(mapping_result.source_identifier)
|
31
|
+
mapping_result.errors.values.each do |error|
|
32
|
+
status.add_error(:mapping, error)
|
33
|
+
end
|
34
|
+
new_model_errors.each do |error|
|
35
|
+
status.add_error(:validation, error)
|
36
|
+
end
|
37
|
+
status.message = (status.errors?) ? failure_message : success_message
|
38
|
+
status.set_timestamp
|
39
|
+
status
|
40
|
+
end
|
41
|
+
|
42
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
class Importer::Strategy::CreateOrUpdateRecord < Importer::Strategy::Base
|
2
|
+
|
3
|
+
def import_record (source_data)
|
4
|
+
mapping_result = mapper.map_input(source_data)
|
5
|
+
|
6
|
+
search_params = mapping_result.data.slice(*mapper.key_fields)
|
7
|
+
model_instances = mapper.model_class.where(search_params)
|
8
|
+
|
9
|
+
if model_instances.any?
|
10
|
+
model_instance = model_instances.first
|
11
|
+
else
|
12
|
+
model_instance = mapper.model_class.new(search_params)
|
13
|
+
end
|
14
|
+
|
15
|
+
generate_messages(model_instance, search_params)
|
16
|
+
|
17
|
+
model_instance.attributes = mapping_result.data
|
18
|
+
model_instance.valid?
|
19
|
+
|
20
|
+
model_errors = model_instance.errors.full_messages
|
21
|
+
status = get_import_status(mapping_result, model_errors)
|
22
|
+
|
23
|
+
model_instance.save if should_persist_import?(status)
|
24
|
+
|
25
|
+
status
|
26
|
+
end
|
27
|
+
|
28
|
+
|
29
|
+
def success_message
|
30
|
+
@success_message
|
31
|
+
end
|
32
|
+
|
33
|
+
def failure_message
|
34
|
+
@failure_message
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def generate_messages(model_instance, search_params)
|
40
|
+
if model_instance.new_record?
|
41
|
+
@success_message = 'Imported record'
|
42
|
+
@failure_message = 'Import failed'
|
43
|
+
else
|
44
|
+
params_string = search_params.map{|k, v| "#{k}: #{v}"}.join(', ')
|
45
|
+
@success_message = "Updated record matching `#{params_string}`"
|
46
|
+
@failure_message = "Update failed for record matching `#{params_string}`"
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|