Using faux accessors to initialize values

— January 22, 2007 at 08:02 PST


One of the little annoyances of ActiveRecord is not being able to override the #initialize method in your class. There's the #after_initialize callback, but there's no way to pass an argument to it when you create the record. So what do you do if you want to create a record and initialize it with some stuff that isn't one of its attributes? The answer is, you cheat.

The implementor must cheat, but not get caught. -- Dan Ingalls

The usual way to instantiate a new record is to use #new and pass a hash of attributes. The cool thing is that the way ActiveRecord assigns those attributes to the new record is easily hackable. For example, consider this:

post = Post.new(:title => "Man bites dog!")

ActiveRecord will set the :title attribute of the new post by using the #title= accessor. Very simple. But that opens a world of possibilities...

class User < ActiveRecord::Base
  def full_name=(full_name)
    self.first = full_name.split(" ").first
    self.last = full_name.split(" ").last
  end
end

user = User.new(:full_name => "Monty Python")

That's a pretty simple example. Here's a more standard alternate approach:

class User < ActiveRecord::Base
  attr :full_name
  def after_initialize
    self.first_name = @full_name.split(" ").first
    self.last_name = @full_name.split(" ").last
  end
end

That's not too bad, but I still think the first example is better. Why muck around with a callback when you don't have to?

Now, check this out.

class Post < ActiveRecord::Base
  has_and_belongs_to_many :categories

  def init_category=(category)
    self.categories << category
  end
end

post = Post.new(:init_category => Category.find_by_name("Main"))

The above example shows how to create a new post with a default category set. Since we're using has_and_belongs_to_many here, we don't have to worry about managing whether the object is a new record since habtm does that for us. But it's a little trickier if we want to use a join model...

class Post < ActiveRecord::Base
  has_many :taggings, :dependent => :destroy
  has_many :tags, :through => :taggings

  def tag_list=(tag_string)
    if self.new_record?
      @tag_string = tag_string
    else
      self.create_taggings(tag_string)  # parse tags, create tags & taggings
    end
  end

  def after_create
    self.tag_list = @tag_string if @tag_string
  end
end

post = Post.new(:tag_list => "chocolate, dessert, sinful")

has_many :through won't work with new records, as it needs saved records with actual ids to use in the foreign keys in the join model. This example defers processing the tag_list until a new record is saved, at which point it parses out the tags and creates the taggings to join them to the post.

Tricks like that are why I just love ruby!

12 commentsassociations, rails

Comments
  1. Jonathan Weiss2007-01-22 09:16:25

    I never had a problem with overriding initialize in AR classes:

    class A < ActiveRecord::Base def initialize(*args) super(*args) set_defaults end

    def set_defaults end

    end

  2. August Lilleaas2007-01-22 13:59:43

    The real power with rails is ruby! I wish people got Ruby for Rails and the pickaxe before starting with AWD and #rubyonrails.

  3. Josh Susser2007-01-22 17:16:09

    Jonathan: 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).

  4. Jason L.2007-01-22 21:49:21

    Interesting - I too have overridden initialize without any problems. It never felt quite right though, and I questioned whether or not I should do it, so I try to use callbacks whenever possible, and I find when I'm forced to think about it carefully, a callback usually handles what I thought I needed to put into initialize. Thanks for the tips on fake_attribute=(stuff), that will come in very handy.

  5. Justin2007-01-23 14:25:50

    In your second example, doesn't hasandbelongsto_many :categories create a categories=(objects) method, letting you call Post.new(:categories => Category.findby_name("Main")) without any hackery?

  6. Chad2007-01-28 20:11:13

    Justin: categories= would expect an array of categories, so it wouldn't be as clean...

  7. Jacqui2007-02-09 09:04:37

    Josh, once again your blog had the answer to my "How do I best accomplish [x] in Ruby / Rails?" question of the moment. You don't know it but you've been instrumental in my Rails education. I'm working on my first big project in it now; as management sees how quickly development goes with it - and how happy I am - the scope gets ever bigger though, and thus more challenging. Thanks!

  8. Tim Connor2007-03-15 20:22:51

    has_many :through won't work with new records, as it needs saved records with actual ids to use in the foreign keys in the join model.

    Actually based on my experiments in getting formfor and hasmany :through to play nice, that's not strictly true, or am I misunderstanding something?

  9. Josh Susser2007-03-15 20:50:33

    Tim: I looked at your post. You seem to be faking out hmt by dealing with the join model as a simple has_many. You can certainly do that since has_many works on unsaved objects, but the hmt built on top of that won't work until you've saved the join model records. That may be find for your purposes, but it's not a solution for the general case.

  10. Tim Connor2007-03-15 23:31:35

    Hmm, I think I might understand. So it's a actually a full hmt, but I am not truly using it there on the creation form. For instance it'd work fine if on a separate edit form I directly called parent.throughmodels, but using the joinmodel.build doesn't actually populate the associated hmt on the parent model - that requires a save?

  11. Tim Connor2007-03-16 00:01:56

    How about something like this?. I tested and it seems to work. Use the build method in the new to avoid having to use a intermediary storage form and then make a safe accessor for the :through for cases (like the new/edit form) where you can't be sure the collection has been saved yet?

    # GET /reports/new
    def new
      @report = Report.new()
      Location.find(:all).each do |location|
        @report.conditions.build(:location => location)
      end
      render :action => 'edit'
    end
    
    class Report < ActiveRecord::Base
      has_many :conditions
      has_many :locations, :through => :conditions
    
      def safe_locations
        locations.count != 0 ? locations : conditions.map {|condition| condition.location}
      end
    

    and then I use @report.safe_locations in my edit form. Everywhere else I should be able to use the power of the fully operation :throigh model, yes? Any short-comings I'm missing?

    Btw, a preview for comments would be nice, for double-checking Markdown mistakes, like in my earlier post. ;)

  12. Tomtom2007-04-12 19:25:08

    Hi guys! Sorry for posting here i have a problem and i don't really know where to ask. I have a form in which users type in excel column indexes such as A,B,C,etc., however i'd like to use these letters as a number i have written a method which converts them into integers. I'd like to use this method as private so i tried to use the model object getter method to invoke this function but i had no success. Here is what i did:

    class ImportForm < ActiveRecord::Base def ddestnamecol1 numberfromalpha(@ddestnamecol1) unless @ddestnamecol1.nil? end def numberfromalpha(letter) numeric = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" if(numeric.include?(letter.upcase) && letter.length==1) numeric.index(letter.upcase) else false end end end

    @import=ImportForm.new(:ddestnamecol1=>"d")

    Result:

    @importform.ddestnamecol_1 => nil

    if i comment out the getter i've got the string that was enter on the form.

    thank in advance,

    tomtom

Sorry, comments for this article are closed.