Danger: ActiveRecord, param hashes, and symbol keys
March 10, 2010Here's a little foible of ActiveRecord that cost me over an hour today. AR accepts both symbol keys and string keys when specifying attributes. Both of these are valid ways of mass assigning attributes to a Rails model:
MyModel.new(:field_1 => 'foo', :field_2 => 'bar') MyModel.new('field_1' => 'foo', 'field_2' => 'bar')
It's convenient, often, to not have to worry about whether your keys are symbols are strings since they get converted around a bit when you pass parameters. The downside of this, however, is that it will happily accept BOTH without complaining, and will quietly default to the symbol key regardless of the order you specify them in:
>> model = MyModel.new(:field_1 => 'foo', 'field_1' => 'bar'); nil; >> mymodel.field_1 => 'foo' >> model = MyModel.new('field_1' => 'foo', :field_1 => 'bar'); nil; >> mymodel.field_1 => 'bar'
Okay, so that's kinda sloppy. Bad ActiveRecord! No Biscuit!
This can cause serious confusion for the unwary. When ActionController hands us a params hash, it always has String keys, like this:
>> eval params => { 'article' => { 'title' => 'Awesome blog post', 'body' => 'I will make you smart' } }
But most of us, canonically, specify params and default AR values with symbols, like this:
post :article => {:title => 'Awesome blog post', :body => 'I will make you smart'}
So we get used to thinking about them as symbols.
This means we can make mistakes like this one I made recently. Consider this block of code for a shopping cart model that pre-fills some fields for an associated Payment by pulling the address from the user's profile, to save the user re-typing their address:
class ShoppingCart < ActiveRecord::Base has_one :payment def build_default_payment(options = {}) #prepopulate the billing address from the profile and merge #with params passed into options build_payment(prepopulated_fields.merge!(options) end def prepopulated_fields if (addr = self.person.address) { :billing_address_1 => addr.line_1, :billing_address_2 => addr.line_2, :city => addr.city, :state => addr.state, :zip => addr.zipcode } else {} end end end
Looks great, right? And if the user's address has a nil field (like no city, or no line_1), it will get overwritten by the hash merge.
Except not. I specified symbol keys in prepopulated_fields, but the hash getting passed to build_default_payment's 'options' argument has string keys, because it's coming from params. So the merge doesn't overwrite the value for :line_1, it simply adds a new key 'line_1'. So, if a user has a profile address but hadn't entered a line_1 (just city and state), and then manually entered line_1 in the payment form to submit, the Payment build during the create action was getting this hash:
build_payment({ :line_1 => nil, :city => 'Pasadena', :state =>'CA', :zipcode => '91106' 'line_1' => '100 Main St.'. })
ActiveRecord was respecting the :line_1 => nil from the profile, and not the 'line_1' => '100 Main St.' from params. This meant that the user couldn't make payment! The payment had validates_inclusion_of line_1, and even though it was typed into the form it was getting ignored because of the nil from his profile address. Very frustrating for a user to manually type in a billing address and get back "Address Line 1 can't be blank." on every submit!
Nasty ... this one took a while to figure out. Beware of this little foible of ActiveRecord!
Add A Comment