importu 0.1.0 → 0.2.0
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/.editorconfig +15 -0
- data/.github/workflows/ci.yml +48 -0
- data/.gitignore +4 -0
- data/.rspec +1 -0
- data/.rubocop.yml +311 -0
- data/.simplecov +14 -0
- data/.yardstick.yml +36 -0
- data/Appraisals +22 -0
- data/CHANGELOG.md +51 -0
- data/CONTRIBUTING.md +86 -0
- data/Gemfile +5 -1
- data/LICENSE +21 -0
- data/README.md +435 -52
- data/Rakefile +71 -0
- data/UPGRADING.md +188 -0
- data/gemfiles/rails_7_2.gemfile +11 -0
- data/gemfiles/rails_7_2.gemfile.lock +268 -0
- data/gemfiles/rails_8_0.gemfile +11 -0
- data/gemfiles/rails_8_0.gemfile.lock +271 -0
- data/gemfiles/rails_8_1.gemfile +11 -0
- data/gemfiles/rails_8_1.gemfile.lock +269 -0
- data/gemfiles/standalone.gemfile +8 -0
- data/gemfiles/standalone.gemfile.lock +197 -0
- data/importu.gemspec +41 -22
- data/lib/importu/backends/active_record.rb +171 -0
- data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
- data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
- data/lib/importu/backends/middleware.rb +11 -0
- data/lib/importu/backends.rb +103 -0
- data/lib/importu/config_dsl.rb +381 -0
- data/lib/importu/converter_context.rb +94 -0
- data/lib/importu/converters.rb +119 -64
- data/lib/importu/definition.rb +23 -0
- data/lib/importu/duplicate_manager.rb +88 -0
- data/lib/importu/exceptions.rb +135 -4
- data/lib/importu/importer.rb +183 -96
- data/lib/importu/record.rb +138 -102
- data/lib/importu/sources/csv.rb +122 -0
- data/lib/importu/sources/json.rb +106 -0
- data/lib/importu/sources/ruby.rb +46 -0
- data/lib/importu/sources/xml.rb +133 -0
- data/lib/importu/sources.rb +13 -0
- data/lib/importu/summary.rb +277 -0
- data/lib/importu/version.rb +3 -1
- data/lib/importu.rb +45 -9
- data/spec/fixtures/books-duplicates/README.md +7 -0
- data/spec/fixtures/books-duplicates/infile.csv +7 -0
- data/spec/fixtures/books-duplicates/model.json +23 -0
- data/spec/fixtures/books-duplicates/summary.json +10 -0
- data/spec/fixtures/books-valid/README.md +13 -0
- data/spec/fixtures/books-valid/infile.csv +4 -0
- data/spec/fixtures/books-valid/infile.json +23 -0
- data/spec/fixtures/books-valid/infile.xml +21 -0
- data/spec/fixtures/books-valid/model.json +23 -0
- data/spec/fixtures/books-valid/record.json +26 -0
- data/spec/fixtures/books-valid/summary.json +8 -0
- data/spec/fixtures/source-empty-file/infile.csv +0 -0
- data/spec/fixtures/source-empty-file/infile.json +0 -0
- data/spec/fixtures/source-empty-file/infile.xml +0 -0
- data/spec/fixtures/source-empty-records/infile.csv +3 -0
- data/spec/fixtures/source-empty-records/infile.json +1 -0
- data/spec/fixtures/source-empty-records/infile.xml +6 -0
- data/spec/fixtures/source-malformed/infile.csv +1 -0
- data/spec/fixtures/source-malformed/infile.json +1 -0
- data/spec/fixtures/source-malformed/infile.xml +3 -0
- data/spec/fixtures/source-no-records/infile.csv +1 -0
- data/spec/fixtures/source-no-records/infile.json +1 -0
- data/spec/fixtures/source-no-records/infile.xml +3 -0
- data/spec/lib/importu/backends/active_record_spec.rb +150 -0
- data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
- data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
- data/spec/lib/importu/backends_spec.rb +170 -0
- data/spec/lib/importu/converters_spec.rb +184 -141
- data/spec/lib/importu/definition_spec.rb +248 -0
- data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
- data/spec/lib/importu/exceptions_spec.rb +69 -16
- data/spec/lib/importu/import_context_spec.rb +199 -0
- data/spec/lib/importu/importer_spec.rb +95 -0
- data/spec/lib/importu/integration_spec.rb +221 -0
- data/spec/lib/importu/record_spec.rb +130 -80
- data/spec/lib/importu/sources/csv_spec.rb +29 -0
- data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
- data/spec/lib/importu/sources/json_spec.rb +29 -0
- data/spec/lib/importu/sources/ruby_spec.rb +102 -0
- data/spec/lib/importu/sources/xml_spec.rb +70 -0
- data/spec/lib/importu/summary_spec.rb +186 -0
- data/spec/spec_helper.rb +91 -7
- data/spec/support/active_record.rb +20 -0
- data/spec/support/book_importer.rb +31 -0
- data/spec/support/dummy_backend.rb +50 -0
- data/spec/support/fixtures_helper.rb +43 -0
- data/spec/support/matchers/delegate_matcher.rb +14 -8
- metadata +173 -100
- data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
- data/lib/importu/core_ext/deep_freeze.rb +0 -3
- data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
- data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
- data/lib/importu/core_ext.rb +0 -3
- data/lib/importu/dsl.rb +0 -127
- data/lib/importu/importer/csv.rb +0 -52
- data/lib/importu/importer/json.rb +0 -45
- data/lib/importu/importer/xml.rb +0 -55
- data/spec/factories/importer.rb +0 -12
- data/spec/factories/importer_record.rb +0 -13
- data/spec/factories/json_importer.rb +0 -14
- data/spec/factories/xml_importer.rb +0 -12
- data/spec/lib/importu/dsl_spec.rb +0 -26
- data/spec/lib/importu/importer/json_spec.rb +0 -37
- data/spec/lib/importu/importer/xml_spec.rb +0 -14
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "set"
|
|
3
|
+
|
|
4
|
+
require "importu/exceptions"
|
|
5
|
+
|
|
6
|
+
# The duplicate manager provides support for recording records and objects
|
|
7
|
+
# encountered during the import process. When records or objects have been
|
|
8
|
+
# encountered previously, a Importu::DuplicateRecord exception is raised.
|
|
9
|
+
class Importu::DuplicateManager
|
|
10
|
+
|
|
11
|
+
# Creates a new instance of the duplicate manager.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# manager = Importu::DuplicateManager.new
|
|
15
|
+
#
|
|
16
|
+
# @param finder_fields [Array<Array<Symbol>>] A list of finder field
|
|
17
|
+
# groups that should be used when checking if records are duplicates.
|
|
18
|
+
# @return [Importu::DuplicateManager]
|
|
19
|
+
#
|
|
20
|
+
# @api public
|
|
21
|
+
def initialize(finder_fields: [])
|
|
22
|
+
# Proc-based finder fields cannot be directly applied to records, as
|
|
23
|
+
# it requires looking up the corresponding object using the backend.
|
|
24
|
+
@finder_fields = finder_fields.reject {|fg| fg.respond_to?(:call) }
|
|
25
|
+
|
|
26
|
+
@encountered = Set.new
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Checks that the unique id of an object returned from the backend has not
|
|
30
|
+
# been encountered before. Raises a DuplicateError exception if the object
|
|
31
|
+
# has been encountered before, otherwise the object is marked as seen.
|
|
32
|
+
#
|
|
33
|
+
# @example
|
|
34
|
+
# manager.check_object!(71)
|
|
35
|
+
# manager.check_object!("0aefe55a-58bb-4a16-b873-ba3425e443bb")
|
|
36
|
+
# manager.check_object!(71) # raises Importu::DuplicateManager
|
|
37
|
+
#
|
|
38
|
+
# @param unique_id [#eql, #hash] A unique object identifier that can be
|
|
39
|
+
# compared against other object identifiers.
|
|
40
|
+
# @return [void]
|
|
41
|
+
# @raise [Importu::DuplicateRecord]
|
|
42
|
+
#
|
|
43
|
+
# @api public
|
|
44
|
+
def check_object!(unique_id)
|
|
45
|
+
return unless unique_id
|
|
46
|
+
|
|
47
|
+
result = @encountered.add?(_object_unique_id: unique_id)
|
|
48
|
+
duplicate_record! if result.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Checks that a conflicting record has not been encountered before. Uses
|
|
52
|
+
# the configured finder_fields to construct sets of key/value pairs that
|
|
53
|
+
# are considered unique enough to look up objects from the backend. Marks
|
|
54
|
+
# all of the key/value pairs as encountered if not seen before. Raises a
|
|
55
|
+
# DuplicateError exception if any were previously encountered.
|
|
56
|
+
#
|
|
57
|
+
# @example
|
|
58
|
+
# manager.check_record!(record)
|
|
59
|
+
# manager.check_record!(record) # raises Importu::DuplicateRecord
|
|
60
|
+
#
|
|
61
|
+
# @param record [Importu::Record]
|
|
62
|
+
# @return [void]
|
|
63
|
+
# @raise [Importu::DuplicateRecord]
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
def check_record!(record)
|
|
67
|
+
results = @finder_fields.map do |field_group|
|
|
68
|
+
conditions = field_group.to_h {|f| [f, record.fetch(f)] }
|
|
69
|
+
@encountered.add?(conditions) ? :added : :duplicate
|
|
70
|
+
rescue KeyError
|
|
71
|
+
# Field group key not defined on record, always nil so invalid
|
|
72
|
+
:skipped
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
duplicate_record! if results.include?(:duplicate)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Raises an Importu::DuplicateRecord exception
|
|
79
|
+
#
|
|
80
|
+
# @return [void] never returns
|
|
81
|
+
# @raise [Importu::DuplicateRecord]
|
|
82
|
+
#
|
|
83
|
+
# @api private
|
|
84
|
+
private def duplicate_record!
|
|
85
|
+
raise Importu::DuplicateRecord, "matches a previous record"
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
end
|
data/lib/importu/exceptions.rb
CHANGED
|
@@ -1,34 +1,165 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
1
2
|
module Importu
|
|
3
|
+
# Base class for all Importu exceptions.
|
|
4
|
+
#
|
|
5
|
+
# @api public
|
|
2
6
|
class ImportuException < StandardError
|
|
7
|
+
# Returns the exception class name without module prefix.
|
|
8
|
+
#
|
|
9
|
+
# @return [String] the short class name
|
|
3
10
|
def name
|
|
4
11
|
self.class.name[/[^:]+$/]
|
|
5
12
|
end
|
|
6
13
|
end
|
|
7
14
|
|
|
15
|
+
# Raised when an importer definition is invalid.
|
|
16
|
+
#
|
|
17
|
+
# Common causes:
|
|
18
|
+
# - Referencing an undefined field in a converter
|
|
19
|
+
# - Using an unregistered converter type
|
|
20
|
+
#
|
|
21
|
+
# @api public
|
|
22
|
+
class InvalidDefinition < ImportuException; end
|
|
23
|
+
|
|
24
|
+
# Raised when source data cannot be parsed.
|
|
25
|
+
#
|
|
26
|
+
# Common causes:
|
|
27
|
+
# - Malformed CSV, JSON, or XML
|
|
28
|
+
# - Empty source file
|
|
29
|
+
# - Encoding issues
|
|
30
|
+
#
|
|
31
|
+
# @api public
|
|
8
32
|
class InvalidInput < ImportuException; end
|
|
9
33
|
|
|
34
|
+
# Raised when a requested backend is not registered.
|
|
35
|
+
#
|
|
36
|
+
# @example
|
|
37
|
+
# model "Book", backend: :nonexistent # raises BackendNotRegistered
|
|
38
|
+
#
|
|
39
|
+
# @api public
|
|
40
|
+
class BackendNotRegistered < ImportuException; end
|
|
41
|
+
|
|
42
|
+
# Raised when no backend matches the configured model.
|
|
43
|
+
#
|
|
44
|
+
# This happens during auto-detection when no registered backend
|
|
45
|
+
# recognizes the model class, or when multiple backends match.
|
|
46
|
+
#
|
|
47
|
+
# @example Fix by specifying backend explicitly
|
|
48
|
+
# model "Book", backend: :active_record
|
|
49
|
+
#
|
|
50
|
+
# @api public
|
|
51
|
+
class BackendMatchError < ImportuException; end
|
|
52
|
+
|
|
53
|
+
# Raised when fields cannot be assigned to the model.
|
|
54
|
+
#
|
|
55
|
+
# This happens when the model doesn't have setter methods for
|
|
56
|
+
# all the fields defined in the importer.
|
|
57
|
+
#
|
|
58
|
+
# @api public
|
|
59
|
+
class UnassignableFields < ImportuException; end
|
|
60
|
+
|
|
61
|
+
# Raised when a record fails validation during import.
|
|
62
|
+
#
|
|
63
|
+
# This is the base class for record-level errors. The import continues
|
|
64
|
+
# processing other records; failed records are tracked in the Summary.
|
|
65
|
+
#
|
|
66
|
+
# @see Importu::Summary#validation_errors
|
|
67
|
+
# @see Importu::Summary#itemized_errors
|
|
68
|
+
# @api public
|
|
10
69
|
class InvalidRecord < ImportuException
|
|
70
|
+
# @return [Array<String>, nil] validation error messages
|
|
11
71
|
attr_reader :validation_errors
|
|
12
72
|
|
|
13
|
-
|
|
73
|
+
# @return [String, nil] aggregation-friendly error message
|
|
74
|
+
attr_reader :normalized_message
|
|
75
|
+
|
|
76
|
+
# Creates a new InvalidRecord exception.
|
|
77
|
+
#
|
|
78
|
+
# @param message [String, nil] the error message
|
|
79
|
+
# @param validation_errors [Array<String>, nil] detailed validation errors
|
|
80
|
+
# @param normalized_message [String, nil] message for error aggregation
|
|
81
|
+
def initialize(message = nil, validation_errors = nil, normalized_message: nil)
|
|
14
82
|
@validation_errors = validation_errors
|
|
83
|
+
@normalized_message = normalized_message
|
|
15
84
|
super(message)
|
|
16
85
|
end
|
|
17
86
|
end
|
|
18
87
|
|
|
19
|
-
|
|
88
|
+
# Raised when a field value cannot be parsed or converted.
|
|
89
|
+
#
|
|
90
|
+
# Common causes:
|
|
91
|
+
# - Invalid date format: "not-a-date" for a date field
|
|
92
|
+
# - Invalid number: "abc" for an integer field
|
|
93
|
+
# - Invalid boolean: "maybe" for a boolean field
|
|
94
|
+
#
|
|
95
|
+
# @api public
|
|
96
|
+
class FieldParseError < InvalidRecord
|
|
97
|
+
# @return [Symbol] the name of the field that failed parsing
|
|
98
|
+
attr_reader :field_name
|
|
99
|
+
|
|
100
|
+
# Creates a new FieldParseError.
|
|
101
|
+
#
|
|
102
|
+
# @param field_name [Symbol] the field that failed
|
|
103
|
+
# @param message [String] description of the parse error
|
|
104
|
+
def initialize(field_name, message)
|
|
105
|
+
@field_name = field_name
|
|
106
|
+
@message = message
|
|
107
|
+
super(message)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Returns the error message with field name prefix.
|
|
111
|
+
#
|
|
112
|
+
# @return [String] formatted error message
|
|
113
|
+
def to_s
|
|
114
|
+
"#{@field_name}: #{@message}"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Raised when a record is a duplicate of one already processed.
|
|
119
|
+
#
|
|
120
|
+
# Duplicates are detected based on the find_by fields. If two records
|
|
121
|
+
# in the same import have matching finder field values, the second
|
|
122
|
+
# is marked as a duplicate.
|
|
123
|
+
#
|
|
124
|
+
# @see Importu::ConfigDSL#find_by
|
|
125
|
+
# @api public
|
|
20
126
|
class DuplicateRecord < InvalidRecord; end
|
|
21
127
|
|
|
128
|
+
# Raised when a required field is missing from source data.
|
|
129
|
+
#
|
|
130
|
+
# By default, all fields are required. Use `required: false` to make
|
|
131
|
+
# a field optional.
|
|
132
|
+
#
|
|
133
|
+
# @example Making a field optional
|
|
134
|
+
# field :notes, required: false
|
|
135
|
+
#
|
|
136
|
+
# @api public
|
|
22
137
|
class MissingField < InvalidRecord
|
|
138
|
+
# @return [Hash] the field definition that was missing
|
|
23
139
|
attr_reader :definition
|
|
24
140
|
|
|
25
|
-
|
|
141
|
+
# @return [Array<String>, nil] fields that were available in source
|
|
142
|
+
attr_reader :available_fields
|
|
143
|
+
|
|
144
|
+
# Creates a new MissingField exception.
|
|
145
|
+
#
|
|
146
|
+
# @param definition [Hash] the field definition
|
|
147
|
+
# @param available_fields [Array<String>, nil] fields present in source data
|
|
148
|
+
def initialize(definition, available_fields: nil)
|
|
26
149
|
@definition = definition
|
|
150
|
+
@available_fields = available_fields
|
|
27
151
|
end
|
|
28
152
|
|
|
153
|
+
# Returns a helpful error message listing available fields.
|
|
154
|
+
#
|
|
155
|
+
# @return [String] the error message
|
|
29
156
|
def message
|
|
30
157
|
field = definition[:label] || definition[:name]
|
|
31
|
-
"missing field \"#{field}\" from source data"
|
|
158
|
+
msg = "missing field \"#{field}\" from source data"
|
|
159
|
+
if available_fields&.any?
|
|
160
|
+
msg += "; available fields: #{available_fields.join(", ")}"
|
|
161
|
+
end
|
|
162
|
+
msg
|
|
32
163
|
end
|
|
33
164
|
end
|
|
34
165
|
end
|
data/lib/importu/importer.rb
CHANGED
|
@@ -1,118 +1,205 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "importu/backends"
|
|
3
|
+
require "importu/converters"
|
|
4
|
+
require "importu/definition"
|
|
5
|
+
require "importu/exceptions"
|
|
6
|
+
require "importu/record"
|
|
7
|
+
require "importu/summary"
|
|
8
|
+
|
|
9
|
+
# The main class for defining and running imports.
|
|
10
|
+
#
|
|
11
|
+
# Subclass Importer to define your import specification, then instantiate
|
|
12
|
+
# with a data source to process records.
|
|
13
|
+
#
|
|
14
|
+
# @example Define an importer
|
|
15
|
+
# class BookImporter < Importu::Importer
|
|
16
|
+
# # Define fields to extract from source data
|
|
17
|
+
# fields :title, :author
|
|
18
|
+
# field :isbn, label: "ISBN-10"
|
|
19
|
+
# field :pages, required: false, &convert_to(:integer)
|
|
20
|
+
#
|
|
21
|
+
# # Connect to a model for persistence (optional)
|
|
22
|
+
# model "Book"
|
|
23
|
+
# allow_actions :create, :update
|
|
24
|
+
# find_by :isbn
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# @example Process records without persistence
|
|
28
|
+
# source = Importu::Sources::CSV.new("books.csv")
|
|
29
|
+
# importer = BookImporter.new(source)
|
|
30
|
+
#
|
|
31
|
+
# importer.records.each do |record|
|
|
32
|
+
# puts "#{record[:title]} by #{record[:author]}"
|
|
33
|
+
# end
|
|
34
|
+
#
|
|
35
|
+
# @example Import with persistence
|
|
36
|
+
# source = Importu::Sources::CSV.new("books.csv")
|
|
37
|
+
# importer = BookImporter.new(source)
|
|
38
|
+
# summary = importer.import!
|
|
39
|
+
#
|
|
40
|
+
# puts summary.result_msg
|
|
41
|
+
# # Total: 100
|
|
42
|
+
# # Created: 95
|
|
43
|
+
# # Updated: 3
|
|
44
|
+
# # Invalid: 2
|
|
45
|
+
# # Unchanged: 0
|
|
46
|
+
#
|
|
47
|
+
# @see Importu::ConfigDSL for all DSL methods
|
|
48
|
+
# @see Importu::Summary for import results
|
|
49
|
+
# @see Importu::Record for accessing record data
|
|
50
|
+
# @api public
|
|
3
51
|
class Importu::Importer
|
|
4
|
-
attr_reader :options, :infile, :outfile, :validation_errors
|
|
5
|
-
attr_reader :total, :invalid, :created, :updated, :unchanged
|
|
6
52
|
|
|
7
|
-
|
|
53
|
+
extend Importu::ConfigDSL
|
|
8
54
|
include Importu::Converters
|
|
9
55
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
56
|
+
# The data source used for generating records
|
|
57
|
+
#
|
|
58
|
+
# @return [#rows]
|
|
59
|
+
#
|
|
60
|
+
# @example
|
|
61
|
+
# importer.source # => #<Importu::Backends::CSV: ...>
|
|
62
|
+
#
|
|
63
|
+
# @api public
|
|
64
|
+
attr_reader :source
|
|
65
|
+
|
|
66
|
+
# Creates a new instance of an importer.
|
|
67
|
+
#
|
|
68
|
+
# @example
|
|
69
|
+
# Importu::Importer.new # => #<Importu::Importer: ...>
|
|
70
|
+
#
|
|
71
|
+
# @param source [#rows] The source to read data from.
|
|
72
|
+
# @param backend [#find, #unique_id, #create, #update] The backend to
|
|
73
|
+
# persist records to.
|
|
74
|
+
# @param definition [Importu::Definition, nil] A definition/contract to
|
|
75
|
+
# use for generating records and controlling the import.
|
|
76
|
+
# @return [Importu::Importer]
|
|
77
|
+
#
|
|
78
|
+
# @api public
|
|
79
|
+
def initialize(source, backend: nil, definition: nil)
|
|
80
|
+
@source = source
|
|
81
|
+
@backend = backend
|
|
82
|
+
@definition = definition || self.class
|
|
83
|
+
@context = Importu::ConverterContext.with_config(**config)
|
|
20
84
|
end
|
|
21
85
|
|
|
22
|
-
|
|
23
|
-
|
|
86
|
+
# A registry of importer backends available for use.
|
|
87
|
+
#
|
|
88
|
+
# @example
|
|
89
|
+
# Importu::Importer.backend_registry # => #<Importu::Backends: ...>
|
|
90
|
+
#
|
|
91
|
+
# @return [Importu::Backend]
|
|
92
|
+
#
|
|
93
|
+
# @api semipublic
|
|
94
|
+
def self.backend_registry
|
|
95
|
+
Importu::Backends.registry
|
|
24
96
|
end
|
|
25
97
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
98
|
+
# A hash-based configuration of the definition used by the importer.
|
|
99
|
+
#
|
|
100
|
+
# @return [Hash]
|
|
101
|
+
#
|
|
102
|
+
# @example
|
|
103
|
+
# importer.config # => { ... }
|
|
104
|
+
#
|
|
105
|
+
# @api semipublic
|
|
106
|
+
def config
|
|
107
|
+
@definition.config
|
|
32
108
|
end
|
|
33
109
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
110
|
+
# Reads data from the source and attempts to create or update records
|
|
111
|
+
# through the backend. A summary of results from the import, including
|
|
112
|
+
# any errors encountered will be returned.
|
|
113
|
+
#
|
|
114
|
+
# If you need a way to track the progress of an import as each record is
|
|
115
|
+
# added, a custom recorder can be provided that can hook into other parts
|
|
116
|
+
# of your system; the recorder's #record method is called after each record
|
|
117
|
+
# has been processed.
|
|
118
|
+
#
|
|
119
|
+
# @example
|
|
120
|
+
# summary = importer.import!
|
|
121
|
+
# summary.created # => 2
|
|
122
|
+
#
|
|
123
|
+
# class CustomRecorder
|
|
124
|
+
# def record(result, index: nil, errors: [])
|
|
125
|
+
# puts "record: #{index}: #{result}"
|
|
126
|
+
# end
|
|
127
|
+
# end
|
|
128
|
+
#
|
|
129
|
+
# importer.import!(CustomRecorder.new)
|
|
130
|
+
# # (stdout) "record 0: created"
|
|
131
|
+
# # (stdout) "record 1: unchanged"
|
|
132
|
+
# # ...
|
|
133
|
+
#
|
|
134
|
+
# @param recorder [#record] An optional recorder to use instead of the
|
|
135
|
+
# default summarizer. Must implement a #record method.
|
|
136
|
+
# @return [Importu::Summary, #record] a summary object, or the same object
|
|
137
|
+
# passed into the method.
|
|
138
|
+
#
|
|
139
|
+
# @api public
|
|
140
|
+
def import!(recorder = Importu::Summary.new)
|
|
141
|
+
backend = with_middleware(@backend || backend_from_config)
|
|
142
|
+
|
|
143
|
+
records.each.with_index do |record, idx|
|
|
144
|
+
import_record(backend, record, idx, recorder)
|
|
46
145
|
end
|
|
47
|
-
|
|
48
|
-
msg
|
|
146
|
+
recorder
|
|
49
147
|
end
|
|
50
148
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
149
|
+
# An iterator of Importu::Record objects from the source data. Each call
|
|
150
|
+
# to the method returns a new iterator from the start.
|
|
151
|
+
#
|
|
152
|
+
# @return [Importu::Record::Iterator]
|
|
153
|
+
#
|
|
154
|
+
# @example
|
|
155
|
+
# importer.records
|
|
156
|
+
#
|
|
157
|
+
# @api public
|
|
158
|
+
def records
|
|
159
|
+
Importu::Record::Iterator.new(@source.rows, **config)
|
|
56
160
|
end
|
|
57
161
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
record.assign_to(object, action, &block)
|
|
70
|
-
|
|
71
|
-
case record.save!
|
|
72
|
-
when :created then @created += 1
|
|
73
|
-
when :updated then @updated += 1
|
|
74
|
-
when :unchanged then @unchanged += 1
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
rescue Importu::InvalidRecord => e
|
|
78
|
-
if errors = e.validation_errors
|
|
79
|
-
# convention: assume data-specific error messages put data inside parens, e.g. 'Dupe record found (sysnum 5489x)'
|
|
80
|
-
errors.each {|error| @validation_errors[error.gsub(/ *\([^)]+\)/,'')] += 1 }
|
|
81
|
-
else
|
|
82
|
-
@validation_errors["#{e.name}: #{e.message}"] += 1
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
@invalid += 1
|
|
86
|
-
raise
|
|
87
|
-
|
|
88
|
-
ensure
|
|
89
|
-
@total += 1
|
|
90
|
-
end
|
|
162
|
+
# Looks for a backend that is compatible with the definition used for
|
|
163
|
+
# the importer.
|
|
164
|
+
#
|
|
165
|
+
# @return [#find, #unique_id, #create, #update]
|
|
166
|
+
# @raise [Importu::BackendMatchError] if a compatible backend could not be
|
|
167
|
+
# found.
|
|
168
|
+
#
|
|
169
|
+
# @api private
|
|
170
|
+
private def backend_from_config
|
|
171
|
+
backend_class = self.class.backend_registry.from_config!(**config[:backend])
|
|
172
|
+
backend_class.new(**config[:backend])
|
|
91
173
|
end
|
|
92
174
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
175
|
+
# Performs an import of a single record. Acts as a wrapper around behavior
|
|
176
|
+
# that interfaces with the backend.
|
|
177
|
+
#
|
|
178
|
+
# @return [void]
|
|
179
|
+
#
|
|
180
|
+
# @api private
|
|
181
|
+
private def import_record(backend, record, index, recorder)
|
|
182
|
+
object = backend.find(record)
|
|
183
|
+
|
|
184
|
+
result, _object = object.nil? \
|
|
185
|
+
? backend.create(record)
|
|
186
|
+
: backend.update(record, object)
|
|
187
|
+
|
|
188
|
+
recorder.record(result, index: index)
|
|
189
|
+
rescue Importu::InvalidRecord => e
|
|
190
|
+
errors = e.validation_errors || ["#{e.name}: #{e.message}"]
|
|
191
|
+
recorder.record(:invalid, index: index, errors: errors)
|
|
110
192
|
end
|
|
111
193
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
194
|
+
# Wraps the configured backend with additional behaviors, such as duplicate
|
|
195
|
+
# detection.
|
|
196
|
+
#
|
|
197
|
+
# @return [#find, #unique_id, #create, #update]
|
|
198
|
+
#
|
|
199
|
+
# @api private
|
|
200
|
+
private def with_middleware(orig_backend)
|
|
201
|
+
Importu::Backends.middleware.inject(orig_backend) do |backend, middleware|
|
|
202
|
+
middleware.new(backend, **config[:backend])
|
|
116
203
|
end
|
|
117
204
|
end
|
|
118
205
|
|