On When Create Is Not Like Create
July 14, 2011 § Leave a comment
I recently ran into an interesting issue in ActiveRecord while trying to set default values on an object using the after_initialize
callback. One would think the following blocks of code would be equivalent:
# new, save Product.new(:name => "Awesome Product").save # new w/ block, save product = Product.new do |p| p.title = "Awesome Product" end product.save # create Product.create(:title => "Awesome Product") # create w/ block Product.create do |p| p.title = "Awesome Product" end
In 99.9% of cases this is going to be true. The last one, create with a block, however, can potentially cause you some problems if you are using after_initialize
. The problem arises when you use after_initialize
to set default values for attributes are are dependent on other attributes. Let us consider our Awesome Product has two more attributes, msrp
and wholesale_price
, that are tied to each other. If we have one of them we can always determine what the other should be. In this case, there wouldn’t really be a reason set both of them when creating a new object. Just set one and let the other one get set automatically.
For our example we’ll say, msrp = 2 * wholesale_price
. You might use an after_initialize
that looks something like this:
def after_initialize # set wholesale_price based on msrp if !msrp.nil? && wholesale_price.nil? self.wholesale_price = msrp / 2 # set msrp based on wholesale_price elsif msrp.nil? && !wholesale_price.nil? self.msrp = wholesale_price * 2 end end
We can instantiate an object like this:
product = Product.new(:name => "Awesome Product", :msrp => 20) => <Product ...> product.save => true product.msrp => 20 product.wholesale => 10
Everything is working as it should. Now let’s use create
instead of new
and save
.
product = Product.create(:name => "Awesome Product", :msrp => 20) => true product.msrp => 20 product.wholesale => 10
Still works just fine. Now create
with a block:
product = Product.create do |p| p.name = "Awesome Product" p.msrp => 20 end => true product.msrp => 20 product.wholesale => nil
Uh, oh… Why didn’t wholesale_price
didn’t get set? Take a look at the implementation of create
in ActiveRecord::Base
.
def create(attributes = nil, &block) if attributes.is_a?(Array) attributes.collect { |attr| create(attr, &block) } else object = new(attributes) yield(object) if block_given? object.save object end end
Notice in the else
block that a new object is created and then the block is yielded. This means that the after_initialize
callback is run on the instantiated object BEFORE the block code is run. msrp
is not set yet when after_initialize
is run, so wholesale_price
can’t be set. create
without a block work fine because it is literally the same as using new
and save
.
TL;DR – after_initialize
runs before the block code when using create
and a block. Be careful when using after_initialize
to set default values for attributes that depend on other attributes.
Leave a Reply