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.
Files changed (34) hide show
  1. checksums.yaml +5 -5
  2. data/CHANGELOG.md +40 -0
  3. data/Gemfile +7 -0
  4. data/README.md +69 -3
  5. data/active_record_importer.gemspec +7 -3
  6. data/lib/active_record_importer/attribute/attributes_builder.rb +1 -0
  7. data/lib/active_record_importer/batch_importer.rb +58 -24
  8. data/lib/active_record_importer/batch_persister.rb +103 -0
  9. data/lib/active_record_importer/data_processor.rb +71 -25
  10. data/lib/active_record_importer/dispatcher.rb +3 -3
  11. data/lib/active_record_importer/failed_file_builder.rb +2 -2
  12. data/lib/active_record_importer/import_callbacker.rb +2 -2
  13. data/lib/active_record_importer/importable.rb +3 -2
  14. data/lib/active_record_importer/instance_builder.rb +17 -9
  15. data/lib/active_record_importer/options_builder.rb +5 -1
  16. data/lib/active_record_importer/record_preloader.rb +72 -0
  17. data/lib/active_record_importer/version.rb +1 -1
  18. data/lib/active_record_importer.rb +6 -1
  19. data/spec/active_record_importer/attribute/builders_spec.rb +51 -0
  20. data/spec/active_record_importer/batch_persister_spec.rb +87 -0
  21. data/spec/active_record_importer/data_processor_spec.rb +89 -0
  22. data/spec/active_record_importer/failed_file_builder_spec.rb +26 -0
  23. data/spec/active_record_importer/import_callbacker_spec.rb +25 -0
  24. data/spec/active_record_importer/instance_builder_spec.rb +77 -0
  25. data/spec/active_record_importer/options_builder_spec.rb +19 -0
  26. data/spec/active_record_importer/record_preloader_spec.rb +59 -0
  27. data/spec/active_record_importer_spec.rb +1 -5
  28. data/spec/integration/import_spec.rb +457 -0
  29. data/spec/spec_helper.rb +41 -0
  30. data/spec/support/csv_helper.rb +42 -0
  31. data/spec/support/database.rb +100 -0
  32. data/spec/support/models.rb +112 -0
  33. data/spec/support/query_counter.rb +22 -0
  34. 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, :insert?, to: :insert_method_inquiry
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
- importable.find_or_initialize_by(find_attributes)
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
- assign_attrs_and_save!(instance)
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 assign_attrs_and_save!(instance)
49
+ def assign_attrs_and_validate!(instance)
42
50
  instance.attributes = instance_attrs
43
- instance.save!
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 error_duplicate?(instance)
52
- instance.persisted? && error_duplicate?
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: nil
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,3 +1,3 @@
1
1
  module ActiveRecordImporter
2
- VERSION = '0.4.0'
2
+ VERSION = '1.0.0'
3
3
  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, 'active_record_importer/failed_file_builder'
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