Метод ActiveRecord::Base#update_attribute согласно документации предназначен для обновления отдельного атрибута и … дальше мало кто читает внимательно — сохранения без прохождения процесса валидации, что приводит к некоторым побочным эффектам (side effects), когда метод может обновлять любое кол-во атрибутов и даже сохранять ассоциации! Смотрите.
Создаем тестовый проект
$ rails side_effects_in_rails_1_active_record_update_attribute
$ cd side_effects_in_rails_1_active_record_update_attribute
$ script/generate rspec_model Post title:string body:string
$ rake db:migrate
$ rake db:test:clone
$ script/generate rspec
Напишем тест
# spec/models/post_spec.rb
describe Post, "#update_attribute" do
before(:each) do
@post = Post.create :title => "Old Title", :body => "Old Body"
@post.should_not be_new_record
end
# строим наше предположение
it "should update title and should NOT update body" do
@post.body = "New Body"
@post.update_attribute(:title, "New Title")
@post.reload # делаем reload, чтобы "сбросить" присвоение body
@post.title.should == "New Title"
@post.body.should == "Old Body"
end
end
Запускаем
$ spec -c -fs spec/models/post_spec.rb
Post#update_attribute
- should update title and should NOT update body (FAILED - 1)
1)
'Post#update_attribute should update title and should NOT update body' FAILED
expected: "Old Body",
got: "New Body" (using ==)
./spec/models/post_spec.rb:18:
Finished in 0.12975 seconds
Атрибут body сохранился, хотя мы этого совсем не хотели. Интересно? Идем дальше.
Сохраняем ассоциации через #update_attribute
Создадим модель Comment
$ script/generate rspec_model Comment body:string post:belongs_to
$ rake db:migrate
$ rake db:test:clone
добавим минимальную валидацию
# app/model/comment.rb
class Comment < ActiveRecord::Base
validates_presence_of :body
end
и в Post добавим ассоциацию comments
# app/model/post.rb
class Post < ActiveRecord::Base
has_many :comments
end
Добавим несколько тестов
- добавление валидного коментария
- добавление невалидного коментария
- добавление сразу двух валидного и невалидного комментариев
# spec/models/post_spec.rb
it "should update title and should NOT save a valid associated comment" do
@post.comments.should be_blank
# build добвляет объект в ассоциацию, но не сохраняет его
@post.comments.build :body => "Comment"
@post.update_attribute(:title, "New Title")
@post.reload
@post.title.should == "New Title"
# вместо ...should be_blank, напишем should == [], чтобы получить @post.comments.inspect
@post.comments.should == []
end
it "should update title and should NOT save an invalid comment" do
@post.comments.should be_blank
@post.comments.build :body => ""
@post.update_attribute(:title, "New Title")
@post.reload
@post.title.should == "New Title"
@post.comments.should == []
end
it "should update title and should NOT save any comment" do
@post.comments.should be_blank
@post.comments.build :body => "" # invalid comment
@post.comments.build :body => "Comment" # valid comment
@post.update_attribute(:title, "New Title")
@post.reload
@post.title.should == "New Title"
@post.comments.should == []
end
Снова запускаем
$ spec -c -fs spec/models/post_spec.rb
Post#update_attribute
- should update title and should NOT update body (FAILED - 1)
- should update title and should NOT save a valid associated comment (FAILED - 2)
- should update title and should NOT save an invalid comment
- should update title and should NOT save any comment (FAILED - 3)
1)
'Post#update_attribute should update title and should NOT update body' FAILED
expected: "Old Body",
got: "New Body" (using ==)
./spec/models/post_spec.rb:19:
2)
'Post#update_attribute should update title and should NOT save a valid associated comment' FAILED
expected: [],
got: [#<Comment id: 1, post_id: 1, body: "Comment", created_at: "2009-07-22 09:11:35", updated_at: "2009-07-22 09:11:35">] (using ==)
./spec/models/post_spec.rb:50:
3)
'Post#update_attribute should update title and should NOT save any comment' FAILED
expected: [],
got: [#<Comment id: 1, post_id: 1, body: "Comment", created_at: "2009-07-22 09:11:35", updated_at: "2009-07-22 09:11:35">] (using ==)
./spec/models/post_spec.rb:75:
Finished in 0.174812 seconds
4 examples, 3 failures
Как видим — валидный коментарий сохранился, невалидный — нет, при добалении обоих в ассоциацию сохранился только 1 валидный и никакого сообщения об ошибке мы не получили. А ведь мы всего лишь хотели обновить один аттрибут!
Какой выход?
Использовать ActiveRecord::Base#update_attribute только в тех случаях, когда известно на 100%, что объект не изменяется, в других же ситуациях использовать ActiveRecord::Base::update
Напишем тест
it "should update title and should NOT update body (v2)" do
@post.body = "New Body"
Post.update(@post, :title => "New Title")
@post.reload
@post.title.should == "New Title"
@post.body.should == "Old Body"
end
Запускаем тест
$ spec -c -fs spec/models/post_spec.rb -l 70
Post#update_attribute
- should update title and should NOT update body (v2)
Finished in 0.224997 seconds
1 example, 0 failures
Вуаля! работает (-l 70 означает запустить отдельный тест, обрамляющий 70-ю строку)
Я создал на гитхабе репозиторий, чтобы можно было пощупать пример целиком.
P.S. Проверено на rails 2.3.2 и 2.3.3
UPD. Этот вопрос уже поднимали в англоязычной среде

Я в свое время тоже долго ломал голову, почему же когда я делаю update_attribute для одного атрибута, у меня в БД записывались и другие атрибуты измененные через сеттеры.
Отличная статья! Спасибо!
Мы в нашем проекте буквально сегодня столкнулись с подобной проблемой в update_attribute, но вроде бы даже не припомнили, что save(false) внутри него может сохранить все dirty аттрибуты в объеке. Думали, как предотвратить проблемы с этим связанные в рамках проекта на будущее. Думали или пропатчить метод и задокументировать у себя, или райзить внутри, чтобы его не использовали и написать дополнительный rake task вроде rake convention:find_update_attribute.