active_record_importer 0.4.0 → 1.0.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 +5 -5
- data/CHANGELOG.md +40 -0
- data/Gemfile +7 -0
- data/README.md +69 -3
- data/active_record_importer.gemspec +7 -3
- data/lib/active_record_importer/attribute/attributes_builder.rb +1 -0
- data/lib/active_record_importer/batch_importer.rb +58 -24
- data/lib/active_record_importer/batch_persister.rb +103 -0
- data/lib/active_record_importer/data_processor.rb +71 -25
- data/lib/active_record_importer/dispatcher.rb +3 -3
- data/lib/active_record_importer/failed_file_builder.rb +2 -2
- data/lib/active_record_importer/import_callbacker.rb +2 -2
- data/lib/active_record_importer/importable.rb +3 -2
- data/lib/active_record_importer/instance_builder.rb +17 -9
- data/lib/active_record_importer/options_builder.rb +5 -1
- data/lib/active_record_importer/record_preloader.rb +72 -0
- data/lib/active_record_importer/version.rb +1 -1
- data/lib/active_record_importer.rb +6 -1
- data/spec/active_record_importer/attribute/builders_spec.rb +51 -0
- data/spec/active_record_importer/batch_persister_spec.rb +87 -0
- data/spec/active_record_importer/data_processor_spec.rb +89 -0
- data/spec/active_record_importer/failed_file_builder_spec.rb +26 -0
- data/spec/active_record_importer/import_callbacker_spec.rb +25 -0
- data/spec/active_record_importer/instance_builder_spec.rb +77 -0
- data/spec/active_record_importer/options_builder_spec.rb +19 -0
- data/spec/active_record_importer/record_preloader_spec.rb +59 -0
- data/spec/active_record_importer_spec.rb +1 -5
- data/spec/integration/import_spec.rb +457 -0
- data/spec/spec_helper.rb +41 -0
- data/spec/support/csv_helper.rb +42 -0
- data/spec/support/database.rb +100 -0
- data/spec/support/models.rb +112 -0
- data/spec/support/query_counter.rb +22 -0
- metadata +57 -14
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe 'Importing a CSV file', type: :integration do
|
|
4
|
+
let(:import) { Import.new(resource: 'User') }
|
|
5
|
+
|
|
6
|
+
describe 'insert' do
|
|
7
|
+
before { User.acts_as_importable }
|
|
8
|
+
|
|
9
|
+
it 'inserts every valid row and records the counts' do
|
|
10
|
+
run_import(User, user_rows(5), import: import)
|
|
11
|
+
|
|
12
|
+
expect(User.count).to eq(5)
|
|
13
|
+
expect(User.pluck(:email)).to match_array((1..5).map { |i| "user#{i}@example.com" })
|
|
14
|
+
expect(import.imported_rows).to eq(5)
|
|
15
|
+
expect(import.failed_rows).to eq(0)
|
|
16
|
+
expect(import.failed_file_content).to be_nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'persists a chunk with a single bulk INSERT' do
|
|
20
|
+
counts = count_queries { run_import(User, user_rows(20)) }
|
|
21
|
+
|
|
22
|
+
expect(User.count).to eq(20)
|
|
23
|
+
expect(counts['INSERT']).to eq(1)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'works without an Import object and prints the errors of failed rows' do
|
|
27
|
+
rows = user_rows(3)
|
|
28
|
+
rows[1][:first_name] = ''
|
|
29
|
+
|
|
30
|
+
expect { run_import(User, rows) }.to output(/First name can't be blank/).to_stdout
|
|
31
|
+
expect(User.count).to eq(2)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'sets timestamps on the inserted records' do
|
|
35
|
+
run_import(User, user_rows(2))
|
|
36
|
+
|
|
37
|
+
expect(User.where(created_at: nil).count).to eq(0)
|
|
38
|
+
expect(User.where(updated_at: nil).count).to eq(0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'uses created_at/updated_at from the csv when present' do
|
|
42
|
+
rows = user_rows(1, created_at: '2020-01-02 03:04:05', updated_at: '2020-02-03 04:05:06')
|
|
43
|
+
run_import(User, rows)
|
|
44
|
+
|
|
45
|
+
user = User.first
|
|
46
|
+
expect(user.created_at.year).to eq(2020)
|
|
47
|
+
expect(user.created_at.month).to eq(1)
|
|
48
|
+
expect(user.updated_at.month).to eq(2)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'ignores csv columns that are not importable columns' do
|
|
52
|
+
run_import(User, user_rows(1, id: 999, unknown_column: 'x'))
|
|
53
|
+
|
|
54
|
+
expect(User.count).to eq(1)
|
|
55
|
+
expect(User.first.id).not_to eq(999)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'skips the model save callbacks in bulk mode' do
|
|
59
|
+
run_import(User, user_rows(2))
|
|
60
|
+
|
|
61
|
+
expect(User.pluck(:imports_count)).to eq([0, 0])
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe 'validations' do
|
|
66
|
+
before { User.acts_as_importable }
|
|
67
|
+
|
|
68
|
+
let(:rows) do
|
|
69
|
+
[
|
|
70
|
+
{ first_name: 'Valid', email: 'valid@example.com', employee_no: 'A1', age: 30 },
|
|
71
|
+
{ first_name: '', email: 'noname@example.com', employee_no: 'A2', age: 30 },
|
|
72
|
+
{ first_name: 'Bad Email', email: 'not-an-email', employee_no: 'A3', age: 30 },
|
|
73
|
+
{ first_name: 'Bad Age', email: 'badage@example.com', employee_no: 'A4', age: -1 },
|
|
74
|
+
{ first_name: 'Also Valid', email: 'also@example.com', employee_no: 'A5', age: 31 }
|
|
75
|
+
]
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'imports the valid rows and rejects the invalid ones' do
|
|
79
|
+
run_import(User, rows, import: import)
|
|
80
|
+
|
|
81
|
+
expect(User.pluck(:email)).to match_array(%w(valid@example.com also@example.com))
|
|
82
|
+
expect(import.imported_rows).to eq(2)
|
|
83
|
+
expect(import.failed_rows).to eq(3)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'writes the failed rows with their validation errors to the failed file' do
|
|
87
|
+
run_import(User, rows, import: import)
|
|
88
|
+
|
|
89
|
+
failed = import.failed_csv
|
|
90
|
+
expect(import.failed_file_content_type).to eq('text/csv')
|
|
91
|
+
expect(failed.headers).to include('first_name', 'email', 'import_errors')
|
|
92
|
+
expect(failed.size).to eq(3)
|
|
93
|
+
|
|
94
|
+
by_email = failed.map { |r| [r['email'], r['import_errors']] }.to_h
|
|
95
|
+
expect(by_email['noname@example.com']).to eq("Validation failed: First name can't be blank")
|
|
96
|
+
expect(by_email['not-an-email']).to eq('Validation failed: Email is invalid')
|
|
97
|
+
expect(by_email['badage@example.com']).to eq('Validation failed: Age must be greater than 0')
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'keeps the original row order in the failed file' do
|
|
101
|
+
run_import(User, rows, import: import)
|
|
102
|
+
|
|
103
|
+
expect(import.failed_csv.map { |r| r['email'] })
|
|
104
|
+
.to eq(%w(noname@example.com not-an-email badage@example.com))
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'still bulk inserts the valid rows' do
|
|
108
|
+
counts = count_queries { run_import(User, rows) }
|
|
109
|
+
|
|
110
|
+
expect(counts['INSERT']).to eq(1)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe 'database level errors' do
|
|
115
|
+
before { User.acts_as_importable }
|
|
116
|
+
|
|
117
|
+
it 'falls back to per-row saving so only the offending row fails' do
|
|
118
|
+
User.create!(first_name: 'Existing', email: 'existing@example.com', employee_no: 'DUP')
|
|
119
|
+
|
|
120
|
+
rows = user_rows(3)
|
|
121
|
+
rows[1][:employee_no] = 'DUP' # violates the unique index, no model validation
|
|
122
|
+
|
|
123
|
+
run_import(User, rows, import: import)
|
|
124
|
+
|
|
125
|
+
expect(User.count).to eq(3)
|
|
126
|
+
expect(User.pluck(:email)).to match_array(
|
|
127
|
+
%w(existing@example.com user1@example.com user3@example.com)
|
|
128
|
+
)
|
|
129
|
+
expect(import.imported_rows).to eq(2)
|
|
130
|
+
expect(import.failed_rows).to eq(1)
|
|
131
|
+
|
|
132
|
+
failed = import.failed_csv
|
|
133
|
+
expect(failed.size).to eq(1)
|
|
134
|
+
expect(failed.first['email']).to eq('user2@example.com')
|
|
135
|
+
expect(failed.first['import_errors']).to match(/DUP|unique|duplicate/i)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
describe 'upsert' do
|
|
140
|
+
before do
|
|
141
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
let!(:existing) do
|
|
145
|
+
User.create!(first_name: 'Old', last_name: 'Name', email: 'user1@example.com',
|
|
146
|
+
employee_no: 'OLD-1', age: 50)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'updates existing records and inserts the new ones' do
|
|
150
|
+
run_import(User, user_rows(3), import: import)
|
|
151
|
+
|
|
152
|
+
expect(User.count).to eq(3)
|
|
153
|
+
existing.reload
|
|
154
|
+
expect(existing.first_name).to eq('User1')
|
|
155
|
+
expect(existing.employee_no).to eq('EMP-1')
|
|
156
|
+
expect(existing.age).to eq(21)
|
|
157
|
+
expect(import.imported_rows).to eq(3)
|
|
158
|
+
expect(import.failed_rows).to eq(0)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
it 'does not touch columns missing from the csv' do
|
|
162
|
+
run_import(User, [{ email: 'user1@example.com', first_name: 'Renamed' }])
|
|
163
|
+
|
|
164
|
+
existing.reload
|
|
165
|
+
expect(existing.first_name).to eq('Renamed')
|
|
166
|
+
expect(existing.last_name).to eq('Name')
|
|
167
|
+
expect(existing.age).to eq(50)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'looks records up once per chunk and persists with one INSERT and one upsert' do
|
|
171
|
+
User.create!(first_name: 'Another', email: 'user2@example.com', employee_no: 'OLD-2')
|
|
172
|
+
|
|
173
|
+
counts = count_queries { run_import(User, user_rows(10)) }
|
|
174
|
+
|
|
175
|
+
expect(counts['SELECT']).to eq(1)
|
|
176
|
+
expect(counts['INSERT']).to eq(2)
|
|
177
|
+
expect(counts['UPDATE'].to_i).to eq(0)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'validates updated records too' do
|
|
181
|
+
run_import(User, [{ email: 'user1@example.com', age: -5 }], import: import)
|
|
182
|
+
|
|
183
|
+
expect(existing.reload.age).to eq(50)
|
|
184
|
+
expect(import.failed_rows).to eq(1)
|
|
185
|
+
expect(import.failed_csv.first['import_errors'])
|
|
186
|
+
.to eq('Validation failed: Age must be greater than 0')
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'merges duplicate rows of the same chunk into one record' do
|
|
190
|
+
rows = [
|
|
191
|
+
{ first_name: 'First', email: 'new@example.com', employee_no: 'N1' },
|
|
192
|
+
{ first_name: 'Second', email: 'new@example.com', employee_no: 'N1' }
|
|
193
|
+
]
|
|
194
|
+
|
|
195
|
+
run_import(User, rows, import: import)
|
|
196
|
+
|
|
197
|
+
created = User.where(email: 'new@example.com')
|
|
198
|
+
expect(created.count).to eq(1)
|
|
199
|
+
expect(created.first.first_name).to eq('Second')
|
|
200
|
+
expect(import.imported_rows).to eq(2)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'supports multiple find options' do
|
|
204
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(first_name last_name)
|
|
205
|
+
doe = User.create!(first_name: 'User2', last_name: 'Doe', email: 'doe@example.com', employee_no: 'D')
|
|
206
|
+
User.create!(first_name: 'User2', last_name: 'Smith', email: 'other@example.com', employee_no: 'X')
|
|
207
|
+
|
|
208
|
+
run_import(User, user_rows(2))
|
|
209
|
+
|
|
210
|
+
expect(User.count).to eq(4) # existing (Old Name), User1 Doe (new), User2 Doe (updated), User2 Smith
|
|
211
|
+
expect(existing.reload.employee_no).to eq('OLD-1') # email is not part of the find options
|
|
212
|
+
expect(doe.reload.email).to eq('user2@example.com')
|
|
213
|
+
expect(User.find_by(first_name: 'User2', last_name: 'Smith').email).to eq('other@example.com')
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
it 'fails rows without find attributes' do
|
|
217
|
+
run_import(User, [{ first_name: 'No Email', email: nil, employee_no: 'Z' }], import: import)
|
|
218
|
+
|
|
219
|
+
expect(import.failed_rows).to eq(1)
|
|
220
|
+
expect(import.failed_csv.first['import_errors'])
|
|
221
|
+
.to eq('Find by option for your csv is missing or incorrect.')
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
it 'takes insert_method and find_options from the Import object' do
|
|
225
|
+
User.acts_as_importable
|
|
226
|
+
import.insert_method = 'upsert'
|
|
227
|
+
import.find_options = 'email'
|
|
228
|
+
|
|
229
|
+
run_import(User, user_rows(1), import: import)
|
|
230
|
+
|
|
231
|
+
expect(User.count).to eq(1)
|
|
232
|
+
expect(existing.reload.first_name).to eq('User1')
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
describe 'error_duplicate' do
|
|
237
|
+
before do
|
|
238
|
+
User.acts_as_importable insert_method: 'error_duplicate', find_options: %i(email)
|
|
239
|
+
User.create!(first_name: 'Old', email: 'user1@example.com', employee_no: 'OLD-1')
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
it 'rejects rows matching an existing record' do
|
|
243
|
+
run_import(User, user_rows(3), import: import)
|
|
244
|
+
|
|
245
|
+
expect(User.count).to eq(3)
|
|
246
|
+
expect(User.find_by(email: 'user1@example.com').first_name).to eq('Old')
|
|
247
|
+
expect(import.imported_rows).to eq(2)
|
|
248
|
+
expect(import.failed_rows).to eq(1)
|
|
249
|
+
expect(import.failed_csv.first['import_errors']).to eq('Duplicate record found!')
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
it 'rejects duplicates within the same chunk' do
|
|
253
|
+
rows = user_rows(2, email: 'same@example.com')
|
|
254
|
+
rows[1][:employee_no] = 'OTHER'
|
|
255
|
+
|
|
256
|
+
run_import(User, rows, import: import)
|
|
257
|
+
|
|
258
|
+
expect(User.where(email: 'same@example.com').count).to eq(1)
|
|
259
|
+
expect(import.imported_rows).to eq(1)
|
|
260
|
+
expect(import.failed_rows).to eq(1)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
describe 'importer options' do
|
|
265
|
+
it 'applies default_attributes (values, procs and symbols)' do
|
|
266
|
+
User.acts_as_importable default_attributes: {
|
|
267
|
+
last_name: 'Default',
|
|
268
|
+
password: proc { |row| "pw-#{row[:first_name]}" },
|
|
269
|
+
role: :member_role
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
run_import(User, [{ first_name: 'Ann', email: 'ann@example.com', employee_no: 'A' }])
|
|
273
|
+
|
|
274
|
+
user = User.first
|
|
275
|
+
expect(user.last_name).to eq('Default')
|
|
276
|
+
expect(user.password).to eq('pw-Ann')
|
|
277
|
+
expect(user.role).to eq('member')
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it 'lets csv values override default_attributes' do
|
|
281
|
+
User.acts_as_importable default_attributes: { last_name: 'Default' }
|
|
282
|
+
|
|
283
|
+
run_import(User, user_rows(1))
|
|
284
|
+
|
|
285
|
+
expect(User.first.last_name).to eq('Doe')
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
it 'imports store accessors' do
|
|
289
|
+
User.acts_as_importable store_accessors: %i(nickname department)
|
|
290
|
+
|
|
291
|
+
run_import(User, user_rows(1, nickname: 'annie', department: 'R&D'))
|
|
292
|
+
|
|
293
|
+
expect(User.first.nickname).to eq('annie')
|
|
294
|
+
expect(User.first.department).to eq('R&D')
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
it 'excludes columns listed in exclude_columns' do
|
|
298
|
+
User.acts_as_importable exclude_columns: %i(age)
|
|
299
|
+
|
|
300
|
+
run_import(User, user_rows(1))
|
|
301
|
+
|
|
302
|
+
expect(User.first.age).to be_nil
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
it 'runs before_save procs before validating' do
|
|
306
|
+
User.acts_as_importable before_save: proc { |user| user.password = 'temporary123' }
|
|
307
|
+
|
|
308
|
+
run_import(User, user_rows(2))
|
|
309
|
+
|
|
310
|
+
expect(User.pluck(:password).uniq).to eq(['temporary123'])
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'keeps options separate per model' do
|
|
314
|
+
User.acts_as_importable
|
|
315
|
+
|
|
316
|
+
expect(User.importer_options.insert_method).to be_nil
|
|
317
|
+
expect(Account.importer_options.insert_method).to eq('upsert')
|
|
318
|
+
expect(Account.importer_options.find_options).to eq([:email])
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
it 'honours csv_opts' do
|
|
322
|
+
User.acts_as_importable csv_opts: { col_sep: '|' }
|
|
323
|
+
path = csv_file(user_rows(1))
|
|
324
|
+
File.write(path, "first_name|email|employee_no\nPipe|pipe@example.com|P1\n")
|
|
325
|
+
|
|
326
|
+
User.import!(file: path)
|
|
327
|
+
|
|
328
|
+
expect(User.first.first_name).to eq('Pipe')
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
describe 'per-row fallback' do
|
|
333
|
+
it 'runs after_save hooks on persisted instances' do
|
|
334
|
+
User.acts_as_importable after_save: proc { |user| User.after_save_log << user.id }
|
|
335
|
+
|
|
336
|
+
run_import(User, user_rows(3), import: import)
|
|
337
|
+
|
|
338
|
+
expect(User.after_save_log.sort).to eq(User.pluck(:id).sort)
|
|
339
|
+
expect(import.imported_rows).to eq(3)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
it 'accepts symbol and array after_save hooks' do
|
|
343
|
+
User.acts_as_importable after_save: [:activate]
|
|
344
|
+
|
|
345
|
+
run_import(User, user_rows(2))
|
|
346
|
+
|
|
347
|
+
expect(User.pluck(:state).uniq).to eq(['active'])
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it 'applies state machine transitions after saving' do
|
|
351
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email),
|
|
352
|
+
state_machine_attr: %i(state)
|
|
353
|
+
pending = User.create!(first_name: 'P', email: 'user1@example.com', employee_no: 'P1', state: 'pending')
|
|
354
|
+
|
|
355
|
+
run_import(User, user_rows(1, state: 'active'), import: import)
|
|
356
|
+
|
|
357
|
+
expect(pending.reload.state).to eq('active')
|
|
358
|
+
expect(User.state_log).to eq([[pending.id, 'active']])
|
|
359
|
+
expect(import.imported_rows).to eq(1)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
it 'fails the row when the transition is invalid' do
|
|
363
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email),
|
|
364
|
+
state_machine_attr: %i(state)
|
|
365
|
+
pending = User.create!(first_name: 'P', email: 'user1@example.com', employee_no: 'P1', state: 'pending')
|
|
366
|
+
|
|
367
|
+
run_import(User, user_rows(1, state: 'archived'), import: import)
|
|
368
|
+
|
|
369
|
+
expect(pending.reload.state).to eq('pending')
|
|
370
|
+
expect(pending.first_name).to eq('P') # rolled back
|
|
371
|
+
expect(import.failed_rows).to eq(1)
|
|
372
|
+
expect(import.failed_csv.first['import_errors']).to eq('Transition is invalid')
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
it 'runs the model callbacks when bulk_insert is disabled' do
|
|
376
|
+
User.acts_as_importable bulk_insert: false
|
|
377
|
+
|
|
378
|
+
counts = count_queries { run_import(User, user_rows(3), import: import) }
|
|
379
|
+
|
|
380
|
+
expect(User.pluck(:imports_count)).to eq([1, 1, 1])
|
|
381
|
+
expect(counts['INSERT']).to eq(3)
|
|
382
|
+
expect(import.imported_rows).to eq(3)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
it 'still reports validation errors' do
|
|
386
|
+
User.acts_as_importable bulk_insert: false
|
|
387
|
+
rows = user_rows(2)
|
|
388
|
+
rows[0][:email] = 'broken'
|
|
389
|
+
|
|
390
|
+
run_import(User, rows, import: import)
|
|
391
|
+
|
|
392
|
+
expect(User.count).to eq(1)
|
|
393
|
+
expect(import.failed_rows).to eq(1)
|
|
394
|
+
expect(import.failed_csv.first['import_errors']).to eq('Validation failed: Email is invalid')
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
describe 'batching' do
|
|
399
|
+
before { User.acts_as_importable }
|
|
400
|
+
|
|
401
|
+
it 'splits the file in chunks of batch_size' do
|
|
402
|
+
import.batch_size = 4
|
|
403
|
+
|
|
404
|
+
counts = count_queries { run_import(User, user_rows(10), import: import) }
|
|
405
|
+
|
|
406
|
+
expect(User.count).to eq(10)
|
|
407
|
+
expect(counts['INSERT']).to eq(3)
|
|
408
|
+
expect(import.imported_rows).to eq(10)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
it 'accumulates counters across chunks' do
|
|
412
|
+
import.batch_size = 2
|
|
413
|
+
rows = user_rows(5)
|
|
414
|
+
rows[0][:first_name] = ''
|
|
415
|
+
rows[4][:first_name] = ''
|
|
416
|
+
|
|
417
|
+
run_import(User, rows, import: import)
|
|
418
|
+
|
|
419
|
+
expect(import.imported_rows).to eq(3)
|
|
420
|
+
expect(import.failed_rows).to eq(2)
|
|
421
|
+
expect(import.failed_csv.size).to eq(2)
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
it 'uses the chunk_size from csv_opts when the import has no batch_size' do
|
|
425
|
+
User.acts_as_importable csv_opts: { chunk_size: 5 }
|
|
426
|
+
|
|
427
|
+
counts = count_queries { run_import(User, user_rows(10)) }
|
|
428
|
+
|
|
429
|
+
expect(counts['INSERT']).to eq(2)
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
describe 'Import#execute!' do
|
|
434
|
+
before { User.acts_as_importable }
|
|
435
|
+
|
|
436
|
+
it 'runs the import for the resource' do
|
|
437
|
+
import.file = File.new(csv_file(user_rows(2)))
|
|
438
|
+
import.save!
|
|
439
|
+
|
|
440
|
+
import.execute!
|
|
441
|
+
|
|
442
|
+
expect(User.count).to eq(2)
|
|
443
|
+
expect(import.reload.imported_rows).to eq(2)
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
describe 'errors' do
|
|
448
|
+
it 'refuses to import a model without acts_as_importable' do
|
|
449
|
+
expect { Plain.import!(file: 'whatever.csv') }.to raise_error(/Plain is not importable/)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
it 'requires a file' do
|
|
453
|
+
User.acts_as_importable
|
|
454
|
+
expect { User.import! }.to raise_error(ActiveRecordImporter::Errors::MissingImportFile)
|
|
455
|
+
end
|
|
456
|
+
end
|
|
457
|
+
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -1,2 +1,43 @@
|
|
|
1
1
|
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
|
|
2
|
+
|
|
3
|
+
require 'active_record'
|
|
2
4
|
require 'active_record_importer'
|
|
5
|
+
|
|
6
|
+
require_relative 'support/database'
|
|
7
|
+
require_relative 'support/csv_helper'
|
|
8
|
+
require_relative 'support/query_counter'
|
|
9
|
+
|
|
10
|
+
RSpec.configure do |config|
|
|
11
|
+
config.expect_with :rspec do |expectations|
|
|
12
|
+
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
config.mock_with :rspec do |mocks|
|
|
16
|
+
mocks.verify_partial_doubles = true
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
config.filter_run_when_matching :focus
|
|
20
|
+
config.disable_monkey_patching!
|
|
21
|
+
config.order = :random
|
|
22
|
+
Kernel.srand config.seed
|
|
23
|
+
|
|
24
|
+
config.include CsvHelper
|
|
25
|
+
config.include QueryCounter
|
|
26
|
+
|
|
27
|
+
config.before(:suite) do
|
|
28
|
+
Database.connect!
|
|
29
|
+
Database.load_schema!
|
|
30
|
+
# Models call acts_as_importable, which reads the table columns,
|
|
31
|
+
# so they can only be loaded once the schema exists
|
|
32
|
+
require_relative 'support/models'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
config.before(:each) do
|
|
36
|
+
Database.clean!
|
|
37
|
+
User.reset_logs!
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
config.after(:each) do
|
|
41
|
+
Array(@csv_files).each(&:close!)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
require 'tempfile'
|
|
2
|
+
require 'csv'
|
|
3
|
+
|
|
4
|
+
module CsvHelper
|
|
5
|
+
##
|
|
6
|
+
# Writes rows (array of hashes sharing the same keys) to a temp csv
|
|
7
|
+
# and returns its path. The file lives for the duration of the example.
|
|
8
|
+
##
|
|
9
|
+
def csv_file(rows, headers: nil)
|
|
10
|
+
headers ||= rows.first.keys
|
|
11
|
+
file = Tempfile.new(['import', '.csv'])
|
|
12
|
+
file.write(CSV.generate_line(headers))
|
|
13
|
+
rows.each { |row| file.write(CSV.generate_line(headers.map { |h| row[h] })) }
|
|
14
|
+
file.flush
|
|
15
|
+
(@csv_files ||= []) << file
|
|
16
|
+
file.path
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def user_rows(count, overrides = {})
|
|
20
|
+
(1..count).map do |i|
|
|
21
|
+
{
|
|
22
|
+
first_name: "User#{i}",
|
|
23
|
+
last_name: 'Doe',
|
|
24
|
+
email: "user#{i}@example.com",
|
|
25
|
+
employee_no: "EMP-#{i}",
|
|
26
|
+
age: 20 + i
|
|
27
|
+
}.merge(overrides)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def run_import(klass, rows, import: nil, headers: nil)
|
|
32
|
+
path = csv_file(rows, headers: headers)
|
|
33
|
+
if import
|
|
34
|
+
import.file = File.new(path)
|
|
35
|
+
import.save!
|
|
36
|
+
klass.import!(object: import)
|
|
37
|
+
import.reload
|
|
38
|
+
else
|
|
39
|
+
klass.import!(file: path)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
##
|
|
2
|
+
# Database used by the spec suite.
|
|
3
|
+
#
|
|
4
|
+
# DB=postgresql bundle exec rspec (default)
|
|
5
|
+
# DB=mysql bundle exec rspec
|
|
6
|
+
#
|
|
7
|
+
# Connection settings can be overridden with the usual environment
|
|
8
|
+
# variables (see .configuration below).
|
|
9
|
+
##
|
|
10
|
+
module Database
|
|
11
|
+
DATABASE = ENV.fetch('DB_NAME', 'active_record_importer_test')
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def adapter
|
|
16
|
+
ENV.fetch('DB', 'postgresql').to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def mysql?
|
|
20
|
+
adapter == 'mysql'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def postgresql?
|
|
24
|
+
adapter == 'postgresql'
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def configuration
|
|
28
|
+
if mysql?
|
|
29
|
+
{
|
|
30
|
+
adapter: ENV.fetch('MYSQL_ADAPTER', 'trilogy'),
|
|
31
|
+
host: ENV.fetch('MYSQL_HOST', '127.0.0.1'),
|
|
32
|
+
port: ENV.fetch('MYSQL_PORT', '3306').to_i,
|
|
33
|
+
username: ENV.fetch('MYSQL_USER', 'root'),
|
|
34
|
+
password: ENV.fetch('MYSQL_PASSWORD', ''),
|
|
35
|
+
database: DATABASE
|
|
36
|
+
}
|
|
37
|
+
elsif postgresql?
|
|
38
|
+
{
|
|
39
|
+
adapter: 'postgresql',
|
|
40
|
+
host: ENV['PG_HOST'],
|
|
41
|
+
port: ENV['PG_PORT'],
|
|
42
|
+
username: ENV['PG_USER'],
|
|
43
|
+
password: ENV['PG_PASSWORD'],
|
|
44
|
+
database: DATABASE
|
|
45
|
+
}.compact
|
|
46
|
+
else
|
|
47
|
+
fail "Unsupported DB=#{adapter.inspect}, use postgresql or mysql"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def connect!
|
|
52
|
+
ActiveRecord::Base.establish_connection(configuration)
|
|
53
|
+
ActiveRecord::Base.connection.execute('SELECT 1')
|
|
54
|
+
rescue ActiveRecord::NoDatabaseError
|
|
55
|
+
create_database!
|
|
56
|
+
ActiveRecord::Base.establish_connection(configuration)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def create_database!
|
|
60
|
+
admin_config = configuration.merge(database: postgresql? ? 'postgres' : nil).compact
|
|
61
|
+
ActiveRecord::Base.establish_connection(admin_config)
|
|
62
|
+
ActiveRecord::Base.connection.create_database(DATABASE)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def load_schema!
|
|
66
|
+
ActiveRecord::Schema.verbose = false
|
|
67
|
+
ActiveRecord::Schema.define do
|
|
68
|
+
create_table :users, force: true do |t|
|
|
69
|
+
t.string :first_name
|
|
70
|
+
t.string :last_name
|
|
71
|
+
t.string :email
|
|
72
|
+
t.string :employee_no
|
|
73
|
+
t.integer :age
|
|
74
|
+
t.string :role
|
|
75
|
+
t.string :state
|
|
76
|
+
t.string :password
|
|
77
|
+
t.text :properties
|
|
78
|
+
t.integer :imports_count, default: 0
|
|
79
|
+
t.timestamps null: true
|
|
80
|
+
end
|
|
81
|
+
add_index :users, :employee_no, unique: true
|
|
82
|
+
|
|
83
|
+
create_table :imports, force: true do |t|
|
|
84
|
+
t.text :properties
|
|
85
|
+
t.string :resource, null: false
|
|
86
|
+
t.integer :imported_rows, default: 0
|
|
87
|
+
t.integer :failed_rows, default: 0
|
|
88
|
+
t.text :failed_file_content
|
|
89
|
+
t.string :failed_file_content_type
|
|
90
|
+
t.timestamps null: true
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def clean!
|
|
96
|
+
%w(users imports).each do |table|
|
|
97
|
+
ActiveRecord::Base.connection.execute("DELETE FROM #{table}")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|