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:
commit
ced387ee58
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user