Saturday, June 27, 2009

Rails - How to set defaults to an ActiveRecord model's attributes?

Problem to Solve

I want to have a default value for some attributes of an ActiveRecord Model. Of course, I can have the dafault value set in the table itself - but that doesn't cover instance creation (it will only be done after save), plus I cannot have a different default value per say, language and, mostly, it doesn't work for hashes.


First Hit

Obie Fernandez has some proposal here, which involves intercepting the attribute getter / setter on the fly, like so:

class Specification <>
def tolerance
read_attribute(:tolerance) or 'n/a'
end
end
class SillyFortuneCookie <>
def message=(txt)
write_attribute(:message, txt + ' in bed')
end
end

A shorter version would be like so:

class Specification <>
def tolerance
self[:tolerance] or 'n/a'
end
end
class SillyFortuneCookie <>
def message=(txt)
self[:message] = txt + ' in bed'
end
end

But of course, this doesn't work for hashes.


Second Thought

Actually, what would be best is to override the default constructor, to set the hash to some empty value rather than nil.
See this post on 3HV. Here's the skinny:

def initialize (params = nil)
super(params)
self.myhash = {} unless self.myhash
end

And actually, this is a very bad thing to do. Never override initialize, because of the way AR::Base deals with it. Here's an extract of a post from Josh Susser about the issue (very informative, I reckon):

I probably should have been more clear about the problems with overriding initialize, but I figured people already knew not to do that. You can get away with it sometimes, but it's risky and won't always do what you expect. For one thing, your approach won't work right if you also use the block syntax for initialized model object, as the code in your subclass initialize method won't have been run yet when the block is executed. I've definitely run into other issues as well, but it's too early in the morning for me to remember all the details (not a coffee achiever).


Third Thought: using callbaks

There is one callback called after_initialize, and this is the method recommended by some Rails luminaries like Michael Koziarski. Here's what you do:

after_initialize :set_sensible_defaults
private
def set_sensible_defaults
# do stuff
end

But this callback is called only when the object is instanciated from the database (meaning, it's a rough duplicate of after_find callback). And it's a bit late to set defaults.

So no, there's no callback that matches what we need (like after_new). And some debate has been going on for years about that (extract: http://groups.google.com/group/rubyonrails-core/browse_thread/thread/b509a2fe2b62ac5)


Fourth Option: Override after_initialize, with a Cost, and a Protection

Here you go:

def after_initialize
if new_record?
#do stuff
end
end

This one will get called in 2 cases: when the object is first instanciated in memory (which is what we're looking for), and also when the object is later retrieved from the database, which is why we protect ourselves using new_record?

The problem here is: the method will be called in the above 2 cases, meaning each time an object is retrieved from the DB, although you don't need that. Small problem arguably.
Now, after_initialize is called AFTER the object has been created in memory and AFTER attributes have been set potentially (as would result from a call like Object.new(:attr1=>"value1")

So, here's what you should really do:

def after_initialize
if new_record?
# Set default only if the attribute is not set already
self.attr1 = "Default value" if self.attr1.nil?
end
end

3 comments:

albert rodriguez said...

Nice approach and explanation. Just perfect.

Congratulations.

HernĂ¡n said...

How about...

def after_initialize
if new_record?
# Set default only if the attribute is not set already
self.attr1 ||= "Default value"
end
end

Rutger said...

My solution was:

def self.new_with_defaults(attributes = nil)
if block_given?
x = new(attributes) { |x| yield x }
else
x = new(attributes)
end
x.attr1 ||= "default value"
end