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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: df7b7a0811459f30502cf09d03741312919b9f54
4
- data.tar.gz: 116840924377d0562121169b715b9fd64f49fde1
3
+ metadata.gz: 6545777a4f5b26c7f43754894478c1ee2b38db44
4
+ data.tar.gz: defa4ee62445d06bfdadaf9453b2c54f19829c90
5
5
  SHA512:
6
- metadata.gz: a9c6b45e74533ba5a1ef07432d677834057649b66656c07c61594ea34e91bb449e5346167fb69cad7d7c464a1c48899e420f8f73a051a22b731afeecfab1db49
7
- data.tar.gz: b64e4b346bbc2f566c5686b95edde07f3f49f86be9a971916ac870a3d583038ef31da07cad2ed4e7d58f9f976c6f4de92343dfd93c7449841523cafd1488176d
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
 
@@ -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/reporter'
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 = Reporter.new(io, Rails.env.production?)
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 &method(:process_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
- attr_reader :finder_callback,
4
- :rescue_callback,
5
- :before_build_callback,
6
- :before_create_callback,
7
- :after_create_callback,
8
- :before_all_callback,
9
- :after_all_callback
10
-
11
- def finder(sym=nil, &block)
12
- @finder_callback = sym || block
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,4 @@
1
+ require 'abstract_importer/reporters/base_reporter'
2
+ require 'abstract_importer/reporters/debug_reporter'
3
+ require 'abstract_importer/reporters/null_reporter'
4
+ require 'abstract_importer/reporters/performance_reporter'
@@ -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