Merge pull request #43003 from sambostock/set-timestamps-on-upsert-insert

Set timestamps on `insert_all`/`upsert_all` record creation
This commit is contained in:
Aaron Patterson 2021-10-13 09:51:17 -07:00 committed by GitHub
commit ced387ee58
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 193 additions and 19 deletions

@ -7,11 +7,12 @@ class InsertAll # :nodoc:
attr_reader :model, :connection, :inserts, :keys
attr_reader :on_duplicate, :returning, :unique_by, :update_sql
def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil)
def initialize(model, inserts, on_duplicate:, 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
@record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps
disallow_raw_sql!(returning)
disallow_raw_sql!(on_duplicate)
@ -64,15 +65,29 @@ def map_key_with_value
inserts.map do |attributes|
attributes = attributes.stringify_keys
attributes.merge!(scope_attributes) if scope_attributes
attributes.reverse_merge!(timestamps_for_create) if record_timestamps?
verify_attributes(attributes)
keys.map do |key|
keys_including_timestamps.map do |key|
yield key, attributes[key]
end
end
end
def record_timestamps?
@record_timestamps
end
# TODO: Consider remaining this method, as it only conditionally extends keys, not always
def keys_including_timestamps
@keys_including_timestamps ||= if record_timestamps?
keys + model.all_timestamp_attributes_in_model
else
keys
end
end
private
attr_reader :scope_attributes
@ -134,7 +149,7 @@ def unique_by_columns
def verify_attributes(attributes)
if keys != attributes.keys.to_set
if keys_including_timestamps != attributes.keys.to_set
raise ArgumentError, "All objects being inserted must have the same keys"
end
end
@ -148,10 +163,14 @@ def disallow_raw_sql!(value)
"by wrapping them in Arel.sql()."
end
def timestamps_for_create
model.all_timestamp_attributes_in_model.index_with(connection.high_precision_current_timestamp)
end
class Builder # :nodoc:
attr_reader :model
delegate :skip_duplicates?, :update_duplicates?, :keys, to: :insert_all
delegate :skip_duplicates?, :update_duplicates?, :keys, :keys_including_timestamps, :record_timestamps?, to: :insert_all
def initialize(insert_all)
@insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection
@ -162,9 +181,10 @@ def into
end
def values_list
types = extract_types_from_columns_on(model.table_name, keys: keys)
types = extract_types_from_columns_on(model.table_name, keys: keys_including_timestamps)
values_list = insert_all.map_key_with_value do |key, value|
next value if Arel::Nodes::SqlLiteral === value
connection.with_yaml_fallback(types[key].serialize(value))
end
@ -196,6 +216,8 @@ def updatable_columns
end
def touch_model_timestamps_unless(&block)
return "" unless update_duplicates? && record_timestamps?
model.timestamp_attributes_for_update_in_model.filter_map do |column_name|
if touch_timestamp_attribute?(column_name)
"#{column_name}=(CASE WHEN (#{updatable_columns.map(&block).join(" AND ")}) THEN #{model.quoted_table_name}.#{column_name} ELSE #{connection.high_precision_current_timestamp} END),"
@ -213,11 +235,11 @@ def raw_update_sql
attr_reader :connection, :insert_all
def touch_timestamp_attribute?(column_name)
update_duplicates? && !insert_all.updatable_columns.include?(column_name)
insert_all.updatable_columns.exclude?(column_name)
end
def columns_list
format_columns(insert_all.keys)
format_columns(insert_all.keys_including_timestamps)
end
def extract_types_from_columns_on(table_name, keys:)

@ -63,8 +63,8 @@ def create!(attributes = nil, &block)
# go through Active Record's type casting and serialization.
#
# See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation.
def insert(attributes, returning: nil, unique_by: nil)
insert_all([ attributes ], returning: returning, unique_by: unique_by)
def insert(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
insert_all([ attributes ], returning: returning, unique_by: unique_by, record_timestamps: record_timestamps)
end
# Inserts multiple records into the database in a single SQL INSERT
@ -131,8 +131,8 @@ def insert(attributes, returning: nil, unique_by: nil)
# { id: 1, title: "Rework" },
# { id: 2, title: "Eloquent Ruby" }
# ])
def insert_all(attributes, returning: nil, unique_by: nil)
InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute
def insert_all(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
end
# Inserts a single record into the database in a single SQL INSERT
@ -141,8 +141,8 @@ def insert_all(attributes, returning: nil, unique_by: nil)
# go through Active Record's type casting and serialization.
#
# See <tt>ActiveRecord::Persistence#insert_all!</tt> for more.
def insert!(attributes, returning: nil)
insert_all!([ attributes ], returning: returning)
def insert!(attributes, returning: nil, record_timestamps: nil)
insert_all!([ attributes ], returning: returning, record_timestamps: record_timestamps)
end
# Inserts multiple records into the database in a single SQL INSERT
@ -188,8 +188,8 @@ def insert!(attributes, returning: nil)
# { id: 1, title: "Rework", author: "David" },
# { id: 1, title: "Eloquent Ruby", author: "Russ" }
# ])
def insert_all!(attributes, returning: nil)
InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute
def insert_all!(attributes, returning: nil, record_timestamps: nil)
InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning, record_timestamps: record_timestamps).execute
end
# Updates or inserts (upserts) a single record into the database in a
@ -198,8 +198,8 @@ def insert_all!(attributes, returning: nil)
# go through Active Record's type casting and serialization.
#
# See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation.
def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil)
upsert_all([ attributes ], on_duplicate: on_duplicate, returning: returning, unique_by: unique_by)
def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil)
upsert_all([ attributes ], on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps)
end
# Updates or inserts (upserts) multiple records into the database in a
@ -261,8 +261,8 @@ def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil)
# ], unique_by: :isbn)
#
# Book.find_by(isbn: "1").title # => "Eloquent Ruby"
def upsert_all(attributes, on_duplicate: :update, returning: nil, unique_by: nil)
InsertAll.new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by).execute
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
end
# Given an attributes hash, +instantiate+ returns a new instance of

@ -4,6 +4,8 @@
require "models/author"
require "models/book"
require "models/cart"
require "models/developer"
require "models/ship"
require "models/speedometer"
require "models/subscription"
require "models/subscriber"
@ -393,6 +395,148 @@ def test_upsert_all_uses_given_updated_on_over_implicit_updated_on
assert_equal updated_on, Book.find(101).updated_on
end
def test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_true
with_record_timestamps(Ship, true) do
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }]
ship = Ship.find(101)
assert_equal Time.new.year, ship.created_at.year
assert_equal Time.new.year, ship.created_on.year
assert_equal Time.new.year, ship.updated_at.year
assert_equal Time.new.year, ship.updated_on.year
end
end
def test_upsert_all_does_not_implicitly_set_timestamps_on_create_when_model_record_timestamps_is_true_but_overridden
with_record_timestamps(Ship, true) do
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }], record_timestamps: false
ship = Ship.find(101)
assert_nil ship.created_at
assert_nil ship.created_on
assert_nil ship.updated_at
assert_nil ship.updated_on
end
end
def test_upsert_all_does_not_implicitly_set_timestamps_on_create_when_model_record_timestamps_is_false
with_record_timestamps(Ship, false) do
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }]
ship = Ship.find(101)
assert_nil ship.created_at
assert_nil ship.created_on
assert_nil ship.updated_at
assert_nil ship.updated_on
end
end
def test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_false_but_overridden
with_record_timestamps(Ship, false) do
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }], record_timestamps: true
ship = Ship.find(101)
assert_equal Time.now.year, ship.created_at.year
assert_equal Time.now.year, ship.created_on.year
assert_equal Time.now.year, ship.updated_at.year
assert_equal Time.now.year, ship.updated_on.year
end
end
def test_upsert_all_respects_created_at_precision_when_touched_implicitly
skip unless supports_datetime_with_precision?
Book.upsert_all [{ id: 101, name: "Out of the Silent Planet", published_on: Date.new(1938, 4, 8) }]
assert_not_predicate Book.find(101).created_at.usec, :zero?, "created_at should have sub-second precision"
end
def test_upsert_all_implicitly_sets_timestamps_on_update_when_model_record_timestamps_is_true
skip unless supports_insert_on_duplicate_update?
with_record_timestamps(Ship, true) do
travel_to(Date.new(2016, 4, 17)) { Ship.create! id: 101, name: "RSS Boaty McBoatface" }
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }]
ship = Ship.find(101)
assert_equal 2016, ship.created_at.year
assert_equal 2016, ship.created_on.year
assert_equal Time.now.year, ship.updated_at.year
assert_equal Time.now.year, ship.updated_on.year
end
end
def test_upsert_all_does_not_implicitly_set_timestamps_on_update_when_model_record_timestamps_is_true_but_overridden
skip unless supports_insert_on_duplicate_update?
with_record_timestamps(Ship, true) do
travel_to(Date.new(2016, 4, 17)) { Ship.create! id: 101, name: "RSS Boaty McBoatface" }
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }], record_timestamps: false
ship = Ship.find(101)
assert_equal 2016, ship.created_at.year
assert_equal 2016, ship.created_on.year
assert_equal 2016, ship.updated_at.year
assert_equal 2016, ship.updated_on.year
end
end
def test_upsert_all_does_not_implicitly_set_timestamps_on_update_when_model_record_timestamps_is_false
skip unless supports_insert_on_duplicate_update?
with_record_timestamps(Ship, false) do
Ship.create! id: 101, name: "RSS Boaty McBoatface"
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }]
ship = Ship.find(101)
assert_nil ship.created_at
assert_nil ship.created_on
assert_nil ship.updated_at
assert_nil ship.updated_on
end
end
def test_upsert_all_implicitly_sets_timestamps_on_update_when_model_record_timestamps_is_false_but_overridden
skip unless supports_insert_on_duplicate_update?
with_record_timestamps(Ship, false) do
Ship.create! id: 101, name: "RSS Boaty McBoatface"
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }], record_timestamps: true
ship = Ship.find(101)
assert_nil ship.created_at
assert_nil ship.created_on
assert_equal Time.now.year, ship.updated_at.year
assert_equal Time.now.year, ship.updated_on.year
end
end
def test_upsert_all_implicitly_sets_timestamps_even_when_columns_are_aliased
skip unless supports_insert_on_duplicate_update?
Developer.upsert_all [{ id: 101, name: "Alice" }]
alice = Developer.find(101)
assert_not_nil alice.created_at
assert_not_nil alice.created_on
assert_not_nil alice.updated_at
assert_not_nil alice.updated_on
alice.update!(created_at: nil, created_on: nil, updated_at: nil, updated_on: nil)
Developer.upsert_all [{ id: alice.id, name: alice.name, salary: alice.salary * 2 }]
alice.reload
assert_nil alice.created_at
assert_nil alice.created_on
assert_not_nil alice.updated_at
assert_not_nil alice.updated_on
end
def test_insert_all_raises_on_unknown_attribute
assert_raise ActiveRecord::UnknownAttributeError do
Book.insert_all! [{ unknown_attribute: "Test" }]
@ -515,4 +659,12 @@ def capture_log_output
ActiveRecord::Base.logger = old_logger
end
end
def with_record_timestamps(model, value)
original = model.record_timestamps
model.record_timestamps = value
yield
ensure
model.record_timestamps = original
end
end