views:

1083

answers:

2

This is not exactly a question, it's rather a report on how I solved an issue with write_attribute when the attribute is an object, on Rails' Active Record. I hope this can be useful to others facing the same problem.

Let me explain with an example. Suppose you have two classes, Book and Author:

class Book < ActiveRecord::Base
  belongs_to :author
end

class Author < ActiveRecord::Base
  has_many :books
end

Very simple. But, for whatever reason, you need to override the author= method on Book. As I'm new to Rails, I've followed the Sam Ruby's suggestion on Agile Web Development with Rails: use attribute_writer private method. So, my first try was:

class Book < ActiveRecord::Base
  belongs_to :author

  def author=(author)
    author = Author.find_or_initialize_by_name(author) if author.is_a? String
    self.write_attribute(:author, author)
  end
end

Unfortunately, this does not work. That's what I get from console:

>> book = Book.new(:name => "Alice's Adventures in Wonderland", :pub_year => 1865)
=> #<Book id: nil, name: "Alice's Adventures in Wonderland", pub_year: 1865, author_id: nil, created_at: nil, updated_at: nil>
>> book.author = "Lewis Carroll"
=> "Lewis Carroll"
>> book
=> #<Book id: nil, name: "Alice's Adventures in Wonderland", pub_year: 1865, author_id: nil, created_at: nil, updated_at: nil>
>> book.author
=> nil

It seems that Rails does not recognize it is an object and makes nothing: after the attribuition, author is still nil! Of course, I could try write_attribute(:author_id, author.id), but it does not help when the author is not saved yet (it still has no id!) and I need the objects be saved together (author must be saved only if book is valid).

After search a lot for a solution (and try many other things in vain), I found this message: http://groups.google.com/group/rubyonrails-talk/browse%5Fthread/thread/4fe057494c6e23e8, so finally I could had some working code:

class Book < ActiveRecord::Base
  belongs_to :author

  def author_with_lookup=(author)
    author = Author.find_or_initialize_by_name(author) if author.is_a? String
    self.author_without_lookup = author
  end
  alias_method_chain :author=, :lookup
end

This time, the console was nice to me:

>> book = Book.new(:name => "Alice's Adventures in Wonderland", :pub_year => 1865)
=> #<Book id: nil, name: "Alice's Adventures in Wonderland", pub_year: 1865, author_id: nil, created_at: nil, updated_at: nil>
>> book.author = "Lewis Carroll"=> "Lewis Carroll"
>> book
=> #<Book id: nil, name: "Alice's Adventures in Wonderland", pub_year: 1865, author_id: nil, created_at: nil, updated_at: nil>
>> book.author
=> #<Author id: nil, name: "Lewis Carroll", created_at: nil, updated_at: nil>

The trick here is the alias_method_chain, that creates an interceptor (in this case author_with_lookup) and an alternative name to the old setter (author_without_lookup). I confess it took some time to understand this arrangement and I'd be glad if someone care to explain it in detail, but what surprised me was the lack of information about this kind of problem. I have to google a lot to find just one post, that by the title seemed initially unrelated to the problem. I'm new to Rails, so what do you think guys: is this a bad practice?

+4  A: 

I recommend creating a virtual attribute instead of overriding the author= method.

class Book < ActiveRecord::Base
  belongs_to :author

  def author_name=(author_name)
    self.author = Author.find_or_initialize_by_name(author_name)
  end

  def author_name
    author.name if author
  end
end

Then you could do cool things like apply it to a form field.

<%= f.text_field :author_name %>

Would this work for your situation?

ryanb
I thought to make this, but I didn't want duplicate attributes. The method I proposed above is working very well; I just wanted to share it. I can indeed make the text_field trick with it. But thanks for your reply! =D
Lailson Bandeira
Wouldn't it be more correct to replace the `author_name` method with `delegate :name, :to => :author, :prefix => true` ?
Adam Lassek
@Adam, That is certainly an alternative way to do it. I usually only use `delegate` when dealing with multiple methods. If there's only one I prefer defining the method directly because I feel it's more clear.
ryanb
Are there any hidden downsides to using delegate, or is this just personal preference?
Adam Lassek
I can't think of any downsides to using `delegate`. It just feels a little more complicated than defining the method directly in this specific scenario.
ryanb
I have very similar needs, but not quite identical, is there a way to do something similar to creating a method `def author=` and inside there somehow referencing the equivalent of `super(author)`?
Dmitriy Likhten
+2  A: 

When you override the accessor, you have to set an actual DB attribute for write_attribute and self[:the_attribute]=, and not the name of the association-generated attribute you're overriding. This works for me.

require 'rubygems'
require 'active_record'
ActiveRecord::Base.establish_connection(:adapter => "sqlite3", :dbfile => ":memory:")
ActiveRecord::Schema.define do
  create_table(:books) {|t| t.string :title }
  create_table(:authors) {|t| t.string :name }
end

class Book < ActiveRecord::Base
  belongs_to :author

  def author=(author_name)
    found_author = Author.find_by_name(author_name)
    if found_author
      self[:author_id] = found_author.id
    else
      build_author(:name => author_name)
    end
  end
end

class Author < ActiveRecord::Base
end

Author.create!(:name => "John Doe")
Author.create!(:name => "Tolkien")

b1 = Book.new(:author => "John Doe")
p b1.author
# => #<Author id: 1, name: "John Doe">

b2 = Book.new(:author => "Noone")
p b2.author
# => #<Author id: nil, name: "Noone">
b2.save
p b2.author
# => #<Author id: 3, name: "Noone">

I strongly recommend doing what Ryan Bates suggests, though; create a new author_name attribute and leave the association generated methods as they are. Less fuzz, less confusion.

August Lilleaas
As I said above, the method you proposed works only if the author is save (i.e. has an id), which is not my case. I can have a new author that must be saved only when the book is saved too.
Lailson Bandeira
I re-wrote it a bit based on your comment. Does it make more sense now?
August Lilleaas
Oh thanks, now this works as I expect. But (in spite of all recommendations) I'll keep the original solution. Nonetheless, it's a good alternative if I change my mind someday. =]
Lailson Bandeira