Skip to content

Commit ced387e

Browse files
authored
Merge pull request rails#43003 from sambostock/set-timestamps-on-upsert-insert
Set timestamps on `insert_all`/`upsert_all` record creation
2 parents 279337d + be129c5 commit ced387e

File tree

3 files changed

+193
-19
lines changed

3 files changed

+193
-19
lines changed

activerecord/lib/active_record/insert_all.rb

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ class InsertAll # :nodoc:
77
attr_reader :model, :connection, :inserts, :keys
88
attr_reader :on_duplicate, :returning, :unique_by, :update_sql
99

10-
def initialize(model, inserts, on_duplicate:, returning: nil, unique_by: nil)
10+
def initialize(model, inserts, on_duplicate:, 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)
1414
@on_duplicate, @returning, @unique_by = on_duplicate, returning, unique_by
15+
@record_timestamps = record_timestamps.nil? ? model.record_timestamps : record_timestamps
1516

1617
disallow_raw_sql!(returning)
1718
disallow_raw_sql!(on_duplicate)
@@ -64,15 +65,29 @@ def map_key_with_value
6465
inserts.map do |attributes|
6566
attributes = attributes.stringify_keys
6667
attributes.merge!(scope_attributes) if scope_attributes
68+
attributes.reverse_merge!(timestamps_for_create) if record_timestamps?
6769

6870
verify_attributes(attributes)
6971

70-
keys.map do |key|
72+
keys_including_timestamps.map do |key|
7173
yield key, attributes[key]
7274
end
7375
end
7476
end
7577

78+
def record_timestamps?
79+
@record_timestamps
80+
end
81+
82+
# TODO: Consider remaining this method, as it only conditionally extends keys, not always
83+
def keys_including_timestamps
84+
@keys_including_timestamps ||= if record_timestamps?
85+
keys + model.all_timestamp_attributes_in_model
86+
else
87+
keys
88+
end
89+
end
90+
7691
private
7792
attr_reader :scope_attributes
7893

@@ -134,7 +149,7 @@ def unique_by_columns
134149

135150

136151
def verify_attributes(attributes)
137-
if keys != attributes.keys.to_set
152+
if keys_including_timestamps != attributes.keys.to_set
138153
raise ArgumentError, "All objects being inserted must have the same keys"
139154
end
140155
end
@@ -148,10 +163,14 @@ def disallow_raw_sql!(value)
148163
"by wrapping them in Arel.sql()."
149164
end
150165

166+
def timestamps_for_create
167+
model.all_timestamp_attributes_in_model.index_with(connection.high_precision_current_timestamp)
168+
end
169+
151170
class Builder # :nodoc:
152171
attr_reader :model
153172

154-
delegate :skip_duplicates?, :update_duplicates?, :keys, to: :insert_all
173+
delegate :skip_duplicates?, :update_duplicates?, :keys, :keys_including_timestamps, :record_timestamps?, to: :insert_all
155174

156175
def initialize(insert_all)
157176
@insert_all, @model, @connection = insert_all, insert_all.model, insert_all.connection
@@ -162,9 +181,10 @@ def into
162181
end
163182

164183
def values_list
165-
types = extract_types_from_columns_on(model.table_name, keys: keys)
184+
types = extract_types_from_columns_on(model.table_name, keys: keys_including_timestamps)
166185

167186
values_list = insert_all.map_key_with_value do |key, value|
187+
next value if Arel::Nodes::SqlLiteral === value
168188
connection.with_yaml_fallback(types[key].serialize(value))
169189
end
170190

@@ -196,6 +216,8 @@ def updatable_columns
196216
end
197217

198218
def touch_model_timestamps_unless(&block)
219+
return "" unless update_duplicates? && record_timestamps?
220+
199221
model.timestamp_attributes_for_update_in_model.filter_map do |column_name|
200222
if touch_timestamp_attribute?(column_name)
201223
"#{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
213235
attr_reader :connection, :insert_all
214236

215237
def touch_timestamp_attribute?(column_name)
216-
update_duplicates? && !insert_all.updatable_columns.include?(column_name)
238+
insert_all.updatable_columns.exclude?(column_name)
217239
end
218240

219241
def columns_list
220-
format_columns(insert_all.keys)
242+
format_columns(insert_all.keys_including_timestamps)
221243
end
222244

223245
def extract_types_from_columns_on(table_name, keys:)

activerecord/lib/active_record/persistence.rb

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,8 @@ def create!(attributes = nil, &block)
6363
# go through Active Record's type casting and serialization.
6464
#
6565
# See <tt>ActiveRecord::Persistence#insert_all</tt> for documentation.
66-
def insert(attributes, returning: nil, unique_by: nil)
67-
insert_all([ attributes ], returning: returning, unique_by: unique_by)
66+
def insert(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
67+
insert_all([ attributes ], returning: returning, unique_by: unique_by, record_timestamps: record_timestamps)
6868
end
6969

7070
# Inserts multiple records into the database in a single SQL INSERT
@@ -131,8 +131,8 @@ def insert(attributes, returning: nil, unique_by: nil)
131131
# { id: 1, title: "Rework" },
132132
# { id: 2, title: "Eloquent Ruby" }
133133
# ])
134-
def insert_all(attributes, returning: nil, unique_by: nil)
135-
InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by).execute
134+
def insert_all(attributes, returning: nil, unique_by: nil, record_timestamps: nil)
135+
InsertAll.new(self, attributes, on_duplicate: :skip, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
136136
end
137137

138138
# 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)
141141
# go through Active Record's type casting and serialization.
142142
#
143143
# See <tt>ActiveRecord::Persistence#insert_all!</tt> for more.
144-
def insert!(attributes, returning: nil)
145-
insert_all!([ attributes ], returning: returning)
144+
def insert!(attributes, returning: nil, record_timestamps: nil)
145+
insert_all!([ attributes ], returning: returning, record_timestamps: record_timestamps)
146146
end
147147

148148
# Inserts multiple records into the database in a single SQL INSERT
@@ -188,8 +188,8 @@ def insert!(attributes, returning: nil)
188188
# { id: 1, title: "Rework", author: "David" },
189189
# { id: 1, title: "Eloquent Ruby", author: "Russ" }
190190
# ])
191-
def insert_all!(attributes, returning: nil)
192-
InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning).execute
191+
def insert_all!(attributes, returning: nil, record_timestamps: nil)
192+
InsertAll.new(self, attributes, on_duplicate: :raise, returning: returning, record_timestamps: record_timestamps).execute
193193
end
194194

195195
# Updates or inserts (upserts) a single record into the database in a
@@ -198,8 +198,8 @@ def insert_all!(attributes, returning: nil)
198198
# go through Active Record's type casting and serialization.
199199
#
200200
# See <tt>ActiveRecord::Persistence#upsert_all</tt> for documentation.
201-
def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil)
202-
upsert_all([ attributes ], on_duplicate: on_duplicate, returning: returning, unique_by: unique_by)
201+
def upsert(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil)
202+
upsert_all([ attributes ], on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps)
203203
end
204204

205205
# 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)
261261
# ], unique_by: :isbn)
262262
#
263263
# Book.find_by(isbn: "1").title # => "Eloquent Ruby"
264-
def upsert_all(attributes, on_duplicate: :update, returning: nil, unique_by: nil)
265-
InsertAll.new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by).execute
264+
def upsert_all(attributes, on_duplicate: :update, returning: nil, unique_by: nil, record_timestamps: nil)
265+
InsertAll.new(self, attributes, on_duplicate: on_duplicate, returning: returning, unique_by: unique_by, record_timestamps: record_timestamps).execute
266266
end
267267

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

activerecord/test/cases/insert_all_test.rb

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
require "models/author"
55
require "models/book"
66
require "models/cart"
7+
require "models/developer"
8+
require "models/ship"
79
require "models/speedometer"
810
require "models/subscription"
911
require "models/subscriber"
@@ -393,6 +395,148 @@ def test_upsert_all_uses_given_updated_on_over_implicit_updated_on
393395
assert_equal updated_on, Book.find(101).updated_on
394396
end
395397

398+
def test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_true
399+
with_record_timestamps(Ship, true) do
400+
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }]
401+
402+
ship = Ship.find(101)
403+
assert_equal Time.new.year, ship.created_at.year
404+
assert_equal Time.new.year, ship.created_on.year
405+
assert_equal Time.new.year, ship.updated_at.year
406+
assert_equal Time.new.year, ship.updated_on.year
407+
end
408+
end
409+
410+
def test_upsert_all_does_not_implicitly_set_timestamps_on_create_when_model_record_timestamps_is_true_but_overridden
411+
with_record_timestamps(Ship, true) do
412+
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }], record_timestamps: false
413+
414+
ship = Ship.find(101)
415+
assert_nil ship.created_at
416+
assert_nil ship.created_on
417+
assert_nil ship.updated_at
418+
assert_nil ship.updated_on
419+
end
420+
end
421+
422+
def test_upsert_all_does_not_implicitly_set_timestamps_on_create_when_model_record_timestamps_is_false
423+
with_record_timestamps(Ship, false) do
424+
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }]
425+
426+
ship = Ship.find(101)
427+
assert_nil ship.created_at
428+
assert_nil ship.created_on
429+
assert_nil ship.updated_at
430+
assert_nil ship.updated_on
431+
end
432+
end
433+
434+
def test_upsert_all_implicitly_sets_timestamps_on_create_when_model_record_timestamps_is_false_but_overridden
435+
with_record_timestamps(Ship, false) do
436+
Ship.upsert_all [{ id: 101, name: "RSS Boaty McBoatface" }], record_timestamps: true
437+
438+
ship = Ship.find(101)
439+
assert_equal Time.now.year, ship.created_at.year
440+
assert_equal Time.now.year, ship.created_on.year
441+
assert_equal Time.now.year, ship.updated_at.year
442+
assert_equal Time.now.year, ship.updated_on.year
443+
end
444+
end
445+
446+
def test_upsert_all_respects_created_at_precision_when_touched_implicitly
447+
skip unless supports_datetime_with_precision?
448+
449+
Book.upsert_all [{ id: 101, name: "Out of the Silent Planet", published_on: Date.new(1938, 4, 8) }]
450+
451+
assert_not_predicate Book.find(101).created_at.usec, :zero?, "created_at should have sub-second precision"
452+
end
453+
454+
def test_upsert_all_implicitly_sets_timestamps_on_update_when_model_record_timestamps_is_true
455+
skip unless supports_insert_on_duplicate_update?
456+
457+
with_record_timestamps(Ship, true) do
458+
travel_to(Date.new(2016, 4, 17)) { Ship.create! id: 101, name: "RSS Boaty McBoatface" }
459+
460+
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }]
461+
462+
ship = Ship.find(101)
463+
assert_equal 2016, ship.created_at.year
464+
assert_equal 2016, ship.created_on.year
465+
assert_equal Time.now.year, ship.updated_at.year
466+
assert_equal Time.now.year, ship.updated_on.year
467+
end
468+
end
469+
470+
def test_upsert_all_does_not_implicitly_set_timestamps_on_update_when_model_record_timestamps_is_true_but_overridden
471+
skip unless supports_insert_on_duplicate_update?
472+
473+
with_record_timestamps(Ship, true) do
474+
travel_to(Date.new(2016, 4, 17)) { Ship.create! id: 101, name: "RSS Boaty McBoatface" }
475+
476+
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }], record_timestamps: false
477+
478+
ship = Ship.find(101)
479+
assert_equal 2016, ship.created_at.year
480+
assert_equal 2016, ship.created_on.year
481+
assert_equal 2016, ship.updated_at.year
482+
assert_equal 2016, ship.updated_on.year
483+
end
484+
end
485+
486+
def test_upsert_all_does_not_implicitly_set_timestamps_on_update_when_model_record_timestamps_is_false
487+
skip unless supports_insert_on_duplicate_update?
488+
489+
with_record_timestamps(Ship, false) do
490+
Ship.create! id: 101, name: "RSS Boaty McBoatface"
491+
492+
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }]
493+
494+
ship = Ship.find(101)
495+
assert_nil ship.created_at
496+
assert_nil ship.created_on
497+
assert_nil ship.updated_at
498+
assert_nil ship.updated_on
499+
end
500+
end
501+
502+
def test_upsert_all_implicitly_sets_timestamps_on_update_when_model_record_timestamps_is_false_but_overridden
503+
skip unless supports_insert_on_duplicate_update?
504+
505+
with_record_timestamps(Ship, false) do
506+
Ship.create! id: 101, name: "RSS Boaty McBoatface"
507+
508+
Ship.upsert_all [{ id: 101, name: "RSS Sir David Attenborough" }], record_timestamps: true
509+
510+
ship = Ship.find(101)
511+
assert_nil ship.created_at
512+
assert_nil ship.created_on
513+
assert_equal Time.now.year, ship.updated_at.year
514+
assert_equal Time.now.year, ship.updated_on.year
515+
end
516+
end
517+
518+
def test_upsert_all_implicitly_sets_timestamps_even_when_columns_are_aliased
519+
skip unless supports_insert_on_duplicate_update?
520+
521+
Developer.upsert_all [{ id: 101, name: "Alice" }]
522+
alice = Developer.find(101)
523+
524+
assert_not_nil alice.created_at
525+
assert_not_nil alice.created_on
526+
assert_not_nil alice.updated_at
527+
assert_not_nil alice.updated_on
528+
529+
alice.update!(created_at: nil, created_on: nil, updated_at: nil, updated_on: nil)
530+
531+
Developer.upsert_all [{ id: alice.id, name: alice.name, salary: alice.salary * 2 }]
532+
alice.reload
533+
534+
assert_nil alice.created_at
535+
assert_nil alice.created_on
536+
assert_not_nil alice.updated_at
537+
assert_not_nil alice.updated_on
538+
end
539+
396540
def test_insert_all_raises_on_unknown_attribute
397541
assert_raise ActiveRecord::UnknownAttributeError do
398542
Book.insert_all! [{ unknown_attribute: "Test" }]
@@ -515,4 +659,12 @@ def capture_log_output
515659
ActiveRecord::Base.logger = old_logger
516660
end
517661
end
662+
663+
def with_record_timestamps(model, value)
664+
original = model.record_timestamps
665+
model.record_timestamps = value
666+
yield
667+
ensure
668+
model.record_timestamps = original
669+
end
518670
end

0 commit comments

Comments
 (0)