diff options
Diffstat (limited to 'activerecord')
-rw-r--r-- | activerecord/CHANGELOG.md | 18 | ||||
-rw-r--r-- | activerecord/lib/active_record/associations/collection_association.rb | 4 | ||||
-rw-r--r-- | activerecord/lib/active_record/connection_adapters/postgresql/oid/array.rb | 2 | ||||
-rw-r--r-- | activerecord/lib/active_record/counter_cache.rb | 68 | ||||
-rw-r--r-- | activerecord/lib/active_record/timestamp.rb | 11 | ||||
-rw-r--r-- | activerecord/test/cases/adapters/postgresql/array_test.rb | 8 | ||||
-rw-r--r-- | activerecord/test/cases/counter_cache_test.rb | 132 | ||||
-rw-r--r-- | activerecord/test/cases/scoping/default_scoping_test.rb | 8 | ||||
-rw-r--r-- | activerecord/test/cases/timestamp_test.rb | 12 | ||||
-rw-r--r-- | activerecord/test/models/developer.rb | 2 |
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 |