active_admin_import 6.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 +59 -2
- data/Gemfile +17 -7
- data/README.md +38 -3
- data/active_admin_import.gemspec +2 -2
- data/lib/active_admin_import/dsl.rb +5 -2
- data/lib/active_admin_import/importer.rb +23 -20
- data/lib/active_admin_import/options.rb +6 -2
- 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 +135 -10
- data/spec/spec_helper.rb +6 -9
- data/spec/support/import_form_selectors.rb +24 -0
- data/spec/support/rails_template.rb +12 -1
- data/spec/support/test_app_paths.rb +14 -0
- data/tasks/test.rake +19 -1
- metadata +10 -4
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,12 +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'
|
|
28
|
+
include:
|
|
29
|
+
# ActiveAdmin 4 requires railties >= 7.2, so cover its floor and the
|
|
30
|
+
# newest supported Rails releases.
|
|
31
|
+
- ruby: '3.4'
|
|
32
|
+
rails: '7.2.0'
|
|
33
|
+
activeadmin: '4.0.0.beta22'
|
|
34
|
+
- ruby: '3.4'
|
|
35
|
+
rails: '8.0.0'
|
|
36
|
+
activeadmin: '4.0.0.beta22'
|
|
37
|
+
- ruby: '3.4'
|
|
38
|
+
rails: '8.1.0'
|
|
39
|
+
activeadmin: '4.0.0.beta22'
|
|
25
40
|
env:
|
|
26
41
|
RAILS: ${{ matrix.rails }}
|
|
27
42
|
AA: ${{ matrix.activeadmin }}
|
|
@@ -31,6 +46,11 @@ jobs:
|
|
|
31
46
|
with:
|
|
32
47
|
ruby-version: ${{ matrix.ruby }}
|
|
33
48
|
bundler-cache: true
|
|
49
|
+
- name: AA v4 environment summary
|
|
50
|
+
if: matrix.activeadmin == '4.0.0.beta22'
|
|
51
|
+
run: |
|
|
52
|
+
bundle exec gem list activeadmin
|
|
53
|
+
bundle info activeadmin
|
|
34
54
|
- name: Run tests
|
|
35
55
|
run: bundle exec rspec spec
|
|
36
56
|
test-mysql:
|
|
@@ -98,6 +118,43 @@ jobs:
|
|
|
98
118
|
bundler-cache: true
|
|
99
119
|
- name: Run tests
|
|
100
120
|
run: bundle exec rspec spec
|
|
121
|
+
test-postgres-aa4:
|
|
122
|
+
name: Ruby 3.4 / Rails 8.0.0 / AA 4.0.0.beta22 / PostgreSQL 16
|
|
123
|
+
runs-on: ubuntu-latest
|
|
124
|
+
env:
|
|
125
|
+
RAILS: '8.0.0'
|
|
126
|
+
AA: '4.0.0.beta22'
|
|
127
|
+
DB: postgres
|
|
128
|
+
DB_HOST: 127.0.0.1
|
|
129
|
+
DB_PORT: 5432
|
|
130
|
+
DB_USERNAME: postgres
|
|
131
|
+
DB_PASSWORD: postgres
|
|
132
|
+
services:
|
|
133
|
+
postgres:
|
|
134
|
+
image: postgres:16
|
|
135
|
+
env:
|
|
136
|
+
POSTGRES_USER: postgres
|
|
137
|
+
POSTGRES_PASSWORD: postgres
|
|
138
|
+
POSTGRES_DB: active_admin_import_test
|
|
139
|
+
ports:
|
|
140
|
+
- 5432:5432
|
|
141
|
+
options: >-
|
|
142
|
+
--health-cmd="pg_isready -U postgres"
|
|
143
|
+
--health-interval=10s
|
|
144
|
+
--health-timeout=5s
|
|
145
|
+
--health-retries=10
|
|
146
|
+
steps:
|
|
147
|
+
- uses: actions/checkout@v4
|
|
148
|
+
- uses: ruby/setup-ruby@v1
|
|
149
|
+
with:
|
|
150
|
+
ruby-version: '3.4'
|
|
151
|
+
bundler-cache: true
|
|
152
|
+
- name: AA v4 environment summary
|
|
153
|
+
run: |
|
|
154
|
+
bundle exec gem list activeadmin
|
|
155
|
+
bundle info activeadmin
|
|
156
|
+
- name: Run tests
|
|
157
|
+
run: bundle exec rspec spec
|
|
101
158
|
coverage:
|
|
102
159
|
name: Coverage
|
|
103
160
|
runs-on: ubuntu-latest
|
data/Gemfile
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
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
|
+
|
|
7
|
+
# `~> 4.0.0.beta22` would admit 4.0.0 GA — pin prereleases exactly so the
|
|
8
|
+
# CI cell tests the AA build it claims to test.
|
|
9
|
+
aa_version = ENV['AA'] || default_activeadmin_version
|
|
10
|
+
aa_op = aa_version.match?(/[a-z]/) ? '=' : '~>'
|
|
6
11
|
|
|
7
12
|
gem 'rails', "~> #{ENV['RAILS'] || default_rails_version}"
|
|
8
|
-
gem 'activeadmin', "
|
|
9
|
-
|
|
10
|
-
|
|
13
|
+
gem 'activeadmin', "#{aa_op} #{aa_version}"
|
|
14
|
+
|
|
15
|
+
if ENV['AA']&.start_with?('4')
|
|
16
|
+
# AA 4 uses Tailwind + importmap; sass-rails conflicts with Tailwind v4.
|
|
17
|
+
gem 'cssbundling-rails'
|
|
18
|
+
gem 'importmap-rails'
|
|
19
|
+
else
|
|
20
|
+
gem 'sprockets-rails'
|
|
21
|
+
gem 'sass-rails'
|
|
22
|
+
end
|
|
11
23
|
|
|
12
24
|
group :test do
|
|
13
25
|
gem 'simplecov', require: false
|
|
@@ -22,6 +34,4 @@ group :test do
|
|
|
22
34
|
end
|
|
23
35
|
gem 'database_cleaner'
|
|
24
36
|
gem 'capybara'
|
|
25
|
-
gem 'cuprite'
|
|
26
|
-
gem 'webrick', require: false
|
|
27
37
|
end
|
data/README.md
CHANGED
|
@@ -73,6 +73,7 @@ Tool | Description
|
|
|
73
73
|
:error_limit |Limit the number of errors reported (default `5`, set to `nil` for all)
|
|
74
74
|
:headers_rewrites |hash with key (csv header) - value (db column name) rows mapping
|
|
75
75
|
:if |Controls whether the 'Import' button is displayed. It supports a proc to be evaluated into a boolean value within the activeadmin render context.
|
|
76
|
+
:action_item_html_options |HTML options passed to the index-page "Import …" action_item link. Defaults to `{ class: 'action-item-button' }` so the link matches AA 4's built-in action_items; the class is a no-op on AA 3. Override to drop the class or add your own (`{ class: 'my-btn' }`, `{ class: '', data: { turbo: false } }`, etc.).
|
|
76
77
|
|
|
77
78
|
|
|
78
79
|
|
|
@@ -224,7 +225,43 @@ end
|
|
|
224
225
|
|
|
225
226
|
##### Update existing records by id
|
|
226
227
|
|
|
227
|
-
|
|
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):
|
|
228
265
|
|
|
229
266
|
```ruby
|
|
230
267
|
ActiveAdmin.register Post do
|
|
@@ -234,8 +271,6 @@ ActiveAdmin.register Post do
|
|
|
234
271
|
end
|
|
235
272
|
```
|
|
236
273
|
|
|
237
|
-
For databases that support upserts you can use `:on_duplicate_key_update` instead.
|
|
238
|
-
|
|
239
274
|
##### Tune batch size
|
|
240
275
|
|
|
241
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'
|
|
@@ -18,5 +18,5 @@ Gem::Specification.new do |gem|
|
|
|
18
18
|
gem.add_runtime_dependency 'activerecord-import', '>= 2.0'
|
|
19
19
|
gem.add_runtime_dependency 'rchardet', '>= 1.6'
|
|
20
20
|
gem.add_runtime_dependency 'rubyzip', '>= 1.2'
|
|
21
|
-
gem.add_dependency 'activeadmin', '>= 3.0', '< 4.
|
|
21
|
+
gem.add_dependency 'activeadmin', '>= 3.0', '< 4.1'
|
|
22
22
|
end
|
|
@@ -27,6 +27,8 @@ module ActiveAdminImport
|
|
|
27
27
|
module DSL
|
|
28
28
|
CONTEXT_METHOD = :active_admin_import_context
|
|
29
29
|
|
|
30
|
+
ACTIVE_ADMIN_V4 = Gem::Version.new(ActiveAdmin::VERSION) >= Gem::Version.new('4.0.0.beta1')
|
|
31
|
+
|
|
30
32
|
def self.prepare_import_model(template_object, controller, params: nil)
|
|
31
33
|
model = template_object.is_a?(Proc) ? template_object.call : template_object
|
|
32
34
|
if params
|
|
@@ -45,7 +47,7 @@ module ActiveAdminImport
|
|
|
45
47
|
model_name = options[:resource_label].downcase
|
|
46
48
|
plural_model_name = options[:plural_resource_label].downcase
|
|
47
49
|
if result.empty?
|
|
48
|
-
flash[:warning] = I18n.t('active_admin_import.file_empty_error')
|
|
50
|
+
flash[ACTIVE_ADMIN_V4 ? :alert : :warning] = I18n.t('active_admin_import.file_empty_error')
|
|
49
51
|
else
|
|
50
52
|
if result.failed?
|
|
51
53
|
flash[:error] = I18n.t(
|
|
@@ -81,7 +83,8 @@ module ActiveAdminImport
|
|
|
81
83
|
if authorized?(ActiveAdminImport::Auth::IMPORT, active_admin_config.resource_class)
|
|
82
84
|
link_to(
|
|
83
85
|
I18n.t('active_admin_import.import_model', plural_model: options[:plural_resource_label]),
|
|
84
|
-
action: :import
|
|
86
|
+
{ action: :import },
|
|
87
|
+
options[:action_item_html_options]
|
|
85
88
|
)
|
|
86
89
|
end
|
|
87
90
|
end
|
|
@@ -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)
|
|
@@ -22,7 +22,8 @@ module ActiveAdminImport
|
|
|
22
22
|
:plural_resource_label,
|
|
23
23
|
:error_limit,
|
|
24
24
|
:headers_rewrites,
|
|
25
|
-
:if
|
|
25
|
+
:if,
|
|
26
|
+
:action_item_html_options
|
|
26
27
|
].freeze
|
|
27
28
|
|
|
28
29
|
def self.options_for(config, options = {})
|
|
@@ -39,7 +40,10 @@ module ActiveAdminImport
|
|
|
39
40
|
plural_resource_label: config.plural_resource_label,
|
|
40
41
|
error_limit: 5,
|
|
41
42
|
headers_rewrites: {},
|
|
42
|
-
if: true
|
|
43
|
+
if: true,
|
|
44
|
+
# AA 4's built-in action_items hardcode this class for Tailwind styling
|
|
45
|
+
# (lib/active_admin/resource/action_items.rb). It's a no-op on AA 3.
|
|
46
|
+
action_item_html_options: { class: 'action-item-button' }
|
|
43
47
|
}.deep_merge(options)
|
|
44
48
|
end
|
|
45
49
|
end
|
data/spec/import_spec.rb
CHANGED
|
@@ -41,8 +41,8 @@ describe 'import', type: :feature do
|
|
|
41
41
|
end
|
|
42
42
|
|
|
43
43
|
def upload_file!(name, ext = 'csv')
|
|
44
|
-
attach_file(
|
|
45
|
-
find_button(
|
|
44
|
+
attach_file(ImportFormSelectors.file_input_id, File.expand_path("./spec/fixtures/files/#{name}.#{ext}"))
|
|
45
|
+
find_button(ImportFormSelectors.import_button_text).click
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
context 'posts index' do
|
|
@@ -118,7 +118,7 @@ describe 'import', type: :feature do
|
|
|
118
118
|
# reload page
|
|
119
119
|
visit '/admin/posts/import'
|
|
120
120
|
# submit form without file
|
|
121
|
-
find_button(
|
|
121
|
+
find_button(ImportFormSelectors.import_button_text).click
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
it 'should render validation error' do
|
|
@@ -171,7 +171,7 @@ describe 'import', type: :feature do
|
|
|
171
171
|
# TODO: removing this causes undefined method `ransack' for #<ActiveRecord::Relation []>
|
|
172
172
|
allow_any_instance_of(Admin::AuthorsController).to receive(:find_collection).and_return(Author.all)
|
|
173
173
|
visit '/admin/authors'
|
|
174
|
-
find_link(
|
|
174
|
+
find_link(ImportFormSelectors.import_link_text).click
|
|
175
175
|
expect(current_path).to eq('/admin/authors/import')
|
|
176
176
|
end
|
|
177
177
|
end
|
|
@@ -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
|
|
@@ -228,14 +303,14 @@ describe 'import', type: :feature do
|
|
|
228
303
|
end
|
|
229
304
|
|
|
230
305
|
it 'has valid form' do
|
|
231
|
-
form = find(
|
|
306
|
+
form = find(ImportFormSelectors.form_css)
|
|
232
307
|
expect(form['action']).to eq('/admin/authors/do_import')
|
|
233
308
|
expect(form['enctype']).to eq('multipart/form-data')
|
|
234
|
-
file_input = form.find(
|
|
309
|
+
file_input = form.find(ImportFormSelectors.file_input_css)
|
|
235
310
|
expect(file_input[:type]).to eq('file')
|
|
236
311
|
expect(file_input.value).to be_blank
|
|
237
|
-
submit_input = form.find(
|
|
238
|
-
expect(submit_input[:value]).to eq(
|
|
312
|
+
submit_input = form.find(ImportFormSelectors.submit_css)
|
|
313
|
+
expect(submit_input[:value]).to eq(ImportFormSelectors.import_button_text)
|
|
239
314
|
expect(submit_input[:type]).to eq('submit')
|
|
240
315
|
end
|
|
241
316
|
|
|
@@ -261,7 +336,7 @@ describe 'import', type: :feature do
|
|
|
261
336
|
|
|
262
337
|
context 'when no file' do
|
|
263
338
|
it 'should render error' do
|
|
264
|
-
find_button(
|
|
339
|
+
find_button(ImportFormSelectors.import_button_text).click
|
|
265
340
|
expect(Author.count).to eq(0)
|
|
266
341
|
expect(page).to have_content I18n.t('active_admin_import.no_file_error')
|
|
267
342
|
end
|
|
@@ -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
|
|
|
@@ -605,7 +730,7 @@ describe 'import', type: :feature do
|
|
|
605
730
|
|
|
606
731
|
# Second submission without selecting a file
|
|
607
732
|
expect do
|
|
608
|
-
find_button(
|
|
733
|
+
find_button(ImportFormSelectors.import_button_text).click
|
|
609
734
|
expect(page).to have_content(I18n.t('active_admin_import.no_file_error'))
|
|
610
735
|
end.not_to change { Author.count }
|
|
611
736
|
end
|
data/spec/spec_helper.rb
CHANGED
|
@@ -12,9 +12,9 @@ Bundler.setup
|
|
|
12
12
|
|
|
13
13
|
ENV['RAILS_ENV'] = 'test'
|
|
14
14
|
require 'rails'
|
|
15
|
+
require 'test_app_paths'
|
|
15
16
|
ENV['RAILS'] = Rails.version
|
|
16
|
-
ENV['
|
|
17
|
-
ENV['RAILS_ROOT'] = File.expand_path("../rails/rails-#{ENV['RAILS']}-#{ENV['DB']}", __FILE__)
|
|
17
|
+
ENV['RAILS_ROOT'] = TestAppPaths.app_root
|
|
18
18
|
system 'rake setup' unless File.exist?(ENV['RAILS_ROOT'])
|
|
19
19
|
|
|
20
20
|
require 'active_model'
|
|
@@ -28,16 +28,13 @@ ActiveAdmin.application.current_user_method = false
|
|
|
28
28
|
|
|
29
29
|
require 'rspec/rails'
|
|
30
30
|
require 'support/admin'
|
|
31
|
+
require 'support/import_form_selectors'
|
|
31
32
|
require 'capybara/rails'
|
|
32
33
|
require 'capybara/rspec'
|
|
33
|
-
require 'capybara/cuprite'
|
|
34
34
|
|
|
35
|
-
Capybara
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
end
|
|
39
|
-
Capybara.javascript_driver = :cuprite
|
|
40
|
-
Capybara.default_max_wait_time = 5
|
|
35
|
+
# Specs exercise ActiveAdmin through Capybara's default rack_test driver — no
|
|
36
|
+
# JavaScript or real browser is needed, so no Cuprite/Chrome or app server.
|
|
37
|
+
Capybara.default_driver = :rack_test
|
|
41
38
|
|
|
42
39
|
RSpec.configure do |config|
|
|
43
40
|
config.use_transactional_fixtures = false
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Formtastic 6 (AA 4) keeps the same DOM IDs as Formtastic 4 (AA 3) for this
|
|
4
|
+
# form, so one selector set serves both — branch here if a future AA shifts an ID.
|
|
5
|
+
module ImportFormSelectors
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
SELECTORS = {
|
|
9
|
+
form_id: 'new_active_admin_import_model',
|
|
10
|
+
file_input_id: 'active_admin_import_model_file',
|
|
11
|
+
file_input_css: 'input#active_admin_import_model_file',
|
|
12
|
+
submit_css: '#active_admin_import_model_submit_action input',
|
|
13
|
+
import_button_text: 'Import',
|
|
14
|
+
import_link_text: 'Import Authors'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def form_id = SELECTORS[:form_id]
|
|
18
|
+
def form_css = "##{form_id}"
|
|
19
|
+
def file_input_id = SELECTORS[:file_input_id]
|
|
20
|
+
def file_input_css = SELECTORS[:file_input_css]
|
|
21
|
+
def submit_css = SELECTORS[:submit_css]
|
|
22
|
+
def import_button_text = SELECTORS[:import_button_text]
|
|
23
|
+
def import_link_text = SELECTORS[:import_link_text]
|
|
24
|
+
end
|
|
@@ -50,8 +50,19 @@ gsub_file "config/environment.rb",
|
|
|
50
50
|
|
|
51
51
|
$LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
|
|
52
52
|
|
|
53
|
+
aa_v4 = ENV['AA']&.start_with?('4')
|
|
54
|
+
|
|
53
55
|
generate :'active_admin:install --skip-users'
|
|
54
|
-
|
|
56
|
+
|
|
57
|
+
if aa_v4
|
|
58
|
+
# `active_admin:assets` swaps AA 3's Sprockets SCSS/JS for AA 4's Tailwind CSS
|
|
59
|
+
# stub. We don't compile it — specs assert on DOM and flash text, not styling,
|
|
60
|
+
# so the stub suffices and no Node is needed. `builds/` satisfies cssbundling-rails.
|
|
61
|
+
generate :'active_admin:assets'
|
|
62
|
+
run 'mkdir -p app/assets/builds'
|
|
63
|
+
else
|
|
64
|
+
generate :'formtastic:install'
|
|
65
|
+
end
|
|
55
66
|
|
|
56
67
|
run 'rm -rf test'
|
|
57
68
|
route "root :to => 'admin/dashboard#index'"
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TestAppPaths
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def app_dir_name
|
|
7
|
+
"rails-#{Rails::VERSION::STRING}-#{ENV['DB'] || 'sqlite'}-aa#{ENV['AA'] || 'default'}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
# Absolute path under spec/rails/, used as RAILS_ROOT.
|
|
11
|
+
def app_root
|
|
12
|
+
File.expand_path("../rails/#{app_dir_name}", __dir__)
|
|
13
|
+
end
|
|
14
|
+
end
|
data/tasks/test.rake
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
require_relative '../spec/support/test_app_paths'
|
|
2
|
+
|
|
1
3
|
desc "Creates a test rails app for the specs to run against"
|
|
2
4
|
task :setup do
|
|
3
5
|
require 'rails/version'
|
|
@@ -8,6 +10,9 @@ task :setup do
|
|
|
8
10
|
when 'postgres', 'postgresql' then 'postgresql'
|
|
9
11
|
else 'sqlite3'
|
|
10
12
|
end
|
|
13
|
+
aa_v4 = ENV['AA']&.start_with?('4')
|
|
14
|
+
|
|
15
|
+
puts "[setup] ActiveAdmin: #{ENV['AA'] || '(Gemfile default)'} / Rails: #{Rails::VERSION::STRING} / DB: #{rails_db}"
|
|
11
16
|
|
|
12
17
|
rails_new_opts = %W(
|
|
13
18
|
--skip-turbolinks
|
|
@@ -17,5 +22,18 @@ task :setup do
|
|
|
17
22
|
-m
|
|
18
23
|
spec/support/rails_template.rb
|
|
19
24
|
)
|
|
20
|
-
|
|
25
|
+
# v4 drops sprockets-rails (see Gemfile), so skip the asset pipeline to
|
|
26
|
+
# avoid the auto-generated `config/initializers/assets.rb` crashing at boot.
|
|
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
|
|
37
|
+
|
|
38
|
+
system "bundle exec rails new spec/rails/#{TestAppPaths.app_dir_name} #{rails_new_opts.join(' ')}"
|
|
21
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
|
|
@@ -60,7 +60,7 @@ dependencies:
|
|
|
60
60
|
version: '3.0'
|
|
61
61
|
- - "<"
|
|
62
62
|
- !ruby/object:Gem::Version
|
|
63
|
-
version: '4.
|
|
63
|
+
version: '4.1'
|
|
64
64
|
type: :runtime
|
|
65
65
|
prerelease: false
|
|
66
66
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -70,7 +70,7 @@ dependencies:
|
|
|
70
70
|
version: '3.0'
|
|
71
71
|
- - "<"
|
|
72
72
|
- !ruby/object:Gem::Version
|
|
73
|
-
version: '4.
|
|
73
|
+
version: '4.1'
|
|
74
74
|
description: The most efficient way to import for Active Admin
|
|
75
75
|
email:
|
|
76
76
|
- fedoronchuk@gmail.com
|
|
@@ -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
|
|
@@ -135,7 +139,9 @@ files:
|
|
|
135
139
|
- spec/spec_helper.rb
|
|
136
140
|
- spec/support/active_model_lint.rb
|
|
137
141
|
- spec/support/admin.rb
|
|
142
|
+
- spec/support/import_form_selectors.rb
|
|
138
143
|
- spec/support/rails_template.rb
|
|
144
|
+
- spec/support/test_app_paths.rb
|
|
139
145
|
- tasks/test.rake
|
|
140
146
|
homepage: https://github.com/activeadmin-plugins/active_admin_import
|
|
141
147
|
licenses:
|
|
@@ -148,7 +154,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
148
154
|
requirements:
|
|
149
155
|
- - ">="
|
|
150
156
|
- !ruby/object:Gem::Version
|
|
151
|
-
version: 3.
|
|
157
|
+
version: 3.3.0
|
|
152
158
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
153
159
|
requirements:
|
|
154
160
|
- - ">="
|