activerecord-import 0.25.0 → 0.26.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: 26224d6c0d4c23f3edaf1faea68466f18c03fbbc
4
- data.tar.gz: 55107f77211b86e3be77b287d224e488dbb280ac
2
+ SHA256:
3
+ metadata.gz: ecfc0672f1042ae828bb3125c0675364d1af8e1b4aefb82af633d3656a8d5fc6
4
+ data.tar.gz: 8b03926293827932a61342fded8e5400fbe8261e92817ce32c09eda7fa16860f
5
5
  SHA512:
6
- metadata.gz: c0815961332178cf2858b39663f1d9f6ef3d61985cd4cb528cef410714e4bf07c354515b5e06124ad4bb5c7e57e7a179c48d7fb5c5e28886bc9e30c4399e82c9
7
- data.tar.gz: df5e8ccd8a658d4981cd26167e09fa8c5e0425c58e4397c48e6f7721221d1de3cc4c6cdf903e3330f07cf6a74b93214cd11cca977f6d75cf6e8b43409807c23b
6
+ metadata.gz: 874795a3c02d59b9411ab831ba6257c0c4dab7bad2b478e69451b3d5344652f4d2f906f727189170dc8417ae64a5a2f236a61da1b24a8c1112a7e731a087a7ca
7
+ data.tar.gz: 00abfb0263c84d2c687451bd1344ae73935291b8db074388c64220d1d6e5ee9dccc515f62217cc4a61c5912acd1de60d5a57ac7229b3734fa145bfd7936f5b56
data/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ ## Changes in 0.26.0
2
+
3
+ ### New Features
4
+
5
+ * Add on_duplicate_key_update for SQLite. Thanks to @jkowens via \#542.
6
+ * Add option to update all fields on_duplicate_key_update. Thanks to @aimerald, @jkowens via \#543.
7
+
8
+ ### Fixes
9
+
10
+ * Handle deeply frozen options hashes. Thanks to @jturkel via \#546.
11
+ * Switch from FactoryGirl to FactoryBot. Thanks to @koic via \#547.
12
+ * Allow import to work with ProxySQL. Thanks to @GregFarrell via \#550.
13
+
1
14
  ## Changes in 0.25.0
2
15
 
3
16
  ### New Features
data/Gemfile CHANGED
@@ -29,7 +29,7 @@ platforms :jruby do
29
29
  end
30
30
 
31
31
  # Support libs
32
- gem "factory_girl", "~> 4.2.0"
32
+ gem "factory_bot"
33
33
  gem "timecop"
34
34
  gem "chronic"
35
35
  gem "mocha", "~> 1.3.0"
data/README.markdown CHANGED
@@ -21,11 +21,53 @@ and then the reviews:
21
21
  That would be about 4M SQL insert statements vs 3, which results in vastly improved performance. In our case, it converted
22
22
  an 18 hour batch process to <2 hrs.
23
23
 
24
- ### More Information : Usage and Examples in Wiki
24
+ ## Table of Contents
25
25
 
26
- For more information on activerecord-import please see its wiki: https://github.com/zdennis/activerecord-import/wiki
26
+ * [Callbacks](#callbacks)
27
+ * [Additional Adapters](#additional-adapters)
28
+ * [Load Path Setup](#load-path-setup)
29
+ * [More Information](#more-information)
30
+
31
+ ### Callbacks
32
+
33
+ ActiveRecord callbacks related to [creating](http://guides.rubyonrails.org/active_record_callbacks.html#creating-an-object), [updating](http://guides.rubyonrails.org/active_record_callbacks.html#updating-an-object), or [destroying](http://guides.rubyonrails.org/active_record_callbacks.html#destroying-an-object) records (other than `before_validation` and `after_validation`) will NOT be called when calling the import method. This is because it is mass importing rows of data and doesn't necessarily have access to in-memory ActiveRecord objects.
34
+
35
+ If you do have a collection of in-memory ActiveRecord objects you can do something like this:
36
+
37
+ ```
38
+ books.each do |book|
39
+ book.run_callbacks(:save) { false }
40
+ book.run_callbacks(:create) { false }
41
+ end
42
+ Book.import(books)
43
+ ```
44
+
45
+ This will run before_create and before_save callbacks on each item. The `false` argument is needed to prevent after_save being run, which wouldn't make sense prior to bulk import. Something to note in this example is that the before_create and before_save callbacks will run before the validation callbacks.
46
+
47
+ If that is an issue, another possible approach is to loop through your models first to do validations and then only run callbacks on and import the valid models.
48
+
49
+ ```
50
+ valid_books = []
51
+ invalid_books = []
52
+
53
+ books.each do |book|
54
+ if book.valid?
55
+ valid_books << book
56
+ else
57
+ invalid_books << book
58
+ end
59
+ end
60
+
61
+ valid_books.each do |book|
62
+ book.run_callbacks(:save) { false }
63
+ book.run_callbacks(:create) { false }
64
+ end
65
+
66
+ Book.import valid_books, validate: false
67
+ ```
68
+
69
+ ### Additional Adapters
27
70
 
28
- ## Additional Adapters
29
71
  Additional adapters can be provided by gems external to activerecord-import by providing an adapter that matches the naming convention setup by activerecord-import (and subsequently activerecord) for dynamically loading adapters. This involves also providing a folder on the load path that follows the activerecord-import naming convention to allow activerecord-import to dynamically load the file.
30
72
 
31
73
  When `ActiveRecord::Import.require_adapter("fake_name")` is called the require will be:
@@ -63,6 +105,12 @@ activerecord-import-fake_name/
63
105
 
64
106
  When rubygems pushes the `lib` folder onto the load path a `require` will now find `activerecord-import/active_record/adapters/fake_name_adapter` as it runs through the lookup process for a ruby file under that path in `$LOAD_PATH`
65
107
 
108
+ ### More Information
109
+
110
+ For more information on activerecord-import please see its wiki: https://github.com/zdennis/activerecord-import/wiki
111
+
112
+ To document new information, please add to the README instead of the wiki. See https://github.com/zdennis/activerecord-import/issues/397 for discussion.
113
+
66
114
  # License
67
115
 
68
116
  This is licensed under the ruby license.
data/Rakefile CHANGED
@@ -32,7 +32,7 @@ ADAPTERS.each do |adapter|
32
32
  namespace :test do
33
33
  desc "Runs #{adapter} database tests."
34
34
  Rake::TestTask.new(adapter) do |t|
35
- # FactoryGirl has an issue with warnings, so turn off, so noisy
35
+ # FactoryBot has an issue with warnings, so turn off, so noisy
36
36
  # t.warning = true
37
37
  t.test_files = FileList["test/adapters/#{adapter}.rb", "test/*_test.rb", "test/active_record/*_test.rb", "test/#{adapter}/**/*_test.rb"]
38
38
  end
@@ -56,7 +56,7 @@ module ActiveRecord::Import::MysqlAdapter
56
56
  # in a single packet
57
57
  def max_allowed_packet # :nodoc:
58
58
  @max_allowed_packet ||= begin
59
- result = execute( "SHOW VARIABLES like 'max_allowed_packet';" )
59
+ result = execute( "SHOW VARIABLES like 'max_allowed_packet'" )
60
60
  # original Mysql gem responds to #fetch_row while Mysql2 responds to #first
61
61
  val = result.respond_to?(:fetch_row) ? result.fetch_row[1] : result.first[1]
62
62
  val.to_i
@@ -1,7 +1,9 @@
1
1
  module ActiveRecord::Import::SQLite3Adapter
2
2
  include ActiveRecord::Import::ImportSupport
3
+ include ActiveRecord::Import::OnDuplicateKeyUpdateSupport
3
4
 
4
5
  MIN_VERSION_FOR_IMPORT = "3.7.11".freeze
6
+ MIN_VERSION_FOR_UPSERT = "3.24.0".freeze
5
7
  SQLITE_LIMIT_COMPOUND_SELECT = 500
6
8
 
7
9
  # Override our conformance to ActiveRecord::Import::ImportSupport interface
@@ -15,6 +17,10 @@ module ActiveRecord::Import::SQLite3Adapter
15
17
  end
16
18
  end
17
19
 
20
+ def supports_on_duplicate_key_update?(current_version = sqlite_version)
21
+ current_version >= MIN_VERSION_FOR_UPSERT
22
+ end
23
+
18
24
  # +sql+ can be a single string or an array. If it is an array all
19
25
  # elements that are in position >= 1 will be appended to the final SQL.
20
26
  def insert_many( sql, values, _options = {}, *args ) # :nodoc:
@@ -40,16 +46,133 @@ module ActiveRecord::Import::SQLite3Adapter
40
46
  ActiveRecord::Import::Result.new([], number_of_inserts, [], [])
41
47
  end
42
48
 
43
- def pre_sql_statements( options)
49
+ def pre_sql_statements( options )
44
50
  sql = []
45
51
  # Options :recursive and :on_duplicate_key_ignore are mutually exclusive
46
- if (options[:ignore] || options[:on_duplicate_key_ignore]) && !options[:recursive]
52
+ if !supports_on_duplicate_key_update? && (options[:ignore] || options[:on_duplicate_key_ignore])
47
53
  sql << "OR IGNORE"
48
54
  end
49
55
  sql + super
50
56
  end
51
57
 
58
+ def post_sql_statements( table_name, options ) # :nodoc:
59
+ sql = []
60
+
61
+ if supports_on_duplicate_key_update?
62
+ # Options :recursive and :on_duplicate_key_ignore are mutually exclusive
63
+ if (options[:ignore] || options[:on_duplicate_key_ignore]) && !options[:on_duplicate_key_update]
64
+ sql << sql_for_on_duplicate_key_ignore( options[:on_duplicate_key_ignore] )
65
+ end
66
+ end
67
+
68
+ sql + super
69
+ end
70
+
52
71
  def next_value_for_sequence(sequence_name)
53
72
  %{nextval('#{sequence_name}')}
54
73
  end
74
+
75
+ # Add a column to be updated on duplicate key update
76
+ def add_column_for_on_duplicate_key_update( column, options = {} ) # :nodoc:
77
+ arg = options[:on_duplicate_key_update]
78
+ if arg.is_a?( Hash )
79
+ columns = arg.fetch( :columns ) { arg[:columns] = [] }
80
+ case columns
81
+ when Array then columns << column.to_sym unless columns.include?( column.to_sym )
82
+ when Hash then columns[column.to_sym] = column.to_sym
83
+ end
84
+ elsif arg.is_a?( Array )
85
+ arg << column.to_sym unless arg.include?( column.to_sym )
86
+ end
87
+ end
88
+
89
+ # Returns a generated ON CONFLICT DO NOTHING statement given the passed
90
+ # in +args+.
91
+ def sql_for_on_duplicate_key_ignore( *args ) # :nodoc:
92
+ arg = args.first
93
+ conflict_target = sql_for_conflict_target( arg ) if arg.is_a?( Hash )
94
+ " ON CONFLICT #{conflict_target}DO NOTHING"
95
+ end
96
+
97
+ # Returns a generated ON CONFLICT DO UPDATE statement given the passed
98
+ # in +args+.
99
+ def sql_for_on_duplicate_key_update( _table_name, *args ) # :nodoc:
100
+ arg, primary_key, locking_column = args
101
+ arg = { columns: arg } if arg.is_a?( Array ) || arg.is_a?( String )
102
+ return unless arg.is_a?( Hash )
103
+
104
+ sql = ' ON CONFLICT '
105
+ conflict_target = sql_for_conflict_target( arg )
106
+
107
+ columns = arg.fetch( :columns, [] )
108
+ condition = arg[:condition]
109
+ if columns.respond_to?( :empty? ) && columns.empty?
110
+ return sql << "#{conflict_target}DO NOTHING"
111
+ end
112
+
113
+ conflict_target ||= sql_for_default_conflict_target( primary_key )
114
+ unless conflict_target
115
+ raise ArgumentError, 'Expected :conflict_target to be specified'
116
+ end
117
+
118
+ sql << "#{conflict_target}DO UPDATE SET "
119
+ if columns.is_a?( Array )
120
+ sql << sql_for_on_duplicate_key_update_as_array( locking_column, columns )
121
+ elsif columns.is_a?( Hash )
122
+ sql << sql_for_on_duplicate_key_update_as_hash( locking_column, columns )
123
+ elsif columns.is_a?( String )
124
+ sql << columns
125
+ else
126
+ raise ArgumentError, 'Expected :columns to be an Array or Hash'
127
+ end
128
+
129
+ sql << " WHERE #{condition}" if condition.present?
130
+
131
+ sql
132
+ end
133
+
134
+ def sql_for_on_duplicate_key_update_as_array( locking_column, arr ) # :nodoc:
135
+ results = arr.map do |column|
136
+ qc = quote_column_name( column )
137
+ "#{qc}=EXCLUDED.#{qc}"
138
+ end
139
+ increment_locking_column!(results, locking_column)
140
+ results.join( ',' )
141
+ end
142
+
143
+ def sql_for_on_duplicate_key_update_as_hash( locking_column, hsh ) # :nodoc:
144
+ results = hsh.map do |column1, column2|
145
+ qc1 = quote_column_name( column1 )
146
+ qc2 = quote_column_name( column2 )
147
+ "#{qc1}=EXCLUDED.#{qc2}"
148
+ end
149
+ increment_locking_column!(results, locking_column)
150
+ results.join( ',' )
151
+ end
152
+
153
+ def sql_for_conflict_target( args = {} )
154
+ conflict_target = args[:conflict_target]
155
+ index_predicate = args[:index_predicate]
156
+ if conflict_target.present?
157
+ '(' << Array( conflict_target ).reject( &:blank? ).join( ', ' ) << ') '.tap do |sql|
158
+ sql << "WHERE #{index_predicate} " if index_predicate
159
+ end
160
+ end
161
+ end
162
+
163
+ def sql_for_default_conflict_target( primary_key )
164
+ conflict_target = Array(primary_key).join(', ')
165
+ "(#{conflict_target}) " if conflict_target.present?
166
+ end
167
+
168
+ # Return true if the statement is a duplicate key record error
169
+ def duplicate_key_update_error?(exception) # :nodoc:
170
+ exception.is_a?(ActiveRecord::StatementInvalid) && exception.to_s.include?('duplicate key')
171
+ end
172
+
173
+ def increment_locking_column!(results, locking_column)
174
+ if locking_column.present?
175
+ results << "\"#{locking_column}\"=EXCLUDED.\"#{locking_column}\"+1"
176
+ end
177
+ end
55
178
  end
@@ -298,8 +298,8 @@ class ActiveRecord::Base
298
298
  # recursive import. For database adapters that normally support
299
299
  # setting primary keys on imported objects, this option prevents
300
300
  # that from occurring.
301
- # * +on_duplicate_key_update+ - an Array or Hash, tells import to
302
- # use MySQL's ON DUPLICATE KEY UPDATE or Postgres 9.5+ ON CONFLICT
301
+ # * +on_duplicate_key_update+ - :all, an Array, or Hash, tells import to
302
+ # use MySQL's ON DUPLICATE KEY UPDATE or Postgres/SQLite ON CONFLICT
303
303
  # DO UPDATE ability. See On Duplicate Key Update below.
304
304
  # * +synchronize+ - an array of ActiveRecord instances for the model
305
305
  # that you are currently importing data into. This synchronizes
@@ -358,7 +358,15 @@ class ActiveRecord::Base
358
358
  #
359
359
  # == On Duplicate Key Update (MySQL)
360
360
  #
361
- # The :on_duplicate_key_update option can be either an Array or a Hash.
361
+ # The :on_duplicate_key_update option can be either :all, an Array, or a Hash.
362
+ #
363
+ # ==== Using :all
364
+ #
365
+ # The :on_duplicate_key_update option can be set to :all. All columns
366
+ # other than the primary key are updated. If a list of column names is
367
+ # supplied, only those columns will be updated. Below is an example:
368
+ #
369
+ # BlogPost.import columns, values, on_duplicate_key_update: :all
362
370
  #
363
371
  # ==== Using an Array
364
372
  #
@@ -377,11 +385,19 @@ class ActiveRecord::Base
377
385
  #
378
386
  # BlogPost.import columns, attributes, on_duplicate_key_update: { title: :title }
379
387
  #
380
- # == On Duplicate Key Update (Postgres 9.5+)
388
+ # == On Duplicate Key Update (Postgres 9.5+ and SQLite 3.24+)
381
389
  #
382
- # The :on_duplicate_key_update option can be an Array or a Hash with up to
390
+ # The :on_duplicate_key_update option can be :all, an Array, or a Hash with up to
383
391
  # three attributes, :conflict_target (and optionally :index_predicate) or
384
- # :constraint_name, and :columns.
392
+ # :constraint_name (Postgres), and :columns.
393
+ #
394
+ # ==== Using :all
395
+ #
396
+ # The :on_duplicate_key_update option can be set to :all. All columns
397
+ # other than the primary key are updated. If a list of column names is
398
+ # supplied, only those columns will be updated. Below is an example:
399
+ #
400
+ # BlogPost.import columns, values, on_duplicate_key_update: :all
385
401
  #
386
402
  # ==== Using an Array
387
403
  #
@@ -439,7 +455,15 @@ class ActiveRecord::Base
439
455
  #
440
456
  # ===== :columns
441
457
  #
442
- # The :columns attribute can be either an Array or a Hash.
458
+ # The :columns attribute can be either :all, an Array, or a Hash.
459
+ #
460
+ # ===== Using :all
461
+ #
462
+ # The :columns attribute can be :all. All columns other than the primary key will be updated.
463
+ # If a list of column names is supplied, only those columns will be updated.
464
+ # Below is an example:
465
+ #
466
+ # BlogPost.import columns, values, on_duplicate_key_update: { conflict_target: :slug, columns: :all }
443
467
  #
444
468
  # ===== Using an Array
445
469
  #
@@ -495,16 +519,6 @@ class ActiveRecord::Base
495
519
  options[:primary_key] = primary_key
496
520
  options[:locking_column] = locking_column if attribute_names.include?(locking_column)
497
521
 
498
- # Don't modify incoming arguments
499
- on_duplicate_key_update = options[:on_duplicate_key_update]
500
- if on_duplicate_key_update && on_duplicate_key_update.duplicable?
501
- options[:on_duplicate_key_update] = if on_duplicate_key_update.is_a?(Hash)
502
- on_duplicate_key_update.each { |k, v| on_duplicate_key_update[k] = v.dup if v.duplicable? }
503
- else
504
- on_duplicate_key_update.dup
505
- end
506
- end
507
-
508
522
  is_validating = options[:validate_with_context].present? ? true : options[:validate]
509
523
  validator = ActiveRecord::Import::Validator.new(options)
510
524
 
@@ -522,8 +536,12 @@ class ActiveRecord::Base
522
536
  end
523
537
  end
524
538
 
525
- if models.first.id.nil? && column_names.include?(primary_key) && columns_hash[primary_key].type == :uuid
526
- column_names.delete(primary_key)
539
+ if models.first.id.nil?
540
+ Array(primary_key).each do |c|
541
+ if column_names.include?(c) && columns_hash[c].type == :uuid
542
+ column_names.delete(c)
543
+ end
544
+ end
527
545
  end
528
546
 
529
547
  default_values = column_defaults
@@ -611,6 +629,29 @@ class ActiveRecord::Base
611
629
  array_of_attributes.each { |a| a.concat(new_fields) }
612
630
  end
613
631
 
632
+ # Don't modify incoming arguments
633
+ on_duplicate_key_update = options[:on_duplicate_key_update]
634
+ if on_duplicate_key_update
635
+ updatable_columns = symbolized_column_names.reject { |c| symbolized_primary_key.include? c }
636
+ options[:on_duplicate_key_update] = if on_duplicate_key_update.is_a?(Hash)
637
+ on_duplicate_key_update.each_with_object({}) do |(k, v), duped_options|
638
+ duped_options[k] = if k == :columns && v == :all
639
+ updatable_columns
640
+ elsif v.duplicable?
641
+ v.dup
642
+ else
643
+ v
644
+ end
645
+ end
646
+ elsif on_duplicate_key_update == :all
647
+ updatable_columns
648
+ elsif on_duplicate_key_update.duplicable?
649
+ on_duplicate_key_update.dup
650
+ else
651
+ on_duplicate_key_update
652
+ end
653
+ end
654
+
614
655
  timestamps = {}
615
656
 
616
657
  # record timestamps unless disabled in ActiveRecord::Base
@@ -646,7 +687,7 @@ class ActiveRecord::Base
646
687
  end
647
688
 
648
689
  if options[:synchronize]
649
- sync_keys = options[:synchronize_keys] || [primary_key]
690
+ sync_keys = options[:synchronize_keys] || Array(primary_key)
650
691
  synchronize( options[:synchronize], sync_keys)
651
692
  end
652
693
  return_obj.num_inserts = 0 if return_obj.num_inserts.nil?
@@ -871,7 +912,7 @@ class ActiveRecord::Base
871
912
  column = columns[j]
872
913
 
873
914
  # be sure to query sequence_name *last*, only if cheaper tests fail, because it's costly
874
- if val.nil? && column.name == primary_key && !sequence_name.blank?
915
+ if val.nil? && Array(primary_key).first == column.name && !sequence_name.blank?
875
916
  connection_memo.next_value_for_sequence(sequence_name)
876
917
  elsif val.respond_to?(:to_sql)
877
918
  "(#{val.to_sql})"
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module Import
3
- VERSION = "0.25.0".freeze
3
+ VERSION = "0.26.0".freeze
4
4
  end
5
5
  end
data/test/import_test.rb CHANGED
@@ -567,7 +567,7 @@ describe "#import" do
567
567
 
568
568
  context "importing through an association scope" do
569
569
  { has_many: :chapters, polymorphic: :discounts }.each do |association_type, association|
570
- book = FactoryGirl.create :book
570
+ book = FactoryBot.create :book
571
571
  scope = book.public_send association
572
572
  klass = { chapters: Chapter, discounts: Discount }[association]
573
573
  column = { chapters: :title, discounts: :amount }[association]
@@ -609,7 +609,7 @@ describe "#import" do
609
609
 
610
610
  context "importing model with polymorphic belongs_to" do
611
611
  it "works without error" do
612
- book = FactoryGirl.create :book
612
+ book = FactoryBot.create :book
613
613
  discount = Discount.new(discountable: book)
614
614
 
615
615
  Discount.import([discount])
@@ -0,0 +1,13 @@
1
+ ActiveRecord::Schema.define do
2
+ create_table :alarms, force: true do |t|
3
+ t.column :device_id, :integer, null: false
4
+ t.column :alarm_type, :integer, null: false
5
+ t.column :status, :integer, null: false
6
+ t.column :metadata, :text
7
+ t.column :secret_key, :binary
8
+ t.datetime :created_at
9
+ t.datetime :updated_at
10
+ end
11
+
12
+ add_index :alarms, [:device_id, :alarm_type], unique: true, where: 'status <> 0'
13
+ end
@@ -1,4 +1,4 @@
1
- FactoryGirl.define do
1
+ FactoryBot.define do
2
2
  sequence(:book_title) { |n| "Book #{n}" }
3
3
  sequence(:chapter_title) { |n| "Chapter #{n}" }
4
4
  sequence(:end_note) { |n| "Endnote #{n}" }
@@ -9,7 +9,7 @@ FactoryGirl.define do
9
9
 
10
10
  factory :invalid_topic, class: "Topic" do
11
11
  sequence(:title) { |n| "Title #{n}" }
12
- author_name nil
12
+ author_name { nil }
13
13
  end
14
14
 
15
15
  factory :topic do
@@ -27,7 +27,7 @@ FactoryGirl.define do
27
27
 
28
28
  trait :with_rule do
29
29
  after(:build) do |question|
30
- question.build_rule(FactoryGirl.attributes_for(:rule))
30
+ question.build_rule(FactoryBot.attributes_for(:rule))
31
31
  end
32
32
  end
33
33
  end
@@ -40,21 +40,21 @@ FactoryGirl.define do
40
40
  factory :topic_with_book, parent: :topic do
41
41
  after(:build) do |topic|
42
42
  2.times do
43
- book = topic.books.build(title: FactoryGirl.generate(:book_title), author_name: 'Stephen King')
43
+ book = topic.books.build(title: FactoryBot.generate(:book_title), author_name: 'Stephen King')
44
44
  3.times do
45
- book.chapters.build(title: FactoryGirl.generate(:chapter_title))
45
+ book.chapters.build(title: FactoryBot.generate(:chapter_title))
46
46
  end
47
47
 
48
48
  4.times do
49
- book.end_notes.build(note: FactoryGirl.generate(:end_note))
49
+ book.end_notes.build(note: FactoryBot.generate(:end_note))
50
50
  end
51
51
  end
52
52
  end
53
53
  end
54
54
 
55
55
  factory :book do
56
- title 'Tortilla Flat'
57
- author_name 'John Steinbeck'
56
+ title { 'Tortilla Flat' }
57
+ author_name { 'John Steinbeck' }
58
58
  end
59
59
 
60
60
  factory :car do
@@ -2,28 +2,28 @@ class ActiveSupport::TestCase
2
2
  def Build(*args) # rubocop:disable Style/MethodName
3
3
  n = args.shift if args.first.is_a?(Numeric)
4
4
  factory = args.shift
5
- factory_girl_args = args.shift || {}
5
+ factory_bot_args = args.shift || {}
6
6
 
7
7
  if n
8
8
  [].tap do |collection|
9
- n.times.each { collection << FactoryGirl.build(factory.to_s.singularize.to_sym, factory_girl_args) }
9
+ n.times.each { collection << FactoryBot.build(factory.to_s.singularize.to_sym, factory_bot_args) }
10
10
  end
11
11
  else
12
- FactoryGirl.build(factory.to_s.singularize.to_sym, factory_girl_args)
12
+ FactoryBot.build(factory.to_s.singularize.to_sym, factory_bot_args)
13
13
  end
14
14
  end
15
15
 
16
16
  def Generate(*args) # rubocop:disable Style/MethodName
17
17
  n = args.shift if args.first.is_a?(Numeric)
18
18
  factory = args.shift
19
- factory_girl_args = args.shift || {}
19
+ factory_bot_args = args.shift || {}
20
20
 
21
21
  if n
22
22
  [].tap do |collection|
23
- n.times.each { collection << FactoryGirl.create(factory.to_s.singularize.to_sym, factory_girl_args) }
23
+ n.times.each { collection << FactoryBot.create(factory.to_s.singularize.to_sym, factory_bot_args) }
24
24
  end
25
25
  else
26
- FactoryGirl.create(factory.to_s.singularize.to_sym, factory_girl_args)
26
+ FactoryBot.create(factory.to_s.singularize.to_sym, factory_bot_args)
27
27
  end
28
28
  end
29
29
  end
@@ -295,6 +295,30 @@ def should_support_postgresql_upsert_functionality
295
295
  end
296
296
 
297
297
  context "using a hash" do
298
+ context "with :columns :all" do
299
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
300
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Jane Doe", "janedoe@example.com", 57]] }
301
+
302
+ macro(:perform_import) do |*opts|
303
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id, columns: :all }, validate: false)
304
+ end
305
+
306
+ setup do
307
+ values = [[99, "Book", "John Doe", "john@doe.com", 17, 3]]
308
+ Topic.import columns + ['replies_count'], values, validate: false
309
+ end
310
+
311
+ it "should update all specified columns" do
312
+ perform_import
313
+ updated_topic = Topic.find(99)
314
+ assert_equal 'Book - 2nd Edition', updated_topic.title
315
+ assert_equal 'Jane Doe', updated_topic.author_name
316
+ assert_equal 'janedoe@example.com', updated_topic.author_email_address
317
+ assert_equal 57, updated_topic.parent_id
318
+ assert_equal 3, updated_topic.replies_count
319
+ end
320
+ end
321
+
298
322
  context "with :columns a hash" do
299
323
  let(:columns) { %w( id title author_name author_email_address parent_id ) }
300
324
  let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
@@ -312,7 +336,7 @@ def should_support_postgresql_upsert_functionality
312
336
  it "should not modify the passed in :on_duplicate_key_update columns array" do
313
337
  assert_nothing_raised do
314
338
  columns = %w(title author_name).freeze
315
- Topic.import columns, [%w(foo, bar)], on_duplicate_key_update: { columns: columns }
339
+ Topic.import columns, [%w(foo, bar)], { on_duplicate_key_update: { columns: columns }.freeze }.freeze
316
340
  end
317
341
  end
318
342
 
@@ -213,6 +213,30 @@ def should_support_basic_on_duplicate_key_update
213
213
  end
214
214
 
215
215
  context "with :on_duplicate_key_update" do
216
+ describe 'using :all' do
217
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
218
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Jane Doe", "janedoe@example.com", 57]] }
219
+
220
+ macro(:perform_import) do |*opts|
221
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: :all, validate: false)
222
+ end
223
+
224
+ setup do
225
+ values = [[99, "Book", "John Doe", "john@doe.com", 17, 3]]
226
+ Topic.import columns + ['replies_count'], values, validate: false
227
+ end
228
+
229
+ it 'updates all specified columns' do
230
+ perform_import
231
+ updated_topic = Topic.find(99)
232
+ assert_equal 'Book - 2nd Edition', updated_topic.title
233
+ assert_equal 'Jane Doe', updated_topic.author_name
234
+ assert_equal 'janedoe@example.com', updated_topic.author_email_address
235
+ assert_equal 57, updated_topic.parent_id
236
+ assert_equal 3, updated_topic.replies_count
237
+ end
238
+ end
239
+
216
240
  describe "argument safety" do
217
241
  it "should not modify the passed in :on_duplicate_key_update array" do
218
242
  assert_nothing_raised do
@@ -11,7 +11,7 @@ def should_support_recursive_import
11
11
  let(:num_chapters) { 18 }
12
12
  let(:num_endnotes) { 24 }
13
13
 
14
- let(:new_question_with_rule) { FactoryGirl.build :question, :with_rule }
14
+ let(:new_question_with_rule) { FactoryBot.build :question, :with_rule }
15
15
 
16
16
  it 'imports top level' do
17
17
  assert_difference "Topic.count", +num_topics do
@@ -1,6 +1,8 @@
1
1
  # encoding: UTF-8
2
2
  def should_support_sqlite3_import_functionality
3
- should_support_on_duplicate_key_ignore
3
+ if ActiveRecord::Base.connection.supports_on_duplicate_key_update?
4
+ should_support_sqlite_upsert_functionality
5
+ end
4
6
 
5
7
  describe "#supports_imports?" do
6
8
  context "and SQLite is 3.7.11 or higher" do
@@ -49,18 +51,193 @@ def should_support_sqlite3_import_functionality
49
51
  assert_equal 2500, Topic.count, "Failed to insert all records. Make sure you have a supported version of SQLite3 (3.7.11 or higher) installed"
50
52
  end
51
53
  end
54
+ end
55
+ end
56
+
57
+ def should_support_sqlite_upsert_functionality
58
+ should_support_basic_on_duplicate_key_update
59
+ should_support_on_duplicate_key_ignore
60
+
61
+ describe "#import" do
62
+ extend ActiveSupport::TestCase::ImportAssertions
63
+
64
+ macro(:perform_import) { raise "supply your own #perform_import in a context below" }
65
+ macro(:updated_topic) { Topic.find(@topic.id) }
66
+
67
+ context "with :on_duplicate_key_ignore and validation checks turned off" do
68
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
69
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
70
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
71
+
72
+ setup do
73
+ Topic.import columns, values, validate: false
74
+ end
75
+
76
+ it "should not update any records" do
77
+ result = Topic.import columns, updated_values, on_duplicate_key_ignore: true, validate: false
78
+ assert_equal [], result.ids
79
+ end
80
+ end
81
+
82
+ context "with :on_duplicate_key_update and validation checks turned off" do
83
+ asssertion_group(:should_support_on_duplicate_key_update) do
84
+ should_not_update_fields_not_mentioned
85
+ should_update_foreign_keys
86
+ should_not_update_created_at_on_timestamp_columns
87
+ should_update_updated_at_on_timestamp_columns
88
+ end
89
+
90
+ context "using a hash" do
91
+ context "with :columns a hash" do
92
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
93
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
94
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
95
+
96
+ macro(:perform_import) do |*opts|
97
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id, columns: update_columns }, validate: false)
98
+ end
99
+
100
+ setup do
101
+ Topic.import columns, values, validate: false
102
+ @topic = Topic.find 99
103
+ end
104
+
105
+ it "should not modify the passed in :on_duplicate_key_update columns array" do
106
+ assert_nothing_raised do
107
+ columns = %w(title author_name).freeze
108
+ Topic.import columns, [%w(foo, bar)], on_duplicate_key_update: { columns: columns }
109
+ end
110
+ end
111
+
112
+ context "using string hash map" do
113
+ let(:update_columns) { { "title" => "title", "author_email_address" => "author_email_address", "parent_id" => "parent_id" } }
114
+ should_support_on_duplicate_key_update
115
+ should_update_fields_mentioned
116
+ end
117
+
118
+ context "using string hash map, but specifying column mismatches" do
119
+ let(:update_columns) { { "title" => "author_email_address", "author_email_address" => "title", "parent_id" => "parent_id" } }
120
+ should_support_on_duplicate_key_update
121
+ should_update_fields_mentioned_with_hash_mappings
122
+ end
123
+
124
+ context "using symbol hash map" do
125
+ let(:update_columns) { { title: :title, author_email_address: :author_email_address, parent_id: :parent_id } }
126
+ should_support_on_duplicate_key_update
127
+ should_update_fields_mentioned
128
+ end
129
+
130
+ context "using symbol hash map, but specifying column mismatches" do
131
+ let(:update_columns) { { title: :author_email_address, author_email_address: :title, parent_id: :parent_id } }
132
+ should_support_on_duplicate_key_update
133
+ should_update_fields_mentioned_with_hash_mappings
134
+ end
135
+ end
136
+
137
+ context 'with :index_predicate' do
138
+ let(:columns) { %w( id device_id alarm_type status metadata ) }
139
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
140
+ let(:updated_values) { [[99, 17, 1, 2, 'bar']] }
141
+
142
+ macro(:perform_import) do |*opts|
143
+ Alarm.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: [:device_id, :alarm_type], index_predicate: 'status <> 0', columns: [:status] }, validate: false)
144
+ end
145
+
146
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
147
+
148
+ setup do
149
+ Alarm.import columns, values, validate: false
150
+ @alarm = Alarm.find 99
151
+ end
152
+
153
+ context 'supports on duplicate key update for partial indexes' do
154
+ it 'should not update created_at timestamp columns' do
155
+ Timecop.freeze Chronic.parse("5 minutes from now") do
156
+ perform_import
157
+ assert_in_delta @alarm.created_at.to_i, updated_alarm.created_at.to_i, 1
158
+ end
159
+ end
160
+
161
+ it 'should update updated_at timestamp columns' do
162
+ time = Chronic.parse("5 minutes from now")
163
+ Timecop.freeze time do
164
+ perform_import
165
+ assert_in_delta time.to_i, updated_alarm.updated_at.to_i, 1
166
+ end
167
+ end
168
+
169
+ it 'should not update fields not mentioned' do
170
+ perform_import
171
+ assert_equal 'foo', updated_alarm.metadata
172
+ end
173
+
174
+ it 'should update fields mentioned with hash mappings' do
175
+ perform_import
176
+ assert_equal 2, updated_alarm.status
177
+ end
178
+ end
179
+ end
180
+
181
+ context 'with :condition' do
182
+ let(:columns) { %w( id device_id alarm_type status metadata) }
183
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
184
+ let(:updated_values) { [[99, 17, 1, 1, 'bar']] }
185
+
186
+ macro(:perform_import) do |*opts|
187
+ Alarm.import(
188
+ columns,
189
+ updated_values,
190
+ opts.extract_options!.merge(
191
+ on_duplicate_key_update: {
192
+ conflict_target: [:id],
193
+ condition: "alarms.metadata NOT LIKE '%foo%'",
194
+ columns: [:metadata]
195
+ },
196
+ validate: false
197
+ )
198
+ )
199
+ end
200
+
201
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
202
+
203
+ setup do
204
+ Alarm.import columns, values, validate: false
205
+ @alarm = Alarm.find 99
206
+ end
207
+
208
+ it 'should not update fields not matched' do
209
+ perform_import
210
+ assert_equal 'foo', updated_alarm.metadata
211
+ end
212
+ end
213
+
214
+ context "with no :conflict_target" do
215
+ context "with no primary key" do
216
+ it "raises ArgumentError" do
217
+ error = assert_raises ArgumentError do
218
+ Rule.import Build(3, :rules), on_duplicate_key_update: [:condition_text], validate: false
219
+ end
220
+ assert_match(/Expected :conflict_target to be specified/, error.message)
221
+ end
222
+ end
223
+ end
224
+
225
+ context "with no :columns" do
226
+ let(:columns) { %w( id title author_name author_email_address ) }
227
+ let(:values) { [[100, "Book", "John Doe", "john@doe.com"]] }
228
+ let(:updated_values) { [[100, "Title Should Not Change", "Author Should Not Change", "john@nogo.com"]] }
52
229
 
53
- context "with :on_duplicate_key_update" do
54
- let(:topics) { Build(1, :topics) }
230
+ macro(:perform_import) do |*opts|
231
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id }, validate: false)
232
+ end
55
233
 
56
- it "should log a warning message" do
57
- log = StringIO.new
58
- logger = Logger.new(log)
59
- logger.level = Logger::WARN
60
- ActiveRecord::Base.connection.stubs(:logger).returns(logger)
234
+ setup do
235
+ Topic.import columns, values, validate: false
236
+ @topic = Topic.find 100
237
+ end
61
238
 
62
- Topic.import topics, on_duplicate_key_update: true
63
- assert_match(/Ignoring on_duplicate_key_update/, log.string)
239
+ should_update_updated_at_on_timestamp_columns
240
+ end
64
241
  end
65
242
  end
66
243
  end
data/test/test_helper.rb CHANGED
@@ -58,7 +58,7 @@ ActiveSupport::Notifications.subscribe(/active_record.sql/) do |_, _, _, _, hsh|
58
58
  ActiveRecord::Base.logger.info hsh[:sql]
59
59
  end
60
60
 
61
- require "factory_girl"
61
+ require "factory_bot"
62
62
  Dir[File.dirname(__FILE__) + "/support/**/*.rb"].each { |file| require file }
63
63
 
64
64
  # Load base/generic schema
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-import
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.25.0
4
+ version: 0.26.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Zach Dennis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2018-07-10 00:00:00.000000000 Z
11
+ date: 2018-10-05 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -146,6 +146,7 @@ files:
146
146
  - test/schema/mysql2_schema.rb
147
147
  - test/schema/postgis_schema.rb
148
148
  - test/schema/postgresql_schema.rb
149
+ - test/schema/sqlite3_schema.rb
149
150
  - test/schema/version.rb
150
151
  - test/sqlite3/import_test.rb
151
152
  - test/support/active_support/test_case_extensions.rb
@@ -183,7 +184,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
183
184
  version: '0'
184
185
  requirements: []
185
186
  rubyforge_project:
186
- rubygems_version: 2.6.13
187
+ rubygems_version: 2.7.7
187
188
  signing_key:
188
189
  specification_version: 4
189
190
  summary: Bulk insert extension for ActiveRecord
@@ -236,6 +237,7 @@ test_files:
236
237
  - test/schema/mysql2_schema.rb
237
238
  - test/schema/postgis_schema.rb
238
239
  - test/schema/postgresql_schema.rb
240
+ - test/schema/sqlite3_schema.rb
239
241
  - test/schema/version.rb
240
242
  - test/sqlite3/import_test.rb
241
243
  - test/support/active_support/test_case_extensions.rb