abstract_importer 1.2.0.rc1 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
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