Skip to content

Commit d133cc4

Browse files
jorgemanrubiajeremy
authored andcommitted
Add support for passing the list of columns to update in upsert_all
rails#41933 added a new `on_duplicate:` option to `upsert_all`, to allow providing custom SQL update code. This change makes `on_duplicate` admit an array of columns too, so that `upsert_all` only updates those columns when a conflict happens. This allows limiting the list of updated column in a database-agnostic way.
1 parent 4799156 commit d133cc4

File tree

4 files changed

+106
-13
lines changed

4 files changed

+106
-13
lines changed

activerecord/CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
* Add a new option `:update_only` to `upsert_all` to configure the list of columns to update in case of conflict.
2+
3+
Before, you could only customize the update SQL sentence via `:on_duplicate`. There is now a new option `:update_only` that lets you provide a list of columns to update in case of conflict:
4+
5+
```ruby
6+
Commodity.upsert_all(
7+
[
8+
{ id: 2, name: "Copper", price: 4.84 },
9+
{ id: 4, name: "Gold", price: 1380.87 },
10+
{ id: 6, name: "Aluminium", price: 0.35 }
11+
],
12+
update_only: [:price] # Only prices will be updated
13+
)
14+
```
15+
16+
*Jorge Manrubia*
17+
118
* Remove deprecated `ActiveRecord::Result#map!` and `ActiveRecord::Result#collect!`.
219

320
*Rafael Mendonça França*

activerecord/lib/active_record/insert_all.rb

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,19 @@
55
module ActiveRecord
66
class InsertAll # :nodoc:
77
attr_reader :model, :connection, :inserts, :keys
8-
attr_reader :on_duplicate, :returning, :unique_by, :update_sql
8+
attr_reader :on_duplicate, :update_only, :returning, :unique_by, :update_sql
99

10-
def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil, record_timestamps: nil)
10+
def initialize(model, inserts, on_duplicate:, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
1111
raise ArgumentError, "Empty list of attributes passed" if inserts.blank?
1212

1313
@model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s)
14-
@on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by
14+
@on_duplicate, @update_only, @returning, @unique_by = on_duplicate, update_only, returning, unique_by
1515
@record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps
1616

17-
disallow_raw_sql!(returning)
1817
disallow_raw_sql!(on_duplicate)
18+
disallow_raw_sql!(returning)
1919

20-
if Arel.arel_node?(on_duplicate)
21-
@update_sql = on_duplicate
22-
@on_duplicate = :update
23-
end
20+
configure_on_duplicate_update_logic
2421

2522
if model.scope_attributes?
2623
@scope_attributes = model.scope_attributes
@@ -45,7 +42,7 @@ def execute
4542
end
4643

4744
def updatable_columns
48-
keys - readonly_columns - unique_by_columns
45+
@updatable_columns ||= keys - readonly_columns - unique_by_columns
4946
end
5047

5148
def primary_keys
@@ -91,6 +88,24 @@ def keys_including_timestamps
9188
private
9289
attr_reader :scope_attributes
9390

91+
def configure_on_duplicate_update_logic
92+
if custom_update_sql_provided? && update_only.present?
93+
raise ArgumentError, "You can't set :update_only and provide custom update SQL via :on_duplicate at the same time"
94+
end
95+
96+
if update_only.present?
97+
@updatable_columns = Array(update_only)
98+
@on_duplicate = :update
99+
elsif custom_update_sql_provided?
100+
@update_sql = on_duplicate
101+
@on_duplicate = :update
102+
end
103+
end
104+
105+
def custom_update_sql_provided?
106+
@custom_update_sql_provided ||= Arel.arel_node?(on_duplicate)
107+
end
108+
94109
def find_unique_index_for(unique_by)
95110
if !connection.supports_insert_conflict_target?
96111
return if unique_by.nil?

activerecord/lib/active_record/persistence.rb

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,10 @@ def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, re
235235
# Returns an <tt>ActiveRecord::Result</tt> with its contents based on
236236
# <tt>:returning</tt> (see below).
237237
#
238+
# By default, +upsert_all+ will update all the columns that can be updated when
239+
# there is a conflict. These are all the columns except primary keys, read-only
240+
# columns, and columns covered by the optional +unique_by+.
241+
#
238242
# ==== Options
239243
#
240244
# [:returning]
@@ -268,9 +272,41 @@ def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, re
268272
# Active Record's schema_cache.
269273
#
270274
# [:on_duplicate]
271-
# Specify a custom SQL for updating rows on conflict.
275+
# Configure the SQL update sentence that will be used in case of conflict.
276+
#
277+
# NOTE: If you use this option you must provide all the columns you want to update
278+
# by yourself.
279+
#
280+
# Example:
281+
#
282+
# Commodity.upsert_all(
283+
# [
284+
# { id: 2, name: "Copper", price: 4.84 },
285+
# { id: 4, name: "Gold", price: 1380.87 },
286+
# { id: 6, name: "Aluminium", price: 0.35 }
287+
# ],
288+
# on_duplicate: Arel.sql("price = GREATEST(commodities.price, EXCLUDED.price)")
289+
# )
290+
#
291+
# See the related +:update_only+ option. Both options can't be used at the same time.
292+
#
293+
# [:update_only]
294+
# Provide a list of column names that will be updated in case of conflict. If not provided,
295+
# +upsert_all+ will update all the columns that can be updated. These are all the columns
296+
# except primary keys, read-only columns, and columns covered by the optional +unique_by+
297+
#
298+
# Example:
299+
#
300+
# Commodity.upsert_all(
301+
# [
302+
# { id: 2, name: "Copper", price: 4.84 },
303+
# { id: 4, name: "Gold", price: 1380.87 },
304+
# { id: 6, name: "Aluminium", price: 0.35 }
305+
# ],
306+
# on_duplicate: [:price] # Only prices will be updated
307+
# )
272308
#
273-
# NOTE: in this case you must provide all the columns you want to update by yourself.
309+
# See the related +:on_duplicate+ option. Both options can't be used at the same time.
274310
#
275311
# [:record_timestamps]
276312
# By default, automatic setting of timestamp columns is controlled by
@@ -294,8 +330,8 @@ def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, re
294330
# ], unique_by: :isbn)
295331
#
296332
# Book.find_by(isbn: "1").title # => "Eloquent Ruby"
297-
def upsert_all(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil)
298-
InsertAll.new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
333+
def upsert_all(attributes, on_duplicate: :update, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
334+
InsertAll.new(self, attributes, on_duplicate: on_duplicate, update_only: update_only, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
299335
end
300336

301337
# Given an attributes hash, +instantiate+ returns a new instance of

activerecord/test/cases/insert_all_test.rb

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,31 @@ def test_upsert_all_does_not_update_primary_keys
336336
assert_equal "1974522598", book.isbn, "Should have updated the isbn"
337337
end
338338

339+
def test_passing_both_on_update_and_update_only_will_raise_an_error
340+
assert_raises ArgumentError do
341+
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }], on_duplicate: "NAME=values(name)", update_only: :name
342+
end
343+
end
344+
345+
def test_upsert_all_only_updates_the_column_provided_via_update_only
346+
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }]
347+
Book.upsert_all [{ id: 101, name: "Perelandra 2", author_id: 7, isbn: "111111" }], update_only: :name
348+
349+
book = Book.find(101)
350+
assert_equal "Perelandra 2", book.name, "Should have updated the name"
351+
assert_equal "1974522598", book.isbn, "Should not have updated the isbn"
352+
end
353+
354+
def test_upsert_all_only_updates_the_list_of_columns_provided_via_update_only
355+
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }]
356+
Book.upsert_all [{ id: 101, name: "Perelandra 2", author_id: 6, isbn: "111111" }], update_only: %i[ name isbn ]
357+
358+
book = Book.find(101)
359+
assert_equal "Perelandra 2", book.name, "Should have updated the name"
360+
assert_equal "111111", book.isbn, "Should have updated the isbn"
361+
assert_equal 7, book.author_id, "Should not have updated the author_id"
362+
end
363+
339364
def test_upsert_all_does_not_perform_an_upsert_if_a_partial_index_doesnt_apply
340365
skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? && supports_partial_index?
341366

0 commit comments

Comments
 (0)