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
|
@@ -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
|
|
@@ -20,7 +20,7 @@ module ActiveRecordImporter
|
|
|
20
20
|
attribute :remove_empty_values, Boolean, default: false
|
|
21
21
|
attribute :comment_regexp, Regexp, default: Regexp.new(/^#=>/)
|
|
22
22
|
attribute :force_utf8, Boolean, default: true
|
|
23
|
-
attribute :chunk_size, Integer, default:
|
|
23
|
+
attribute :chunk_size, Integer, default: 500
|
|
24
24
|
attribute :col_sep, String, default: ','
|
|
25
25
|
end
|
|
26
26
|
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
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::OptionsBuilder do
|
|
4
|
+
it 'builds importer options with defaults' do
|
|
5
|
+
options = described_class.build
|
|
6
|
+
|
|
7
|
+
expect(options.find_options).to eq([])
|
|
8
|
+
expect(options.bulk_insert).to be(true)
|
|
9
|
+
expect(options.csv_opts.chunk_size).to eq(500)
|
|
10
|
+
expect(options.csv_opts.col_sep).to eq(',')
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'merges csv options' do
|
|
14
|
+
options = described_class.build(csv_opts: { chunk_size: 10, col_sep: '|' }, bulk_insert: false)
|
|
15
|
+
|
|
16
|
+
expect(options.csv_opts.to_hash).to include(chunk_size: 10, col_sep: '|', force_utf8: true)
|
|
17
|
+
expect(options.bulk_insert).to be(false)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
require 'spec_helper'
|
|
2
|
+
|
|
3
|
+
RSpec.describe ActiveRecordImporter::RecordPreloader do
|
|
4
|
+
subject(:preloader) { described_class.new(User) }
|
|
5
|
+
|
|
6
|
+
let!(:ann) { User.create!(first_name: 'Ann', last_name: 'Lee', email: 'ann@example.com', employee_no: 'A', age: 30) }
|
|
7
|
+
let!(:bob) { User.create!(first_name: 'Bob', last_name: 'Lee', email: 'bob@example.com', employee_no: 'B', age: 40) }
|
|
8
|
+
|
|
9
|
+
describe '#preload' do
|
|
10
|
+
it 'loads all matching records with a single query' do
|
|
11
|
+
counts = count_queries do
|
|
12
|
+
preloader.preload([{ email: 'ann@example.com' }, { email: 'bob@example.com' }, { email: 'nobody@example.com' }])
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
expect(counts['SELECT']).to eq(1)
|
|
16
|
+
expect(count_queries { preloader.find(email: 'ann@example.com') }['SELECT']).to eq(0)
|
|
17
|
+
expect(preloader.find(email: 'ann@example.com')).to eq(ann)
|
|
18
|
+
expect(preloader.find(email: 'bob@example.com')).to eq(bob)
|
|
19
|
+
expect(preloader.find(email: 'nobody@example.com')).to be_nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'matches on every column for composite find options' do
|
|
23
|
+
preloader.preload([{ first_name: 'Ann', last_name: 'Lee' }, { first_name: 'Bob', last_name: 'Smith' }])
|
|
24
|
+
|
|
25
|
+
expect(preloader.find(first_name: 'Ann', last_name: 'Lee')).to eq(ann)
|
|
26
|
+
expect(preloader.find(first_name: 'Bob', last_name: 'Smith')).to be_nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'casts values with the column type' do
|
|
30
|
+
preloader.preload([{ age: '30' }])
|
|
31
|
+
|
|
32
|
+
expect(preloader.find(age: '30')).to eq(ann)
|
|
33
|
+
expect(preloader.find(age: 30)).to eq(ann)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'ignores blank entries' do
|
|
37
|
+
expect { preloader.preload([nil, {}]) }.not_to raise_error
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '#find' do
|
|
42
|
+
it 'queries the database for a key that was not preloaded' do
|
|
43
|
+
expect(preloader.find(email: 'bob@example.com')).to eq(bob)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'returns nil for blank attributes' do
|
|
47
|
+
expect(preloader.find({})).to be_nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#register' do
|
|
52
|
+
it 'returns registered instances for subsequent lookups' do
|
|
53
|
+
instance = User.new(email: 'new@example.com')
|
|
54
|
+
|
|
55
|
+
expect(preloader.register({ email: 'new@example.com' }, instance)).to be(instance)
|
|
56
|
+
expect(preloader.find(email: 'new@example.com')).to be(instance)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
require 'spec_helper'
|
|
2
2
|
|
|
3
|
-
describe ActiveRecordImporter do
|
|
3
|
+
RSpec.describe ActiveRecordImporter do
|
|
4
4
|
it 'has a version number' do
|
|
5
5
|
expect(ActiveRecordImporter::VERSION).not_to be nil
|
|
6
6
|
end
|
|
7
|
-
|
|
8
|
-
it 'does something useful' do
|
|
9
|
-
expect(false).to eq(true)
|
|
10
|
-
end
|
|
11
7
|
end
|