active_record_importer 0.3.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 +72 -5
- data/active_record_importer.gemspec +8 -4
- data/lib/active_record_importer/attribute/attributes_builder.rb +1 -0
- data/lib/active_record_importer/batch_importer.rb +61 -34
- data/lib/active_record_importer/batch_persister.rb +103 -0
- data/lib/active_record_importer/data_processor.rb +72 -26
- data/lib/active_record_importer/dispatcher.rb +34 -4
- data/lib/active_record_importer/failed_file_builder.rb +12 -55
- data/lib/active_record_importer/import_callbacker.rb +2 -2
- data/lib/active_record_importer/importable.rb +11 -4
- data/lib/active_record_importer/instance_builder.rb +17 -9
- data/lib/active_record_importer/options_builder.rb +4 -0
- 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 +58 -15
|
@@ -5,74 +5,31 @@ module ActiveRecordImporter
|
|
|
5
5
|
|
|
6
6
|
def initialize(import)
|
|
7
7
|
@import = import
|
|
8
|
-
@failed_rows = []
|
|
9
8
|
end
|
|
10
|
-
|
|
11
|
-
def build
|
|
12
|
-
return if failed_rows.blank?
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
destroy_temp_file
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
private
|
|
10
|
+
def write(failed_row = {})
|
|
11
|
+
return if failed_row.blank?
|
|
20
12
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
13
|
+
if File.exist?(temp_failed_file_path)
|
|
14
|
+
File.open(temp_failed_file_path, 'a') do |file|
|
|
15
|
+
file.write failed_row.values.to_csv
|
|
16
|
+
end
|
|
24
17
|
else
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def write_csv_file
|
|
30
|
-
CSV.open(temp_file_path, 'wb') do |csv|
|
|
31
|
-
csv << failed_rows.first.keys
|
|
32
|
-
insert_failed_rows(csv)
|
|
18
|
+
File.open(temp_failed_file_path, 'w') do |file|
|
|
19
|
+
file.write failed_row.keys.to_csv
|
|
20
|
+
file.write failed_row.values.to_csv
|
|
21
|
+
end
|
|
33
22
|
end
|
|
34
23
|
end
|
|
35
24
|
|
|
36
|
-
|
|
37
|
-
return if import.failed_file.blank?
|
|
38
|
-
CSV.open(import.failed_file_path, 'a+') do |csv|
|
|
39
|
-
insert_failed_rows(csv)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def insert_failed_rows(csv)
|
|
45
|
-
failed_rows.each do |hash|
|
|
46
|
-
csv << hash.values
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def destroy_temp_file
|
|
51
|
-
return unless File.exists?(temp_file_path)
|
|
52
|
-
FileUtils.rm(temp_file_path)
|
|
53
|
-
end
|
|
25
|
+
private
|
|
54
26
|
|
|
55
|
-
def
|
|
27
|
+
def temp_failed_file_path
|
|
56
28
|
"/tmp/#{target_file_name}"
|
|
57
29
|
end
|
|
58
30
|
|
|
59
31
|
def target_file_name
|
|
60
32
|
"failed_file_#{import.id}.csv"
|
|
61
33
|
end
|
|
62
|
-
|
|
63
|
-
def create_import_failed_file
|
|
64
|
-
return if import.failed_file.present?
|
|
65
|
-
File.open(temp_file_path) do |file|
|
|
66
|
-
import.failed_file = file
|
|
67
|
-
|
|
68
|
-
# I forced to save it as 'text/csv' because
|
|
69
|
-
# the file is being saved as 'text/x-pascal'
|
|
70
|
-
# and I still have no idea why?!?
|
|
71
|
-
|
|
72
|
-
import.failed_file_content_type = 'text/csv'
|
|
73
|
-
import.save!
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
end
|
|
77
34
|
end
|
|
78
35
|
end
|
|
@@ -21,18 +21,19 @@ module ActiveRecordImporter
|
|
|
21
21
|
# end
|
|
22
22
|
##
|
|
23
23
|
def acts_as_importable(options = {})
|
|
24
|
-
|
|
25
|
-
::IMPORTABLES << self.name unless ::IMPORTABLES.include?(self.name)
|
|
24
|
+
@importer_options = OptionsBuilder.build(options.merge(allowed_columns_hash(options)))
|
|
26
25
|
|
|
27
26
|
include ActiveRecordImporter::Importable::InstanceMethods
|
|
27
|
+
extend ActiveRecordImporter::Importable::SingletonMethods
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def importer_options
|
|
31
|
-
|
|
31
|
+
return @importer_options if defined?(@importer_options) && @importer_options
|
|
32
|
+
superclass.importer_options if superclass.respond_to?(:importer_options)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
def importable?
|
|
35
|
-
|
|
36
|
+
false
|
|
36
37
|
end
|
|
37
38
|
|
|
38
39
|
##
|
|
@@ -121,5 +122,11 @@ module ActiveRecordImporter
|
|
|
121
122
|
@importing ||= false
|
|
122
123
|
end
|
|
123
124
|
end
|
|
125
|
+
|
|
126
|
+
module SingletonMethods
|
|
127
|
+
def importable?
|
|
128
|
+
true
|
|
129
|
+
end
|
|
130
|
+
end
|
|
124
131
|
end
|
|
125
132
|
end
|
|
@@ -6,7 +6,13 @@ module ActiveRecordImporter
|
|
|
6
6
|
attribute :insert_method, String, default: 'insert'
|
|
7
7
|
attribute :instance_attrs, Hash, default: {}
|
|
8
8
|
attribute :find_attributes, Hash, default: {}
|
|
9
|
+
attribute :lookup, Object, default: :default_lookup
|
|
9
10
|
|
|
11
|
+
##
|
|
12
|
+
# Builds (or finds) the instance for a row, runs the before_save
|
|
13
|
+
# callback, assigns the attributes and validates it.
|
|
14
|
+
# The instance is NOT persisted here; see BatchPersister.
|
|
15
|
+
##
|
|
10
16
|
def build
|
|
11
17
|
instance = initialize_instance
|
|
12
18
|
process_data(instance)
|
|
@@ -14,23 +20,25 @@ module ActiveRecordImporter
|
|
|
14
20
|
|
|
15
21
|
private
|
|
16
22
|
|
|
17
|
-
delegate :error_duplicate
|
|
23
|
+
delegate :error_duplicate?, :insert?, to: :insert_method_inquiry
|
|
18
24
|
|
|
19
25
|
def initialize_instance
|
|
20
26
|
return importable.new if insert?
|
|
21
27
|
|
|
22
28
|
fail Errors::MissingFindByOption if find_attributes.blank?
|
|
23
|
-
|
|
29
|
+
|
|
30
|
+
existing = lookup.find(find_attributes)
|
|
31
|
+
fail Errors::DuplicateRecord if existing && error_duplicate?
|
|
32
|
+
|
|
33
|
+
existing || lookup.register(find_attributes, importable.new)
|
|
24
34
|
end
|
|
25
35
|
|
|
26
36
|
delegate :importer_options, to: :importable
|
|
27
37
|
delegate :before_save, to: :importer_options
|
|
28
38
|
|
|
29
39
|
def process_data(instance)
|
|
30
|
-
fail Errors::DuplicateRecord if error_duplicate?(instance)
|
|
31
|
-
|
|
32
40
|
before_save_callback(instance)
|
|
33
|
-
|
|
41
|
+
assign_attrs_and_validate!(instance)
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
def before_save_callback(instance)
|
|
@@ -38,9 +46,9 @@ module ActiveRecordImporter
|
|
|
38
46
|
ImportCallbacker.new(instance, before_save).call
|
|
39
47
|
end
|
|
40
48
|
|
|
41
|
-
def
|
|
49
|
+
def assign_attrs_and_validate!(instance)
|
|
42
50
|
instance.attributes = instance_attrs
|
|
43
|
-
instance.
|
|
51
|
+
fail ActiveRecord::RecordInvalid.new(instance) unless instance.valid?
|
|
44
52
|
instance
|
|
45
53
|
end
|
|
46
54
|
|
|
@@ -48,8 +56,8 @@ module ActiveRecordImporter
|
|
|
48
56
|
insert_method.inquiry
|
|
49
57
|
end
|
|
50
58
|
|
|
51
|
-
def
|
|
52
|
-
|
|
59
|
+
def default_lookup
|
|
60
|
+
RecordPreloader.new(importable)
|
|
53
61
|
end
|
|
54
62
|
end
|
|
55
63
|
end
|
|
@@ -42,5 +42,9 @@ module ActiveRecordImporter
|
|
|
42
42
|
attribute :before_save
|
|
43
43
|
attribute :after_save
|
|
44
44
|
attribute :state_machine_attr, Array
|
|
45
|
+
|
|
46
|
+
# Set to false to save rows one by one (within a single transaction
|
|
47
|
+
# per chunk) so the model's own ActiveRecord callbacks keep running
|
|
48
|
+
attribute :bulk_insert, Boolean, default: true
|
|
45
49
|
end
|
|
46
50
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module ActiveRecordImporter
|
|
2
|
+
##
|
|
3
|
+
# Loads the existing records matched by the find options of a whole
|
|
4
|
+
# chunk with a single query per distinct set of find columns, so that
|
|
5
|
+
# upsert/error_duplicate do not have to query the database once per row.
|
|
6
|
+
#
|
|
7
|
+
# Instances built during the chunk can be registered too, so duplicates
|
|
8
|
+
# within the same chunk resolve to the same instance.
|
|
9
|
+
##
|
|
10
|
+
class RecordPreloader
|
|
11
|
+
attr_reader :importable
|
|
12
|
+
|
|
13
|
+
def initialize(importable)
|
|
14
|
+
@importable = importable
|
|
15
|
+
@records = {}
|
|
16
|
+
@preloaded_column_sets = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def preload(find_attributes_list)
|
|
20
|
+
find_attributes_list.compact.reject(&:blank?).group_by(&:keys).each do |columns, list|
|
|
21
|
+
load_records(columns, list)
|
|
22
|
+
end
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def find(find_attributes)
|
|
27
|
+
return if find_attributes.blank?
|
|
28
|
+
|
|
29
|
+
key = key_for(find_attributes)
|
|
30
|
+
return @records[key] if @records.key?(key)
|
|
31
|
+
|
|
32
|
+
# Fallback for callers that did not preload this set of columns
|
|
33
|
+
@records[key] = importable.find_by(find_attributes)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def register(find_attributes, instance)
|
|
37
|
+
@records[key_for(find_attributes)] = instance
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def load_records(columns, find_attributes_list)
|
|
43
|
+
return if @preloaded_column_sets.include?(columns)
|
|
44
|
+
@preloaded_column_sets << columns
|
|
45
|
+
|
|
46
|
+
conditions = columns.each_with_object({}) do |column, hash|
|
|
47
|
+
hash[column] = find_attributes_list.map { |attrs| attrs[column] }.uniq
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
importable.where(conditions).each do |record|
|
|
51
|
+
attrs = columns.each_with_object({}) { |c, h| h[c] = record.read_attribute(c) }
|
|
52
|
+
@records[key_for(attrs)] ||= record
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
find_attributes_list.each do |attrs|
|
|
56
|
+
key = key_for(attrs)
|
|
57
|
+
@records[key] = nil unless @records.key?(key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def key_for(find_attributes)
|
|
62
|
+
find_attributes.sort_by { |column, _| column.to_s }.map do |column, value|
|
|
63
|
+
[column.to_sym, cast(column, value)]
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def cast(column, value)
|
|
68
|
+
return value unless importable.respond_to?(:type_for_attribute)
|
|
69
|
+
importable.type_for_attribute(column.to_s).cast(value)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
require 'csv'
|
|
1
2
|
require 'active_record_importer/version'
|
|
2
3
|
require 'active_support/core_ext/module'
|
|
4
|
+
require 'active_record'
|
|
5
|
+
require 'activerecord-import'
|
|
3
6
|
require 'virtus'
|
|
4
7
|
require 'enumerize'
|
|
5
8
|
require 'smarter_csv'
|
|
@@ -11,7 +14,9 @@ module ActiveRecordImporter
|
|
|
11
14
|
::IMPORTABLES = []
|
|
12
15
|
|
|
13
16
|
autoload :BatchImporter, 'active_record_importer/batch_importer'
|
|
17
|
+
autoload :BatchPersister, 'active_record_importer/batch_persister'
|
|
14
18
|
autoload :DataProcessor, 'active_record_importer/data_processor'
|
|
19
|
+
autoload :RecordPreloader, 'active_record_importer/record_preloader'
|
|
15
20
|
autoload :Dispatcher, 'active_record_importer/dispatcher'
|
|
16
21
|
autoload :Errors, 'active_record_importer/errors'
|
|
17
22
|
autoload :Importable, 'active_record_importer/importable'
|
|
@@ -19,7 +24,7 @@ module ActiveRecordImporter
|
|
|
19
24
|
autoload :OptionsBuilder, 'active_record_importer/options_builder'
|
|
20
25
|
autoload :TransitionProcessor, 'active_record_importer/transition_processor'
|
|
21
26
|
autoload :ImportCallbacker, 'active_record_importer/import_callbacker'
|
|
22
|
-
autoload :FailedFileBuilder,
|
|
27
|
+
autoload :FailedFileBuilder, 'active_record_importer/failed_file_builder'
|
|
23
28
|
|
|
24
29
|
module Attribute
|
|
25
30
|
autoload :AttributesBuilder, 'active_record_importer/attribute/attributes_builder'
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::Attribute::AttributesBuilder do
|
|
4
|
+
before { User.acts_as_importable default_attributes: { role: 'member' } }
|
|
5
|
+
|
|
6
|
+
def build(row)
|
|
7
|
+
described_class.new(User, row).build
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'keeps only importable columns with present values' do
|
|
11
|
+
attrs = build(first_name: 'Ann', last_name: '', email: nil, id: 5, bogus: 'x')
|
|
12
|
+
|
|
13
|
+
expect(attrs).to include(first_name: 'Ann', role: 'member', importing: true)
|
|
14
|
+
expect(attrs.keys).not_to include(:last_name, :email, :id, :bogus)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'adds created_at and updated_at' do
|
|
18
|
+
attrs = build(first_name: 'Ann')
|
|
19
|
+
|
|
20
|
+
expect(attrs[:created_at]).to be_a(Time)
|
|
21
|
+
expect(attrs[:updated_at]).to eq(attrs[:created_at])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'parses timestamps from the row' do
|
|
25
|
+
attrs = build(first_name: 'Ann', created_at: '2021-05-06 07:08:09')
|
|
26
|
+
|
|
27
|
+
expect(attrs[:created_at]).to eq(Time.parse('2021-05-06 07:08:09'))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'forces utf-8 encoding on strings' do
|
|
31
|
+
attrs = build(first_name: 'Ann'.dup.force_encoding('ASCII-8BIT'))
|
|
32
|
+
|
|
33
|
+
expect(attrs[:first_name].encoding).to eq(Encoding::UTF_8)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
RSpec.describe ActiveRecordImporter::Attribute::FindOptionsBuilder do
|
|
38
|
+
let(:attrs) { { first_name: 'Ann', last_name: nil, email: 'ann@example.com', ref_email: 'r@example.com' } }
|
|
39
|
+
|
|
40
|
+
def build(options)
|
|
41
|
+
described_class.new({ importable: User, attrs: attrs }.merge(options)).build
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'slices the find options, dropping nil values' do
|
|
45
|
+
expect(build(find_options: 'first_name, last_name,email')).to eq(first_name: 'Ann', email: 'ann@example.com')
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'supports a prefix' do
|
|
49
|
+
expect(build(find_options: 'email', prefix: 'ref_')).to eq(email: 'r@example.com')
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::BatchPersister do
|
|
4
|
+
before { User.acts_as_importable }
|
|
5
|
+
|
|
6
|
+
def processor_for(attrs)
|
|
7
|
+
ActiveRecordImporter::DataProcessor.new(importable: User, row_attrs: attrs).tap(&:process)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
let(:rows) { user_rows(3) }
|
|
11
|
+
let(:processors) { rows.map { |row| processor_for(row) } }
|
|
12
|
+
|
|
13
|
+
subject(:persister) { described_class.new(importable: User, processors: processors) }
|
|
14
|
+
|
|
15
|
+
it 'does nothing without processors' do
|
|
16
|
+
expect { described_class.new(importable: User).persist! }.not_to change(User, :count)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'inserts new instances in bulk' do
|
|
20
|
+
counts = count_queries { persister.persist! }
|
|
21
|
+
|
|
22
|
+
expect(User.count).to eq(3)
|
|
23
|
+
expect(counts['INSERT']).to eq(1)
|
|
24
|
+
expect(processors.none?(&:failed?)).to be(true)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'marks the offending row as failed when a database error occurs' do
|
|
28
|
+
User.create!(first_name: 'Dup', email: 'dup@example.com', employee_no: 'EMP-2')
|
|
29
|
+
|
|
30
|
+
persister.persist!
|
|
31
|
+
|
|
32
|
+
expect(User.count).to eq(3)
|
|
33
|
+
expect(processors.map(&:failed?)).to eq([false, true, false])
|
|
34
|
+
expect(processors[1].row_errors).to be_present
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'updates existing instances in bulk' do
|
|
38
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email)
|
|
39
|
+
first = User.create!(first_name: 'Old', email: 'user1@example.com', employee_no: 'X1')
|
|
40
|
+
second = User.create!(first_name: 'Old', email: 'user2@example.com', employee_no: 'X2')
|
|
41
|
+
|
|
42
|
+
counts = count_queries { persister.persist! }
|
|
43
|
+
|
|
44
|
+
expect(counts['INSERT']).to eq(2) # one for the new row, one upsert for the existing rows
|
|
45
|
+
expect(first.reload.first_name).to eq('User1')
|
|
46
|
+
expect(second.reload.first_name).to eq('User2')
|
|
47
|
+
expect(User.count).to eq(3)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'skips the update statement when nothing changed' do
|
|
51
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email)
|
|
52
|
+
User.create!(first_name: 'Same', email: 'same@example.com')
|
|
53
|
+
processor = ActiveRecordImporter::DataProcessor.new(
|
|
54
|
+
importable: User, row_attrs: { email: 'same@example.com' }
|
|
55
|
+
)
|
|
56
|
+
processor.process
|
|
57
|
+
processor.instance.updated_at = processor.instance.updated_at_was
|
|
58
|
+
processor.instance.created_at = processor.instance.created_at_was
|
|
59
|
+
|
|
60
|
+
counts = count_queries { described_class.new(importable: User, processors: [processor]).persist! }
|
|
61
|
+
|
|
62
|
+
expect(counts['INSERT'].to_i).to eq(0)
|
|
63
|
+
expect(processor.failed?).to be(false)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'when importer callbacks need a persisted instance' do
|
|
67
|
+
before { User.acts_as_importable after_save: proc { |u| User.after_save_log << u.id } }
|
|
68
|
+
|
|
69
|
+
it 'saves rows one by one and runs the callbacks' do
|
|
70
|
+
counts = count_queries { persister.persist! }
|
|
71
|
+
|
|
72
|
+
expect(counts['INSERT']).to eq(3)
|
|
73
|
+
expect(User.after_save_log.size).to eq(3)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
context 'when bulk_insert is disabled' do
|
|
78
|
+
before { User.acts_as_importable bulk_insert: false }
|
|
79
|
+
|
|
80
|
+
it 'saves rows one by one' do
|
|
81
|
+
counts = count_queries { persister.persist! }
|
|
82
|
+
|
|
83
|
+
expect(counts['INSERT']).to eq(3)
|
|
84
|
+
expect(User.pluck(:imports_count).uniq).to eq([1])
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::DataProcessor do
|
|
4
|
+
before { User.acts_as_importable }
|
|
5
|
+
|
|
6
|
+
def processor(row_attrs, options = {})
|
|
7
|
+
described_class.new({ importable: User, row_attrs: row_attrs }.merge(options))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe '#process' do
|
|
11
|
+
it 'builds a valid, unsaved instance' do
|
|
12
|
+
subject = processor(first_name: 'Ann', email: 'ann@example.com')
|
|
13
|
+
|
|
14
|
+
expect(subject.process).to be(true)
|
|
15
|
+
expect(subject.instance).to be_new_record
|
|
16
|
+
expect(subject.instance.first_name).to eq('Ann')
|
|
17
|
+
expect(subject.instance.importing?).to be(true)
|
|
18
|
+
expect(subject).to be_ready
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'records validation errors without raising' do
|
|
22
|
+
subject = processor(first_name: '', email: 'ann@example.com')
|
|
23
|
+
|
|
24
|
+
expect(subject.process).to be(false)
|
|
25
|
+
expect(subject).to be_failed
|
|
26
|
+
expect(subject.row_errors).to eq("Validation failed: First name can't be blank")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'records errors raised while building attributes' do
|
|
30
|
+
User.acts_as_importable default_attributes: { role: proc { fail 'boom' } }
|
|
31
|
+
subject = processor(first_name: 'Ann', email: 'ann@example.com')
|
|
32
|
+
|
|
33
|
+
expect(subject.process).to be(false)
|
|
34
|
+
expect(subject.row_errors).to eq('boom')
|
|
35
|
+
expect(subject.instance).to be_nil
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe 'insert method and find options' do
|
|
40
|
+
it 'defaults to insert' do
|
|
41
|
+
expect(processor({}).insert_method).to eq('insert')
|
|
42
|
+
expect(processor({}).find_attributes_for_lookup).to be_nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'prefers the Import object settings over the importer options' do
|
|
46
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email)
|
|
47
|
+
import = Import.new(resource: 'User', insert_method: 'error_duplicate', find_options: 'first_name')
|
|
48
|
+
subject = processor({ first_name: 'Ann', email: 'ann@example.com' }, import: import)
|
|
49
|
+
subject.prepare
|
|
50
|
+
|
|
51
|
+
expect(subject.insert_method).to eq('error_duplicate')
|
|
52
|
+
expect(subject.find_attributes_for_lookup).to eq(first_name: 'Ann')
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'uses the importer options when there is no Import object' do
|
|
56
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email first_name)
|
|
57
|
+
subject = processor(first_name: 'Ann', email: 'ann@example.com')
|
|
58
|
+
subject.prepare
|
|
59
|
+
|
|
60
|
+
expect(subject.insert_method).to eq('upsert')
|
|
61
|
+
expect(subject.find_attributes).to eq(email: 'ann@example.com', first_name: 'Ann')
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#after_persist' do
|
|
66
|
+
it 'runs state transitions and after_save hooks on persisted instances' do
|
|
67
|
+
User.acts_as_importable insert_method: 'upsert', find_options: %i(email),
|
|
68
|
+
state_machine_attr: %i(state),
|
|
69
|
+
after_save: proc { |u| User.after_save_log << u.id }
|
|
70
|
+
user = User.create!(first_name: 'Ann', email: 'ann@example.com', state: 'pending')
|
|
71
|
+
subject = processor(email: 'ann@example.com', state: 'active')
|
|
72
|
+
subject.process
|
|
73
|
+
subject.instance.save!
|
|
74
|
+
|
|
75
|
+
subject.after_persist
|
|
76
|
+
|
|
77
|
+
expect(user.reload.state).to eq('active')
|
|
78
|
+
expect(User.after_save_log).to eq([user.id])
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'does not assign state machine attributes directly' do
|
|
82
|
+
User.acts_as_importable state_machine_attr: %i(state)
|
|
83
|
+
subject = processor(first_name: 'Ann', email: 'ann@example.com', state: 'active')
|
|
84
|
+
subject.process
|
|
85
|
+
|
|
86
|
+
expect(subject.instance.state).to be_nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::FailedFileBuilder do
|
|
4
|
+
let(:import) { Import.create!(resource: 'User') }
|
|
5
|
+
let(:path) { "/tmp/failed_file_#{import.id}.csv" }
|
|
6
|
+
|
|
7
|
+
subject(:builder) { described_class.new(import) }
|
|
8
|
+
|
|
9
|
+
after { FileUtils.rm_f(path) }
|
|
10
|
+
|
|
11
|
+
it 'writes a header row followed by the failed rows' do
|
|
12
|
+
builder.write(first_name: 'Ann', email: 'ann@example.com', import_errors: 'boom')
|
|
13
|
+
builder.write(first_name: 'Bob', email: 'bob@example.com', import_errors: 'bang, "quoted"')
|
|
14
|
+
|
|
15
|
+
csv = CSV.read(path, headers: true)
|
|
16
|
+
expect(csv.headers).to eq(%w(first_name email import_errors))
|
|
17
|
+
expect(csv.size).to eq(2)
|
|
18
|
+
expect(csv[1]['import_errors']).to eq('bang, "quoted"')
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'ignores blank rows' do
|
|
22
|
+
builder.write({})
|
|
23
|
+
|
|
24
|
+
expect(File.exist?(path)).to be(false)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::ImportCallbacker do
|
|
4
|
+
let(:object) { User.new(first_name: 'Ann') }
|
|
5
|
+
|
|
6
|
+
it 'calls a symbol method on the object' do
|
|
7
|
+
expect(object).to receive(:activate)
|
|
8
|
+
described_class.new(object, :activate).call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'calls a proc with the object' do
|
|
12
|
+
received = nil
|
|
13
|
+
described_class.new(object, proc { |o| received = o }).call
|
|
14
|
+
|
|
15
|
+
expect(received).to be(object)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'runs every callback of an array' do
|
|
19
|
+
received = []
|
|
20
|
+
expect(object).to receive(:archive)
|
|
21
|
+
described_class.new(object, [:archive, proc { |o| received << o }]).call
|
|
22
|
+
|
|
23
|
+
expect(received).to eq([object])
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::InstanceBuilder do
|
|
4
|
+
before { User.acts_as_importable }
|
|
5
|
+
|
|
6
|
+
let(:lookup) { ActiveRecordImporter::RecordPreloader.new(User) }
|
|
7
|
+
|
|
8
|
+
def build(options = {})
|
|
9
|
+
described_class.new({ importable: User, lookup: lookup }.merge(options)).build
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'builds a new validated instance for insert' do
|
|
13
|
+
instance = build(instance_attrs: { first_name: 'Ann', email: 'ann@example.com' })
|
|
14
|
+
|
|
15
|
+
expect(instance).to be_new_record
|
|
16
|
+
expect(instance).to be_valid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'raises RecordInvalid for invalid instances' do
|
|
20
|
+
expect { build(instance_attrs: { first_name: 'Ann', email: 'nope' }) }
|
|
21
|
+
.to raise_error(ActiveRecord::RecordInvalid, 'Validation failed: Email is invalid')
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'runs the before_save callback before assigning attributes' do
|
|
25
|
+
User.acts_as_importable before_save: proc { |user| user.last_name = 'Callback' }
|
|
26
|
+
|
|
27
|
+
instance = build(instance_attrs: { first_name: 'Ann', email: 'ann@example.com' })
|
|
28
|
+
|
|
29
|
+
expect(instance.last_name).to eq('Callback')
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
context 'upsert' do
|
|
33
|
+
let!(:ann) { User.create!(first_name: 'Ann', email: 'ann@example.com') }
|
|
34
|
+
|
|
35
|
+
it 'returns the existing record with the new attributes' do
|
|
36
|
+
instance = build(insert_method: 'upsert',
|
|
37
|
+
find_attributes: { email: 'ann@example.com' },
|
|
38
|
+
instance_attrs: { first_name: 'Annie' })
|
|
39
|
+
|
|
40
|
+
expect(instance).to eq(ann)
|
|
41
|
+
expect(instance.first_name).to eq('Annie')
|
|
42
|
+
expect(instance).to be_changed
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'initializes and registers a new record when none matches' do
|
|
46
|
+
instance = build(insert_method: 'upsert',
|
|
47
|
+
find_attributes: { email: 'new@example.com' },
|
|
48
|
+
instance_attrs: { first_name: 'New', email: 'new@example.com' })
|
|
49
|
+
|
|
50
|
+
expect(instance).to be_new_record
|
|
51
|
+
expect(lookup.find(email: 'new@example.com')).to be(instance)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'requires find attributes' do
|
|
55
|
+
expect { build(insert_method: 'upsert', find_attributes: {}) }
|
|
56
|
+
.to raise_error(ActiveRecordImporter::Errors::MissingFindByOption)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context 'error_duplicate' do
|
|
61
|
+
it 'raises when the record exists' do
|
|
62
|
+
User.create!(first_name: 'Ann', email: 'ann@example.com')
|
|
63
|
+
|
|
64
|
+
expect do
|
|
65
|
+
build(insert_method: 'error_duplicate', find_attributes: { email: 'ann@example.com' })
|
|
66
|
+
end.to raise_error(ActiveRecordImporter::Errors::DuplicateRecord)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'raises when the record was registered earlier in the chunk' do
|
|
70
|
+
lookup.register({ email: 'ann@example.com' }, User.new)
|
|
71
|
+
|
|
72
|
+
expect do
|
|
73
|
+
build(insert_method: 'error_duplicate', find_attributes: { email: 'ann@example.com' })
|
|
74
|
+
end.to raise_error(ActiveRecordImporter::Errors::DuplicateRecord)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|