Skip to content
This repository
Browse code

This patch changes update_attribute implementatino so:

- it will only save the attribute it has been asked to save and not all dirty attributes

- it does not invoke callbacks

- it does change updated_at/on

Signed-off-by: José Valim <jose.valim@gmail.com>
  • Loading branch information...
commit 01629d180468049d17a8be6900e27a4f0d2b18c4 1 parent 690352d
Neeraj Singh authored josevalim committed
17  activerecord/lib/active_record/persistence.rb
@@ -102,12 +102,19 @@ def becomes(klass)
102 102
       became
103 103
     end
104 104
 
105  
-    # Updates a single attribute and saves the record without going through the normal validation procedure.
106  
-    # This is especially useful for boolean flags on existing records. The regular +update_attribute+ method
107  
-    # in Base is replaced with this when the validations module is mixed in, which it is by default.
  105
+    # Updates a single attribute and saves the record without going through the normal validation procedure
  106
+    # or callbacks. This is especially useful for boolean flags on existing records.
108 107
     def update_attribute(name, value)
109 108
       send("#{name}=", value)
110  
-      save(:validate => false)
  109
+      primary_key = self.class.primary_key
  110
+      h = {name => value}
  111
+      if should_record_update_timestamps
  112
+        self.send(:record_update_timestamps)
  113
+        current_time = current_time_from_proper_timezone
  114
+        timestamp_attributes_for_update_in_model.each { |column| h.merge!(column => current_time) }
  115
+      end
  116
+      self.class.update_all(h, {primary_key => self[primary_key]}) == 1
  117
+      @changed_attributes.delete(name.to_s)
111 118
     end
112 119
 
113 120
     # Updates all the attributes from the passed-in Hash and saves the record. 
@@ -234,4 +241,4 @@ def attributes_from_column_definition
234 241
       end
235 242
     end
236 243
   end
237  
-end
  244
+end
12  activerecord/lib/active_record/timestamp.rb
@@ -58,14 +58,22 @@ def create #:nodoc:
58 58
     end
59 59
 
60 60
     def update(*args) #:nodoc:
61  
-      if record_timestamps && (!partial_updates? || changed?)
  61
+      record_update_timestamps
  62
+      super
  63
+    end
  64
+
  65
+    def record_update_timestamps
  66
+      if should_record_update_timestamps
62 67
         current_time = current_time_from_proper_timezone
63 68
         timestamp_attributes_for_update_in_model.each { |column| write_attribute(column.to_s, current_time) }
64 69
       end
  70
+    end
65 71
 
66  
-      super
  72
+    def should_record_update_timestamps
  73
+      record_timestamps && (!partial_updates? || changed?)
67 74
     end
68 75
 
  76
+
69 77
     def timestamp_attributes_for_update #:nodoc:
70 78
       [:updated_at, :updated_on]
71 79
     end
40  activerecord/test/cases/base_test.rb
@@ -893,6 +893,46 @@ def test_update_attribute
893 893
     assert !Topic.find(1).approved?
894 894
   end
895 895
 
  896
+  def test_update_attribute_with_one_changed_and_one_updated
  897
+    t = Topic.order('id').limit(1).first
  898
+    title, author_name = t.title, t.author_name
  899
+    t.author_name = 'John'
  900
+    t.update_attribute(:title, 'super_title')
  901
+    assert_equal 'John', t.author_name
  902
+    assert_equal 'super_title', t.title
  903
+    assert t.changed?, "topic should have changed"
  904
+    assert t.author_name_changed?, "author_name should have changed"
  905
+    assert !t.title_changed?, "title should not have changed"
  906
+    assert_nil t.title_change, 'title change should be nil'
  907
+    assert_equal ['author_name'], t.changed
  908
+
  909
+    t.reload
  910
+    assert_equal 'David', t.author_name
  911
+    assert_equal 'super_title', t.title
  912
+  end
  913
+
  914
+  def test_update_attribute_with_one_updated
  915
+    t = Topic.first
  916
+    title = t.title
  917
+    t.update_attribute(:title, 'super_title')
  918
+    assert_equal 'super_title', t.title
  919
+    assert !t.changed?, "topic should not have changed"
  920
+    assert !t.title_changed?, "title should not have changed"
  921
+    assert_nil t.title_change, 'title change should be nil'
  922
+
  923
+    t.reload
  924
+    assert_equal 'super_title', t.title
  925
+  end
  926
+
  927
+  def test_update_attribute_for_udpated_at_on
  928
+    developer = Developer.find(1)
  929
+    updated_at = developer.updated_at
  930
+    developer.update_attribute(:salary, 80001)
  931
+    assert_not_equal updated_at, developer.updated_at
  932
+    developer.reload
  933
+    assert_not_equal updated_at, developer.updated_at
  934
+  end
  935
+
896 936
   def test_update_attributes
897 937
     topic = Topic.find(1)
898 938
     assert !topic.approved?
7  activerecord/test/cases/dirty_test.rb
@@ -475,10 +475,9 @@ def test_previous_changes
475 475
     pirate = Pirate.find_by_catchphrase("Ahoy!")
476 476
     pirate.update_attribute(:catchphrase, "Ninjas suck!")
477 477
 
478  
-    assert_equal 2, pirate.previous_changes.size
479  
-    assert_equal ["Ahoy!", "Ninjas suck!"], pirate.previous_changes['catchphrase']
480  
-    assert_not_nil pirate.previous_changes['updated_on'][0]
481  
-    assert_not_nil pirate.previous_changes['updated_on'][1]
  478
+    assert_equal 0, pirate.previous_changes.size
  479
+    assert_nil pirate.previous_changes['catchphrase']
  480
+    assert_nil pirate.previous_changes['updated_on']
482 481
     assert !pirate.previous_changes.key?('parrot_id')
483 482
     assert !pirate.previous_changes.key?('created_on')    
484 483
   end
18  activerecord/test/cases/nested_attributes_test.rb
@@ -195,7 +195,7 @@ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy
195 195
     [1, '1', true, 'true'].each do |truth|
196 196
       @pirate.reload.create_ship(:name => 'Mister Pablo')
197 197
       assert_difference('Ship.count', -1) do
198  
-        @pirate.update_attribute(:ship_attributes, { :id => @pirate.ship.id, :_destroy => truth })
  198
+        @pirate.update_attributes(:ship_attributes => { :id => @pirate.ship.id, :_destroy => truth })
199 199
       end
200 200
     end
201 201
   end
@@ -203,7 +203,7 @@ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy
203 203
   def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
204 204
     [nil, '0', 0, 'false', false].each do |not_truth|
205 205
       assert_no_difference('Ship.count') do
206  
-        @pirate.update_attribute(:ship_attributes, { :id => @pirate.ship.id, :_destroy => not_truth })
  206
+        @pirate.update_attributes(:ship_attributes => { :id => @pirate.ship.id, :_destroy => not_truth })
207 207
       end
208 208
     end
209 209
   end
@@ -212,7 +212,7 @@ def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
212 212
     Pirate.accepts_nested_attributes_for :ship, :allow_destroy => false, :reject_if => proc { |attributes| attributes.empty? }
213 213
 
214 214
     assert_no_difference('Ship.count') do
215  
-      @pirate.update_attribute(:ship_attributes, { :id => @pirate.ship.id, :_destroy => '1' })
  215
+      @pirate.update_attributes(:ship_attributes => { :id => @pirate.ship.id, :_destroy => '1' })
216 216
     end
217 217
 
218 218
     Pirate.accepts_nested_attributes_for :ship, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? }
@@ -247,13 +247,13 @@ def test_should_automatically_enable_autosave_on_the_association
247 247
   end
248 248
 
249 249
   def test_should_accept_update_only_option
250  
-    @pirate.update_attribute(:update_only_ship_attributes, { :id => @pirate.ship.id, :name => 'Mayflower' })
  250
+    @pirate.update_attributes(:update_only_ship_attributes => { :id => @pirate.ship.id, :name => 'Mayflower' })
251 251
   end
252 252
 
253 253
   def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true
254 254
     @ship.delete
255 255
     assert_difference('Ship.count', 1) do
256  
-      @pirate.reload.update_attribute(:update_only_ship_attributes, { :name => 'Mayflower' })
  256
+      @pirate.reload.update_attributes(:update_only_ship_attributes => { :name => 'Mayflower' })
257 257
     end
258 258
   end
259 259
 
@@ -353,7 +353,7 @@ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy
353 353
     [1, '1', true, 'true'].each do |truth|
354 354
       @ship.reload.create_pirate(:catchphrase => 'Arr')
355 355
       assert_difference('Pirate.count', -1) do
356  
-        @ship.update_attribute(:pirate_attributes, { :id => @ship.pirate.id, :_destroy => truth })
  356
+        @ship.update_attributes(:pirate_attributes => { :id => @ship.pirate.id, :_destroy => truth })
357 357
       end
358 358
     end
359 359
   end
@@ -361,7 +361,7 @@ def test_should_destroy_an_existing_record_if_there_is_a_matching_id_and_destroy
361 361
   def test_should_not_destroy_an_existing_record_if_destroy_is_not_truthy
362 362
     [nil, '0', 0, 'false', false].each do |not_truth|
363 363
       assert_no_difference('Pirate.count') do
364  
-        @ship.update_attribute(:pirate_attributes, { :id => @ship.pirate.id, :_destroy => not_truth })
  364
+        @ship.update_attributes(:pirate_attributes => { :id => @ship.pirate.id, :_destroy => not_truth })
365 365
       end
366 366
     end
367 367
   end
@@ -370,7 +370,7 @@ def test_should_not_destroy_an_existing_record_if_allow_destroy_is_false
370 370
     Ship.accepts_nested_attributes_for :pirate, :allow_destroy => false, :reject_if => proc { |attributes| attributes.empty? }
371 371
 
372 372
     assert_no_difference('Pirate.count') do
373  
-      @ship.update_attribute(:pirate_attributes, { :id => @ship.pirate.id, :_destroy => '1' })
  373
+      @ship.update_attributes(:pirate_attributes => { :id => @ship.pirate.id, :_destroy => '1' })
374 374
     end
375 375
 
376 376
     Ship.accepts_nested_attributes_for :pirate, :allow_destroy => true, :reject_if => proc { |attributes| attributes.empty? }
@@ -398,7 +398,7 @@ def test_should_automatically_enable_autosave_on_the_association
398 398
   def test_should_create_new_model_when_nothing_is_there_and_update_only_is_true
399 399
     @pirate.delete
400 400
     assert_difference('Pirate.count', 1) do
401  
-      @ship.reload.update_attribute(:update_only_pirate_attributes, { :catchphrase => 'Arr' })
  401
+      @ship.reload.update_attributes(:update_only_pirate_attributes => { :catchphrase => 'Arr' })
402 402
     end
403 403
   end
404 404
 

12 notes on commit 01629d1

Sven Fuchs

Why the hell would update_attribute do this (i.e. hide itself from dirty tracking)? Isn't this a regression compared to AR 2.3.x (see http://github.com/rails/rails/blob/v2.3.8/activerecord/lib/active_record/base.rb#L2657)?

Christian Seiler

This change at such a late time (just before RC/release) looks scary to me

VOKLE, Inc.

hmm.... If this does go in there should be a huge note about it since this is a pretty core method. I imagine it could break a bunch of plugins because of the callbacks thing

Neeraj Singh

after the operation is performed then :name attribute is no longer dirty. Hence it is being removed from the @changed_attributes. Yes it is a change from the way AR 2.3.x works.

Neeraj Singh
Collaborator

@csmuc yes this is unfortunate that it was implemented so late in the game. Do notice that previously update_attribute used to save all the dirty attributes not just the attribute asked for. And that bug needed to be fixed.

@vokle: Doc has been updated http://github.com/lifo/docrails/blob/master/activerecord/lib/active_record/persistence.rb#L105

update_attribute was originally meant for sure shot way of updating a record. Assume that after sending out an email you want to mark the record as sent. @user.update_attribute(:email_sent, true). Previously this method used to skip validations but still used to call all the callbacks. Which means if one of the callbacks returns false then record will not be updated. And potentially user might be multiple emails. So it was necessary to ensure that callbacks are not invoked.

Previously one could do @user.update_attribute(:car, Car.new). That won't work anymore. Remember update_attribute skips all the validations and this way of assigning record is not recommended to beging with. With this commit one can't use update_attribute to assign a new record. Already I marked a few tickets as 'invalid' related to this change.

Overall the places where update_attribute could be used has been restricted. One should use update_attributes for anything more than just updating a column.

It is an unfortunate side effect that some plugins might break. But rails3 is a big change and it already changes the way plugins are written.

Xavier Noria
Owner
fxn commented on 01629d1

This change is going to break existing code because update_attribute no longer invokes callbacks. For sure. And it is unfortunate because there's no way Rails can warn about it in 2.3 or anything. So those breakages won't be even apparent in general. That's not good.

The email example... well in that case you have a bug. update_attribute invokes callbacks, a callback halts save, no update is performed. That's fine! There's nothing to fix here, is the way it is supposed to be. On the other hand, when I write an after_save, I want it called whenever the model is saved. And update_attribute was following that expectation.

If you wanted a way to get directly to the database bypassing callbacks then a new method should have been implemented in my view.

Neeraj Singh
Collaborator

I had a rather long chat with José Valim regarding this issue. Later Jeremy Kemper was also involved in the decision making process.

If you look at update_attribute and update_attributes the only difference was that update_attribute was not calling validation. Now we could create a new method (as suggested by Xavier) which would hit the database straight. Or we could shift update_attribute to do that and if you were using update_attribute for not invoking validation then change the code to have update_attributes(:validation => false).

Based on the number of tickets being generated in this area it seems it would have been better to deprecate update_attribute and create a new method. And then get rid of update_attribute in 3.1. That would have saved a lot of trouble.

aaronchi

Why not just create a new method for updating without callbacks/validation and leave update_attribute as is?

Neeraj Singh
Collaborator

update_attribute saves all the dirty attributes. leaving it as is would be a bug.

Neeraj Singh
Collaborator

Also touch uses update_attribute . So when you meant to touch :email_sent_at it was saving all the dirty attributes.

aaronchi

I'm saying, don't make it save all dirty attributes but still trigger callbacks/validations like before. Then, create a new method to update without callbacks/validations.

In that way, update_attribute and update_attributes work basically the same way and update_attribute is just a convenience method for updating only one attribute. Then, create a new method or add an option for updating attribute/attributes without triggering callbacks/validations

Michael Dvorkin

@aaronchi instead of entirely new method we could have update_attribute(name, value, :validate => true/false)

aaronchi

considering that save now has :validate => true/false it might be nice to add these to all save methods

:validate => true/false

:callbacks => true/false

:touch => true/false

Geoff Garside

I was bitten by #touch not triggering callbacks earlier today, as I only needed it to trigger callbacks in one case within my models I opted to override #touch such that it invoked save! after calling super which seems kind of bad as it now results in two DB hits, having a :callbacks => true/false would be nice.

Sven Fuchs

i'm not sure i understand what your saying. it should be dirty during the after_update callback (which now is not longer called) like that behaved in 2.3.x. what exactly is the reasoning for not just sticking to the implementation from 2.3.x?

Please sign in to comment.
Something went wrong with that request. Please try again.