Reputation: 9246
I have two models Article
and ArticleVote
. When I destroy an article vote (user cancels his vote), I want article's score to be changed. So I made a callback. Here is what my ArticleVote model looks like:
class ArticleVote < ActiveRecord::Base
belongs_to :article
belongs_to :user
before_destroy :before_destroy
validates :value, inclusion: {in: [1, -1]}
def self.upvote(user, article)
cast_vote(user, article, 1)
end
def self.downvote(user, article)
cast_vote(user, article, -1)
end
private
def self.cast_vote(user, article, value)
vote = ArticleVote.where(user_id: user.id, article_id: article.id).first_or_initialize
vote.value = value
vote.save!
article.score += value
article.save!
end
def before_destroy
article.score -= value
article.save
end
end
My ArticleVote#destroy
test fails:
context '#destroy' do
let(:user) { FactoryGirl.create(:user) }
let(:article) { FactoryGirl.create(:article) }
it 'changes article score by negative vote value' do
ArticleVote.upvote(user, article)
expect{ ArticleVote.where(user: user, article: article).first.destroy }.to change{ article.score }.by -1
end
end
Failures:
1) ArticleVote voting #destroy should change article score by nevative vote value Failure/Error: expect{ ArticleVote.where(user: user, article: article).first.destroy }.to change{ article.score }.by -1 result should have been changed by -1, but was changed by 0 # ./spec/models/article_vote_spec.rb:32:in `block (4 levels) in '
When I change my test to this, it passes:
context '#destroy' do
let(:user) { FactoryGirl.create(:user) }
let(:article) { FactoryGirl.create(:article) }
it 'changes article score by nevative vote value' do
ArticleVote.upvote(user, article)
vote = ArticleVote.where(user: user, article: article).first
expect{ vote.destroy }.to change{ vote.article.score }.by -1
end
end
Shouldn't these two be equivalent? Shouldn't my article
and vote.article
reference to same instance?
Upvotes: 0
Views: 132
Reputation: 44685
In your first test you are creating new Article object in the memory. Rails is not going to check attribute values in db every time you call article.score
as it would make everything extremely slow - those value are stored in the memory (it is kind-of caching the results). Hence article.score
is not going to change at any point. You need to tell rails to reload all the attributes from the database - use article.reload.score
within change
block.
Additional explanation:
Let say we did:
model_1 = Model.where(<condition>).first
model_2 = Model.where(<some condition>).first
Both model_1 and model_2 are created from some row in the database, however they are different objects in the memory. Hence when you do:
model_1.some_attribute = 'new value'
model_1.save
model_2.some_attribute #=> 'old_value'
The reason is performance - Rails is not going to check the database whether given attribute has changed or not within database. model_2
did the sql query when it was created and will not re-check until you tell it to do so.
However in most cases there is no point in creating two duplicate objects in the memory and it is the best practice not to do so. It is not always as obvious where those obejcts are created. In case of your first test, the problem is that ArticleVote.where(user: user, article: article).first.article
is a duplicate of your original article
object, hence your before_save
callback follows same pattern as model_1, model_2
example.
Best way to avoid such a problems is a proper use of associations, including inverse_of
option and using model.associations.where(...)
in place of AssocatedClass.where(model: model, ...)
or model.association.create(...)
in place of 'AssociationClass.create(model: model, ...)
Upvotes: 2