Strangeness in Belongs_to

I was implementing dirty checking for an application, and I found something that is a little counterintuitive. Let me start with a quick quiz:

bob=User.find(1)
alice=User.find(2)
trip=Trip.new

trip.driver=bob
old_driver=trip.driver
trip.driver=alice

In this example, who is old_driver? If you guessed Alice, you’re right.

Every once in a while, you see something odd inside rails. This was definitely one of those times. Luckily, the problems is relatively straightforward.

In rails, when you say belongs_to :user rails does a little magic to create a few methods for you. These methods can be found in associations.rb. The two main ones in this case are user and user=. The methods that rails builds need to do a little bit more than just set a variable. They need to detect changes (so that they can update the id fields) and do some other housekeeping. To accomplish this, Rails uses a proxy object. For a belongs_to association, this is BelongsToAssociation and it is stored in a variable in the class. In my example, it is stored in user.

BelongsToAssociation is a somewhat magical class. It needs to know how to load itself from the database (so that loading the main class can load the associations only when needed) and also handle things like reload and destroying dependent records. Another key thing is that it needs to look like the object it represents so that we can call things like trip.user.full_name. When we write this code, we aren’t calling full_name on the user directly, instead, we call full_name on the association proxy. That uses method_missing to pass the call on to the user. That’s why we can write code like bob=trip.user; puts bob.full_name and not notice that anything strange is going on.

So why is the driver alice in my initial example? Let’s take a look at the code in associations.rb to see how this assignment actually works. Inside a method called association_accessor_methods, we find the following segment:

define_method("#{reflection.name}=") do |new_value|
  association = instance_variable_get("@#{reflection.name}")
  if association.nil? 
    association = association_proxy_class.new(self, reflection)
  end

  association.replace(new_value)

  unless new_value.nil?
    instance_variable_set("@#{reflection.name}", association)
  else
    instance_variable_set("@#{reflection.name}", nil)
    return nil
  end

  association
end

This section does a few things. First, it looks to see if there is already an association proxy instance by looking at @user. If there isn’t then it creates a new proxy. In the next line, we replace the value of the proxy with the new value. Therein lies the trouble. When we say old_driver=trip.driver, we don’t get bob, we get the proxy. When we assign alice to be the driver, the proxy we are holding gets updated with a new value for target. That changes the value out from underneath us.

So what can we do? That’s an open question. If you want a really ugly solution, you can say old_driver=trip.driver.target. Alternatively, we could always create a new proxy object on assignment. I sent this same issue to the rails core list, so we’ll see what they think. Hopefully we’ll have a fix shortly.

Update

I was able to submit a patch for this issue which was accepted by the core team. You can see the change on GitHub.