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.
Files changed (56) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +17 -0
  3. data/Gemfile +4 -0
  4. data/LICENSE +20 -0
  5. data/LICENSE.txt +22 -0
  6. data/README.md +38 -0
  7. data/Rakefile +1 -0
  8. data/lib/Topographer/exceptions.rb +4 -0
  9. data/lib/Topographer/importer.rb +57 -0
  10. data/lib/Topographer/importer/helpers.rb +18 -0
  11. data/lib/Topographer/importer/helpers/write_log_to_csv.rb +85 -0
  12. data/lib/Topographer/importer/importable.rb +5 -0
  13. data/lib/Topographer/importer/input.rb +5 -0
  14. data/lib/Topographer/importer/input/base.rb +13 -0
  15. data/lib/Topographer/importer/input/roo.rb +28 -0
  16. data/lib/Topographer/importer/input/source_data.rb +8 -0
  17. data/lib/Topographer/importer/logger.rb +8 -0
  18. data/lib/Topographer/importer/logger/base.rb +69 -0
  19. data/lib/Topographer/importer/logger/fatal_error_entry.rb +19 -0
  20. data/lib/Topographer/importer/logger/file.rb +0 -0
  21. data/lib/Topographer/importer/logger/log_entry.rb +34 -0
  22. data/lib/Topographer/importer/logger/simple.rb +27 -0
  23. data/lib/Topographer/importer/mapper.rb +161 -0
  24. data/lib/Topographer/importer/mapper/default_field_mapping.rb +22 -0
  25. data/lib/Topographer/importer/mapper/field_mapping.rb +55 -0
  26. data/lib/Topographer/importer/mapper/ignored_field_mapping.rb +10 -0
  27. data/lib/Topographer/importer/mapper/result.rb +21 -0
  28. data/lib/Topographer/importer/mapper/validation_field_mapping.rb +36 -0
  29. data/lib/Topographer/importer/strategy.rb +7 -0
  30. data/lib/Topographer/importer/strategy/base.rb +42 -0
  31. data/lib/Topographer/importer/strategy/create_or_update_record.rb +50 -0
  32. data/lib/Topographer/importer/strategy/import_new_record.rb +17 -0
  33. data/lib/Topographer/importer/strategy/import_status.rb +28 -0
  34. data/lib/Topographer/importer/strategy/update_record.rb +33 -0
  35. data/lib/Topographer/version.rb +3 -0
  36. data/lib/topographer.rb +6 -0
  37. data/spec/Cartographer/importer/helpers/write_log_to_csv_spec.rb +69 -0
  38. data/spec/Cartographer/importer/helpers_spec.rb +33 -0
  39. data/spec/Cartographer/importer/importable_spec.rb +13 -0
  40. data/spec/Cartographer/importer/importer_spec.rb +132 -0
  41. data/spec/Cartographer/importer/logger/base_spec.rb +12 -0
  42. data/spec/Cartographer/importer/logger/fatal_error_entry_spec.rb +31 -0
  43. data/spec/Cartographer/importer/logger/simple_spec.rb +53 -0
  44. data/spec/Cartographer/importer/mapper/default_field_mapping_spec.rb +41 -0
  45. data/spec/Cartographer/importer/mapper/field_mapping_spec.rb +126 -0
  46. data/spec/Cartographer/importer/mapper/validation_field_mapping_spec.rb +42 -0
  47. data/spec/Cartographer/importer/mapper_spec.rb +318 -0
  48. data/spec/Cartographer/importer/strategy/base_spec.rb +43 -0
  49. data/spec/Cartographer/importer/strategy/create_or_update_record_spec.rb +46 -0
  50. data/spec/Cartographer/importer/strategy/import_new_records_spec.rb +66 -0
  51. data/spec/Cartographer/importer/strategy/import_status_spec.rb +24 -0
  52. data/spec/Cartographer/importer/strategy/mapped_model.rb +41 -0
  53. data/spec/Cartographer/importer/strategy/update_record_spec.rb +45 -0
  54. data/spec/spec_helper.rb +1 -0
  55. data/topographer.gemspec +26 -0
  56. 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,10 @@
1
+ class Importer::Mapper::IgnoredFieldMapping < Importer::Mapper::FieldMapping
2
+ def initialize(input_columns)
3
+ @input_columns = input_columns
4
+ @output_field = nil
5
+ end
6
+
7
+ def required?
8
+ false
9
+ end
10
+ 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,7 @@
1
+ module Importer::Strategy
2
+ require_relative 'strategy/base'
3
+ require_relative 'strategy/import_new_record'
4
+ require_relative 'strategy/update_record'
5
+ require_relative 'strategy/create_or_update_record'
6
+ require_relative 'strategy/import_status'
7
+ 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