aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord
diff options
context:
space:
mode:
Diffstat (limited to 'activerecord')
-rw-r--r--activerecord/CHANGELOG.md18
-rw-r--r--activerecord/lib/active_record/associations/collection_association.rb4
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb2
-rw-r--r--activerecord/lib/active_record/counter_cache.rb68
-rw-r--r--activerecord/lib/active_record/timestamp.rb11
-rw-r--r--activerecord/test/cases/adapters/postgresql/array_test.rb8
-rw-r--r--activerecord/test/cases/counter_cache_test.rb132
-rw-r--r--activerecord/test/cases/scoping/default_scoping_test.rb8
-rw-r--r--activerecord/test/cases/timestamp_test.rb12
-rw-r--r--activerecord/test/models/developer.rb2
10 files changed, 235 insertions, 30 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index b535b35ba0..b59d24f88d 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,3 +1,21 @@
+* Add `touch` option to counter cache modifying methods.
+
+ Works when updating, resetting, incrementing and decrementing counters:
+
+ # Touches `updated_at`/`updated_on`.
+ Topic.increment_counter(:messages_count, 1, touch: true)
+ Topic.decrement_counter(:messages_count, 1, touch: true)
+
+ # Touches `last_discussed_at`.
+ Topic.reset_counters(18, :messages, touch: :last_discussed_at)
+
+ # Touches `updated_at` and `last_discussed_at`.
+ Topic.update_counters(18, messages_count: 5, touch: %i( updated_at last_discussed_at ))
+
+ Fixes #26724.
+
+ *Jarred Trost*
+
* Remove deprecated `#uniq`, `#uniq!`, and `#uniq_value`.
*Ryuta Kamizono*
diff --git a/activerecord/lib/active_record/associations/collection_association.rb b/activerecord/lib/active_record/associations/collection_association.rb
index 974405e019..9fa8b5d469 100644
--- a/activerecord/lib/active_record/associations/collection_association.rb
+++ b/activerecord/lib/active_record/associations/collection_association.rb
@@ -47,9 +47,7 @@ module ActiveRecord
# Implements the ids reader method, e.g. foo.item_ids for Foo.has_many :items
def ids_reader
if loaded?
- load_target.map do |record|
- record.send(reflection.association_primary_key)
- end
+ target.pluck(reflection.association_primary_key)
else
@association_ids ||= (
column = "#{reflection.quoted_table_name}.#{reflection.association_primary_key}"
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb
index d9daaaa23e..8ccdd69b1d 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb
@@ -6,7 +6,7 @@ module ActiveRecord
include Type::Helpers::Mutable
attr_reader :subtype, :delimiter
- delegate :type, :user_input_in_time_zone, :limit, to: :subtype
+ delegate :type, :user_input_in_time_zone, :limit, :precision, :scale, to: :subtype
def initialize(subtype, delimiter = ",")
@subtype = subtype
diff --git a/activerecord/lib/active_record/counter_cache.rb b/activerecord/lib/active_record/counter_cache.rb
index e2da512813..88ac10bffd 100644
--- a/activerecord/lib/active_record/counter_cache.rb
+++ b/activerecord/lib/active_record/counter_cache.rb
@@ -12,13 +12,21 @@ module ActiveRecord
#
# * +id+ - The id of the object you wish to reset a counter on.
# * +counters+ - One or more association counters to reset. Association name or counter name can be given.
+ # * <tt>:touch</tt> - Touch timestamp columns when updating.
+ # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to
+ # touch that column or an array of symbols to touch just those ones.
#
# ==== Examples
#
- # # For Post with id #1 records reset the comments_count
+ # # For the Post with id #1, reset the comments_count
# Post.reset_counters(1, :comments)
- def reset_counters(id, *counters)
+ #
+ # # For the Post with id #1, reset the comments_count
+ # # and update the +updated_at+ and/or +updated_on+ attributes.
+ # Post.reset_counters(1, :comments, touch: true)
+ def reset_counters(id, *counters, touch: nil)
object = find(id)
+
counters.each do |counter_association|
has_many_association = _reflect_on_association(counter_association)
unless has_many_association
@@ -37,10 +45,12 @@ module ActiveRecord
reflection = child_class._reflections.values.find { |e| e.belongs_to? && e.foreign_key.to_s == foreign_key && e.options[:counter_cache].present? }
counter_name = reflection.counter_cache_column
- unscoped.where(primary_key => object.id).update_all(
- counter_name => object.send(counter_association).count(:all)
- )
+ updates = { counter_name.to_sym => object.send(counter_association).count(:all) }
+ updates.merge!(touch_updates(object, touch)) if touch
+
+ unscoped.where(primary_key => object.id).update_all(updates)
end
+
return true
end
@@ -55,6 +65,9 @@ module ActiveRecord
# * +id+ - The id of the object you wish to update a counter on or an array of ids.
# * +counters+ - A Hash containing the names of the fields
# to update as keys and the amount to update the field by as values.
+ # * <tt>:touch</tt> option - Touch timestamp columns when updating.
+ # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to
+ # touch that column or an array of symbols to touch just those ones.
#
# ==== Examples
#
@@ -73,13 +86,29 @@ module ActiveRecord
# # UPDATE posts
# # SET comment_count = COALESCE(comment_count, 0) + 1
# # WHERE id IN (10, 15)
+ #
+ # # For the Posts with id of 10 and 15, increment the comment_count by 1
+ # # and update the updated_at value for each counter.
+ # Post.update_counters [10, 15], comment_count: 1, touch: true
+ # # Executes the following SQL:
+ # # UPDATE posts
+ # # SET comment_count = COALESCE(comment_count, 0) + 1,
+ # # `updated_at` = '2016-10-13T09:59:23-05:00'
+ # # WHERE id IN (10, 15)
def update_counters(id, counters)
+ touch = counters.delete(:touch)
+
updates = counters.map do |counter_name, value|
operator = value < 0 ? "-" : "+"
quoted_column = connection.quote_column_name(counter_name)
"#{quoted_column} = COALESCE(#{quoted_column}, 0) #{operator} #{value.abs}"
end
+ if touch
+ object = find(id)
+ updates << object.class.send(:sanitize_sql_for_assignment, touch_updates(object, touch))
+ end
+
unscoped.where(primary_key => id).update_all updates.join(", ")
end
@@ -94,13 +123,20 @@ module ActiveRecord
#
# * +counter_name+ - The name of the field that should be incremented.
# * +id+ - The id of the object that should be incremented or an array of ids.
+ # * <tt>:touch</tt> - Touch timestamp columns when updating.
+ # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to
+ # touch that column or an array of symbols to touch just those ones.
#
# ==== Examples
#
# # Increment the posts_count column for the record with an id of 5
# DiscussionBoard.increment_counter(:posts_count, 5)
- def increment_counter(counter_name, id)
- update_counters(id, counter_name => 1)
+ #
+ # # Increment the posts_count column for the record with an id of 5
+ # # and update the updated_at value.
+ # DiscussionBoard.increment_counter(:posts_count, 5, touch: true)
+ def increment_counter(counter_name, id, touch: nil)
+ update_counters(id, counter_name => 1, touch: touch)
end
# Decrement a numeric field by one, via a direct SQL update.
@@ -112,14 +148,28 @@ module ActiveRecord
#
# * +counter_name+ - The name of the field that should be decremented.
# * +id+ - The id of the object that should be decremented or an array of ids.
+ # * <tt>:touch</tt> - Touch timestamp columns when updating.
+ # Pass +true+ to touch +updated_at+ and/or +updated_on+. Pass a symbol to
+ # touch that column or an array of symbols to touch just those ones.
#
# ==== Examples
#
# # Decrement the posts_count column for the record with an id of 5
# DiscussionBoard.decrement_counter(:posts_count, 5)
- def decrement_counter(counter_name, id)
- update_counters(id, counter_name => -1)
+ #
+ # # Decrement the posts_count column for the record with an id of 5
+ # # and update the updated_at value.
+ # DiscussionBoard.decrement_counter(:posts_count, 5, touch: true)
+ def decrement_counter(counter_name, id, touch: nil)
+ update_counters(id, counter_name => -1, touch: touch)
end
+
+ private
+ def touch_updates(object, touch)
+ touch = object.send(:timestamp_attributes_for_update_in_model) if touch == true
+ touch_time = object.send(:current_time_from_proper_timezone)
+ Array(touch).map { |column| [ column, touch_time ] }.to_h
+ end
end
private
diff --git a/activerecord/lib/active_record/timestamp.rb b/activerecord/lib/active_record/timestamp.rb
index 63100e38a1..030fc35e2f 100644
--- a/activerecord/lib/active_record/timestamp.rb
+++ b/activerecord/lib/active_record/timestamp.rb
@@ -1,3 +1,4 @@
+# frozen_string_literal: true
module ActiveRecord
# = Active Record \Timestamp
#
@@ -58,7 +59,6 @@ module ActiveRecord
current_time = current_time_from_proper_timezone
all_timestamp_attributes.each do |column|
- column = column.to_s
if has_attribute?(column) && !attribute_present?(column)
write_attribute(column, current_time)
end
@@ -73,7 +73,6 @@ module ActiveRecord
current_time = current_time_from_proper_timezone
timestamp_attributes_for_update_in_model.each do |column|
- column = column.to_s
next if will_save_change_to_attribute?(column)
write_attribute(column, current_time)
end
@@ -86,11 +85,11 @@ module ActiveRecord
end
def timestamp_attributes_for_create_in_model
- timestamp_attributes_for_create.select { |c| self.class.column_names.include?(c.to_s) }
+ timestamp_attributes_for_create.select { |c| self.class.column_names.include?(c) }
end
def timestamp_attributes_for_update_in_model
- timestamp_attributes_for_update.select { |c| self.class.column_names.include?(c.to_s) }
+ timestamp_attributes_for_update.select { |c| self.class.column_names.include?(c) }
end
def all_timestamp_attributes_in_model
@@ -98,11 +97,11 @@ module ActiveRecord
end
def timestamp_attributes_for_update
- [:updated_at, :updated_on]
+ ["updated_at", "updated_on"]
end
def timestamp_attributes_for_create
- [:created_at, :created_on]
+ ["created_at", "created_on"]
end
def all_timestamp_attributes
diff --git a/activerecord/test/cases/adapters/postgresql/array_test.rb b/activerecord/test/cases/adapters/postgresql/array_test.rb
index 680dad9706..982a91782f 100644
--- a/activerecord/test/cases/adapters/postgresql/array_test.rb
+++ b/activerecord/test/cases/adapters/postgresql/array_test.rb
@@ -16,10 +16,11 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase
@connection.transaction do
@connection.create_table("pg_arrays") do |t|
- t.string "tags", array: true
+ t.string "tags", array: true, limit: 255
t.integer "ratings", array: true
t.datetime :datetimes, array: true
t.hstore :hstores, array: true
+ t.decimal :decimals, array: true, default: [], precision: 10, scale: 2
end
end
PgArray.reset_column_information
@@ -34,7 +35,7 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase
def test_column
assert_equal :string, @column.type
- assert_equal "character varying", @column.sql_type
+ assert_equal "character varying(255)", @column.sql_type
assert @column.array?
assert_not @type.binary?
@@ -110,8 +111,9 @@ class PostgresqlArrayTest < ActiveRecord::PostgreSQLTestCase
def test_schema_dump_with_shorthand
output = dump_table_schema "pg_arrays"
- assert_match %r[t\.string\s+"tags",\s+array: true], output
+ assert_match %r[t\.string\s+"tags",\s+limit: 255,\s+array: true], output
assert_match %r[t\.integer\s+"ratings",\s+array: true], output
+ assert_match %r[t\.decimal\s+"decimals",\s+precision: 10,\s+scale: 2,\s+default: \[\],\s+array: true], output
end
def test_select_with_strings
diff --git a/activerecord/test/cases/counter_cache_test.rb b/activerecord/test/cases/counter_cache_test.rb
index 84f2c3a465..c735e13715 100644
--- a/activerecord/test/cases/counter_cache_test.rb
+++ b/activerecord/test/cases/counter_cache_test.rb
@@ -211,4 +211,136 @@ class CounterCacheTest < ActiveRecord::TestCase
aircraft.wheels.first.destroy
end
end
+
+ test "update counters doesn't touch timestamps by default" do
+ @topic.update_column :updated_at, 5.minutes.ago
+ previously_updated_at = @topic.updated_at
+
+ Topic.update_counters(@topic.id, replies_count: -1)
+
+ assert_equal previously_updated_at, @topic.updated_at
+ end
+
+ test "update counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: true)
+ end
+ end
+
+ test "update multiple counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: true)
+ end
+ end
+
+ test "reset counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.reset_counters(@topic.id, :replies, touch: true)
+ end
+ end
+
+ test "reset multiple counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: true)
+ end
+ end
+
+ test "increment counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.increment_counter(:replies_count, @topic.id, touch: true)
+ end
+ end
+
+ test "decrement counters with touch: true" do
+ assert_touching @topic, :updated_at do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: true)
+ end
+ end
+
+ test "update counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: :written_on)
+ end
+ end
+
+ test "update multiple counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: :written_on)
+ end
+ end
+
+ test "reset counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.reset_counters(@topic.id, :replies, touch: :written_on)
+ end
+ end
+
+ test "reset multiple counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: :written_on)
+ end
+ end
+
+ test "increment counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.increment_counter(:replies_count, @topic.id, touch: :written_on)
+ end
+ end
+
+ test "decrement counters with touch: :written_on" do
+ assert_touching @topic, :written_on do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: :written_on)
+ end
+ end
+
+ test "update counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: -1, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "update multiple counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 2, unique_replies_count: 2, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "reset counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.reset_counters(@topic.id, :replies, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "reset multiple counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.update_counters(@topic.id, replies_count: 1, unique_replies_count: 1)
+ Topic.reset_counters(@topic.id, :replies, :unique_replies, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "increment counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.increment_counter(:replies_count, @topic.id, touch: %i( updated_at written_on ))
+ end
+ end
+
+ test "decrement counters with touch: %i( updated_at written_on )" do
+ assert_touching @topic, :updated_at, :written_on do
+ Topic.decrement_counter(:replies_count, @topic.id, touch: %i( updated_at written_on ))
+ end
+ end
+
+ private
+ def assert_touching(record, *attributes)
+ record.update_columns attributes.map { |attr| [ attr, 5.minutes.ago ] }.to_h
+ touch_times = attributes.map { |attr| [ attr, record.public_send(attr) ] }.to_h
+
+ yield
+
+ touch_times.each do |attr, previous_touch_time|
+ assert_operator previous_touch_time, :<, record.reload.public_send(attr)
+ end
+ end
end
diff --git a/activerecord/test/cases/scoping/default_scoping_test.rb b/activerecord/test/cases/scoping/default_scoping_test.rb
index b408a6000b..3a04f4bf7d 100644
--- a/activerecord/test/cases/scoping/default_scoping_test.rb
+++ b/activerecord/test/cases/scoping/default_scoping_test.rb
@@ -5,6 +5,7 @@ require "models/developer"
require "models/computer"
require "models/vehicle"
require "models/cat"
+require "concurrent/atomic/cyclic_barrier"
class DefaultScopingTest < ActiveRecord::TestCase
fixtures :developers, :posts, :comments
@@ -437,12 +438,17 @@ class DefaultScopingTest < ActiveRecord::TestCase
threads = []
assert_not_equal 1, ThreadsafeDeveloper.unscoped.count
+ barrier_1 = Concurrent::CyclicBarrier.new(2)
+ barrier_2 = Concurrent::CyclicBarrier.new(2)
+
threads << Thread.new do
- Thread.current[:long_default_scope] = true
+ Thread.current[:default_scope_delay] = -> { barrier_1.wait; barrier_2.wait }
assert_equal 1, ThreadsafeDeveloper.all.to_a.count
ThreadsafeDeveloper.connection.close
end
threads << Thread.new do
+ Thread.current[:default_scope_delay] = -> { barrier_2.wait }
+ barrier_1.wait
assert_equal 1, ThreadsafeDeveloper.all.to_a.count
ThreadsafeDeveloper.connection.close
end
diff --git a/activerecord/test/cases/timestamp_test.rb b/activerecord/test/cases/timestamp_test.rb
index cd83518e84..7327926706 100644
--- a/activerecord/test/cases/timestamp_test.rb
+++ b/activerecord/test/cases/timestamp_test.rb
@@ -432,32 +432,32 @@ class TimestampTest < ActiveRecord::TestCase
def test_timestamp_attributes_for_create
toy = Toy.first
- assert_equal [:created_at, :created_on], toy.send(:timestamp_attributes_for_create)
+ assert_equal ["created_at", "created_on"], toy.send(:timestamp_attributes_for_create)
end
def test_timestamp_attributes_for_update
toy = Toy.first
- assert_equal [:updated_at, :updated_on], toy.send(:timestamp_attributes_for_update)
+ assert_equal ["updated_at", "updated_on"], toy.send(:timestamp_attributes_for_update)
end
def test_all_timestamp_attributes
toy = Toy.first
- assert_equal [:created_at, :created_on, :updated_at, :updated_on], toy.send(:all_timestamp_attributes)
+ assert_equal ["created_at", "created_on", "updated_at", "updated_on"], toy.send(:all_timestamp_attributes)
end
def test_timestamp_attributes_for_create_in_model
toy = Toy.first
- assert_equal [:created_at], toy.send(:timestamp_attributes_for_create_in_model)
+ assert_equal ["created_at"], toy.send(:timestamp_attributes_for_create_in_model)
end
def test_timestamp_attributes_for_update_in_model
toy = Toy.first
- assert_equal [:updated_at], toy.send(:timestamp_attributes_for_update_in_model)
+ assert_equal ["updated_at"], toy.send(:timestamp_attributes_for_update_in_model)
end
def test_all_timestamp_attributes_in_model
toy = Toy.first
- assert_equal [:created_at, :updated_at], toy.send(:all_timestamp_attributes_in_model)
+ assert_equal ["created_at", "updated_at"], toy.send(:all_timestamp_attributes_in_model)
end
def test_index_is_created_for_both_timestamps
diff --git a/activerecord/test/models/developer.rb b/activerecord/test/models/developer.rb
index 5ca1d37f6d..ea4f719517 100644
--- a/activerecord/test/models/developer.rb
+++ b/activerecord/test/models/developer.rb
@@ -251,7 +251,7 @@ class ThreadsafeDeveloper < ActiveRecord::Base
self.table_name = "developers"
def self.default_scope
- sleep 0.05 if Thread.current[:long_default_scope]
+ Thread.current[:default_scope_delay].call
limit(1)
end
end