Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP

Loading…

first_or_create is applying erroneous scope to model callbacks #7853

Closed
uberllama opened this Issue · 12 comments

4 participants

@uberllama

The following code does not result in a raise to the controller.

Wish.rb

  before_create :verify_wish_limit

  private

  def verify_wish_limit
    raise OverLimitException # if blah blah
  end

wishes_controller.rb

  def create
    @wish = @user.wishes.where(:product_id => @product.id).first_or_create
  rescue Wish::OverLimitException
    # handle error
  end

Changing the creation line to the following does:

@wish = @user.wishes.find_or_create_by_product_id(@product.id)
@senny
Owner

I'll investigate.

@senny senny referenced this issue from a commit in senny/rails
@senny senny test case for #7853 493d5a6
@senny
Owner

I tried to reproduce the error in the test-case mentioned above but as I see it, everything works as expected:

class Topic
  before_create  :default_written_on

  def default_written_on
    self.written_on = Time.now unless attribute_present?("written_on")
  end
end
  def test_first_or_create_with_callbacks
    # make sure, that the attribute is not set without the callback beeing run
    assert_equal nil, Topic.new.written_on
    topic = Topic.where(:title => 'new topic').first_or_create
    assert_not_equal nil, topic.written_on
  end

@uberllama If my test-case represents your situation I need some more inputs how to proceed. could it be related to your if condition? Did you make sure that the callback does not run or did you just check if the exception is not raised? What rails version did you use?

@senny
Owner

@steveklabnik could you tag this with needs feedback?

@uberllama

Senny, the problem specifically occurs with exceptions. Changing the controller code from a first_or_create to a find_or_create_by results in the exception being caught and handled.

@uberllama

Sorry, Rails 3.2.8.

In further model testing I found that the wish is actually being created with the first_or_create call, despite an exception being raised in before_create.

# creates record when it shouldn't
Wish.where(:product_id => 44444).first_or_create

# raises exception and does not create record
Wish.find_or_create_by_product_id(44444)
@senny
Owner

I don't see why it should behave differently when exceptions are involved. I modified my test case to raise an exception in the before_create callback and I could catch that exception in the test case. Also the record was not created.

@uberllama can you test it against master? Feel free to fork my copy of rails and modify the test-case I created to reveal the bug. Also does your before_create callback run or not when you use first_or_create?

@uberllama

This is definitely a confounding issue. I'm looking deeper into my actual conditional that throws the exception and will report back.

@uberllama

I've looked at the log, and it seems like there's a deeper scoping issue at play. Let me try to explain, with clearer code.

Wish.rb

belongs_to :user
before_create :verify_wish_limit
 def verify_wish_limit
   raise OverLimitException unless user.wishes.count < 5
 end

Now, assuming the user has reached their limit in this case, look at these logs:

user.wishes.where(:product_id => 12345).first_or_create:

SELECT "wishes".* FROM "wishes" WHERE "wishes"."user_id" = 1 AND "wishes"."product_id" = 12345 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT COUNT(*) FROM "wishes" WHERE "wishes"."product_id" = 12345 AND "wishes"."user_id" = 5
INSERT INTO "wishes"...

Note the last select. Its erroneously applying the original where condition to the user.wishes.count call in Wish#verify_wish_limit

user.wishes.find_or_create_by_product_id(12345):

SELECT "wishes".* FROM "wishes" WHERE "wishes"."user_id" = 1 AND "wishes"."product_id" = 12345 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT COUNT(*) FROM "wishes" WHERE "wishes"."user_id" = 1

This is correct behaviour.

@senny
Owner

@uberllama can you adjust the issue title to reflect the actual issue?

@uberllama

Done!

@rafaelfranca
Owner

I'm not sure but when you call user.wishes.where(:product_id => 12345) it is mutating the wishes relation in the scope of the create. This is why it is using the same where clause in the count.

@jonleighton could you take a look on this one?

@jonleighton jonleighton was assigned
@jonleighton
Collaborator

This:

@user.wishes.where(:product_id => @product.id).first_or_create

Expands to:

scope = @user.wishes.where(:product_id => @product.id)
scope.first || scope.create

Which expands to:

scope = @user.wishes.where(:product_id => @product.id)
scope.first || scope.scoping { Wish.create }

Therefore, you can easily see that the create callbacks will run within the scope.

I think the only solution is to add a find_or_create_by method to Relation:

@user.wishes.find_or_create_by(:product_id => @product.id)

This actually reads better to me than first_or_create, tbh. And it's consistent with the new Relation#find_by method.

So I'm gonna do that.

@jonleighton jonleighton closed this issue from a commit
@jonleighton jonleighton Add Relation#find_or_create_by and friends
This is similar to #first_or_create, but slightly different and a nicer
API. See the CHANGELOG/docs in the commit.

Fixes #7853
eb72e62
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Something went wrong with that request. Please try again.