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,381 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# DSL methods for configuring importers.
|
|
4
|
+
#
|
|
5
|
+
# These methods define your import specification: what fields to extract,
|
|
6
|
+
# how to convert them, where to persist, and what actions are allowed.
|
|
7
|
+
#
|
|
8
|
+
# ## Field Definition
|
|
9
|
+
# - {#field} - Define a single field with options
|
|
10
|
+
# - {#fields} - Define multiple fields at once
|
|
11
|
+
# - {#converter} - Create a custom converter
|
|
12
|
+
# - {#convert_to} - Reference a built-in or custom converter
|
|
13
|
+
#
|
|
14
|
+
# ## Persistence
|
|
15
|
+
# - {#model} - Connect to a model class for persistence
|
|
16
|
+
# - {#find_by} - Specify how to find existing records
|
|
17
|
+
# - {#allow_actions} - Control create/update permissions
|
|
18
|
+
# - {#before_save} - Hook to modify records before saving
|
|
19
|
+
#
|
|
20
|
+
# ## Source Configuration
|
|
21
|
+
# - {#source} - Configure source-specific options
|
|
22
|
+
#
|
|
23
|
+
# @example Complete importer definition
|
|
24
|
+
# class BookImporter < Importu::Importer
|
|
25
|
+
# # Fields
|
|
26
|
+
# fields :title, :author
|
|
27
|
+
# field :isbn, label: "ISBN-10"
|
|
28
|
+
# field :pages, required: false, &convert_to(:integer)
|
|
29
|
+
# field :price, &convert_to(:decimal)
|
|
30
|
+
#
|
|
31
|
+
# # Persistence
|
|
32
|
+
# model "Book"
|
|
33
|
+
# allow_actions :create, :update
|
|
34
|
+
# find_by :isbn
|
|
35
|
+
#
|
|
36
|
+
# # Hooks
|
|
37
|
+
# before_save { object.title = object.title.titleize }
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# @see Importu::Converters for built-in converters
|
|
41
|
+
# @api public
|
|
42
|
+
module Importu::ConfigDSL
|
|
43
|
+
|
|
44
|
+
# Returns the current configuration hash for this importer.
|
|
45
|
+
#
|
|
46
|
+
# @return [Hash] the configuration hash
|
|
47
|
+
# @api semipublic
|
|
48
|
+
def config
|
|
49
|
+
@config ||= superclass.respond_to?(:config) \
|
|
50
|
+
? superclass.config
|
|
51
|
+
: default_config
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Specifies which persistence actions are allowed during import.
|
|
55
|
+
#
|
|
56
|
+
# @param actions [Array<Symbol>] the actions to allow (:create and/or :update)
|
|
57
|
+
# @return [void]
|
|
58
|
+
#
|
|
59
|
+
# @example Allow only creating new records (default)
|
|
60
|
+
# allow_actions :create
|
|
61
|
+
#
|
|
62
|
+
# @example Allow both creating and updating
|
|
63
|
+
# allow_actions :create, :update
|
|
64
|
+
#
|
|
65
|
+
# @api public
|
|
66
|
+
def allow_actions(*actions)
|
|
67
|
+
@config = { **config,
|
|
68
|
+
backend: { **config[:backend], allowed_actions: actions.compact }
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Creates a converter reference for use with field definitions.
|
|
73
|
+
#
|
|
74
|
+
# @param type [Symbol] the converter type (:string, :integer, :date, etc.)
|
|
75
|
+
# @param options [Hash] converter-specific options
|
|
76
|
+
# @return [ConverterStub] a callable that applies the converter
|
|
77
|
+
#
|
|
78
|
+
# @example Basic usage
|
|
79
|
+
# field :pages, &convert_to(:integer)
|
|
80
|
+
#
|
|
81
|
+
# @example With options
|
|
82
|
+
# field :release_date, &convert_to(:date, format: "%Y-%m-%d")
|
|
83
|
+
#
|
|
84
|
+
# @api public
|
|
85
|
+
def convert_to(type, **options)
|
|
86
|
+
config[:converters].fetch(type) # validate converter exists
|
|
87
|
+
ConverterStub.for(type, **options)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Defines a custom converter for use in field definitions.
|
|
91
|
+
#
|
|
92
|
+
# @param name [Symbol] the converter name
|
|
93
|
+
# @yield [field_name, **options] block that performs the conversion
|
|
94
|
+
# @yieldparam field_name [Symbol] the field being converted
|
|
95
|
+
# @yieldparam options [Hash] any options passed to convert_to
|
|
96
|
+
# @yieldreturn the converted value
|
|
97
|
+
# @return [void]
|
|
98
|
+
#
|
|
99
|
+
# @example Simple converter
|
|
100
|
+
# converter(:uppercase) do |name|
|
|
101
|
+
# value = raw(name)
|
|
102
|
+
# value.respond_to?(:upcase) ? value.upcase : value
|
|
103
|
+
# end
|
|
104
|
+
#
|
|
105
|
+
# @example Converter with options
|
|
106
|
+
# converter(:varchar) do |name, length: 255|
|
|
107
|
+
# value = trimmed(name)
|
|
108
|
+
# value.nil? ? nil : String(value).slice(0, length)
|
|
109
|
+
# end
|
|
110
|
+
#
|
|
111
|
+
# @api public
|
|
112
|
+
def converter(name, &block)
|
|
113
|
+
@config = { **config,
|
|
114
|
+
converters: { **config[:converters], name => block }
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Defines a single field with its configuration.
|
|
119
|
+
#
|
|
120
|
+
# @param name [Symbol] the field name (used as the key in record hashes)
|
|
121
|
+
# @param props [Hash] field options
|
|
122
|
+
# @option props [String] :label the column/key name in source data (default: field name as string)
|
|
123
|
+
# @option props [Boolean] :required whether the field must be present (default: true)
|
|
124
|
+
# @option props [Boolean] :abstract whether the field is computed, not from source (default: false)
|
|
125
|
+
# @option props [Object] :default value to use when field is nil and not required
|
|
126
|
+
# @option props [Boolean] :create include this field when creating records (default: true)
|
|
127
|
+
# @option props [Boolean] :update include this field when updating records (default: true)
|
|
128
|
+
# @yield [field_name] optional block for custom conversion logic
|
|
129
|
+
# @return [void]
|
|
130
|
+
#
|
|
131
|
+
# @example Basic field
|
|
132
|
+
# field :title
|
|
133
|
+
#
|
|
134
|
+
# @example Field with label mapping
|
|
135
|
+
# field :authors, label: "author"
|
|
136
|
+
#
|
|
137
|
+
# @example Field with converter
|
|
138
|
+
# field :pages, &convert_to(:integer)
|
|
139
|
+
#
|
|
140
|
+
# @example Field with custom conversion block
|
|
141
|
+
# field :authors do
|
|
142
|
+
# trimmed(:authors).to_s.split(", ")
|
|
143
|
+
# end
|
|
144
|
+
#
|
|
145
|
+
# @api public
|
|
146
|
+
def field(name, **props, &block)
|
|
147
|
+
field = config[:fields].fetch(name, field_defaults(name))
|
|
148
|
+
props[:converter] = block if block
|
|
149
|
+
|
|
150
|
+
@config = { **config,
|
|
151
|
+
fields: { **config[:fields],
|
|
152
|
+
name => { **field, **props }
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Defines multiple fields with the same configuration.
|
|
158
|
+
#
|
|
159
|
+
# @param names [Array<Symbol>] the field names
|
|
160
|
+
# @param props [Hash] field options (see {#field} for available options)
|
|
161
|
+
# @yield optional block for custom conversion (applied to all fields)
|
|
162
|
+
# @return [void]
|
|
163
|
+
#
|
|
164
|
+
# @example Multiple fields
|
|
165
|
+
# fields :title, :author, :isbn
|
|
166
|
+
#
|
|
167
|
+
# @example Multiple fields with shared converter
|
|
168
|
+
# fields :pages, :quantity, &convert_to(:integer)
|
|
169
|
+
#
|
|
170
|
+
# @api public
|
|
171
|
+
def fields(*names, **props, &block)
|
|
172
|
+
names.each {|name| field(name, **props, &block) }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Specifies how to find existing records for updates.
|
|
176
|
+
#
|
|
177
|
+
# @param field_groups [Array<Symbol, Array<Symbol>>] fields to match against
|
|
178
|
+
# @yield [record] optional block for custom lookup logic
|
|
179
|
+
# @yieldparam record [Importu::Record] the record being imported
|
|
180
|
+
# @return [void]
|
|
181
|
+
#
|
|
182
|
+
# @example Single field lookup
|
|
183
|
+
# find_by :isbn
|
|
184
|
+
#
|
|
185
|
+
# @example Multiple fields (all must match)
|
|
186
|
+
# find_by :title, :author
|
|
187
|
+
#
|
|
188
|
+
# @example Multiple field groups (tried in order)
|
|
189
|
+
# find_by :isbn, [:title, :author]
|
|
190
|
+
#
|
|
191
|
+
# @example Custom lookup block
|
|
192
|
+
# find_by do |record|
|
|
193
|
+
# find_by(title: record[:title].downcase)
|
|
194
|
+
# end
|
|
195
|
+
#
|
|
196
|
+
# @api public
|
|
197
|
+
def find_by(*field_groups, &block)
|
|
198
|
+
finder_fields = [*field_groups, block].compact.map do |field_group|
|
|
199
|
+
field_group.respond_to?(:call) ? field_group : Array(field_group)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
@config = { **config,
|
|
203
|
+
backend: { **config[:backend], finder_fields: finder_fields }
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Associates the importer with a model class for persistence.
|
|
208
|
+
#
|
|
209
|
+
# @param name [String, Class] the model class or class name
|
|
210
|
+
# @param backend [Symbol, nil] the backend to use (:active_record, :auto, or nil)
|
|
211
|
+
# @return [void]
|
|
212
|
+
#
|
|
213
|
+
# @example Basic usage
|
|
214
|
+
# model "Book"
|
|
215
|
+
#
|
|
216
|
+
# @example With explicit backend
|
|
217
|
+
# model "Book", backend: :active_record
|
|
218
|
+
#
|
|
219
|
+
# @example With auto-detection (default behavior)
|
|
220
|
+
# model "Book", backend: :auto
|
|
221
|
+
#
|
|
222
|
+
# The backend can be:
|
|
223
|
+
# - nil or :auto - auto-detect from registered backends
|
|
224
|
+
# - :active_record - use ActiveRecord backend explicitly
|
|
225
|
+
# - Any other symbol registered with Importu::Backends.registry
|
|
226
|
+
#
|
|
227
|
+
# @see #allow_actions
|
|
228
|
+
# @see #find_by
|
|
229
|
+
# @api public
|
|
230
|
+
def model(name, backend: nil)
|
|
231
|
+
@config = { **config,
|
|
232
|
+
backend: { **config[:backend], name: backend, model: name }
|
|
233
|
+
}
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Defines a callback to execute just before saving a record.
|
|
237
|
+
#
|
|
238
|
+
# The block is executed in the context of an AssignmentContext, which
|
|
239
|
+
# provides access to:
|
|
240
|
+
# - object: the model instance being saved
|
|
241
|
+
# - record: the converted record data
|
|
242
|
+
# - action: :create or :update
|
|
243
|
+
#
|
|
244
|
+
# This is a backend hook. Backends may choose to honor it or ignore it.
|
|
245
|
+
# The ActiveRecord backend executes it after assigning field values but
|
|
246
|
+
# before calling save!.
|
|
247
|
+
#
|
|
248
|
+
# @yield block to execute before saving
|
|
249
|
+
# @return [void]
|
|
250
|
+
#
|
|
251
|
+
# @example Normalize title before saving
|
|
252
|
+
# before_save { object.title = object.title.titleize }
|
|
253
|
+
#
|
|
254
|
+
# @example Set audit field
|
|
255
|
+
# before_save { object.imported_at = Time.current }
|
|
256
|
+
#
|
|
257
|
+
# @example Conditional logic based on action
|
|
258
|
+
# before_save do
|
|
259
|
+
# object.created_by = "importer" if action == :create
|
|
260
|
+
# object.updated_by = "importer" if action == :update
|
|
261
|
+
# end
|
|
262
|
+
#
|
|
263
|
+
# @see Importu::Backends::ActiveRecord::AssignmentContext
|
|
264
|
+
# @api public
|
|
265
|
+
def before_save(&block)
|
|
266
|
+
@config = { **config,
|
|
267
|
+
backend: { **config[:backend], before_save: block }
|
|
268
|
+
}
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# Configures source-specific options.
|
|
272
|
+
#
|
|
273
|
+
# @param type [Symbol] the source type (:csv, :json, :xml, :ruby)
|
|
274
|
+
# @param props [Hash] source-specific options
|
|
275
|
+
# @return [void]
|
|
276
|
+
#
|
|
277
|
+
# @example XML source with xpath
|
|
278
|
+
# source :xml, records_xpath: "//book"
|
|
279
|
+
#
|
|
280
|
+
# @example CSV source with custom delimiter
|
|
281
|
+
# source :csv, col_sep: "\t"
|
|
282
|
+
#
|
|
283
|
+
# @api public
|
|
284
|
+
def source(type, **props)
|
|
285
|
+
source = config[:sources].fetch(type, {})
|
|
286
|
+
|
|
287
|
+
@config = { **config,
|
|
288
|
+
sources: { **config[:sources],
|
|
289
|
+
type => { **source, **props }
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# @!visibility private
|
|
295
|
+
def default_config
|
|
296
|
+
raw_converter = ->(n) { raw_value(n) }
|
|
297
|
+
|
|
298
|
+
{
|
|
299
|
+
backend: {
|
|
300
|
+
name: nil,
|
|
301
|
+
model: nil,
|
|
302
|
+
finder_fields: [],
|
|
303
|
+
before_save: nil,
|
|
304
|
+
allowed_actions: [:create],
|
|
305
|
+
},
|
|
306
|
+
sources: {
|
|
307
|
+
csv: {},
|
|
308
|
+
json: {},
|
|
309
|
+
ruby: {},
|
|
310
|
+
xml: {
|
|
311
|
+
records_xpath: nil,
|
|
312
|
+
},
|
|
313
|
+
},
|
|
314
|
+
converters: {
|
|
315
|
+
raw: raw_converter,
|
|
316
|
+
default: raw_converter,
|
|
317
|
+
},
|
|
318
|
+
fields: {},
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# @!visibility private
|
|
323
|
+
def field_defaults(name)
|
|
324
|
+
{
|
|
325
|
+
name: name,
|
|
326
|
+
label: name.to_s,
|
|
327
|
+
required: true,
|
|
328
|
+
abstract: false,
|
|
329
|
+
default: nil,
|
|
330
|
+
create: true,
|
|
331
|
+
update: true,
|
|
332
|
+
converter: convert_to(:default),
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# A proc-like object that stores converter type and options.
|
|
337
|
+
#
|
|
338
|
+
# This allows subclassed definitions to override a converter's behavior
|
|
339
|
+
# and have it affect already defined fields.
|
|
340
|
+
#
|
|
341
|
+
# @api private
|
|
342
|
+
class ConverterStub < Proc
|
|
343
|
+
# @return [Symbol] the converter type
|
|
344
|
+
attr_reader :type
|
|
345
|
+
|
|
346
|
+
# @return [Hash] the converter options
|
|
347
|
+
attr_reader :options
|
|
348
|
+
|
|
349
|
+
# Creates a new converter stub.
|
|
350
|
+
#
|
|
351
|
+
# @param type [Symbol] the converter type
|
|
352
|
+
# @param options [Hash] options to pass to the converter
|
|
353
|
+
# @param block [Proc] the converter implementation
|
|
354
|
+
def initialize(type, options, &block)
|
|
355
|
+
@type, @options = type, options
|
|
356
|
+
super(&block)
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Creates a stub that delegates to a named converter.
|
|
360
|
+
#
|
|
361
|
+
# @param type [Symbol] the converter type
|
|
362
|
+
# @param options [Hash] options to pass to the converter
|
|
363
|
+
# @return [ConverterStub] a new stub
|
|
364
|
+
def self.for(type, **options)
|
|
365
|
+
block = options.any? \
|
|
366
|
+
? ->(n) { send(type, n, **options) }
|
|
367
|
+
: ->(n) { send(type, n) }
|
|
368
|
+
|
|
369
|
+
new(type, options, &block)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Compares stubs by type and options.
|
|
373
|
+
#
|
|
374
|
+
# @param other [ConverterStub] the other stub
|
|
375
|
+
# @return [Boolean] true if type and options match
|
|
376
|
+
def ==(other)
|
|
377
|
+
type == other.type && options == other.options
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "importu/exceptions"
|
|
3
|
+
|
|
4
|
+
# Execution context for field converters.
|
|
5
|
+
#
|
|
6
|
+
# When you define a custom converter or field block, the code executes within
|
|
7
|
+
# a ConverterContext instance. This provides access to field values and other
|
|
8
|
+
# converters while isolating converter code from internal implementation details.
|
|
9
|
+
#
|
|
10
|
+
# @example Accessing values in a custom converter
|
|
11
|
+
# converter :full_name do |field_name|
|
|
12
|
+
# # raw_value gets the untransformed source data
|
|
13
|
+
# first = raw_value(:first_name)
|
|
14
|
+
# last = raw_value(:last_name)
|
|
15
|
+
# "#{first} #{last}".strip
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Referencing other fields
|
|
19
|
+
# field :tax do
|
|
20
|
+
# # field_value gets the converted value of another field
|
|
21
|
+
# subtotal = field_value(:subtotal)
|
|
22
|
+
# subtotal * 0.08
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @api semipublic
|
|
26
|
+
class Importu::ConverterContext
|
|
27
|
+
# Creates a new context with the given source data.
|
|
28
|
+
#
|
|
29
|
+
# @param data [Hash] the raw source data for one record
|
|
30
|
+
# @api private
|
|
31
|
+
def initialize(data)
|
|
32
|
+
@data = data
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Creates a context subclass configured with converters and field definitions.
|
|
36
|
+
#
|
|
37
|
+
# @param converters [Hash{Symbol => Proc}] converter name to implementation
|
|
38
|
+
# @param fields [Hash{Symbol => Hash}] field name to definition
|
|
39
|
+
# @return [Class] a configured ConverterContext subclass
|
|
40
|
+
# @api private
|
|
41
|
+
def self.with_config(converters:, fields:, **)
|
|
42
|
+
Class.new(self) do
|
|
43
|
+
define_method(:field_definitions) { fields }
|
|
44
|
+
converters.each {|name, block| define_method(name, &block) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns the converted value of a field.
|
|
49
|
+
#
|
|
50
|
+
# Runs the field's converter and applies validation. Use this when you need
|
|
51
|
+
# the final, converted value of another field.
|
|
52
|
+
#
|
|
53
|
+
# @param name [Symbol] the field name
|
|
54
|
+
# @return [Object] the converted field value
|
|
55
|
+
# @raise [Importu::FieldParseError] if conversion fails
|
|
56
|
+
# @raise [Importu::MissingField] if field is required but missing
|
|
57
|
+
# @raise [Importu::InvalidDefinition] if field is not defined
|
|
58
|
+
def field_value(name)
|
|
59
|
+
definition = fetch_field_definition(name)
|
|
60
|
+
|
|
61
|
+
begin
|
|
62
|
+
value = instance_exec(name, &definition[:converter])
|
|
63
|
+
rescue ArgumentError => e
|
|
64
|
+
# conversion of field value most likely failed
|
|
65
|
+
raise Importu::FieldParseError.new(name, e.message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
if value.nil? && definition[:required]
|
|
69
|
+
raise Importu::MissingField.new(definition, available_fields: @data.keys)
|
|
70
|
+
else
|
|
71
|
+
value.nil? ? definition[:default] : value
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns the raw, unconverted value from source data.
|
|
76
|
+
#
|
|
77
|
+
# Use this when you need the original value before any conversion. The value
|
|
78
|
+
# is looked up using the field's label (which defaults to the field name).
|
|
79
|
+
#
|
|
80
|
+
# @param name [Symbol] the field name
|
|
81
|
+
# @return [Object, nil] the raw source value
|
|
82
|
+
# @raise [Importu::InvalidDefinition] if field is not defined
|
|
83
|
+
def raw_value(name)
|
|
84
|
+
definition = fetch_field_definition(name)
|
|
85
|
+
@data[definition.fetch(:label)]
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private def fetch_field_definition(name)
|
|
89
|
+
field_definitions.fetch(name) do
|
|
90
|
+
raise Importu::InvalidDefinition, "importer field not defined: #{name}"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
end
|
data/lib/importu/converters.rb
CHANGED
|
@@ -1,82 +1,137 @@
|
|
|
1
|
-
|
|
2
|
-
require
|
|
3
|
-
require
|
|
4
|
-
|
|
5
|
-
require 'bigdecimal'
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "bigdecimal"
|
|
3
|
+
require "date"
|
|
6
4
|
|
|
5
|
+
# Built-in converters for common data types.
|
|
6
|
+
#
|
|
7
|
+
# This module is automatically included in importers and provides these converters:
|
|
8
|
+
#
|
|
9
|
+
# ## :boolean
|
|
10
|
+
# Converts to true/false. Accepts: true, false, 1, 0, "true", "false", "yes", "no".
|
|
11
|
+
# Returns nil for nil/empty, raises ArgumentError for other values.
|
|
12
|
+
#
|
|
13
|
+
# field :active, &convert_to(:boolean)
|
|
14
|
+
#
|
|
15
|
+
# ## :date
|
|
16
|
+
# Parses date strings. Uses Date.parse by default, or Date.strptime with format option.
|
|
17
|
+
#
|
|
18
|
+
# field :published, &convert_to(:date)
|
|
19
|
+
# field :published, &convert_to(:date, format: "%Y-%m-%d")
|
|
20
|
+
#
|
|
21
|
+
# ## :datetime
|
|
22
|
+
# Parses datetime strings to Time (UTC). Uses DateTime.parse or strptime with format.
|
|
23
|
+
#
|
|
24
|
+
# field :created_at, &convert_to(:datetime)
|
|
25
|
+
# field :created_at, &convert_to(:datetime, format: "%Y-%m-%d %H:%M:%S")
|
|
26
|
+
#
|
|
27
|
+
# ## :decimal
|
|
28
|
+
# Converts to BigDecimal. Accepts numeric strings like "123.45".
|
|
29
|
+
# Returns nil for nil/empty, raises ArgumentError for invalid format.
|
|
30
|
+
#
|
|
31
|
+
# field :price, &convert_to(:decimal)
|
|
32
|
+
#
|
|
33
|
+
# ## :float
|
|
34
|
+
# Converts to Float using Kernel#Float.
|
|
35
|
+
#
|
|
36
|
+
# field :rating, &convert_to(:float)
|
|
37
|
+
#
|
|
38
|
+
# ## :integer
|
|
39
|
+
# Converts to Integer (base 10). Accepts integers or numeric strings.
|
|
40
|
+
#
|
|
41
|
+
# field :pages, &convert_to(:integer)
|
|
42
|
+
#
|
|
43
|
+
# ## :string
|
|
44
|
+
# Converts to String. Trims whitespace, returns nil for empty strings.
|
|
45
|
+
#
|
|
46
|
+
# field :name, &convert_to(:string)
|
|
47
|
+
#
|
|
48
|
+
# ## :trimmed (default)
|
|
49
|
+
# Strips whitespace from strings, returns nil for empty. Non-strings pass through.
|
|
50
|
+
# This is the default converter when none is specified.
|
|
51
|
+
#
|
|
52
|
+
# field :title # uses :trimmed by default
|
|
53
|
+
#
|
|
54
|
+
# @see Importu::ConfigDSL#converter
|
|
55
|
+
# @see Importu::ConfigDSL#convert_to
|
|
56
|
+
# @api semipublic
|
|
7
57
|
module Importu::Converters
|
|
8
|
-
|
|
58
|
+
# Defines built-in converters when included in a class.
|
|
59
|
+
#
|
|
60
|
+
# @api private
|
|
61
|
+
def self.included(base)
|
|
62
|
+
base.class_eval do
|
|
9
63
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
64
|
+
converter :boolean do |name|
|
|
65
|
+
value = trimmed(name)
|
|
66
|
+
case value
|
|
67
|
+
when nil then nil
|
|
68
|
+
when true, 1, /\A(?:true|yes|1)\z/i then true
|
|
69
|
+
when false, 0, /\A(?:false|no|0)\z/i then false
|
|
70
|
+
else raise ArgumentError, "invalid boolean value '#{value}'"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
14
73
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
74
|
+
converter :date do |name, format: nil|
|
|
75
|
+
if value = trimmed(name)
|
|
76
|
+
format \
|
|
77
|
+
? Date.strptime(value, format)
|
|
78
|
+
: Date.parse(value)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
19
81
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
82
|
+
converter :datetime do |name, format: nil|
|
|
83
|
+
if value = trimmed(name)
|
|
84
|
+
dt = format \
|
|
85
|
+
? DateTime.strptime(value, format)
|
|
86
|
+
: DateTime.parse(value)
|
|
87
|
+
# Convert DateTime to Time without triggering ActiveSupport deprecation
|
|
88
|
+
offset_seconds = (dt.offset * 86_400).to_i
|
|
89
|
+
Time.new(dt.year, dt.month, dt.day, dt.hour, dt.min, dt.sec, offset_seconds).utc
|
|
90
|
+
end
|
|
91
|
+
end
|
|
26
92
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
93
|
+
converter :decimal do |name|
|
|
94
|
+
value = trimmed(name)
|
|
95
|
+
case value
|
|
96
|
+
when nil then nil
|
|
97
|
+
when BigDecimal then value
|
|
98
|
+
when /\A-?\d+(?:\.\d+)?\Z/ then BigDecimal(value)
|
|
99
|
+
else raise ArgumentError, "invalid decimal value '#{value}'"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
30
102
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
103
|
+
converter :float do |name|
|
|
104
|
+
value = trimmed(name)
|
|
105
|
+
value.nil? ? nil : Float(value)
|
|
106
|
+
end
|
|
35
107
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
value.nil? ? nil : Float(value)
|
|
39
|
-
end
|
|
108
|
+
converter :integer do |name|
|
|
109
|
+
value = trimmed(name)
|
|
40
110
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
when /\A-?\d+(?:\.\d+)?\Z/ then BigDecimal(value)
|
|
47
|
-
else raise ArgumentError, "invalid decimal value '#{value}'"
|
|
111
|
+
case value
|
|
112
|
+
when nil then nil
|
|
113
|
+
when Integer then value
|
|
114
|
+
else Integer(value.to_s, 10)
|
|
115
|
+
end
|
|
48
116
|
end
|
|
49
|
-
end
|
|
50
117
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
when nil then nil
|
|
55
|
-
when true, 'true', 'yes', '1', 1 then true
|
|
56
|
-
when false, 'false', 'no', '0', 0 then false
|
|
57
|
-
else raise ArgumentError, "invalid boolean value '#{value}'"
|
|
118
|
+
converter :string do |name|
|
|
119
|
+
value = trimmed(name)
|
|
120
|
+
value.nil? ? nil : String(value)
|
|
58
121
|
end
|
|
59
|
-
end
|
|
60
122
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
123
|
+
converter :trimmed do |name|
|
|
124
|
+
value = raw_value(name)
|
|
125
|
+
if value.is_a?(String)
|
|
126
|
+
new_value = value.strip
|
|
127
|
+
new_value.empty? ? nil : new_value
|
|
128
|
+
else
|
|
129
|
+
value
|
|
130
|
+
end
|
|
68
131
|
end
|
|
69
|
-
end
|
|
70
132
|
|
|
71
|
-
|
|
72
|
-
if value = convert(name, :clean, options)
|
|
73
|
-
# TODO: options[:date_format] is deprecated
|
|
74
|
-
date_format = options[:date_format] || options[:format]
|
|
75
|
-
date_format \
|
|
76
|
-
? DateTime.strptime(value, date_format).utc
|
|
77
|
-
: DateTime.parse(value).utc
|
|
78
|
-
end
|
|
79
|
-
end
|
|
133
|
+
converter :default, &convert_to(:trimmed)
|
|
80
134
|
|
|
135
|
+
end
|
|
81
136
|
end
|
|
82
137
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "importu/config_dsl"
|
|
3
|
+
require "importu/converters"
|
|
4
|
+
|
|
5
|
+
# Base class for import definitions.
|
|
6
|
+
#
|
|
7
|
+
# Definition provides the DSL for declaring fields, converters, and import
|
|
8
|
+
# configuration. Extend this class to create reusable field definitions that
|
|
9
|
+
# can be shared across multiple importers.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a reusable definition
|
|
12
|
+
# class BookFields < Importu::Definition
|
|
13
|
+
# fields :title, :author
|
|
14
|
+
# field :isbn, &convert_to(:string)
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# @see Importu::Importer
|
|
18
|
+
# @see Importu::ConfigDSL
|
|
19
|
+
# @api public
|
|
20
|
+
class Importu::Definition
|
|
21
|
+
extend Importu::ConfigDSL
|
|
22
|
+
include Importu::Converters
|
|
23
|
+
end
|