Igor Pantović
Igor Pantović

Reputation: 9246

ActiveRecord changing related model attributes

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

Answers (1)

BroiSatse
BroiSatse

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

Related Questions