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,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
@@ -1,82 +1,137 @@
1
- require 'active_support/core_ext/object/blank'
2
- require 'active_support/core_ext/date_time/conversions'
3
- require 'active_support/concern'
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
- extend ActiveSupport::Concern
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
- included do
11
- converter :raw do |name,options|
12
- definition = definitions[name] \
13
- or raise "importer field not defined: #{name}"
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
- label = definition[:label]
16
- raise Importu::MissingField, definition unless data.key?(label)
17
- data[label]
18
- end
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
- converter :clean do |name,options|
21
- value = convert(name, :raw, options)
22
- value.is_a?(String) \
23
- ? (value.blank? ? nil : value.strip)
24
- : value
25
- end
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
- converter :string do |name,options|
28
- convert(name, :clean, options).try(:to_s)
29
- end
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
- converter :integer do |name,options|
32
- value = convert(name, :clean, options)
33
- value.nil? ? nil : Integer(value)
34
- end
103
+ converter :float do |name|
104
+ value = trimmed(name)
105
+ value.nil? ? nil : Float(value)
106
+ end
35
107
 
36
- converter :float do |name,options|
37
- value = convert(name, :clean, options)
38
- value.nil? ? nil : Float(value)
39
- end
108
+ converter :integer do |name|
109
+ value = trimmed(name)
40
110
 
41
- converter :decimal do |name,options|
42
- value = convert(name, :clean, options)
43
- case value
44
- when nil then nil
45
- when BigDecimal then value
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
- converter :boolean do |name,options|
52
- value = convert(name, :clean, options)
53
- case value
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
- converter :date do |name,options|
62
- if value = convert(name, :clean, options)
63
- # TODO: options[:date_format] is deprecated
64
- date_format = options[:date_format] || options[:format]
65
- date_format \
66
- ? Date.strptime(value, date_format)
67
- : Date.parse(value)
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
- converter :datetime do |name,options|
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