Peter Christensen

  • Presentations
  • Contact Me

Faking an ActiveRecord Attribute

June 12, 2015 by Peter Leave a Comment

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)
  • 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.

Filed Under: Programming, Rails

Leave a Reply

You must be logged in to post a comment.

Categories

  • Blog
  • Book Review
  • Business
  • Clojure
  • Education
  • Emacs
  • Fun
  • iOS
  • Lisp
  • Personal Sprints
  • Pictures
  • Polyphasic
  • Presentations
  • Programming
  • Rails
  • Startups
  • Uncategorized

Copyright © 2025 · Minimum Pro Theme on Genesis Framework · WordPress · Log in