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.
Files changed (110) hide show
  1. checksums.yaml +7 -0
  2. data/.editorconfig +15 -0
  3. data/.github/workflows/ci.yml +48 -0
  4. data/.gitignore +4 -0
  5. data/.rspec +1 -0
  6. data/.rubocop.yml +311 -0
  7. data/.simplecov +14 -0
  8. data/.yardstick.yml +36 -0
  9. data/Appraisals +22 -0
  10. data/CHANGELOG.md +51 -0
  11. data/CONTRIBUTING.md +86 -0
  12. data/Gemfile +5 -1
  13. data/LICENSE +21 -0
  14. data/README.md +435 -52
  15. data/Rakefile +71 -0
  16. data/UPGRADING.md +188 -0
  17. data/gemfiles/rails_7_2.gemfile +11 -0
  18. data/gemfiles/rails_7_2.gemfile.lock +268 -0
  19. data/gemfiles/rails_8_0.gemfile +11 -0
  20. data/gemfiles/rails_8_0.gemfile.lock +271 -0
  21. data/gemfiles/rails_8_1.gemfile +11 -0
  22. data/gemfiles/rails_8_1.gemfile.lock +269 -0
  23. data/gemfiles/standalone.gemfile +8 -0
  24. data/gemfiles/standalone.gemfile.lock +197 -0
  25. data/importu.gemspec +41 -22
  26. data/lib/importu/backends/active_record.rb +171 -0
  27. data/lib/importu/backends/middleware/duplicate_manager_proxy.rb +41 -0
  28. data/lib/importu/backends/middleware/enforce_allowed_actions.rb +52 -0
  29. data/lib/importu/backends/middleware.rb +11 -0
  30. data/lib/importu/backends.rb +103 -0
  31. data/lib/importu/config_dsl.rb +381 -0
  32. data/lib/importu/converter_context.rb +94 -0
  33. data/lib/importu/converters.rb +119 -64
  34. data/lib/importu/definition.rb +23 -0
  35. data/lib/importu/duplicate_manager.rb +88 -0
  36. data/lib/importu/exceptions.rb +135 -4
  37. data/lib/importu/importer.rb +183 -96
  38. data/lib/importu/record.rb +138 -102
  39. data/lib/importu/sources/csv.rb +122 -0
  40. data/lib/importu/sources/json.rb +106 -0
  41. data/lib/importu/sources/ruby.rb +46 -0
  42. data/lib/importu/sources/xml.rb +133 -0
  43. data/lib/importu/sources.rb +13 -0
  44. data/lib/importu/summary.rb +277 -0
  45. data/lib/importu/version.rb +3 -1
  46. data/lib/importu.rb +45 -9
  47. data/spec/fixtures/books-duplicates/README.md +7 -0
  48. data/spec/fixtures/books-duplicates/infile.csv +7 -0
  49. data/spec/fixtures/books-duplicates/model.json +23 -0
  50. data/spec/fixtures/books-duplicates/summary.json +10 -0
  51. data/spec/fixtures/books-valid/README.md +13 -0
  52. data/spec/fixtures/books-valid/infile.csv +4 -0
  53. data/spec/fixtures/books-valid/infile.json +23 -0
  54. data/spec/fixtures/books-valid/infile.xml +21 -0
  55. data/spec/fixtures/books-valid/model.json +23 -0
  56. data/spec/fixtures/books-valid/record.json +26 -0
  57. data/spec/fixtures/books-valid/summary.json +8 -0
  58. data/spec/fixtures/source-empty-file/infile.csv +0 -0
  59. data/spec/fixtures/source-empty-file/infile.json +0 -0
  60. data/spec/fixtures/source-empty-file/infile.xml +0 -0
  61. data/spec/fixtures/source-empty-records/infile.csv +3 -0
  62. data/spec/fixtures/source-empty-records/infile.json +1 -0
  63. data/spec/fixtures/source-empty-records/infile.xml +6 -0
  64. data/spec/fixtures/source-malformed/infile.csv +1 -0
  65. data/spec/fixtures/source-malformed/infile.json +1 -0
  66. data/spec/fixtures/source-malformed/infile.xml +3 -0
  67. data/spec/fixtures/source-no-records/infile.csv +1 -0
  68. data/spec/fixtures/source-no-records/infile.json +1 -0
  69. data/spec/fixtures/source-no-records/infile.xml +3 -0
  70. data/spec/lib/importu/backends/active_record_spec.rb +150 -0
  71. data/spec/lib/importu/backends/middleware/duplicate_manager_proxy_spec.rb +70 -0
  72. data/spec/lib/importu/backends/middleware/enforce_allowed_actions_spec.rb +70 -0
  73. data/spec/lib/importu/backends_spec.rb +170 -0
  74. data/spec/lib/importu/converters_spec.rb +184 -141
  75. data/spec/lib/importu/definition_spec.rb +248 -0
  76. data/spec/lib/importu/duplicate_manager_spec.rb +92 -0
  77. data/spec/lib/importu/exceptions_spec.rb +69 -16
  78. data/spec/lib/importu/import_context_spec.rb +199 -0
  79. data/spec/lib/importu/importer_spec.rb +95 -0
  80. data/spec/lib/importu/integration_spec.rb +221 -0
  81. data/spec/lib/importu/record_spec.rb +130 -80
  82. data/spec/lib/importu/sources/csv_spec.rb +29 -0
  83. data/spec/lib/importu/sources/importer_source_examples.rb +175 -0
  84. data/spec/lib/importu/sources/json_spec.rb +29 -0
  85. data/spec/lib/importu/sources/ruby_spec.rb +102 -0
  86. data/spec/lib/importu/sources/xml_spec.rb +70 -0
  87. data/spec/lib/importu/summary_spec.rb +186 -0
  88. data/spec/spec_helper.rb +91 -7
  89. data/spec/support/active_record.rb +20 -0
  90. data/spec/support/book_importer.rb +31 -0
  91. data/spec/support/dummy_backend.rb +50 -0
  92. data/spec/support/fixtures_helper.rb +43 -0
  93. data/spec/support/matchers/delegate_matcher.rb +14 -8
  94. metadata +173 -100
  95. data/lib/importu/core_ext/array/deep_freeze.rb +0 -7
  96. data/lib/importu/core_ext/deep_freeze.rb +0 -3
  97. data/lib/importu/core_ext/hash/deep_freeze.rb +0 -7
  98. data/lib/importu/core_ext/object/deep_freeze.rb +0 -6
  99. data/lib/importu/core_ext.rb +0 -3
  100. data/lib/importu/dsl.rb +0 -127
  101. data/lib/importu/importer/csv.rb +0 -52
  102. data/lib/importu/importer/json.rb +0 -45
  103. data/lib/importu/importer/xml.rb +0 -55
  104. data/spec/factories/importer.rb +0 -12
  105. data/spec/factories/importer_record.rb +0 -13
  106. data/spec/factories/json_importer.rb +0 -14
  107. data/spec/factories/xml_importer.rb +0 -12
  108. data/spec/lib/importu/dsl_spec.rb +0 -26
  109. data/spec/lib/importu/importer/json_spec.rb +0 -37
  110. 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
@@ -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
- def initialize(message = nil, validation_errors = nil)
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
- class FieldParseError < InvalidRecord; end
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
- def initialize(definition)
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
@@ -1,118 +1,205 @@
1
- require 'active_record/errors'
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
- include Importu::Dsl
53
+ extend Importu::ConfigDSL
8
54
  include Importu::Converters
9
55
 
10
- def initialize(infile, options = {})
11
- @options = options
12
- @total = @invalid = @created = @updated = @unchanged = 0
13
- @validation_errors = Hash.new(0) # counter for each validation error
14
-
15
- @infile = infile.respond_to?(:readline) ? infile : File.open(infile, 'rb')
16
- end
17
-
18
- def records
19
- [].to_enum # implement in a subclass
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
- def outfile
23
- @outfile ||= Tempfile.new('import', Rails.root.join('tmp'), 'wb+')
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
- def import!(finder_scope = nil, &block)
27
- # if a scope is passed in, that scope becomes the starting scope used by
28
- # the finder, otherwise the model's default scope is used).
29
-
30
- finder_scope ||= model_class.scoped
31
- records.each {|r| import_record(r, finder_scope, &block) }
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
- def result_msg
35
- msg = <<-END.strip_heredoc
36
- Total: #{@total}
37
- Created: #{@created}
38
- Updated: #{@updated}
39
- Invalid: #{@invalid}
40
- Unchanged: #{@unchanged}
41
- END
42
-
43
- if @validation_errors.any?
44
- msg << "\nValidation Errors:\n"
45
- msg << @validation_errors.map {|e,c| " - #{e}: #{c}" }.join("\n")
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
- protected
53
-
54
- def model_class
55
- @model_class ||= model.constantize
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
- def import_record(record, finder_scope, &block)
59
- begin
60
- object = find(finder_scope, record) || model_class.new
61
- action = object.new_record? ? :create : :update
62
- check_duplicate(object) if action == :update
63
-
64
- case ([action] - allowed_actions).first
65
- when :create then raise Importu::InvalidRecord, "#{model} not found"
66
- when :update then raise Importu::InvalidRecord, "existing #{model} found"
67
- end
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
- def find(scope, record)
94
- # FIXME: find does not report if it finds more than one record matching
95
- # the :find_by conditions passed in. it just uses the first match for
96
- # now. what should be the correct behaviour?
97
-
98
- field_groups = self.class.finder_fields or return
99
- field_groups.each do |field_group|
100
- if field_group.respond_to?(:call) # proc
101
- object = scope.instance_exec(record, &field_group).first
102
- else
103
- conditions = Hash[field_group.map {|f| [f, record[f]]}]
104
- object = scope.where(conditions).first
105
- end
106
-
107
- return object if object
108
- end
109
- nil
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
- def check_duplicate(record)
113
- return unless id = record.respond_to?(:id) && record.id
114
- if ((@encountered||=Hash.new(0))[id] += 1) > 1
115
- raise Importu::DuplicateRecord, 'matches a previously imported record'
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