From 51bcd56d0d76497f4775247c96fef84d7127a861 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Mon, 15 Jun 2026 10:30:19 +0200 Subject: [PATCH 1/2] test: cover id-based upsert via :on_duplicate_key_update (#187) Issue #187 asked for updating existing records by id during import. This is already possible through activerecord-import's upsert support, but it was untested and only vaguely documented. Add a spec proving a single import updates colliding rows (matched on :id) and inserts non-colliding ones in one pass, and replace the hand-wavy README note with a concrete example plus the validate:false and callbacks caveats. --- README.md | 23 ++++++++++++++++++++++- spec/import_spec.rb | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7808fd2..9335879 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,28 @@ ActiveAdmin.register Post do end ``` -For databases that support upserts you can use `:on_duplicate_key_update` instead. +On databases that support upserts (MySQL, PostgreSQL 9.5+, SQLite 3.24+) you can +update colliding rows and insert new ones in a single pass with +`:on_duplicate_key_update` — no `delete_all` required: + +```ruby +ActiveAdmin.register Author do + active_admin_import validate: false, + on_duplicate_key_update: { + conflict_target: [:id], + columns: %i[name last_name birthday] + } +end +``` + +Notes: + +* `conflict_target` names the unique column(s) used to detect a collision + (`[:id]` for the primary key). MySQL infers it and ignores this option. +* Turn `validate` off for id-based upserts. `activerecord-import` runs + uniqueness validations against the very rows the upsert is about to overwrite, + so a model-level `validates_uniqueness_of` would otherwise reject the update. +* Active Record callbacks are not fired for bulk imports. ##### Tune batch size diff --git a/spec/import_spec.rb b/spec/import_spec.rb index 2c75df8..3b2a9e4 100644 --- a/spec/import_spec.rb +++ b/spec/import_spec.rb @@ -217,6 +217,44 @@ def upload_file!(name, ext = 'csv') expect(Author.find(2).name).to eq('Jane') end end + + # Issue #187: update existing records by id without a delete_all workaround. + # On databases that support upserts, :on_duplicate_key_update lets a single + # import update colliding rows and insert new ones in one pass. + context 'upserting authors by id via :on_duplicate_key_update' do + before do + # Existing row shares its id with the first CSV row but carries a stale + # birthday; the second CSV row (id 2) has no match and must be inserted. + Author.delete_all + Author.create!(id: 1, name: 'John', last_name: 'Doe', birthday: '1900-01-01') + + add_author_resource( + # Uniqueness validation runs against the rows the upsert is about to + # overwrite, so it must be off for an id-based upsert (see README). + validate: false, + on_duplicate_key_update: { + conflict_target: [:id], + columns: %i[name last_name birthday] + } + ) + visit '/admin/authors/import' + upload_file!(:authors_with_ids) + end + + it 'reports every row as imported' do + expect(page).to have_content 'Successfully imported 2 authors' + end + + it 'updates the existing author instead of duplicating it' do + expect(Author.count).to eq(2) + expect(Author.find(1).birthday).to eq(Date.new(1986, 5, 1)) + end + + it 'inserts the non-colliding author' do + expect(Author.find(2).name).to eq('Jane') + expect(Author.find(2).last_name).to eq('Roe') + end + end end context 'with valid options' do From ba066366f3cd3d6509f302cb578e6d642f26a4a6 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Mon, 15 Jun 2026 10:45:28 +0200 Subject: [PATCH 2/2] test: use adapter-specific :on_duplicate_key_update shape MySQL rejects the :conflict_target hash key (treats it as a column: "Unknown column 'conflict_target'"). It infers the conflicting key on its own, so it takes the bare column list, while PostgreSQL/SQLite need the explicit conflict_target. Branch the option in the spec and document both shapes in the README. --- README.md | 13 +++++++++++-- spec/import_spec.rb | 15 +++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 9335879..17e6e94 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,7 @@ update colliding rows and insert new ones in a single pass with ```ruby ActiveAdmin.register Author do + # PostgreSQL / SQLite active_admin_import validate: false, on_duplicate_key_update: { conflict_target: [:id], @@ -251,8 +252,16 @@ end Notes: -* `conflict_target` names the unique column(s) used to detect a collision - (`[:id]` for the primary key). MySQL infers it and ignores this option. +* The option shape is **adapter-specific**, since it is passed straight to + `activerecord-import`: + * PostgreSQL / SQLite need an explicit `:conflict_target` — the unique + column(s) used to detect a collision (`[:id]` for the primary key). + * MySQL infers the conflicting key, so pass just the column list and omit + `:conflict_target` (passing it raises `Unknown column 'conflict_target'`): + + ```ruby + on_duplicate_key_update: %i[name last_name birthday] + ``` * Turn `validate` off for id-based upserts. `activerecord-import` runs uniqueness validations against the very rows the upsert is about to overwrite, so a model-level `validates_uniqueness_of` would otherwise reject the update. diff --git a/spec/import_spec.rb b/spec/import_spec.rb index 3b2a9e4..595acfb 100644 --- a/spec/import_spec.rb +++ b/spec/import_spec.rb @@ -228,14 +228,21 @@ def upload_file!(name, ext = 'csv') Author.delete_all Author.create!(id: 1, name: 'John', last_name: 'Doe', birthday: '1900-01-01') + # The option shape is adapter-specific: MySQL infers the conflicting key + # and only wants the column list, while PostgreSQL/SQLite need an explicit + # :conflict_target (see README). + on_duplicate_key_update = + if ActiveRecord::Base.connection.adapter_name.match?(/mysql/i) + %i[name last_name birthday] + else + { conflict_target: [:id], columns: %i[name last_name birthday] } + end + add_author_resource( # Uniqueness validation runs against the rows the upsert is about to # overwrite, so it must be off for an id-based upsert (see README). validate: false, - on_duplicate_key_update: { - conflict_target: [:id], - columns: %i[name last_name birthday] - } + on_duplicate_key_update: on_duplicate_key_update ) visit '/admin/authors/import' upload_file!(:authors_with_ids)