activerecord-import 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml 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