Recently, I had a problem in Rails that took some extra research to solve. In the process, I read numerous StackOverflow posts, mailing list posts dating back to 2008, and tried, like, a million things before coming to a fairly clean solution. The solution is either clever or genius, and either way, future me will need present me’s help to understand it, which is why I’m writing it down.
The problem was that I had a Rails ActiveRecord model User, that had an attribute named phone. We switched our associations so that a User could have multiple PhoneNumber objects, but we wanted to preserve the user.phone and user.phone= interface.
The classes look something like this:
class User < ActiveRecord::Base has_many :phone_numbers, as: :phoneable, dependent: :destroy end class PhoneNumber < ActiveRecord::Base belongs_to :phoneable, polymorphic: true end
All the rest of the code is in the User class.
In order to ensure that the accessor methods were operating on the same object, I wrote a wrapper method for accessing the same object:
def phone_number_obj phone_numbers.find_by_primary(true) end
The getter looked like this:
def phone_number self.phone_number_obj.try(:number) # PhoneNumber objects have a string field named number end
The setter was a little more complicated, because it had to either create or update a PhoneNumber object:
def phone_number=(new_phone_number) if p = self.phone_number_obj # Number already exists if p.number != new_phone_number # Only update if number changes p.update_attributes(number: new_phone_number) end else phone_numbers.create({primary: true, number: new_phone_number}) end end
All this worked lovely and wonderfully for objects that already existed, but it caused a number of problems when creating objects. ActiveRecord uses attribute setters when creating and initializing objects, but phone_number= requires that the User must already be persisted in order to create a PhoneNumber object in its phone_numbers association. I needed to strip out the phone_number attribute when creating a new User, save that value, and then create the PhoneNumber object once the User was saved.
The second part, creating the PhoneNumber object was saved, was easy. ActiveRecord callbacks (http://guides.rubyonrails.org/active_record_callbacks.html#available-callbacks) include after_create:
after_create :assign_phone_number def assign_phone_number if @phone_number self.phone_number = @phone_number end @phone_number = nil end
The first part, stripping out the incoming attributes when creating a new object, that was not so easy. Lots of painful searches (most of which told me to not do what I was trying to do) and experiments later, I learned some things about Ruby and Rails and came up with something that worked.
- The earliest AR callback, after_initialize, was too late for what I needed to do. When it runs, all of the attributes have already been assigned.
- I thought of them interchangeably, but new is a class method on the model, and initialize is an instance method (this was a good explanation: http://stackoverflow.com/a/10383786/1895697). Model.new creates an object (allocates memory, connects functions, language implementation stuff), and the it calls initialize on the new object, which sets values for attributes. I tried overriding new (based on the implementation in http://api.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-create), on the model, and it worked as expected in console but it failed in every rspec test, with the very unhelpful error:
- Failure/Error: Unable to find matching line from backtrace
ArgumentError:
wrong number of arguments (2 for 0..1)
- Failure/Error: Unable to find matching line from backtrace
- super is a helpful keyword when using inheritance. You can call it with specific parameters
- super(a, b)
- or if you call it with no parans or arguments, it passes the same parameters in the same order as the containing function.
Once I figured that out, the implementation was pretty straightforward:
def initialize(attributes = nil, options = {}) phone_number = nil if attributes.is_a?(Hash) phone_number = attributes.delete(:phone_number) end new_user = super phone_number && new_user.instance_variable_set(:@phone_number, phone_number) new_user end
This lets me:
- strip out the phone_number attribute so the setter is not called during initialization
- use super to assign all the remaining attributes with ActiveRecord::Base.initialize
- Save the phone_number you stripped into an instance variable so the after_create hook can use it
- Return the user object as expected
Since it preserves all the behavior of initialize, ActiveRecord association methods that create Users still work.
Caveats
- This is a usable but not perfect copy of the AR interface. For instance, you can’t put phone_number in a .where query or dynamic finder.
- This does a lot more database queries. It could be reduced by caching the phone number in an instance variable but for now I’m going for correctness.
- The setter (phone=) uses update_attributes on the PhoneNumber record, so there’s no way to assign a new phone number and save later.
- This doesn’t play nicely with new, build, etc – the User has to be persisted before the PhoneNumber can be assigned to it. Lots of our tests and factories used build instead of create to save time, but those had to be changed from:
- let(:user) { build(:user, phone_number: “555-555-5555”) }
- to
- let (:user) do
u = create(:user)
u.phone_number = “555-555-5555”
u
end
Conclusion
In retrospect, it probably would have been easier to break the .phone interface rather than hop through these hoops. This code is sort of a land mine that looks like one thing but behaves slightly differently. Use at your own risk, but it’s here in case you have this specific need.
Leave a Reply
You must be logged in to post a comment.