activerecord-import 1.0.3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (123) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +32 -0
  3. data/.rubocop.yml +49 -0
  4. data/.rubocop_todo.yml +36 -0
  5. data/.travis.yml +74 -0
  6. data/Brewfile +3 -0
  7. data/CHANGELOG.md +430 -0
  8. data/Gemfile +59 -0
  9. data/LICENSE +56 -0
  10. data/README.markdown +619 -0
  11. data/Rakefile +68 -0
  12. data/activerecord-import.gemspec +23 -0
  13. data/benchmarks/README +32 -0
  14. data/benchmarks/benchmark.rb +68 -0
  15. data/benchmarks/lib/base.rb +138 -0
  16. data/benchmarks/lib/cli_parser.rb +107 -0
  17. data/benchmarks/lib/float.rb +15 -0
  18. data/benchmarks/lib/mysql2_benchmark.rb +19 -0
  19. data/benchmarks/lib/output_to_csv.rb +19 -0
  20. data/benchmarks/lib/output_to_html.rb +64 -0
  21. data/benchmarks/models/test_innodb.rb +3 -0
  22. data/benchmarks/models/test_memory.rb +3 -0
  23. data/benchmarks/models/test_myisam.rb +3 -0
  24. data/benchmarks/schema/mysql_schema.rb +16 -0
  25. data/gemfiles/3.2.gemfile +2 -0
  26. data/gemfiles/4.0.gemfile +2 -0
  27. data/gemfiles/4.1.gemfile +2 -0
  28. data/gemfiles/4.2.gemfile +2 -0
  29. data/gemfiles/5.0.gemfile +2 -0
  30. data/gemfiles/5.1.gemfile +2 -0
  31. data/gemfiles/5.2.gemfile +2 -0
  32. data/gemfiles/6.0.gemfile +1 -0
  33. data/gemfiles/6.1.gemfile +1 -0
  34. data/lib/activerecord-import.rb +6 -0
  35. data/lib/activerecord-import/active_record/adapters/abstract_adapter.rb +9 -0
  36. data/lib/activerecord-import/active_record/adapters/jdbcmysql_adapter.rb +6 -0
  37. data/lib/activerecord-import/active_record/adapters/jdbcpostgresql_adapter.rb +6 -0
  38. data/lib/activerecord-import/active_record/adapters/jdbcsqlite3_adapter.rb +6 -0
  39. data/lib/activerecord-import/active_record/adapters/mysql2_adapter.rb +6 -0
  40. data/lib/activerecord-import/active_record/adapters/postgresql_adapter.rb +6 -0
  41. data/lib/activerecord-import/active_record/adapters/seamless_database_pool_adapter.rb +7 -0
  42. data/lib/activerecord-import/active_record/adapters/sqlite3_adapter.rb +6 -0
  43. data/lib/activerecord-import/adapters/abstract_adapter.rb +66 -0
  44. data/lib/activerecord-import/adapters/em_mysql2_adapter.rb +5 -0
  45. data/lib/activerecord-import/adapters/mysql2_adapter.rb +5 -0
  46. data/lib/activerecord-import/adapters/mysql_adapter.rb +129 -0
  47. data/lib/activerecord-import/adapters/postgresql_adapter.rb +217 -0
  48. data/lib/activerecord-import/adapters/sqlite3_adapter.rb +180 -0
  49. data/lib/activerecord-import/base.rb +43 -0
  50. data/lib/activerecord-import/import.rb +1059 -0
  51. data/lib/activerecord-import/mysql2.rb +7 -0
  52. data/lib/activerecord-import/postgresql.rb +7 -0
  53. data/lib/activerecord-import/sqlite3.rb +7 -0
  54. data/lib/activerecord-import/synchronize.rb +66 -0
  55. data/lib/activerecord-import/value_sets_parser.rb +77 -0
  56. data/lib/activerecord-import/version.rb +5 -0
  57. data/test/adapters/jdbcmysql.rb +1 -0
  58. data/test/adapters/jdbcpostgresql.rb +1 -0
  59. data/test/adapters/jdbcsqlite3.rb +1 -0
  60. data/test/adapters/makara_postgis.rb +1 -0
  61. data/test/adapters/mysql2.rb +1 -0
  62. data/test/adapters/mysql2_makara.rb +1 -0
  63. data/test/adapters/mysql2spatial.rb +1 -0
  64. data/test/adapters/postgis.rb +1 -0
  65. data/test/adapters/postgresql.rb +1 -0
  66. data/test/adapters/postgresql_makara.rb +1 -0
  67. data/test/adapters/seamless_database_pool.rb +1 -0
  68. data/test/adapters/spatialite.rb +1 -0
  69. data/test/adapters/sqlite3.rb +1 -0
  70. data/test/database.yml.sample +52 -0
  71. data/test/import_test.rb +903 -0
  72. data/test/jdbcmysql/import_test.rb +5 -0
  73. data/test/jdbcpostgresql/import_test.rb +4 -0
  74. data/test/jdbcsqlite3/import_test.rb +4 -0
  75. data/test/makara_postgis/import_test.rb +8 -0
  76. data/test/models/account.rb +3 -0
  77. data/test/models/alarm.rb +2 -0
  78. data/test/models/bike_maker.rb +7 -0
  79. data/test/models/book.rb +9 -0
  80. data/test/models/car.rb +3 -0
  81. data/test/models/chapter.rb +4 -0
  82. data/test/models/dictionary.rb +4 -0
  83. data/test/models/discount.rb +3 -0
  84. data/test/models/end_note.rb +4 -0
  85. data/test/models/group.rb +3 -0
  86. data/test/models/promotion.rb +3 -0
  87. data/test/models/question.rb +3 -0
  88. data/test/models/rule.rb +3 -0
  89. data/test/models/tag.rb +4 -0
  90. data/test/models/topic.rb +23 -0
  91. data/test/models/user.rb +3 -0
  92. data/test/models/user_token.rb +4 -0
  93. data/test/models/vendor.rb +7 -0
  94. data/test/models/widget.rb +24 -0
  95. data/test/mysql2/import_test.rb +5 -0
  96. data/test/mysql2_makara/import_test.rb +6 -0
  97. data/test/mysqlspatial2/import_test.rb +6 -0
  98. data/test/postgis/import_test.rb +8 -0
  99. data/test/postgresql/import_test.rb +4 -0
  100. data/test/schema/generic_schema.rb +194 -0
  101. data/test/schema/jdbcpostgresql_schema.rb +1 -0
  102. data/test/schema/mysql2_schema.rb +19 -0
  103. data/test/schema/postgis_schema.rb +1 -0
  104. data/test/schema/postgresql_schema.rb +47 -0
  105. data/test/schema/sqlite3_schema.rb +13 -0
  106. data/test/schema/version.rb +10 -0
  107. data/test/sqlite3/import_test.rb +4 -0
  108. data/test/support/active_support/test_case_extensions.rb +75 -0
  109. data/test/support/assertions.rb +73 -0
  110. data/test/support/factories.rb +64 -0
  111. data/test/support/generate.rb +29 -0
  112. data/test/support/mysql/import_examples.rb +98 -0
  113. data/test/support/postgresql/import_examples.rb +563 -0
  114. data/test/support/shared_examples/on_duplicate_key_ignore.rb +43 -0
  115. data/test/support/shared_examples/on_duplicate_key_update.rb +368 -0
  116. data/test/support/shared_examples/recursive_import.rb +216 -0
  117. data/test/support/sqlite3/import_examples.rb +231 -0
  118. data/test/synchronize_test.rb +41 -0
  119. data/test/test_helper.rb +75 -0
  120. data/test/travis/database.yml +66 -0
  121. data/test/value_sets_bytes_parser_test.rb +104 -0
  122. data/test/value_sets_records_parser_test.rb +32 -0
  123. metadata +259 -0
@@ -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
@@ -0,0 +1,10 @@
1
+ class SchemaInfo < ActiveRecord::Base
2
+ if respond_to?(:table_name=)
3
+ self.table_name = 'schema_info'
4
+ else
5
+ # this is becoming deprecated in ActiveRecord but not all adapters supported it
6
+ # at this time
7
+ set_table_name 'schema_info'
8
+ end
9
+ VERSION = 12
10
+ end
@@ -0,0 +1,4 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../test_helper')
2
+ require File.expand_path(File.dirname(__FILE__) + '/../support/sqlite3/import_examples')
3
+
4
+ should_support_sqlite3_import_functionality
@@ -0,0 +1,75 @@
1
+ class ActiveSupport::TestCase
2
+ include ActiveRecord::TestFixtures
3
+
4
+ if ENV['AR_VERSION'].to_f >= 5.0
5
+ self.use_transactional_tests = true
6
+ else
7
+ self.use_transactional_fixtures = true
8
+ end
9
+
10
+ class << self
11
+ def requires_active_record_version(version_string, &blk)
12
+ return unless Gem::Dependency.new('', version_string).match?('', ActiveRecord::VERSION::STRING)
13
+ instance_eval(&blk)
14
+ end
15
+
16
+ def assertion(name, &block)
17
+ mc = class << self; self; end
18
+ mc.class_eval do
19
+ define_method(name) do
20
+ it(name, &block)
21
+ end
22
+ end
23
+ end
24
+
25
+ def asssertion_group(name, &block)
26
+ mc = class << self; self; end
27
+ mc.class_eval do
28
+ define_method(name, &block)
29
+ end
30
+ end
31
+
32
+ def macro(name, &block)
33
+ class_eval do
34
+ define_method(name, &block)
35
+ end
36
+ end
37
+
38
+ def describe(description, toplevel = nil, &blk)
39
+ text = toplevel ? description : "#{name} #{description}"
40
+ klass = Class.new(self)
41
+
42
+ klass.class_eval <<-RUBY_EVAL
43
+ def self.name
44
+ "#{text}"
45
+ end
46
+ RUBY_EVAL
47
+
48
+ # do not inherit test methods from the superclass
49
+ klass.class_eval do
50
+ instance_methods.grep(/^test.+/) do |method|
51
+ undef_method method
52
+ end
53
+ end
54
+
55
+ klass.instance_eval(&blk)
56
+ end
57
+ alias context describe
58
+
59
+ def let(name, &blk)
60
+ define_method(name) do
61
+ instance_variable_name = "@__let_#{name}"
62
+ return instance_variable_get(instance_variable_name) if instance_variable_defined?(instance_variable_name)
63
+ instance_variable_set(instance_variable_name, instance_eval(&blk))
64
+ end
65
+ end
66
+
67
+ def it(description, &blk)
68
+ define_method("test_#{name}_#{description}", &blk)
69
+ end
70
+ end
71
+ end
72
+
73
+ def describe(description, &blk)
74
+ ActiveSupport::TestCase.describe(description, true, &blk)
75
+ end
@@ -0,0 +1,73 @@
1
+ class ActiveSupport::TestCase
2
+ module ImportAssertions
3
+ def self.extended(klass)
4
+ klass.instance_eval do
5
+ assertion(:should_not_update_created_at_on_timestamp_columns) do
6
+ Timecop.freeze Chronic.parse("5 minutes from now") do
7
+ perform_import
8
+ assert_in_delta @topic.created_at.to_i, updated_topic.created_at.to_i, 1
9
+ assert_in_delta @topic.created_on.to_i, updated_topic.created_on.to_i, 1
10
+ end
11
+ end
12
+
13
+ assertion(:should_update_updated_at_on_timestamp_columns) do
14
+ time = Chronic.parse("5 minutes from now")
15
+ Timecop.freeze time do
16
+ perform_import
17
+ assert_in_delta time.to_i, updated_topic.updated_at.to_i, 1
18
+ assert_in_delta time.to_i, updated_topic.updated_on.to_i, 1
19
+ end
20
+ end
21
+
22
+ assertion(:should_not_update_updated_at_on_timestamp_columns) do
23
+ time = Chronic.parse("5 minutes from now")
24
+ Timecop.freeze time do
25
+ perform_import
26
+ assert_in_delta @topic.updated_at.to_i, updated_topic.updated_at.to_i, 1
27
+ assert_in_delta @topic.updated_on.to_i, updated_topic.updated_on.to_i, 1
28
+ end
29
+ end
30
+
31
+ assertion(:should_not_update_timestamps) do
32
+ Timecop.freeze Chronic.parse("5 minutes from now") do
33
+ perform_import timestamps: false
34
+ assert_in_delta @topic.created_at.to_i, updated_topic.created_at.to_i, 1
35
+ assert_in_delta @topic.created_on.to_i, updated_topic.created_on.to_i, 1
36
+ assert_in_delta @topic.updated_at.to_i, updated_topic.updated_at.to_i, 1
37
+ assert_in_delta @topic.updated_on.to_i, updated_topic.updated_on.to_i, 1
38
+ end
39
+ end
40
+
41
+ assertion(:should_not_update_fields_not_mentioned) do
42
+ assert_equal "John Doe", updated_topic.author_name
43
+ end
44
+
45
+ assertion(:should_update_fields_mentioned) do
46
+ perform_import
47
+ assert_equal "Book - 2nd Edition", updated_topic.title
48
+ assert_equal "johndoe@example.com", updated_topic.author_email_address
49
+ end
50
+
51
+ assertion(:should_raise_update_fields_mentioned) do
52
+ assert_raise ActiveRecord::RecordNotUnique do
53
+ perform_import
54
+ end
55
+
56
+ assert_equal "Book", updated_topic.title
57
+ assert_equal "john@doe.com", updated_topic.author_email_address
58
+ end
59
+
60
+ assertion(:should_update_fields_mentioned_with_hash_mappings) do
61
+ perform_import
62
+ assert_equal "johndoe@example.com", updated_topic.title
63
+ assert_equal "Book - 2nd Edition", updated_topic.author_email_address
64
+ end
65
+
66
+ assertion(:should_update_foreign_keys) do
67
+ perform_import
68
+ assert_equal 57, updated_topic.parent_id
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,64 @@
1
+ FactoryBot.define do
2
+ sequence(:book_title) { |n| "Book #{n}" }
3
+ sequence(:chapter_title) { |n| "Chapter #{n}" }
4
+ sequence(:end_note) { |n| "Endnote #{n}" }
5
+
6
+ factory :group do
7
+ sequence(:order) { |n| "Order #{n}" }
8
+ end
9
+
10
+ factory :invalid_topic, class: "Topic" do
11
+ sequence(:title) { |n| "Title #{n}" }
12
+ author_name { nil }
13
+ end
14
+
15
+ factory :topic do
16
+ sequence(:title) { |n| "Title #{n}" }
17
+ sequence(:author_name) { |n| "Author #{n}" }
18
+ sequence(:content) { |n| "Content #{n}" }
19
+ end
20
+
21
+ factory :widget do
22
+ sequence(:w_id) { |n| n }
23
+ end
24
+
25
+ factory :question do
26
+ sequence(:body) { |n| "Text #{n}" }
27
+
28
+ trait :with_rule do
29
+ after(:build) do |question|
30
+ question.build_rule(FactoryBot.attributes_for(:rule))
31
+ end
32
+ end
33
+ end
34
+
35
+ factory :rule do
36
+ sequence(:id) { |n| n }
37
+ sequence(:condition_text) { |n| "q_#{n}_#{n}" }
38
+ end
39
+
40
+ factory :topic_with_book, parent: :topic do
41
+ after(:build) do |topic|
42
+ 2.times do
43
+ book = topic.books.build(title: FactoryBot.generate(:book_title), author_name: 'Stephen King')
44
+ 3.times do
45
+ book.chapters.build(title: FactoryBot.generate(:chapter_title))
46
+ end
47
+
48
+ 4.times do
49
+ book.end_notes.build(note: FactoryBot.generate(:end_note))
50
+ end
51
+ end
52
+ end
53
+ end
54
+
55
+ factory :book do
56
+ title { 'Tortilla Flat' }
57
+ author_name { 'John Steinbeck' }
58
+ end
59
+
60
+ factory :car do
61
+ sequence(:Name) { |n| n }
62
+ sequence(:Features) { |n| "Feature #{n}" }
63
+ end
64
+ end
@@ -0,0 +1,29 @@
1
+ class ActiveSupport::TestCase
2
+ def Build(*args) # rubocop:disable Style/MethodName
3
+ n = args.shift if args.first.is_a?(Numeric)
4
+ factory = args.shift
5
+ factory_bot_args = args.shift || {}
6
+
7
+ if n
8
+ [].tap do |collection|
9
+ n.times.each { collection << FactoryBot.build(factory.to_s.singularize.to_sym, factory_bot_args) }
10
+ end
11
+ else
12
+ FactoryBot.build(factory.to_s.singularize.to_sym, factory_bot_args)
13
+ end
14
+ end
15
+
16
+ def Generate(*args) # rubocop:disable Style/MethodName
17
+ n = args.shift if args.first.is_a?(Numeric)
18
+ factory = args.shift
19
+ factory_bot_args = args.shift || {}
20
+
21
+ if n
22
+ [].tap do |collection|
23
+ n.times.each { collection << FactoryBot.create(factory.to_s.singularize.to_sym, factory_bot_args) }
24
+ end
25
+ else
26
+ FactoryBot.create(factory.to_s.singularize.to_sym, factory_bot_args)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,98 @@
1
+ # encoding: UTF-8
2
+ def should_support_mysql_import_functionality
3
+ # Forcefully disable strict mode for this session.
4
+ ActiveRecord::Base.connection.execute "set sql_mode='STRICT_ALL_TABLES'"
5
+
6
+ should_support_basic_on_duplicate_key_update
7
+ should_support_on_duplicate_key_ignore
8
+
9
+ describe "#import" do
10
+ context "with :on_duplicate_key_update and validation checks turned off" do
11
+ extend ActiveSupport::TestCase::ImportAssertions
12
+
13
+ asssertion_group(:should_support_on_duplicate_key_update) do
14
+ should_not_update_fields_not_mentioned
15
+ should_update_foreign_keys
16
+ should_not_update_created_at_on_timestamp_columns
17
+ should_update_updated_at_on_timestamp_columns
18
+ end
19
+
20
+ macro(:perform_import) { raise "supply your own #perform_import in a context below" }
21
+ macro(:updated_topic) { Topic.find(@topic.id) }
22
+
23
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
24
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
25
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
26
+
27
+ macro(:perform_import) do |*opts|
28
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: update_columns, validate: false)
29
+ end
30
+
31
+ setup do
32
+ Topic.import columns, values, validate: false
33
+ @topic = Topic.find 99
34
+ end
35
+
36
+ context "using string hash map" do
37
+ let(:update_columns) { { "title" => "title", "author_email_address" => "author_email_address", "parent_id" => "parent_id" } }
38
+ should_support_on_duplicate_key_update
39
+ should_update_fields_mentioned
40
+ end
41
+
42
+ context "using string hash map, but specifying column mismatches" do
43
+ let(:update_columns) { { "title" => "author_email_address", "author_email_address" => "title", "parent_id" => "parent_id" } }
44
+ should_support_on_duplicate_key_update
45
+ should_update_fields_mentioned_with_hash_mappings
46
+ end
47
+
48
+ context "using symbol hash map" do
49
+ let(:update_columns) { { title: :title, author_email_address: :author_email_address, parent_id: :parent_id } }
50
+ should_support_on_duplicate_key_update
51
+ should_update_fields_mentioned
52
+ end
53
+
54
+ context "using symbol hash map, but specifying column mismatches" do
55
+ let(:update_columns) { { title: :author_email_address, author_email_address: :title, parent_id: :parent_id } }
56
+ should_support_on_duplicate_key_update
57
+ should_update_fields_mentioned_with_hash_mappings
58
+ end
59
+ end
60
+
61
+ context "with :synchronization option" do
62
+ let(:topics) { [] }
63
+ let(:values) { [[topics.first.id, "Jerry Carter", "title1"], [topics.last.id, "Chad Fowler", "title2"]] }
64
+ let(:columns) { %w(id author_name title) }
65
+
66
+ setup do
67
+ topics << Topic.create!(title: "LDAP", author_name: "Big Bird", content: "Putting Directories to Work.")
68
+ topics << Topic.create!(title: "Rails Recipes", author_name: "Elmo", content: "A trusted collection of solutions.")
69
+ end
70
+
71
+ it "synchronizes passed in ActiveRecord model instances with the data just imported" do
72
+ columns2update = ['author_name']
73
+
74
+ expected_count = Topic.count
75
+ Topic.import( columns, values,
76
+ validate: false,
77
+ on_duplicate_key_update: columns2update,
78
+ synchronize: topics )
79
+
80
+ assert_equal expected_count, Topic.count, "no new records should have been created!"
81
+ assert_equal "Jerry Carter", topics.first.author_name, "wrong author!"
82
+ assert_equal "Chad Fowler", topics.last.author_name, "wrong author!"
83
+ end
84
+ end
85
+
86
+ if ENV['AR_VERSION'].to_f >= 5.1
87
+ context "with virtual columns" do
88
+ let(:books) { [Book.new(author_name: "foo", title: "bar")] }
89
+
90
+ it "ignores virtual columns and creates record" do
91
+ assert_difference "Book.count", +1 do
92
+ Book.import books
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,563 @@
1
+ # encoding: UTF-8
2
+ def should_support_postgresql_import_functionality
3
+ should_support_recursive_import
4
+
5
+ if ActiveRecord::Base.connection.supports_on_duplicate_key_update?
6
+ should_support_postgresql_upsert_functionality
7
+ end
8
+
9
+ describe "#supports_imports?" do
10
+ it "should support import" do
11
+ assert ActiveRecord::Base.supports_import?
12
+ end
13
+ end
14
+
15
+ describe "#import" do
16
+ it "should import with a single insert" do
17
+ # see ActiveRecord::ConnectionAdapters::AbstractAdapter test for more specifics
18
+ assert_difference "Topic.count", +10 do
19
+ result = Topic.import Build(3, :topics)
20
+ assert_equal 1, result.num_inserts
21
+
22
+ result = Topic.import Build(7, :topics)
23
+ assert_equal 1, result.num_inserts
24
+ end
25
+ end
26
+
27
+ context "setting attributes and marking clean" do
28
+ let(:topic) { Build(:topics) }
29
+
30
+ setup { Topic.import([topic]) }
31
+
32
+ it "assigns ids" do
33
+ assert topic.id.present?
34
+ end
35
+
36
+ it "marks models as clean" do
37
+ assert !topic.changed?
38
+ end
39
+
40
+ if ENV['AR_VERSION'].to_f > 4.1
41
+ it "moves the dirty changes to previous_changes" do
42
+ assert topic.previous_changes.present?
43
+ end
44
+ end
45
+
46
+ it "marks models as persisted" do
47
+ assert !topic.new_record?
48
+ assert topic.persisted?
49
+ end
50
+
51
+ it "assigns timestamps" do
52
+ assert topic.created_at.present?
53
+ assert topic.updated_at.present?
54
+ end
55
+ end
56
+
57
+ describe "with query cache enabled" do
58
+ setup do
59
+ unless ActiveRecord::Base.connection.query_cache_enabled
60
+ ActiveRecord::Base.connection.enable_query_cache!
61
+ @disable_cache_on_teardown = true
62
+ end
63
+ end
64
+
65
+ it "clears cache on insert" do
66
+ before_import = Topic.all.to_a
67
+
68
+ Topic.import(Build(2, :topics), validate: false)
69
+
70
+ after_import = Topic.all.to_a
71
+ assert_equal 2, after_import.size - before_import.size
72
+ end
73
+
74
+ teardown do
75
+ if @disable_cache_on_teardown
76
+ ActiveRecord::Base.connection.disable_query_cache!
77
+ end
78
+ end
79
+ end
80
+
81
+ describe "no_returning" do
82
+ let(:books) { [Book.new(author_name: "foo", title: "bar")] }
83
+
84
+ it "creates records" do
85
+ assert_difference "Book.count", +1 do
86
+ Book.import books, no_returning: true
87
+ end
88
+ end
89
+
90
+ it "returns no ids" do
91
+ assert_equal [], Book.import(books, no_returning: true).ids
92
+ end
93
+ end
94
+
95
+ describe "returning" do
96
+ let(:books) { [Book.new(author_name: "King", title: "It")] }
97
+ let(:result) { Book.import(books, returning: %w(author_name title)) }
98
+ let(:book_id) do
99
+ if RUBY_PLATFORM == 'java' || ENV['AR_VERSION'].to_i >= 5.0
100
+ books.first.id
101
+ else
102
+ books.first.id.to_s
103
+ end
104
+ end
105
+
106
+ it "creates records" do
107
+ assert_difference("Book.count", +1) { result }
108
+ end
109
+
110
+ it "returns ids" do
111
+ result
112
+ assert_equal [book_id], result.ids
113
+ end
114
+
115
+ it "returns specified columns" do
116
+ assert_equal [%w(King It)], result.results
117
+ end
118
+
119
+ context "when given an empty array" do
120
+ let(:result) { Book.import([], returning: %w(title)) }
121
+
122
+ setup { result }
123
+
124
+ it "returns empty arrays for ids and results" do
125
+ assert_equal [], result.ids
126
+ assert_equal [], result.results
127
+ end
128
+ end
129
+
130
+ context "when primary key and returning overlap" do
131
+ let(:result) { Book.import(books, returning: %w(id title)) }
132
+
133
+ setup { result }
134
+
135
+ it "returns ids" do
136
+ assert_equal [book_id], result.ids
137
+ end
138
+
139
+ it "returns specified columns" do
140
+ assert_equal [[book_id, 'It']], result.results
141
+ end
142
+ end
143
+
144
+ context "setting model attributes" do
145
+ let(:code) { 'abc' }
146
+ let(:discount) { 0.10 }
147
+ let(:original_promotion) do
148
+ Promotion.new(code: code, discount: discount)
149
+ end
150
+ let(:updated_promotion) do
151
+ Promotion.new(code: code, description: 'ABC discount')
152
+ end
153
+ let(:returning_columns) { %w(discount) }
154
+
155
+ setup do
156
+ Promotion.import([original_promotion])
157
+ Promotion.import([updated_promotion],
158
+ on_duplicate_key_update: { conflict_target: %i(code), columns: %i(description) },
159
+ returning: returning_columns)
160
+ end
161
+
162
+ it "sets model attributes" do
163
+ assert_equal updated_promotion.discount, discount
164
+ end
165
+
166
+ context "returning multiple columns" do
167
+ let(:returning_columns) { %w(discount description) }
168
+
169
+ it "sets model attributes" do
170
+ assert_equal updated_promotion.discount, discount
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
176
+
177
+ if ENV['AR_VERSION'].to_f >= 4.0
178
+ describe "with a uuid primary key" do
179
+ let(:vendor) { Vendor.new(name: "foo") }
180
+ let(:vendors) { [vendor] }
181
+
182
+ it "creates records" do
183
+ assert_difference "Vendor.count", +1 do
184
+ Vendor.import vendors
185
+ end
186
+ end
187
+
188
+ it "assigns an id to the model objects" do
189
+ Vendor.import vendors
190
+ assert_not_nil vendor.id
191
+ end
192
+ end
193
+
194
+ describe "with an assigned uuid primary key" do
195
+ let(:id) { SecureRandom.uuid }
196
+ let(:vendor) { Vendor.new(id: id, name: "foo") }
197
+ let(:vendors) { [vendor] }
198
+
199
+ it "creates records with correct id" do
200
+ assert_difference "Vendor.count", +1 do
201
+ Vendor.import vendors
202
+ end
203
+ assert_equal id, vendor.id
204
+ end
205
+ end
206
+ end
207
+
208
+ describe "with store accessor fields" do
209
+ if ENV['AR_VERSION'].to_f >= 4.0
210
+ it "imports values for json fields" do
211
+ vendors = [Vendor.new(name: 'Vendor 1', size: 100)]
212
+ assert_difference "Vendor.count", +1 do
213
+ Vendor.import vendors
214
+ end
215
+ assert_equal(100, Vendor.first.size)
216
+ end
217
+
218
+ it "imports values for hstore fields" do
219
+ vendors = [Vendor.new(name: 'Vendor 1', contact: 'John Smith')]
220
+ assert_difference "Vendor.count", +1 do
221
+ Vendor.import vendors
222
+ end
223
+ assert_equal('John Smith', Vendor.first.contact)
224
+ end
225
+ end
226
+
227
+ if ENV['AR_VERSION'].to_f >= 4.2
228
+ it "imports values for jsonb fields" do
229
+ vendors = [Vendor.new(name: 'Vendor 1', charge_code: '12345')]
230
+ assert_difference "Vendor.count", +1 do
231
+ Vendor.import vendors
232
+ end
233
+ assert_equal('12345', Vendor.first.charge_code)
234
+ end
235
+ end
236
+ end
237
+
238
+ if ENV['AR_VERSION'].to_f >= 4.2
239
+ describe "with serializable fields" do
240
+ it "imports default values as correct data type" do
241
+ vendors = [Vendor.new(name: 'Vendor 1')]
242
+ assert_difference "Vendor.count", +1 do
243
+ Vendor.import vendors
244
+ end
245
+ assert_equal({}, Vendor.first.json_data)
246
+ end
247
+ end
248
+
249
+ %w(json jsonb).each do |json_type|
250
+ describe "with pure #{json_type} fields" do
251
+ let(:data) { { a: :b } }
252
+ let(:json_field_name) { "pure_#{json_type}_data" }
253
+ it "imports the values from saved records" do
254
+ vendor = Vendor.create!(name: 'Vendor 1', json_field_name => data)
255
+
256
+ Vendor.import [vendor], on_duplicate_key_update: [json_field_name]
257
+ assert_equal(data.as_json, vendor.reload[json_field_name])
258
+ end
259
+ end
260
+ end
261
+ end
262
+
263
+ describe "with binary field" do
264
+ let(:binary_value) { "\xE0'c\xB2\xB0\xB3Bh\\\xC2M\xB1m\\I\xC4r".force_encoding('ASCII-8BIT') }
265
+ it "imports the correct values for binary fields" do
266
+ alarms = [Alarm.new(device_id: 1, alarm_type: 1, status: 1, secret_key: binary_value)]
267
+ assert_difference "Alarm.count", +1 do
268
+ Alarm.import alarms
269
+ end
270
+ assert_equal(binary_value, Alarm.first.secret_key)
271
+ end
272
+ end
273
+ end
274
+
275
+ def should_support_postgresql_upsert_functionality
276
+ should_support_basic_on_duplicate_key_update
277
+ should_support_on_duplicate_key_ignore
278
+
279
+ describe "#import" do
280
+ extend ActiveSupport::TestCase::ImportAssertions
281
+
282
+ macro(:perform_import) { raise "supply your own #perform_import in a context below" }
283
+ macro(:updated_topic) { Topic.find(@topic.id) }
284
+
285
+ context "with :on_duplicate_key_ignore and validation checks turned off" do
286
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
287
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
288
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
289
+
290
+ setup do
291
+ Topic.import columns, values, validate: false
292
+ end
293
+
294
+ it "should not update any records" do
295
+ result = Topic.import columns, updated_values, on_duplicate_key_ignore: true, validate: false
296
+ assert_equal [], result.ids
297
+ end
298
+ end
299
+
300
+ context "with :on_duplicate_key_ignore and :recursive enabled" do
301
+ let(:new_topic) { Build(1, :topic_with_book) }
302
+ let(:mixed_topics) { Build(1, :topic_with_book) + new_topic + Build(1, :topic_with_book) }
303
+
304
+ setup do
305
+ Topic.import new_topic, recursive: true
306
+ end
307
+
308
+ # Recursive import depends on the primary keys of the parent model being returned
309
+ # on insert. With on_duplicate_key_ignore enabled, not all ids will be returned
310
+ # and it is possible that a model will be assigned the wrong id and then its children
311
+ # would be associated with the wrong parent.
312
+ it ":on_duplicate_key_ignore is ignored" do
313
+ assert_raise ActiveRecord::RecordNotUnique do
314
+ Topic.import mixed_topics, recursive: true, on_duplicate_key_ignore: true, validate: false
315
+ end
316
+ end
317
+ end
318
+
319
+ context "with :on_duplicate_key_update and validation checks turned off" do
320
+ asssertion_group(:should_support_on_duplicate_key_update) do
321
+ should_not_update_fields_not_mentioned
322
+ should_update_foreign_keys
323
+ should_not_update_created_at_on_timestamp_columns
324
+ should_update_updated_at_on_timestamp_columns
325
+ end
326
+
327
+ context "using a hash" do
328
+ context "with :columns :all" do
329
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
330
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Jane Doe", "janedoe@example.com", 57]] }
331
+
332
+ macro(:perform_import) do |*opts|
333
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id, columns: :all }, validate: false)
334
+ end
335
+
336
+ setup do
337
+ values = [[99, "Book", "John Doe", "john@doe.com", 17, 3]]
338
+ Topic.import columns + ['replies_count'], values, validate: false
339
+ end
340
+
341
+ it "should update all specified columns" do
342
+ perform_import
343
+ updated_topic = Topic.find(99)
344
+ assert_equal 'Book - 2nd Edition', updated_topic.title
345
+ assert_equal 'Jane Doe', updated_topic.author_name
346
+ assert_equal 'janedoe@example.com', updated_topic.author_email_address
347
+ assert_equal 57, updated_topic.parent_id
348
+ assert_equal 3, updated_topic.replies_count
349
+ end
350
+ end
351
+
352
+ context "with :columns a hash" do
353
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
354
+ let(:values) { [[99, "Book", "John Doe", "john@doe.com", 17]] }
355
+ let(:updated_values) { [[99, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
356
+
357
+ macro(:perform_import) do |*opts|
358
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id, columns: update_columns }, validate: false)
359
+ end
360
+
361
+ setup do
362
+ Topic.import columns, values, validate: false
363
+ @topic = Topic.find 99
364
+ end
365
+
366
+ it "should not modify the passed in :on_duplicate_key_update columns array" do
367
+ assert_nothing_raised do
368
+ columns = %w(title author_name).freeze
369
+ Topic.import columns, [%w(foo, bar)], { on_duplicate_key_update: { columns: columns }.freeze }.freeze
370
+ end
371
+ end
372
+
373
+ context "using string hash map" do
374
+ let(:update_columns) { { "title" => "title", "author_email_address" => "author_email_address", "parent_id" => "parent_id" } }
375
+ should_support_on_duplicate_key_update
376
+ should_update_fields_mentioned
377
+ end
378
+
379
+ context "using string hash map, but specifying column mismatches" do
380
+ let(:update_columns) { { "title" => "author_email_address", "author_email_address" => "title", "parent_id" => "parent_id" } }
381
+ should_support_on_duplicate_key_update
382
+ should_update_fields_mentioned_with_hash_mappings
383
+ end
384
+
385
+ context "using symbol hash map" do
386
+ let(:update_columns) { { title: :title, author_email_address: :author_email_address, parent_id: :parent_id } }
387
+ should_support_on_duplicate_key_update
388
+ should_update_fields_mentioned
389
+ end
390
+
391
+ context "using symbol hash map, but specifying column mismatches" do
392
+ let(:update_columns) { { title: :author_email_address, author_email_address: :title, parent_id: :parent_id } }
393
+ should_support_on_duplicate_key_update
394
+ should_update_fields_mentioned_with_hash_mappings
395
+ end
396
+ end
397
+
398
+ context 'with :index_predicate' do
399
+ let(:columns) { %w( id device_id alarm_type status metadata ) }
400
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
401
+ let(:updated_values) { [[99, 17, 1, 2, 'bar']] }
402
+
403
+ macro(:perform_import) do |*opts|
404
+ 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)
405
+ end
406
+
407
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
408
+
409
+ setup do
410
+ Alarm.import columns, values, validate: false
411
+ @alarm = Alarm.find 99
412
+ end
413
+
414
+ context 'supports on duplicate key update for partial indexes' do
415
+ it 'should not update created_at timestamp columns' do
416
+ Timecop.freeze Chronic.parse("5 minutes from now") do
417
+ perform_import
418
+ assert_in_delta @alarm.created_at.to_i, updated_alarm.created_at.to_i, 1
419
+ end
420
+ end
421
+
422
+ it 'should update updated_at timestamp columns' do
423
+ time = Chronic.parse("5 minutes from now")
424
+ Timecop.freeze time do
425
+ perform_import
426
+ assert_in_delta time.to_i, updated_alarm.updated_at.to_i, 1
427
+ end
428
+ end
429
+
430
+ it 'should not update fields not mentioned' do
431
+ perform_import
432
+ assert_equal 'foo', updated_alarm.metadata
433
+ end
434
+
435
+ it 'should update fields mentioned with hash mappings' do
436
+ perform_import
437
+ assert_equal 2, updated_alarm.status
438
+ end
439
+ end
440
+ end
441
+
442
+ context 'with :condition' do
443
+ let(:columns) { %w( id device_id alarm_type status metadata) }
444
+ let(:values) { [[99, 17, 1, 1, 'foo']] }
445
+ let(:updated_values) { [[99, 17, 1, 1, 'bar']] }
446
+
447
+ macro(:perform_import) do |*opts|
448
+ Alarm.import(
449
+ columns,
450
+ updated_values,
451
+ opts.extract_options!.merge(
452
+ on_duplicate_key_update: {
453
+ conflict_target: [:id],
454
+ condition: "alarms.metadata NOT LIKE '%foo%'",
455
+ columns: [:metadata]
456
+ },
457
+ validate: false
458
+ )
459
+ )
460
+ end
461
+
462
+ macro(:updated_alarm) { Alarm.find(@alarm.id) }
463
+
464
+ setup do
465
+ Alarm.import columns, values, validate: false
466
+ @alarm = Alarm.find 99
467
+ end
468
+
469
+ it 'should not update fields not matched' do
470
+ perform_import
471
+ assert_equal 'foo', updated_alarm.metadata
472
+ end
473
+ end
474
+
475
+ context "with :constraint_name" do
476
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
477
+ let(:values) { [[100, "Book", "John Doe", "john@doe.com", 17]] }
478
+ let(:updated_values) { [[100, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
479
+
480
+ macro(:perform_import) do |*opts|
481
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { constraint_name: :topics_pkey, columns: update_columns }, validate: false)
482
+ end
483
+
484
+ setup do
485
+ Topic.import columns, values, validate: false
486
+ @topic = Topic.find 100
487
+ end
488
+
489
+ let(:update_columns) { [:title, :author_email_address, :parent_id] }
490
+ should_support_on_duplicate_key_update
491
+ should_update_fields_mentioned
492
+ end
493
+
494
+ context "default to the primary key" do
495
+ let(:columns) { %w( id title author_name author_email_address parent_id ) }
496
+ let(:values) { [[100, "Book", "John Doe", "john@doe.com", 17]] }
497
+ let(:updated_values) { [[100, "Book - 2nd Edition", "Author Should Not Change", "johndoe@example.com", 57]] }
498
+ let(:update_columns) { [:title, :author_email_address, :parent_id] }
499
+
500
+ setup do
501
+ Topic.import columns, values, validate: false
502
+ @topic = Topic.find 100
503
+ end
504
+
505
+ context "with no :conflict_target or :constraint_name" do
506
+ macro(:perform_import) do |*opts|
507
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { columns: update_columns }, validate: false)
508
+ end
509
+
510
+ should_support_on_duplicate_key_update
511
+ should_update_fields_mentioned
512
+ end
513
+
514
+ context "with empty value for :conflict_target" do
515
+ macro(:perform_import) do |*opts|
516
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: [], columns: update_columns }, validate: false)
517
+ end
518
+
519
+ should_support_on_duplicate_key_update
520
+ should_update_fields_mentioned
521
+ end
522
+
523
+ context "with empty value for :constraint_name" do
524
+ macro(:perform_import) do |*opts|
525
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { constraint_name: '', columns: update_columns }, validate: false)
526
+ end
527
+
528
+ should_support_on_duplicate_key_update
529
+ should_update_fields_mentioned
530
+ end
531
+ end
532
+
533
+ context "with no :conflict_target or :constraint_name" do
534
+ context "with no primary key" do
535
+ it "raises ArgumentError" do
536
+ error = assert_raises ArgumentError do
537
+ Rule.import Build(3, :rules), on_duplicate_key_update: [:condition_text], validate: false
538
+ end
539
+ assert_match(/Expected :conflict_target or :constraint_name to be specified/, error.message)
540
+ end
541
+ end
542
+ end
543
+
544
+ context "with no :columns" do
545
+ let(:columns) { %w( id title author_name author_email_address ) }
546
+ let(:values) { [[100, "Book", "John Doe", "john@doe.com"]] }
547
+ let(:updated_values) { [[100, "Title Should Not Change", "Author Should Not Change", "john@nogo.com"]] }
548
+
549
+ macro(:perform_import) do |*opts|
550
+ Topic.import columns, updated_values, opts.extract_options!.merge(on_duplicate_key_update: { conflict_target: :id }, validate: false)
551
+ end
552
+
553
+ setup do
554
+ Topic.import columns, values, validate: false
555
+ @topic = Topic.find 100
556
+ end
557
+
558
+ should_update_updated_at_on_timestamp_columns
559
+ end
560
+ end
561
+ end
562
+ end
563
+ end