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 +4 -4
- data/.github/workflows/test.yml +10 -2
- data/Gemfile +2 -2
- data/README.md +37 -3
- data/active_admin_import.gemspec +1 -1
- data/lib/active_admin_import/importer.rb +23 -20
- data/lib/active_admin_import/version.rb +1 -1
- data/spec/fixtures/files/authors_blank_header_end.csv +3 -0
- data/spec/fixtures/files/authors_blank_header_middle.csv +3 -0
- data/spec/fixtures/files/authors_empty_header.csv +3 -0
- data/spec/fixtures/files/authors_many.csv +6 -0
- data/spec/import_spec.rb +125 -0
- data/tasks/test.rake +10 -1
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f6e8f38e5549666b0bc1cd0d996f53fc0bebdfa5449750434fa4dbeb2ec4bc3b
|
|
4
|
+
data.tar.gz: 47298769ad0b0c7dfae9b21b470cf78b1164e6978aeb374ee9d4991de037814e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b45454513b6a1312b1dd0490ab0f34def99e0928177a3fc209220be9fcb7d6cb371b0c9d086818ef8318f62d97ef39f8f38264acdef32d5795af02c90d54332c
|
|
7
|
+
data.tar.gz: c8a7a5aa657e2e78f9678e5e4da6ab348af718473b5431bdbbd84393050b58da560970da80b4d81904e901ddc13e7ca1f98b63e5bcb2367c41c8f2aa023cc7d3
|
data/.github/workflows/test.yml
CHANGED
|
@@ -16,19 +16,27 @@ jobs:
|
|
|
16
16
|
strategy:
|
|
17
17
|
fail-fast: false
|
|
18
18
|
matrix:
|
|
19
|
-
ruby: ['3.
|
|
20
|
-
rails: ['7.
|
|
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 = '
|
|
5
|
-
default_activeadmin_version = '3.
|
|
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
|
-
|
|
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
|
data/active_admin_import.gemspec
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
#
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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)
|
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
|
-
|
|
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:
|
|
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.
|
|
157
|
+
version: 3.3.0
|
|
154
158
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
155
159
|
requirements:
|
|
156
160
|
- - ">="
|