activerecord-import 1.6.0 → 1.7.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
2
  SHA256:
3
- metadata.gz: 32b6a19035fc43e21eeb0c6750e622a2605daf865b05aa9d551a697768456595
4
- data.tar.gz: 7ba72ad6b48a05f52299bf6a49a487c880c82beacda25c6d6d6510a2d87bdf47
3
+ metadata.gz: 5aeed63fcbc9ad647ab901931265d2765baf5d0e5a1e9d2cf2c68af32dd5e1be
4
+ data.tar.gz: 47480cc7244aada4bc69ba28043e8ab85ae1cece4420db4453400425a5773126
5
5
  SHA512:
6
- metadata.gz: ac0cb801e0c0883ec3f09432cb500c919fda756c22b6a4fb29bf3e0ecb3ce0aa0a8700e1112ff9340c9f5f7cdb756618eeba5dcae1ce06602216d21c82506ae4
7
- data.tar.gz: 572f5ea800559cb5fa928d1c82f4e1ef03edac7d1bed5e4bb2849423fdd8fc01bf235f829ef2ab1d230de8832e0d078615e60fc8feb8a48a92f74bb94e7b23a9
6
+ metadata.gz: 5f6b5749dd6ce81784f7016c6227a6d0f5047c59a45beb13616853a89599ac507241012a92c3e4f9996c7a465d4d926e0e8d1183af1854f7a0c8a05e2e59219f
7
+ data.tar.gz: 72b00e7a5c61af3923b618ebe0d96f945a7af145f38ec5312962220b925e1dfc918da6028bb48d8ff7842c32584b34b06cec8fd8288dbb83212ca035f7a16c08
@@ -34,7 +34,7 @@ jobs:
34
34
  fail-fast: false
35
35
  matrix:
36
36
  ruby:
37
- - 3.2
37
+ - 3.3
38
38
  env:
39
39
  - AR_VERSION: '7.1'
40
40
  RUBYOPT: --enable-frozen-string-literal
@@ -43,6 +43,15 @@ jobs:
43
43
  - AR_VERSION: 6.1
44
44
  RUBYOPT: --enable-frozen-string-literal
45
45
  include:
46
+ - ruby: 3.2
47
+ env:
48
+ AR_VERSION: '7.1'
49
+ - ruby: 3.2
50
+ env:
51
+ AR_VERSION: '7.0'
52
+ - ruby: 3.2
53
+ env:
54
+ AR_VERSION: 6.1
46
55
  - ruby: 3.1
47
56
  env:
48
57
  AR_VERSION: '7.1'
@@ -126,7 +135,7 @@ jobs:
126
135
  bundle exec rake test:spatialite
127
136
  bundle exec rake test:sqlite3
128
137
  - name: Run trilogy tests
129
- if: ${{ matrix.env.AR_VERSION >= '7.0' && matrix.ruby != 'jruby' }}
138
+ if: ${{ matrix.env.AR_VERSION >= '7.0' && !startsWith(matrix.ruby, 'jruby') }}
130
139
  run: bundle exec rake test:trilogy
131
140
  lint:
132
141
  runs-on: ubuntu-latest
data/.gitignore CHANGED
@@ -13,12 +13,16 @@ tmtags
13
13
  ## VIM
14
14
  *.swp
15
15
 
16
+ ## Idea
17
+ .idea
18
+
16
19
  ## PROJECT::GENERAL
17
20
  coverage
18
21
  rdoc
19
22
  pkg
20
23
  *.gem
21
24
  *.lock
25
+ .byebug_history
22
26
 
23
27
  ## PROJECT::SPECIFIC
24
28
  log/*.log
data/.rubocop_todo.yml CHANGED
@@ -24,3 +24,7 @@ Lint/UnusedMethodArgument:
24
24
  Style/CombinableLoops:
25
25
  Exclude:
26
26
  - 'test/support/shared_examples/recursive_import.rb'
27
+
28
+ Naming/FileName:
29
+ Exclude:
30
+ - 'lib/activerecord-import.rb'
data/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## Changes in 1.7.0
2
+
3
+ ### New Features
4
+
5
+ * Add support for ActiveRecord 7.1 composite primary keys. Thanks to @fragkakis via \##837.
6
+ * Add support for upserting associations when doing recursive imports. Thanks to @ramblex via \##778.
7
+
1
8
  ## Changes in 1.6.0
2
9
 
3
10
  ### New Features
data/README.markdown CHANGED
@@ -265,7 +265,7 @@ Book.import books, recursive: true
265
265
  Key | Options | Default | Description
266
266
  ------------------------- | --------------------- | ------------------ | -----------
267
267
  :validate | `true`/`false` | `true` | Whether or not to run `ActiveRecord` validations (uniqueness skipped). This option will always be true when using `import!`.
268
- :validate_uniqueness | `true`/`false` | `false` | Whether or not to run uniqueness validations, has potential pitfalls, use with caution (requires `>= v0.27.0`).
268
+ :validate_uniqueness | `true`/`false` | `false` | Whether or not to run ActiveRecord uniqueness validations. Beware this will incur an sql query per-record (N+1 queries). (requires `>= v0.27.0`).
269
269
  :validate_with_context | `Symbol` |`:create`/`:update` | Allows passing an ActiveModel validation context for each model. Default is `:create` for new records and `:update` for existing ones.
270
270
  :track_validation_failures| `true`/`false` | `false` | When this is set to true, `failed_instances` will be an array of arrays, with each inner array having the form `[:index_in_dataset, :object_with_errors]`
271
271
  :on_duplicate_key_ignore | `true`/`false` | `false` | Allows skipping records with duplicate keys. See [here](#duplicate-key-ignore) for more details.
@@ -274,6 +274,7 @@ Key | Options | Default | Descrip
274
274
  :synchronize | `Array` | N/A | An array of ActiveRecord instances. This synchronizes existing instances in memory with updates from the import.
275
275
  :timestamps | `true`/`false` | `true` | Enables/disables timestamps on imported records.
276
276
  :recursive | `true`/`false` | `false` | Imports has_many/has_one associations (PostgreSQL only).
277
+ :recursive_on_duplicate_key_update | `Hash` | N/A | Allows upsert logic to be used for recursive associations. The hash key is the association name and the value has the same options as `:on_duplicate_key_update`. See [here](#duplicate-key-update) for more details.
277
278
  :batch_size | `Integer` | total # of records | Max number of records to insert per import
278
279
  :raise_error | `true`/`false` | `false` | Raises an exception at the first invalid record. This means there will not be a result object returned. The `import!` method is a shortcut for this.
279
280
  :all_or_none | `true`/`false` | `false` | Will not import any records if there is a record with validation errors.
@@ -857,6 +857,15 @@ class ActiveRecord::Base
857
857
 
858
858
  private
859
859
 
860
+ def associated_options(options, associated_class)
861
+ return options unless options.key?(:recursive_on_duplicate_key_update)
862
+
863
+ table_name = associated_class.arel_table.name.to_sym
864
+ options.merge(
865
+ on_duplicate_key_update: options[:recursive_on_duplicate_key_update][table_name]
866
+ )
867
+ end
868
+
860
869
  def set_attributes_and_mark_clean(models, import_result, timestamps, options)
861
870
  return if models.nil?
862
871
  models -= import_result.failed_instances
@@ -941,7 +950,7 @@ class ActiveRecord::Base
941
950
  association = association.target
942
951
  next if association.blank? || model.public_send(column_name).present?
943
952
 
944
- association_primary_key = Array(association_reflection.association_primary_key)[column_index]
953
+ association_primary_key = Array(association_reflection.association_primary_key.tr("[]:", "").split(", "))[column_index]
945
954
  model.public_send("#{column_name}=", association.send(association_primary_key))
946
955
  end
947
956
  end
@@ -963,7 +972,11 @@ class ActiveRecord::Base
963
972
 
964
973
  associated_objects_by_class.each_value do |associations|
965
974
  associations.each_value do |associated_records|
966
- associated_records.first.class.bulk_import(associated_records, options) unless associated_records.empty?
975
+ next if associated_records.empty?
976
+
977
+ associated_class = associated_records.first.class
978
+ associated_class.bulk_import(associated_records,
979
+ associated_options(options, associated_class))
967
980
  end
968
981
  end
969
982
  end
@@ -996,7 +1009,10 @@ class ActiveRecord::Base
996
1009
 
997
1010
  changed_objects = association.select { |a| a.new_record? || a.changed? }
998
1011
  changed_objects.each do |child|
999
- child.public_send("#{association_reflection.foreign_key}=", model.id)
1012
+ Array(association_reflection.inverse_of&.foreign_key || association_reflection.foreign_key).each_with_index do |column, index|
1013
+ child.public_send("#{column}=", Array(model.id)[index])
1014
+ end
1015
+
1000
1016
  # For polymorphic associations
1001
1017
  association_name = if model.class.respond_to?(:polymorphic_name)
1002
1018
  model.class.polymorphic_name
@@ -2,6 +2,6 @@
2
2
 
3
3
  module ActiveRecord
4
4
  module Import
5
- VERSION = "1.6.0"
5
+ VERSION = "1.7.0"
6
6
  end
7
7
  end
@@ -1,4 +1,3 @@
1
- # rubocop:disable Naming/FileName
2
1
  # frozen_string_literal: true
3
2
 
4
3
  require "active_support/lazy_load_hooks"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Author < ActiveRecord::Base
4
+ if ENV['AR_VERSION'].to_f >= 7.1
5
+ has_many :composite_books, query_constraints: [:id, :author_id], inverse_of: :author
6
+ end
7
+ end
data/test/models/book.rb CHANGED
@@ -2,8 +2,11 @@
2
2
 
3
3
  class Book < ActiveRecord::Base
4
4
  belongs_to :topic, inverse_of: :books
5
- belongs_to :tag, foreign_key: [:tag_id, :parent_id] unless ENV["SKIP_COMPOSITE_PK"]
6
-
5
+ if ENV['AR_VERSION'].to_f <= 7.0
6
+ belongs_to :tag, foreign_key: [:tag_id, :parent_id] unless ENV["SKIP_COMPOSITE_PK"]
7
+ else
8
+ belongs_to :tag, query_constraints: [:tag_id, :parent_id] unless ENV["SKIP_COMPOSITE_PK"]
9
+ end
7
10
  has_many :chapters, inverse_of: :book
8
11
  has_many :discounts, as: :discountable
9
12
  has_many :end_notes, inverse_of: :book
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CompositeBook < ActiveRecord::Base
4
+ self.primary_key = %i[id author_id]
5
+ belongs_to :author
6
+ if ENV['AR_VERSION'].to_f <= 7.0
7
+ unless ENV["SKIP_COMPOSITE_PK"]
8
+ has_many :composite_chapters, inverse_of: :composite_book,
9
+ foreign_key: [:id, :author_id]
10
+ end
11
+ else
12
+ has_many :composite_chapters, inverse_of: :composite_book,
13
+ query_constraints: [:id, :author_id]
14
+ end
15
+
16
+ def self.sequence_name
17
+ "composite_book_id_seq"
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CompositeChapter < ActiveRecord::Base
4
+ if ENV['AR_VERSION'].to_f >= 7.1
5
+ belongs_to :composite_book, inverse_of: :composite_chapters,
6
+ query_constraints: [:composite_book_id, :author_id]
7
+ end
8
+ validates :title, presence: true
9
+ end
@@ -2,9 +2,17 @@
2
2
 
3
3
  class Customer < ActiveRecord::Base
4
4
  unless ENV["SKIP_COMPOSITE_PK"]
5
- has_many :orders,
6
- inverse_of: :customer,
7
- primary_key: %i(account_id id),
8
- foreign_key: %i(account_id customer_id)
5
+ if ENV['AR_VERSION'].to_f <= 7.0
6
+ has_many :orders,
7
+ inverse_of: :customer,
8
+ primary_key: %i(account_id id),
9
+ foreign_key: %i(account_id customer_id)
10
+ else
11
+ has_many :orders,
12
+ inverse_of: :customer,
13
+ primary_key: %i(account_id id),
14
+ query_constraints: %i(account_id customer_id)
15
+ end
16
+
9
17
  end
10
18
  end
data/test/models/order.rb CHANGED
@@ -2,9 +2,16 @@
2
2
 
3
3
  class Order < ActiveRecord::Base
4
4
  unless ENV["SKIP_COMPOSITE_PK"]
5
- belongs_to :customer,
6
- inverse_of: :orders,
7
- primary_key: %i(account_id id),
8
- foreign_key: %i(account_id customer_id)
5
+ if ENV['AR_VERSION'].to_f <= 7.0
6
+ belongs_to :customer,
7
+ inverse_of: :orders,
8
+ primary_key: %i(account_id id),
9
+ foreign_key: %i(account_id customer_id)
10
+ else
11
+ belongs_to :customer,
12
+ inverse_of: :orders,
13
+ primary_key: %i(account_id id),
14
+ query_constraints: %i(account_id customer_id)
15
+ end
9
16
  end
10
17
  end
data/test/models/tag.rb CHANGED
@@ -1,7 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Tag < ActiveRecord::Base
4
- self.primary_keys = :tag_id, :publisher_id unless ENV["SKIP_COMPOSITE_PK"]
4
+ if ENV['AR_VERSION'].to_f <= 7.0
5
+ self.primary_keys = :tag_id, :publisher_id unless ENV["SKIP_COMPOSITE_PK"]
6
+ else
7
+ self.primary_key = [:tag_id, :publisher_id] unless ENV["SKIP_COMPOSITE_PK"]
8
+ end
9
+ self.primary_key = [:tag_id, :publisher_id] unless ENV["SKIP_COMPOSITE_PK"]
5
10
  has_many :books, inverse_of: :tag
6
11
  has_many :tag_aliases, inverse_of: :tag
7
12
  end
@@ -2,6 +2,10 @@
2
2
 
3
3
  class TagAlias < ActiveRecord::Base
4
4
  unless ENV["SKIP_COMPOSITE_PK"]
5
- belongs_to :tag, foreign_key: [:tag_id, :parent_id], required: true
5
+ if ENV['AR_VERSION'].to_f <= 7.0
6
+ belongs_to :tag, foreign_key: [:tag_id, :parent_id], required: true
7
+ else
8
+ belongs_to :tag, query_constraints: [:tag_id, :parent_id], required: true
9
+ end
6
10
  end
7
11
  end
@@ -57,4 +57,38 @@ ActiveRecord::Schema.define do
57
57
  end
58
58
 
59
59
  add_index :alarms, [:device_id, :alarm_type], unique: true, where: 'status <> 0'
60
+
61
+ unless ENV["SKIP_COMPOSITE_PK"]
62
+ create_table :authors, force: :cascade do |t|
63
+ t.string :name
64
+ end
65
+
66
+ execute %(
67
+ DROP SEQUENCE IF EXISTS composite_book_id_seq CASCADE;
68
+ CREATE SEQUENCE composite_book_id_seq
69
+ AS integer
70
+ START WITH 1
71
+ INCREMENT BY 1
72
+ NO MINVALUE
73
+ NO MAXVALUE
74
+ CACHE 1;
75
+
76
+ DROP TABLE IF EXISTS composite_books;
77
+ CREATE TABLE composite_books (
78
+ id bigint DEFAULT nextval('composite_book_id_seq'::regclass) NOT NULL,
79
+ title character varying,
80
+ author_id bigint
81
+ );
82
+
83
+ ALTER TABLE ONLY composite_books ADD CONSTRAINT fk_rails_040a418131 FOREIGN KEY (author_id) REFERENCES authors(id);
84
+ ).split.join(' ').strip
85
+ end
86
+
87
+ create_table :composite_chapters, force: :cascade do |t|
88
+ t.string :title
89
+ t.integer :composite_book_id, null: false
90
+ t.integer :author_id, null: false
91
+ t.datetime :created_at
92
+ t.datetime :updated_at
93
+ end
60
94
  end
@@ -351,6 +351,18 @@ def should_support_postgresql_import_functionality
351
351
  assert_equal db_customer.orders.last, db_order
352
352
  assert_not_equal db_order.customer_id, nil
353
353
  end
354
+
355
+ it "should import models with auto-incrementing ID successfully" do
356
+ author = Author.create!(name: "Foo Barson")
357
+
358
+ books = []
359
+ 2.times do |i|
360
+ books << CompositeBook.new(author_id: author.id, title: "book #{i}")
361
+ end
362
+ assert_difference "CompositeBook.count", +2 do
363
+ CompositeBook.import books
364
+ end
365
+ end
354
366
  end
355
367
  end
356
368
  end
@@ -165,6 +165,24 @@ def should_support_recursive_import
165
165
  assert_equal 1, tags[0].tag_id
166
166
  assert_equal 2, tags[1].tag_id
167
167
  end
168
+
169
+ if ENV['AR_VERSION'].to_f >= 7.1
170
+ it "should import models with auto-incrementing ID successfully with recursive set to true" do
171
+ author = Author.create!(name: "Foo Barson")
172
+ books = []
173
+ 2.times do |i|
174
+ books << CompositeBook.new(author_id: author.id, title: "Book #{i}", composite_chapters: [
175
+ CompositeChapter.new(title: "Book #{i} composite chapter 1"),
176
+ CompositeChapter.new(title: "Book #{i} composite chapter 2"),
177
+ ])
178
+ end
179
+ assert_difference "CompositeBook.count", +2 do
180
+ assert_difference "CompositeChapter.count", +4 do
181
+ CompositeBook.import books, recursive: true
182
+ end
183
+ end
184
+ end
185
+ end
168
186
  end
169
187
  end
170
188
 
@@ -213,6 +231,54 @@ def should_support_recursive_import
213
231
  end
214
232
  end
215
233
  end
234
+
235
+ describe "recursive_on_duplicate_key_update" do
236
+ let(:new_topics) { Build(1, :topic_with_book) }
237
+
238
+ setup do
239
+ Topic.import new_topics, recursive: true
240
+ end
241
+
242
+ it "updates associated objects" do
243
+ new_author_name = 'Richard Bachman'
244
+ topic = new_topics.first
245
+ topic.books.each do |book|
246
+ book.author_name = new_author_name
247
+ end
248
+
249
+ assert_nothing_raised do
250
+ Topic.import new_topics,
251
+ recursive: true,
252
+ on_duplicate_key_update: [:id],
253
+ recursive_on_duplicate_key_update: {
254
+ books: { conflict_target: [:id], columns: [:author_name] }
255
+ }
256
+ end
257
+ Topic.find(topic.id).books.each do |book|
258
+ assert_equal new_author_name, book.author_name
259
+ end
260
+ end
261
+
262
+ it "updates nested associated objects" do
263
+ new_chapter_title = 'The Final Chapter'
264
+ book = new_topics.first.books.first
265
+ book.author_name = 'Richard Bachman'
266
+
267
+ example_chapter = book.chapters.first
268
+ example_chapter.title = new_chapter_title
269
+
270
+ assert_nothing_raised do
271
+ Topic.import new_topics,
272
+ recursive: true,
273
+ on_duplicate_key_update: [:id],
274
+ recursive_on_duplicate_key_update: {
275
+ books: { conflict_target: [:id], columns: [:author_name] },
276
+ chapters: { conflict_target: [:id], columns: [:title] }
277
+ }
278
+ end
279
+ assert_equal new_chapter_title, Chapter.find(example_chapter.id).title
280
+ end
281
+ end
216
282
  end
217
283
 
218
284
  # If returning option is provided, it is only applied to top level models so that SQL with invalid
data/test/test_helper.rb CHANGED
@@ -33,7 +33,9 @@ require 'chronic'
33
33
  begin
34
34
  require 'composite_primary_keys'
35
35
  rescue LoadError
36
- ENV["SKIP_COMPOSITE_PK"] = "true"
36
+ if ENV['AR_VERSION'].to_f <= 7.1
37
+ ENV['SKIP_COMPOSITE_PK'] = 'true'
38
+ end
37
39
  end
38
40
 
39
41
  # Support MySQL 5.7
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: 1.6.0
4
+ version: 1.7.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: 2024-03-15 00:00:00.000000000 Z
11
+ date: 2024-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -127,11 +127,14 @@ files:
127
127
  - test/models/account.rb
128
128
  - test/models/alarm.rb
129
129
  - test/models/animal.rb
130
+ - test/models/author.rb
130
131
  - test/models/bike_maker.rb
131
132
  - test/models/book.rb
132
133
  - test/models/car.rb
133
134
  - test/models/card.rb
134
135
  - test/models/chapter.rb
136
+ - test/models/composite_book.rb
137
+ - test/models/composite_chapter.rb
135
138
  - test/models/customer.rb
136
139
  - test/models/deck.rb
137
140
  - test/models/dictionary.rb
@@ -226,11 +229,14 @@ test_files:
226
229
  - test/models/account.rb
227
230
  - test/models/alarm.rb
228
231
  - test/models/animal.rb
232
+ - test/models/author.rb
229
233
  - test/models/bike_maker.rb
230
234
  - test/models/book.rb
231
235
  - test/models/car.rb
232
236
  - test/models/card.rb
233
237
  - test/models/chapter.rb
238
+ - test/models/composite_book.rb
239
+ - test/models/composite_chapter.rb
234
240
  - test/models/customer.rb
235
241
  - test/models/deck.rb
236
242
  - test/models/dictionary.rb