Add support for passing the list of columns to update in upsert_all

#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.
This commit is contained in:
Jorge Manrubia 2021-10-15 10:05:20 +02:00 committed by Jeremy Daer
parent 4799156cc3
commit d133cc40ff
4 changed files with 106 additions and 13 deletions

View File

@ -1,3 +1,20 @@
* Add a new option `:update_only` to `upsert_all` to configure the list of columns to update in case of conflict.
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:
```ruby
Commodity.upsert_all(
[
{ id: 2, name: "Copper", price: 4.84 },
{ id: 4, name: "Gold", price: 1380.87 },
{ id: 6, name: "Aluminium", price: 0.35 }
],
update_only: [:price] # Only prices will be updated
)
```
*Jorge Manrubia*
* Remove deprecated `ActiveRecord::Result#map!` and `ActiveRecord::Result#collect!`.
*Rafael Mendonça França*

View File

@ -5,22 +5,19 @@ require "active_support/core_ext/enumerable"
module ActiveRecord
class InsertAll # :nodoc:
attr_reader :model, :connection, :inserts, :keys
attr_reader :on_duplicate, :returning, :unique_by, :update_sql
attr_reader :on_duplicate, :update_only, :returning, :unique_by, :update_sql
def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil, record_timestamps: nil)
def initialize(model, inserts, on_duplicate:, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
raise ArgumentError, "Empty list of attributes passed" if inserts.blank?
@model, @connection, @inserts, @keys = model, model.connection, inserts, inserts.first.keys.map(&:to_s)
@on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by
@on_duplicate, @update_only, @returning, @unique_by = on_duplicate, update_only, returning, unique_by
@record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps
disallow_raw_sql!(returning)
disallow_raw_sql!(on_duplicate)
disallow_raw_sql!(returning)
if Arel.arel_node?(on_duplicate)
@update_sql = on_duplicate
@on_duplicate = :update
end
configure_on_duplicate_update_logic
if model.scope_attributes?
@scope_attributes = model.scope_attributes
@ -45,7 +42,7 @@ module ActiveRecord
end
def updatable_columns
keys - readonly_columns - unique_by_columns
@updatable_columns ||= keys - readonly_columns - unique_by_columns
end
def primary_keys
@ -91,6 +88,24 @@ module ActiveRecord
private
attr_reader :scope_attributes
def configure_on_duplicate_update_logic
if custom_update_sql_provided? && update_only.present?
raise ArgumentError, "You can't set :update_only and provide custom update SQL via :on_duplicate at the same time"
end
if update_only.present?
@updatable_columns = Array(update_only)
@on_duplicate = :update
elsif custom_update_sql_provided?
@update_sql = on_duplicate
@on_duplicate = :update
end
end
def custom_update_sql_provided?
@custom_update_sql_provided ||= Arel.arel_node?(on_duplicate)
end
def find_unique_index_for(unique_by)
if !connection.supports_insert_conflict_target?
return if unique_by.nil?

View File

@ -235,6 +235,10 @@ module ActiveRecord
# Returns an <tt>ActiveRecord::Result</tt> with its contents based on
# <tt>:returning</tt> (see below).
#
# By default, +upsert_all+ will update all the columns that can be updated when
# there is a conflict. These are all the columns except primary keys, read-only
# columns, and columns covered by the optional +unique_by+.
#
# ==== Options
#
# [:returning]
@ -268,9 +272,41 @@ module ActiveRecord
# Active Record's schema_cache.
#
# [:on_duplicate]
# Specify a custom SQL for updating rows on conflict.
# Configure the SQL update sentence that will be used in case of conflict.
#
# NOTE: in this case you must provide all the columns you want to update by yourself.
# NOTE: If you use this option you must provide all the columns you want to update
# by yourself.
#
# Example:
#
# Commodity.upsert_all(
# [
# { id: 2, name: "Copper", price: 4.84 },
# { id: 4, name: "Gold", price: 1380.87 },
# { id: 6, name: "Aluminium", price: 0.35 }
# ],
# on_duplicate: Arel.sql("price = GREATEST(commodities.price, EXCLUDED.price)")
# )
#
# See the related +:update_only+ option. Both options can't be used at the same time.
#
# [:update_only]
# Provide a list of column names that will be updated in case of conflict. If not provided,
# +upsert_all+ will update all the columns that can be updated. These are all the columns
# except primary keys, read-only columns, and columns covered by the optional +unique_by+
#
# Example:
#
# Commodity.upsert_all(
# [
# { id: 2, name: "Copper", price: 4.84 },
# { id: 4, name: "Gold", price: 1380.87 },
# { id: 6, name: "Aluminium", price: 0.35 }
# ],
# on_duplicate: [:price] # Only prices will be updated
# )
#
# See the related +:on_duplicate+ option. Both options can't be used at the same time.
#
# [:record_timestamps]
# By default, automatic setting of timestamp columns is controlled by
@ -294,8 +330,8 @@ module ActiveRecord
# ], unique_by: :isbn)
#
# Book.find_by(isbn: "1").title # => "Eloquent Ruby"
def upsert_all(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil)
InsertAll.new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
def upsert_all(attributes, on_duplicate: :update, update_only: nil, returning: nil, unique_by: nil, record_timestamps: nil)
InsertAll.new(self, attributes, on_duplicate: on_duplicate, update_only: update_only, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
end
# Given an attributes hash, +instantiate+ returns a new instance of

View File

@ -336,6 +336,31 @@ class InsertAllTest < ActiveRecord::TestCase
assert_equal "1974522598", book.isbn, "Should have updated the isbn"
end
def test_passing_both_on_update_and_update_only_will_raise_an_error
assert_raises ArgumentError do
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }], on_duplicate: "NAME=values(name)", update_only: :name
end
end
def test_upsert_all_only_updates_the_column_provided_via_update_only
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }]
Book.upsert_all [{ id: 101, name: "Perelandra 2", author_id: 7, isbn: "111111" }], update_only: :name
book = Book.find(101)
assert_equal "Perelandra 2", book.name, "Should have updated the name"
assert_equal "1974522598", book.isbn, "Should not have updated the isbn"
end
def test_upsert_all_only_updates_the_list_of_columns_provided_via_update_only
Book.upsert_all [{ id: 101, name: "Perelandra", author_id: 7, isbn: "1974522598" }]
Book.upsert_all [{ id: 101, name: "Perelandra 2", author_id: 6, isbn: "111111" }], update_only: %i[ name isbn ]
book = Book.find(101)
assert_equal "Perelandra 2", book.name, "Should have updated the name"
assert_equal "111111", book.isbn, "Should have updated the isbn"
assert_equal 7, book.author_id, "Should not have updated the author_id"
end
def test_upsert_all_does_not_perform_an_upsert_if_a_partial_index_doesnt_apply
skip unless supports_insert_on_duplicate_update? && supports_insert_conflict_target? && supports_partial_index?