abstract_importer 1.2.0.rc1 → 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +20 -2
- data/abstract_importer.gemspec +1 -0
- data/lib/abstract_importer/base.rb +26 -3
- data/lib/abstract_importer/collection.rb +15 -0
- data/lib/abstract_importer/collection_importer.rb +8 -69
- data/lib/abstract_importer/import_options.rb +21 -35
- data/lib/abstract_importer/reporters.rb +4 -0
- data/lib/abstract_importer/reporters/base_reporter.rb +72 -0
- data/lib/abstract_importer/reporters/debug_reporter.rb +131 -0
- data/lib/abstract_importer/reporters/null_reporter.rb +19 -0
- data/lib/abstract_importer/reporters/performance_reporter.rb +103 -0
- data/lib/abstract_importer/strategies.rb +2 -0
- data/lib/abstract_importer/strategies/base.rb +30 -0
- data/lib/abstract_importer/strategies/default_strategy.rb +83 -0
- data/lib/abstract_importer/strategies/replace_strategy.rb +67 -0
- data/lib/abstract_importer/version.rb +1 -1
- data/test/importer_test.rb +79 -6
- data/test/support/mock_data_source.rb +3 -3
- data/test/test_helper.rb +3 -2
- metadata +53 -31
- data/lib/abstract_importer/reporter.rb +0 -150
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6545777a4f5b26c7f43754894478c1ee2b38db44
|
4
|
+
data.tar.gz: defa4ee62445d06bfdadaf9453b2c54f19829c90
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 607c5f129b8ac2742ad76f8c4a964b3ee24f5304b7d1701409bc5e3b0a5bf3eca0dda5d00d5f8556383d7ec32ba3786259946d55d3eee9f88709b83028c595fc
|
7
|
+
data.tar.gz: edaecfc589c1844af50cf53239940ebfc17052be632fa2bf563fc8bcd74d8e33521b157b9caa73c3285c3685d4e724bda514749cefa9f3fc51ba85d787b4810e
|
data/README.md
CHANGED
@@ -107,6 +107,7 @@ AbstractImporter optionally takes a hash of settings as a third argument:
|
|
107
107
|
* `:dry_run` (default: `false`) when set to `true`, goes through all the steps except creating the records
|
108
108
|
* `:io` (default: `$stderr`) an IO object that is passed to the reporter
|
109
109
|
* `:reporter` (default: `AbstractImporter::Reporter.new(io)`) performs logging in response to various events
|
110
|
+
* `:strategy` allows you to use alternate import strategies for particular collections (See below)
|
110
111
|
|
111
112
|
|
112
113
|
|
@@ -147,7 +148,7 @@ The complete list of callbacks is below.
|
|
147
148
|
|
148
149
|
`before_build` allows a callback to modify the hash of attributes before it is passed to `ActiveRecord::Relation#build`.
|
149
150
|
|
150
|
-
##### before_create
|
151
|
+
##### before_create, before_update, before_save
|
151
152
|
|
152
153
|
`before_create` allows a callback to modify a record before `save` is called on it.
|
153
154
|
|
@@ -155,7 +156,7 @@ The complete list of callbacks is below.
|
|
155
156
|
|
156
157
|
`rescue` (like `before_create`) is called with a record just before `save` is called. Unlike `before_create`, `rescue` is only called if the record does not pass validations.
|
157
158
|
|
158
|
-
##### after_create
|
159
|
+
##### after_create, after_update, after_save
|
159
160
|
|
160
161
|
`after_create` is called with the original hash of attributes and the newly-saved record right after it is successfully saved.
|
161
162
|
|
@@ -169,6 +170,23 @@ The complete list of callbacks is below.
|
|
169
170
|
|
170
171
|
|
171
172
|
|
173
|
+
### Strategies
|
174
|
+
|
175
|
+
The importer's default strategy is to skip records that have already been imported and create records one-by-one as ActiveRecord objects.
|
176
|
+
|
177
|
+
But AbstractImporter supports alternate strategies which you can specify per collection like this:
|
178
|
+
|
179
|
+
```ruby
|
180
|
+
summary = MyImport.new(parent, data_source, strategy: {students: :replace}).perform!
|
181
|
+
```
|
182
|
+
|
183
|
+
The following alternate strategies are built in:
|
184
|
+
|
185
|
+
##### replace
|
186
|
+
|
187
|
+
Replaces records that have already been imported rather than skipping them.
|
188
|
+
|
189
|
+
|
172
190
|
|
173
191
|
## Contributing
|
174
192
|
|
data/abstract_importer.gemspec
CHANGED
@@ -20,6 +20,7 @@ Gem::Specification.new do |spec|
|
|
20
20
|
spec.add_dependency "activerecord"
|
21
21
|
|
22
22
|
spec.add_development_dependency "bundler", "~> 1.3"
|
23
|
+
spec.add_development_dependency "minitest", "~> 4.7"
|
23
24
|
spec.add_development_dependency "rake"
|
24
25
|
spec.add_development_dependency "rails"
|
25
26
|
spec.add_development_dependency "sqlite3"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
require 'abstract_importer/import_options'
|
2
2
|
require 'abstract_importer/import_plan'
|
3
|
-
require 'abstract_importer/
|
3
|
+
require 'abstract_importer/reporters'
|
4
4
|
require 'abstract_importer/collection'
|
5
5
|
require 'abstract_importer/collection_importer'
|
6
6
|
require 'abstract_importer/id_map'
|
@@ -29,13 +29,16 @@ module AbstractImporter
|
|
29
29
|
@parent = parent
|
30
30
|
|
31
31
|
io = options.fetch(:io, $stderr)
|
32
|
-
@reporter =
|
32
|
+
@reporter = default_reporter(io)
|
33
33
|
@dry_run = options.fetch(:dry_run, false)
|
34
34
|
|
35
35
|
@id_map = IdMap.new
|
36
36
|
@results = {}
|
37
37
|
@import_plan = self.class.import_plan.to_h
|
38
38
|
@atomic = options.fetch(:atomic, false)
|
39
|
+
@strategies = options.fetch(:strategy, {})
|
40
|
+
@skip = Array(options[:skip])
|
41
|
+
@only = Array(options[:only]) if options.key?(:only)
|
39
42
|
@collections = []
|
40
43
|
end
|
41
44
|
|
@@ -80,12 +83,24 @@ module AbstractImporter
|
|
80
83
|
end
|
81
84
|
|
82
85
|
def import_collection(collection)
|
86
|
+
return if skip? collection
|
83
87
|
results[collection.name] = CollectionImporter.new(self, collection).perform!
|
84
88
|
end
|
85
89
|
|
86
90
|
def teardown
|
87
91
|
end
|
88
92
|
|
93
|
+
def skip?(collection)
|
94
|
+
return true if skip.member?(collection.name)
|
95
|
+
return true if only && !only.member?(collection.name)
|
96
|
+
false
|
97
|
+
end
|
98
|
+
|
99
|
+
def strategy_for(collection)
|
100
|
+
strategy_name = @strategies.fetch collection.name, :default
|
101
|
+
AbstractImporter::Strategies.const_get :"#{strategy_name.capitalize}Strategy"
|
102
|
+
end
|
103
|
+
|
89
104
|
|
90
105
|
|
91
106
|
|
@@ -119,7 +134,7 @@ module AbstractImporter
|
|
119
134
|
|
120
135
|
private
|
121
136
|
|
122
|
-
attr_reader :collections, :import_plan
|
137
|
+
attr_reader :collections, :import_plan, :skip, :only
|
123
138
|
|
124
139
|
def verify_source!
|
125
140
|
import_plan.keys.each do |collection|
|
@@ -199,5 +214,13 @@ module AbstractImporter
|
|
199
214
|
end
|
200
215
|
end
|
201
216
|
|
217
|
+
def default_reporter(io)
|
218
|
+
case ENV["IMPORT_REPORTER"].to_s.downcase
|
219
|
+
when "none" then Reporters::NullReporter.new(io)
|
220
|
+
when "performance" then Reporters::PerformanceReporter.new(io)
|
221
|
+
else Reporters::DebugReporter.new(io)
|
222
|
+
end
|
223
|
+
end
|
224
|
+
|
202
225
|
end
|
203
226
|
end
|
@@ -1,4 +1,19 @@
|
|
1
1
|
module AbstractImporter
|
2
2
|
class Collection < Struct.new(:name, :model, :table_name, :scope, :options)
|
3
|
+
|
4
|
+
def association_attrs
|
5
|
+
return @assocation_attrs if defined?(@assocation_attrs)
|
6
|
+
|
7
|
+
# Instead of calling `tenant.people.build(__)`, we'll reflect on the
|
8
|
+
# association to find its foreign key and its owner's id, so that we
|
9
|
+
# can call `Person.new(__.merge(tenant_id: id))`.
|
10
|
+
@assocation_attrs = {}
|
11
|
+
assocation = scope.instance_variable_get(:@association)
|
12
|
+
unless assocation.is_a?(ActiveRecord::Associations::HasManyThroughAssociation)
|
13
|
+
@assocation_attrs.merge!(assocation.reflection.foreign_key.to_sym => assocation.owner.id)
|
14
|
+
end
|
15
|
+
@assocation_attrs.freeze
|
16
|
+
end
|
17
|
+
|
3
18
|
end
|
4
19
|
end
|
@@ -1,18 +1,22 @@
|
|
1
|
+
require "abstract_importer/strategies"
|
2
|
+
|
1
3
|
module AbstractImporter
|
2
4
|
class CollectionImporter
|
3
5
|
|
4
6
|
def initialize(importer, collection)
|
5
7
|
@importer = importer
|
6
8
|
@collection = collection
|
9
|
+
@strategy = importer.strategy_for(collection).new(self)
|
7
10
|
end
|
8
11
|
|
9
|
-
attr_reader :importer, :collection, :summary
|
12
|
+
attr_reader :importer, :collection, :summary, :strategy
|
10
13
|
|
11
14
|
delegate :name,
|
12
15
|
:table_name,
|
13
16
|
:model,
|
14
17
|
:scope,
|
15
18
|
:options,
|
19
|
+
:association_attrs,
|
16
20
|
:to => :collection
|
17
21
|
|
18
22
|
delegate :dry_run?,
|
@@ -32,7 +36,9 @@ module AbstractImporter
|
|
32
36
|
|
33
37
|
invoke_callback(:before_all)
|
34
38
|
summary.ms = Benchmark.ms do
|
35
|
-
each_new_record
|
39
|
+
each_new_record do |attributes|
|
40
|
+
strategy.process_record(attributes)
|
41
|
+
end
|
36
42
|
end
|
37
43
|
invoke_callback(:after_all)
|
38
44
|
|
@@ -100,46 +106,14 @@ module AbstractImporter
|
|
100
106
|
|
101
107
|
|
102
108
|
|
103
|
-
|
104
|
-
|
105
109
|
def each_new_record
|
106
110
|
source.public_send(name).each do |attrs|
|
107
111
|
yield attrs.dup
|
108
112
|
end
|
109
113
|
end
|
110
114
|
|
111
|
-
def process_record(hash)
|
112
|
-
summary.total += 1
|
113
|
-
|
114
|
-
if already_imported?(hash)
|
115
|
-
summary.already_imported += 1
|
116
|
-
return
|
117
|
-
end
|
118
|
-
|
119
|
-
remap_foreign_keys!(hash)
|
120
|
-
|
121
|
-
if redundant_record?(hash)
|
122
|
-
summary.redundant += 1
|
123
|
-
return
|
124
|
-
end
|
125
|
-
|
126
|
-
if create_record(hash)
|
127
|
-
summary.created += 1
|
128
|
-
else
|
129
|
-
summary.invalid += 1
|
130
|
-
end
|
131
|
-
rescue ::AbstractImporter::Skip
|
132
|
-
summary.skipped += 1
|
133
|
-
end
|
134
|
-
|
135
|
-
|
136
|
-
|
137
115
|
|
138
116
|
|
139
|
-
def already_imported?(hash)
|
140
|
-
id_map.contains? table_name, hash[:id]
|
141
|
-
end
|
142
|
-
|
143
117
|
def remap_foreign_keys!(hash)
|
144
118
|
@mappings.each do |proc|
|
145
119
|
proc.call(hash)
|
@@ -158,41 +132,6 @@ module AbstractImporter
|
|
158
132
|
|
159
133
|
|
160
134
|
|
161
|
-
|
162
|
-
|
163
|
-
def create_record(hash)
|
164
|
-
record = build_record(hash)
|
165
|
-
|
166
|
-
return true if dry_run?
|
167
|
-
|
168
|
-
invoke_callback(:before_create, record)
|
169
|
-
|
170
|
-
# rescue_callback has one shot to fix things
|
171
|
-
invoke_callback(:rescue, record) unless record.valid?
|
172
|
-
|
173
|
-
if record.valid? && record.save
|
174
|
-
invoke_callback(:after_create, hash, record)
|
175
|
-
id_map << record
|
176
|
-
|
177
|
-
reporter.record_created(record)
|
178
|
-
true
|
179
|
-
else
|
180
|
-
|
181
|
-
reporter.record_failed(record, hash)
|
182
|
-
false
|
183
|
-
end
|
184
|
-
end
|
185
|
-
|
186
|
-
def build_record(hash)
|
187
|
-
hash = invoke_callback(:before_build, hash) || hash
|
188
|
-
|
189
|
-
legacy_id = hash.delete(:id)
|
190
|
-
|
191
|
-
scope.build hash.merge(legacy_id: legacy_id)
|
192
|
-
end
|
193
|
-
|
194
|
-
|
195
|
-
|
196
135
|
def invoke_callback(callback, *args)
|
197
136
|
callback_name = :"#{callback}_callback"
|
198
137
|
callback = options.public_send(callback_name)
|
@@ -1,40 +1,26 @@
|
|
1
1
|
module AbstractImporter
|
2
2
|
class ImportOptions
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
3
|
+
CALLBACKS = [ :finder,
|
4
|
+
:rescue,
|
5
|
+
:before_build,
|
6
|
+
:before_create,
|
7
|
+
:before_update,
|
8
|
+
:before_save,
|
9
|
+
:after_create,
|
10
|
+
:after_update,
|
11
|
+
:after_save,
|
12
|
+
:before_all,
|
13
|
+
:after_all ]
|
14
|
+
|
15
|
+
CALLBACKS.each do |callback|
|
16
|
+
attr_reader :"#{callback}_callback"
|
17
|
+
|
18
|
+
class_eval <<-RUBY, __FILE__, __LINE__ + 1
|
19
|
+
def #{callback}(sym=nil, &block)
|
20
|
+
@#{callback}_callback = sym || block
|
21
|
+
end
|
22
|
+
RUBY
|
13
23
|
end
|
14
|
-
|
15
|
-
def before_build(sym=nil, &block)
|
16
|
-
@before_build_callback = sym || block
|
17
|
-
end
|
18
|
-
|
19
|
-
def before_create(sym=nil, &block)
|
20
|
-
@before_create_callback = sym || block
|
21
|
-
end
|
22
|
-
|
23
|
-
def after_create(sym=nil, &block)
|
24
|
-
@after_create_callback = sym || block
|
25
|
-
end
|
26
|
-
|
27
|
-
def rescue(sym=nil, &block)
|
28
|
-
@rescue_callback = sym || block
|
29
|
-
end
|
30
|
-
|
31
|
-
def before_all(sym=nil, &block)
|
32
|
-
@before_all_callback = sym || block
|
33
|
-
end
|
34
|
-
|
35
|
-
def after_all(sym=nil, &block)
|
36
|
-
@after_all_callback = sym || block
|
37
|
-
end
|
38
|
-
|
24
|
+
|
39
25
|
end
|
40
26
|
end
|
@@ -0,0 +1,72 @@
|
|
1
|
+
module AbstractImporter
|
2
|
+
module Reporters
|
3
|
+
class BaseReporter
|
4
|
+
attr_reader :io
|
5
|
+
|
6
|
+
def initialize(io)
|
7
|
+
@io = io
|
8
|
+
end
|
9
|
+
|
10
|
+
|
11
|
+
|
12
|
+
def start_all(importer)
|
13
|
+
io.puts "Importing #{importer.describe_source} to #{importer.describe_destination}\n"
|
14
|
+
end
|
15
|
+
|
16
|
+
def finish_all(importer, ms)
|
17
|
+
io.puts "\n\nFinished in #{distance_of_time(ms)}"
|
18
|
+
end
|
19
|
+
|
20
|
+
def finish_setup(ms)
|
21
|
+
io.puts "Setup took #{distance_of_time(ms)}\n"
|
22
|
+
end
|
23
|
+
|
24
|
+
def start_collection(collection)
|
25
|
+
io.puts "\n#{("="*80)}\nImporting #{collection.name}\n#{("="*80)}\n"
|
26
|
+
end
|
27
|
+
|
28
|
+
def finish_collection(collection, summary)
|
29
|
+
end
|
30
|
+
|
31
|
+
|
32
|
+
|
33
|
+
def record_created(record)
|
34
|
+
end
|
35
|
+
|
36
|
+
def record_failed(record, hash)
|
37
|
+
end
|
38
|
+
|
39
|
+
|
40
|
+
|
41
|
+
def count_notice(message)
|
42
|
+
end
|
43
|
+
|
44
|
+
def count_error(message)
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
|
49
|
+
protected
|
50
|
+
|
51
|
+
def distance_of_time(milliseconds)
|
52
|
+
milliseconds = milliseconds.to_i
|
53
|
+
seconds = milliseconds / 1000
|
54
|
+
milliseconds %= 1000
|
55
|
+
minutes = seconds / 60
|
56
|
+
seconds %= 60
|
57
|
+
hours = minutes / 60
|
58
|
+
minutes %= 60
|
59
|
+
days = hours / 24
|
60
|
+
hours %= 24
|
61
|
+
|
62
|
+
time = []
|
63
|
+
time << "#{days} days" unless days.zero?
|
64
|
+
time << "#{hours} hours" unless hours.zero?
|
65
|
+
time << "#{minutes} minutes" unless minutes.zero?
|
66
|
+
time << "#{seconds}.#{milliseconds.to_s.rjust(3, "0")} seconds"
|
67
|
+
time.join(", ")
|
68
|
+
end
|
69
|
+
|
70
|
+
end
|
71
|
+
end
|
72
|
+
end
|
@@ -0,0 +1,131 @@
|
|
1
|
+
module AbstractImporter
|
2
|
+
module Reporters
|
3
|
+
class DebugReporter < BaseReporter
|
4
|
+
attr_reader :invalid_params
|
5
|
+
|
6
|
+
def initialize(io)
|
7
|
+
super
|
8
|
+
@notices = {}
|
9
|
+
@errors = {}
|
10
|
+
@invalid_params = {}
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
|
15
|
+
def production?
|
16
|
+
Rails.env.production?
|
17
|
+
end
|
18
|
+
|
19
|
+
|
20
|
+
|
21
|
+
def start_all(importer)
|
22
|
+
super
|
23
|
+
end
|
24
|
+
|
25
|
+
def finish_all(importer, ms)
|
26
|
+
print_invalid_params
|
27
|
+
super
|
28
|
+
end
|
29
|
+
|
30
|
+
|
31
|
+
|
32
|
+
def finish_setup(ms)
|
33
|
+
super
|
34
|
+
end
|
35
|
+
|
36
|
+
|
37
|
+
|
38
|
+
def start_collection(collection)
|
39
|
+
super
|
40
|
+
@notices = {}
|
41
|
+
@errors = {}
|
42
|
+
end
|
43
|
+
|
44
|
+
def finish_collection(collection, summary)
|
45
|
+
print_summary summary, collection.name
|
46
|
+
print_messages @notices, "Notices"
|
47
|
+
print_messages @errors, "Errors"
|
48
|
+
end
|
49
|
+
|
50
|
+
|
51
|
+
|
52
|
+
def record_created(record)
|
53
|
+
io.print "." unless production?
|
54
|
+
end
|
55
|
+
|
56
|
+
def record_failed(record, hash)
|
57
|
+
io.print "×" unless production?
|
58
|
+
|
59
|
+
error_messages = invalid_params[record.class.name] ||= {}
|
60
|
+
record.errors.full_messages.each do |error_message|
|
61
|
+
error_messages[error_message] = hash unless error_messages.key?(error_message)
|
62
|
+
count_error(error_message)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
|
66
|
+
|
67
|
+
|
68
|
+
def status(s)
|
69
|
+
io.puts s
|
70
|
+
end
|
71
|
+
|
72
|
+
def stat(s)
|
73
|
+
io.puts " #{s}"
|
74
|
+
end
|
75
|
+
alias :info :stat
|
76
|
+
|
77
|
+
def file(s)
|
78
|
+
io.puts s.inspect
|
79
|
+
end
|
80
|
+
|
81
|
+
|
82
|
+
|
83
|
+
def count_notice(message)
|
84
|
+
return if production?
|
85
|
+
@notices[message] = (@notices[message] || 0) + 1
|
86
|
+
end
|
87
|
+
|
88
|
+
def count_error(message)
|
89
|
+
@errors[message] = (@errors[message] || 0) + 1
|
90
|
+
end
|
91
|
+
|
92
|
+
|
93
|
+
|
94
|
+
private
|
95
|
+
|
96
|
+
def print_invalid_params
|
97
|
+
return if invalid_params.empty?
|
98
|
+
status "\n\n\n#{("="*80)}\nExamples of invalid hashes\n#{("="*80)}"
|
99
|
+
invalid_params.each do |model_name, errors|
|
100
|
+
status "\n\n--#{model_name}#{("-"*(78 - model_name.length))}"
|
101
|
+
errors.each do |error_message, hash|
|
102
|
+
status "\n #{error_message}:\n #{hash.inspect}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
def print_summary(summary, plural)
|
108
|
+
stat "\n #{summary.total} #{plural} were found"
|
109
|
+
if summary.total > 0
|
110
|
+
stat "#{summary.already_imported} #{plural} were imported previously"
|
111
|
+
stat "#{summary.redundant} #{plural} would create duplicates and will not be imported"
|
112
|
+
stat "#{summary.invalid} #{plural} were invalid"
|
113
|
+
stat "#{summary.skipped} #{plural} were skipped"
|
114
|
+
stat "#{summary.created} #{plural} were imported"
|
115
|
+
stat "#{distance_of_time(summary.ms)} elapsed (#{summary.average_ms.to_i}ms each)"
|
116
|
+
else
|
117
|
+
stat "#{distance_of_time(summary.ms)} elapsed"
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def print_messages(array, caption)
|
122
|
+
return if array.empty?
|
123
|
+
status "\n--#{caption}#{("-"*(78-caption.length))}\n\n"
|
124
|
+
array.each do |message, count|
|
125
|
+
stat "#{count} × #{message}"
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
end
|
130
|
+
end
|
131
|
+
end
|