Проблема с переопределением сеттера в ActiveRecord

Это не вопрос, а скорее отчет о том, как я решил проблему с write_attribute, когда атрибут является объектом, в Rails 'Active Record. Я надеюсь, что это может быть полезно для других, сталкивающихся с одной и той же проблемой.

Позвольте мне объяснить пример. Предположим, у вас есть два класса: Book и Author:

class Book < ActiveRecord::Base
  belongs_to :author
end

class Author < ActiveRecord::Base
  has_many :books
end

Очень просто. Но по какой-либо причине вам нужно переопределить метод Author= на Book. Поскольку я новичок в Rails, я следовал предложению Сэма Руби на Agile Web Development с Rails: используйте частный метод attribute_writer. Итак, моя первая попытка:

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

К сожалению, это не работает. Что я получаю от консоли:

>> book = Book.new(:name => "Alice Adventures in Wonderland", :pub_year => 1865)
=> #<Book id: nil, name: "Alice 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 Adventures in Wonderland", pub_year: 1865, author_id: nil, created_at: nil, updated_at: nil>
>> book.author
=> nil

Кажется, что Rails не признает, что это объект и ничего не делает: после атрибуции автор все еще ноль! Конечно, я мог бы попробовать write_attribute(:author_id, author.id), но это не помогает, когда автор еще не сохранен (он по-прежнему не имеет идентификатора!), И мне нужно, чтобы объекты сохранялись вместе (автор должен быть сохранен, только если книга действительна).

После многого поиска решения (и попробуйте много других вещей напрасно), я нашел это сообщение: http://groups.google.com/group/rubyonrails-talk/browse_thread/thread/4fe057494c6e23e8, поэтому наконец, у меня мог быть рабочий код:

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

На этот раз консоль была приятна для меня:

>> book = Book.new(:name => "Alice Adventures in Wonderland", :pub_year => 1865)
=> #<Book id: nil, name: "Alice 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 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>

Трюк здесь alias_method_chain, который создает перехватчик (в данном случае author_with_lookup) и альтернативное имя для старого установщика (author_without_lookup). Я признаюсь, что потребовалось некоторое время, чтобы понять эту договоренность, и я был бы рад, если кто-то захочет объяснить это подробно, но меня удивило отсутствие информации об этой проблеме. Мне нужно много google, чтобы найти только одно сообщение, которое по названию казалось первоначально не связанным с проблемой. Я новичок в Rails, так что вы думаете, ребята: это плохая практика?

Ответ 1

Я рекомендую создать виртуальный атрибут вместо переопределения метода author=.

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

Затем вы можете делать классные вещи, например, применять его к полю формы.

<%= f.text_field :author_name %>

Будет ли это работать для вашей ситуации?

Ответ 2

Когда вы переопределяете аксессор, вы должны установить фактический атрибут БД для write_attribute и self[:the_attribute]=, а не имя атрибута, сгенерированного ассоциацией, который вы переопределяете. Это работает для меня.

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">

Я настоятельно рекомендую сделать то, что предлагает Райан Бэйтс; создайте новый атрибут author_name и оставьте методы, сгенерированные ассоциацией, такими, какие они есть. Меньше путаницы, меньше путаницы.

Ответ 3

Я решил эту проблему, используя alias_method

class Book < ActiveRecord::Base
  belongs_to :author

  alias_method :set_author, :author=
  def author=(author)
    author = Author.find_or_initialize_by_name(author) if author.is_a? String
    set_author(author)
  end
end