From c03fddfa0a64cc839f08391f7b4aea881a421e2c Mon Sep 17 00:00:00 2001 From: Drew Tempelmeyer Date: Mon, 10 Jan 2022 15:53:27 -0600 Subject: [PATCH] Add ActiveRecord::Persistence#update_attribute! --- activerecord/CHANGELOG.md | 23 +++++++ activerecord/lib/active_record/persistence.rb | 24 ++++++- activerecord/test/cases/persistence_test.rb | 63 +++++++++++++++++++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 9e0422472a..9f6d4b2eda 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,3 +1,26 @@ +* Add `update_attribute!` to `ActiveRecord::Persistence` + + Similar to `update_attribute`, but raises `ActiveRecord::RecordNotSaved` when a `before_*` callback throws `:abort`. + + ```ruby + class Topic < ActiveRecord::Base + before_save :check_title + + def check_title + throw(:abort) if title == "abort" + end + end + + topic = Topic.create(title: "Test Title") + # #=> # + topic.update_attribute!(:title, "Another Title") + # #=> # + topic.update_attribute!(:title, "abort") + # raises ActiveRecord::RecordNotSaved + ``` + + *Drew Tempelmeyer* + * Avoid loading every record in `ActiveRecord::Relation#pretty_print` ```ruby diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 09c997d339..0d42905c59 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -747,7 +747,7 @@ module ActiveRecord # * updated_at/updated_on column is updated if that column is available. # * Updates all the attributes that are dirty in this object. # - # This method raises an ActiveRecord::ActiveRecordError if the + # This method raises an ActiveRecord::ActiveRecordError if the # attribute is marked as readonly. # # Also see #update_column. @@ -759,6 +759,28 @@ module ActiveRecord save(validate: false) end + # Updates a single attribute and saves the record. + # This is especially useful for boolean flags on existing records. Also note that + # + # * Validation is skipped. + # * \Callbacks are invoked. + # * updated_at/updated_on column is updated if that column is available. + # * Updates all the attributes that are dirty in this object. + # + # This method raises an ActiveRecord::ActiveRecordError if the + # attribute is marked as readonly. + # + # If any of the before_* callbacks throws +:abort+ the action is cancelled + # and #update_attribute! raises ActiveRecord::RecordNotSaved. See + # ActiveRecord::Callbacks for further details. + def update_attribute!(name, value) + name = name.to_s + verify_readonly_attribute(name) + public_send("#{name}=", value) + + save!(validate: false) + end + # Updates the attributes of the model from the passed-in hash and saves the # record, all wrapped in a transaction. If the object is invalid, the saving # will fail and false will be returned. diff --git a/activerecord/test/cases/persistence_test.rb b/activerecord/test/cases/persistence_test.rb index 0eca43bb67..3e7ec69dc3 100644 --- a/activerecord/test/cases/persistence_test.rb +++ b/activerecord/test/cases/persistence_test.rb @@ -813,6 +813,69 @@ class PersistenceTest < ActiveRecord::TestCase assert_not_equal prev_month, developer.updated_at end + def test_update_attribute! + assert_not_predicate Topic.find(1), :approved? + Topic.find(1).update_attribute!("approved", true) + assert_predicate Topic.find(1), :approved? + + Topic.find(1).update_attribute!(:approved, false) + assert_not_predicate Topic.find(1), :approved? + + Topic.find(1).update_attribute!(:change_approved_before_save, true) + assert_predicate Topic.find(1), :approved? + end + + def test_update_attribute_for_readonly_attribute! + minivan = Minivan.find("m1") + assert_raises(ActiveRecord::ActiveRecordError) { minivan.update_attribute!(:color, "black") } + end + + def test_update_attribute_with_one_updated! + t = Topic.first + t.update_attribute!(:title, "super_title") + assert_equal "super_title", t.title + assert_not t.changed?, "topic should not have changed" + assert_not t.title_changed?, "title should not have changed" + assert_nil t.title_change, "title change should be nil" + + t.reload + assert_equal "super_title", t.title + end + + def test_update_attribute_for_updated_at_on! + developer = Developer.find(1) + prev_month = Time.now.prev_month.change(usec: 0) + + developer.update_attribute!(:updated_at, prev_month) + assert_equal prev_month, developer.updated_at + + developer.update_attribute!(:salary, 80001) + assert_not_equal prev_month, developer.updated_at + + developer.reload + assert_not_equal prev_month, developer.updated_at + end + + def test_update_attribute_for_aborted_callback! + klass = Class.new(Topic) do + def self.name; "Topic"; end + + before_update :throw_abort + + def throw_abort + throw(:abort) + end + end + + t = klass.create(title: "New Topic", author_name: "Not David") + + assert_raises(ActiveRecord::RecordNotSaved) { t.update_attribute!(:title, "super_title") } + + t_reloaded = Topic.find(t.id) + + assert_equal "New Topic", t_reloaded.title + end + def test_update_column topic = Topic.find(1) topic.update_column("approved", true)