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 comment