active_admin_import 7.0.0 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f72286b16c17763984dba1b9da29ada72f8f7be7e64e5ae64010a1dcd7926af3
4
- data.tar.gz: 2f50c17a8aed4cf2f02d8f28e2e83976e1af4fb593d5fcd763339f4a221be7ca
3
+ metadata.gz: f6e8f38e5549666b0bc1cd0d996f53fc0bebdfa5449750434fa4dbeb2ec4bc3b
4
+ data.tar.gz: 47298769ad0b0c7dfae9b21b470cf78b1164e6978aeb374ee9d4991de037814e
5
5
  SHA512:
6
- metadata.gz: 8921862e1b2de5bfa6d72f5655fe3cc0fc9d2ac7b4410d8ded785521443daf4b582d8e9b2978645c66bb0446b6d11478d7e72e452c70433274d118baa35e6127
7
- data.tar.gz: 332150892d0732efeabf1b7a3de70fa81c9910e6f235ac08b83710eb993709281fb62aec6c21388f6ea9786cf7afb9791dffabdd7c651ac4275cd1ffebd57b3f
6
+ metadata.gz: b45454513b6a1312b1dd0490ab0f34def99e0928177a3fc209220be9fcb7d6cb371b0c9d086818ef8318f62d97ef39f8f38264acdef32d5795af02c90d54332c
7
+ data.tar.gz: c8a7a5aa657e2e78f9678e5e4da6ab348af718473b5431bdbbd84393050b58da560970da80b4d81904e901ddc13e7ca1f98b63e5bcb2367c41c8f2aa023cc7d3
@@ -16,19 +16,27 @@ jobs:
16
16
  strategy:
17
17
  fail-fast: false
18
18
  matrix:
19
- ruby: ['3.2', '3.3', '3.4']
20
- rails: ['7.1.0', '7.2.0', '8.0.0']
19
+ ruby: ['3.3', '3.4', '4.0']
20
+ rails: ['7.2.0', '8.0.0', '8.1.0']
21
21
  activeadmin: ['3.2.0', '3.3.0', '3.4.0', '3.5.1']
22
22
  exclude:
23
+ # ActiveAdmin 3.2.0 predates Rails 8 support.
23
24
  - rails: '8.0.0'
24
25
  activeadmin: '3.2.0'
26
+ - rails: '8.1.0'
27
+ activeadmin: '3.2.0'
25
28
  include:
29
+ # ActiveAdmin 4 requires railties >= 7.2, so cover its floor and the
30
+ # newest supported Rails releases.
26
31
  - ruby: '3.4'
27
32
  rails: '7.2.0'
28
33
  activeadmin: '4.0.0.beta22'
29
34
  - ruby: '3.4'
30
35
  rails: '8.0.0'
31
36
  activeadmin: '4.0.0.beta22'
37
+ - ruby: '3.4'
38
+ rails: '8.1.0'
39
+ activeadmin: '4.0.0.beta22'
32
40
  env:
33
41
  RAILS: ${{ matrix.rails }}
34
42
  AA: ${{ matrix.activeadmin }}
data/Gemfile CHANGED
@@ -1,8 +1,8 @@
1
1
  source 'https://rubygems.org'
2
2
  gemspec
3
3
 
4
- default_rails_version = '7.1.0'
5
- default_activeadmin_version = '3.2.0'
4
+ default_rails_version = '8.0.0'
5
+ default_activeadmin_version = '3.5.1'
6
6
 
7
7
  # `~> 4.0.0.beta22` would admit 4.0.0 GA — pin prereleases exactly so the
8
8
  # CI cell tests the AA build it claims to test.
data/README.md CHANGED
@@ -225,7 +225,43 @@ end
225
225
 
226
226
  ##### Update existing records by id
227
227
 
228
- Delete colliding rows just before each batch insert:
228
+ Two strategies, depending on your database and whether you need validations.
229
+
230
+ ###### Native upsert (recommended where supported)
231
+
232
+ On databases that support upserts (MySQL, PostgreSQL 9.5+, SQLite 3.24+),
233
+ `:on_duplicate_key_update` updates colliding rows and inserts new ones in a
234
+ single statement — no extra delete. The option is passed straight to
235
+ `activerecord-import`, so its shape depends on the adapter:
236
+
237
+ ```ruby
238
+ # PostgreSQL / SQLite
239
+ on_duplicate_key_update: { conflict_target: [:id], columns: %i[name last_name birthday] }
240
+
241
+ # MySQL (infers the key from the columns)
242
+ on_duplicate_key_update: %i[name last_name birthday]
243
+ ```
244
+
245
+ ```ruby
246
+ ActiveAdmin.register Author do
247
+ active_admin_import validate: false,
248
+ on_duplicate_key_update: { conflict_target: [:id], columns: %i[name last_name birthday] }
249
+ end
250
+ ```
251
+
252
+ Notes:
253
+
254
+ * Only the columns you list are updated; other columns on the existing row keep their values.
255
+ * Use `validate: false` — `activerecord-import` runs uniqueness validations against the very rows the upsert is about to overwrite, so `validates_uniqueness_of` would otherwise reject the update.
256
+ * Active Record callbacks are not fired for bulk imports.
257
+
258
+ ###### Delete-then-insert (any database)
259
+
260
+ When you can't rely on upsert support — an older database, or you need your model
261
+ validations to run — delete the colliding rows just before each batch insert.
262
+ The old row is gone before the insert, so `validates_uniqueness_of` doesn't trip,
263
+ at the cost of a second query and full-row **replacement** (columns absent from
264
+ the CSV are reset, not preserved):
229
265
 
230
266
  ```ruby
231
267
  ActiveAdmin.register Post do
@@ -235,8 +271,6 @@ ActiveAdmin.register Post do
235
271
  end
236
272
  ```
237
273
 
238
- For databases that support upserts you can use `:on_duplicate_key_update` instead.
239
-
240
274
  ##### Tune batch size
241
275
 
242
276
  ```ruby
@@ -9,7 +9,7 @@ Gem::Specification.new do |gem|
9
9
  gem.summary = 'ActiveAdmin import based on activerecord-import gem.'
10
10
  gem.homepage = 'https://github.com/activeadmin-plugins/active_admin_import'
11
11
  gem.license = 'MIT'
12
- gem.required_ruby_version = '>= 3.1.0'
12
+ gem.required_ruby_version = '>= 3.3.0'
13
13
  gem.files = `git ls-files`.split($OUTPUT_RECORD_SEPARATOR)
14
14
  gem.executables = gem.files.grep(%r{^bin/}).map { |f| File.basename(f) }
15
15
  gem.name = 'active_admin_import'
@@ -82,23 +82,15 @@ module ActiveAdminImport
82
82
  # end
83
83
  #
84
84
  def batch_slice_columns(slice_columns)
85
- # Only set @use_indexes for the first batch so that @use_indexes are in correct
86
- # position for subsequent batches
87
- unless defined?(@use_indexes)
88
- @use_indexes = []
89
- headers.values.each_with_index do |val, index|
90
- @use_indexes << index if val.in?(slice_columns)
91
- end
92
- return csv_lines if @use_indexes.empty?
93
-
94
- # slice CSV headers
95
- @headers = headers.to_a.values_at(*@use_indexes).to_h
96
- end
85
+ columns = headers.values
86
+ indexes = columns.each_index.select { |i| columns[i].in?(slice_columns) }
87
+ return csv_lines if indexes.empty?
97
88
 
98
- # slice CSV values
99
- csv_lines.map! do |line|
100
- line.values_at(*@use_indexes)
101
- end
89
+ # @headers is reset to the full set at the start of every batch (see
90
+ # #batch_import), so each call narrows the previous call's result and every
91
+ # batch slices the same way — calling this more than once now composes (#186).
92
+ @headers = headers.to_a.values_at(*indexes).to_h
93
+ csv_lines.map! { |line| line.values_at(*indexes) }
102
94
  end
103
95
 
104
96
  def values_at(header_key)
@@ -129,10 +121,18 @@ module ActiveAdminImport
129
121
  end
130
122
 
131
123
  def prepare_headers
132
- headers = self.headers.present? ? self.headers.map(&:to_s) : yield
133
- @headers = Hash[headers.zip(headers.map { |el| el.underscore.gsub(/\s+/, '_') })].with_indifferent_access
134
- @headers.merge!(options[:headers_rewrites].symbolize_keys.slice(*@headers.symbolize_keys.keys))
135
- @headers
124
+ names = self.headers.present? ? self.headers : yield
125
+ blank_positions = names.each_index.select { |i| names[i].to_s.strip.empty? }
126
+ unless blank_positions.empty?
127
+ raise ActiveAdminImport::Exception,
128
+ "blank column header at column #{blank_positions.map { |i| i + 1 }.join(', ')}"
129
+ end
130
+
131
+ names = names.map(&:to_s)
132
+ # @source_headers is the complete parsed header row; batch_import copies it
133
+ # into the per-batch working @headers (see #batch_import).
134
+ @source_headers = Hash[names.zip(names.map { |el| el.underscore.gsub(/\s+/, '_') })].with_indifferent_access
135
+ @source_headers.merge!(options[:headers_rewrites].symbolize_keys.slice(*@source_headers.symbolize_keys.keys))
136
136
  end
137
137
 
138
138
  def run_callback(name)
@@ -141,6 +141,9 @@ module ActiveAdminImport
141
141
 
142
142
  def batch_import
143
143
  batch_result = nil
144
+ # Every batch re-parses full-width rows, so restore the full header set
145
+ # before slicing; batch_slice_columns then narrows this working copy.
146
+ @headers = @source_headers.dup
144
147
  @resource.transaction do
145
148
  run_callback(:before_batch_import)
146
149
  batch_result = resource.import(headers.values, csv_lines, import_options)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveAdminImport
4
- VERSION = '7.0.0'
4
+ VERSION = '8.0.0'
5
5
  end
@@ -0,0 +1,3 @@
1
+ Name,Last name,Birthday,
2
+ John,Doe,1986-05-01,x
3
+ Jane,Roe,1988-11-16,y
@@ -0,0 +1,3 @@
1
+ Name,,Last name,Birthday
2
+ John,x,Doe,1986-05-01
3
+ Jane,y,Roe,1988-11-16
@@ -0,0 +1,3 @@
1
+ ,Name,Last name,Birthday
2
+ x,John,Doe,1986-05-01
3
+ y,Jane,Roe,1988-11-16
@@ -0,0 +1,6 @@
1
+ Birthday,Name,Last name
2
+ 1986-05-01,John,Doe
3
+ 1988-11-16,Jane,Roe
4
+ 1990-01-01,Jack,Smith
5
+ 1991-02-02,Jill,Jones
6
+ 1992-03-03,Joe,Brown
data/spec/import_spec.rb CHANGED
@@ -190,6 +190,36 @@ describe 'import', type: :feature do
190
190
  end
191
191
  end
192
192
 
193
+ # Issue #197: a CSV with an empty header cell used to crash with
194
+ # `undefined method 'underscore' for nil`, wherever the blank sits.
195
+ context 'with a blank column header' do
196
+ shared_examples 'a rejected blank header' do |fixture, column|
197
+ before do
198
+ add_author_resource
199
+ visit '/admin/authors/import'
200
+ upload_file!(fixture)
201
+ end
202
+
203
+ it 'reports a clear error and imports nothing' do
204
+ expect(page).to have_content "blank column header at column #{column}"
205
+ expect(page).to have_no_content 'undefined method'
206
+ expect(Author.count).to eq(0)
207
+ end
208
+ end
209
+
210
+ context 'at the beginning' do
211
+ it_behaves_like 'a rejected blank header', :authors_empty_header, 1
212
+ end
213
+
214
+ context 'in the middle' do
215
+ it_behaves_like 'a rejected blank header', :authors_blank_header_middle, 2
216
+ end
217
+
218
+ context 'at the end' do
219
+ it_behaves_like 'a rejected blank header', :authors_blank_header_end, 4
220
+ end
221
+ end
222
+
193
223
  context 'authors already exist' do
194
224
  before do
195
225
  Author.create!(id: 1, name: 'Jane', last_name: 'Roe')
@@ -217,6 +247,51 @@ describe 'import', type: :feature do
217
247
  expect(Author.find(2).name).to eq('Jane')
218
248
  end
219
249
  end
250
+
251
+ # Issue #187: update existing records by id without a delete_all workaround.
252
+ # On databases that support upserts, :on_duplicate_key_update lets a single
253
+ # import update colliding rows and insert new ones in one pass.
254
+ context 'upserting authors by id via :on_duplicate_key_update' do
255
+ before do
256
+ # Existing row shares its id with the first CSV row but carries a stale
257
+ # birthday; the second CSV row (id 2) has no match and must be inserted.
258
+ Author.delete_all
259
+ Author.create!(id: 1, name: 'John', last_name: 'Doe', birthday: '1900-01-01')
260
+
261
+ # The option shape is adapter-specific: MySQL infers the conflicting key
262
+ # and only wants the column list, while PostgreSQL/SQLite need an explicit
263
+ # :conflict_target (see README).
264
+ on_duplicate_key_update =
265
+ if ActiveRecord::Base.connection.adapter_name.match?(/mysql/i)
266
+ %i[name last_name birthday]
267
+ else
268
+ { conflict_target: [:id], columns: %i[name last_name birthday] }
269
+ end
270
+
271
+ add_author_resource(
272
+ # Uniqueness validation runs against the rows the upsert is about to
273
+ # overwrite, so it must be off for an id-based upsert (see README).
274
+ validate: false,
275
+ on_duplicate_key_update: on_duplicate_key_update
276
+ )
277
+ visit '/admin/authors/import'
278
+ upload_file!(:authors_with_ids)
279
+ end
280
+
281
+ it 'reports every row as imported' do
282
+ expect(page).to have_content 'Successfully imported 2 authors'
283
+ end
284
+
285
+ it 'updates the existing author instead of duplicating it' do
286
+ expect(Author.count).to eq(2)
287
+ expect(Author.find(1).birthday).to eq(Date.new(1986, 5, 1))
288
+ end
289
+
290
+ it 'inserts the non-colliding author' do
291
+ expect(Author.find(2).name).to eq('Jane')
292
+ expect(Author.find(2).last_name).to eq('Roe')
293
+ end
294
+ end
220
295
  end
221
296
 
222
297
  context 'with valid options' do
@@ -581,6 +656,56 @@ describe 'import', type: :feature do
581
656
  end
582
657
  end
583
658
 
659
+ # Issue #186: each call must slice the result of the previous one, not re-apply
660
+ # the first call's indices to an already-sliced row.
661
+ context "with successive batch_slice_columns calls" do
662
+ before do
663
+ # validate: false isolates the slicing behaviour from the model's
664
+ # uniqueness validation, which would otherwise reject a later batch's
665
+ # author for sharing a NULL last_name with an earlier one.
666
+ add_author_resource template_object: ActiveAdminImport::Model.new,
667
+ validate: false,
668
+ before_batch_import: lambda { |importer|
669
+ importer.batch_slice_columns(%w(name last_name))
670
+ importer.batch_slice_columns(%w(name))
671
+ },
672
+ batch_size: batch_size
673
+ visit "/admin/authors/import"
674
+ upload_file!(fixture)
675
+ end
676
+
677
+ context "within a single batch" do
678
+ let(:batch_size) { 2 } # authors.csv has 2 rows -> 1 batch
679
+ let(:fixture) { :authors }
680
+
681
+ it "narrows the columns progressively" do
682
+ expect(Author.pluck(:name, :last_name, :birthday)).to match_array(
683
+ [
684
+ ["Jane", nil, nil],
685
+ ["John", nil, nil]
686
+ ]
687
+ )
688
+ end
689
+ end
690
+
691
+ context "across several batches" do
692
+ let(:batch_size) { 2 } # authors_many.csv has 5 rows -> 3 batches
693
+ let(:fixture) { :authors_many }
694
+
695
+ it "narrows the columns progressively in every batch" do
696
+ expect(Author.pluck(:name, :last_name, :birthday)).to match_array(
697
+ [
698
+ ["John", nil, nil],
699
+ ["Jane", nil, nil],
700
+ ["Jack", nil, nil],
701
+ ["Jill", nil, nil],
702
+ ["Joe", nil, nil]
703
+ ]
704
+ )
705
+ end
706
+ end
707
+ end
708
+
584
709
  context 'with invalid options' do
585
710
  let(:options) { { invalid_option: :invalid_value } }
586
711
 
data/tasks/test.rake CHANGED
@@ -24,7 +24,16 @@ task :setup do
24
24
  )
25
25
  # v4 drops sprockets-rails (see Gemfile), so skip the asset pipeline to
26
26
  # avoid the auto-generated `config/initializers/assets.rb` crashing at boot.
27
- rails_new_opts.unshift('--skip-asset-pipeline') if aa_v4
27
+ if aa_v4
28
+ rails_new_opts.unshift('--skip-asset-pipeline')
29
+ else
30
+ # Rails 8.1 wires importmap-rails into the generated ApplicationController
31
+ # (`stale_when_importmap_changes`). The AA 3 app uses Sprockets, not
32
+ # importmap, so that gem is absent and the controller raises NameError at
33
+ # boot. Skip JavaScript so the macro is never generated — the specs run on
34
+ # rack_test and never execute JS anyway.
35
+ rails_new_opts.unshift('--skip-javascript')
36
+ end
28
37
 
29
38
  system "bundle exec rails new spec/rails/#{TestAppPaths.app_dir_name} #{rails_new_opts.join(' ')}"
30
39
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_admin_import
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.0
4
+ version: 8.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Igor Fedoronchuk
@@ -114,9 +114,13 @@ files:
114
114
  - spec/fixtures/files/author_invalid.csv
115
115
  - spec/fixtures/files/author_invalid_format.txt
116
116
  - spec/fixtures/files/authors.csv
117
+ - spec/fixtures/files/authors_blank_header_end.csv
118
+ - spec/fixtures/files/authors_blank_header_middle.csv
117
119
  - spec/fixtures/files/authors_bom.csv
120
+ - spec/fixtures/files/authors_empty_header.csv
118
121
  - spec/fixtures/files/authors_invalid_db.csv
119
122
  - spec/fixtures/files/authors_invalid_model.csv
123
+ - spec/fixtures/files/authors_many.csv
120
124
  - spec/fixtures/files/authors_no_headers.csv
121
125
  - spec/fixtures/files/authors_values_exceeded_headers.csv
122
126
  - spec/fixtures/files/authors_win1251_win_endline.csv
@@ -150,7 +154,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
150
154
  requirements:
151
155
  - - ">="
152
156
  - !ruby/object:Gem::Version
153
- version: 3.1.0
157
+ version: 3.3.0
154
158
  required_rubygems_version: !ruby/object:Gem::Requirement
155
159
  requirements:
156
160
  - - ">="