Les Hill github twitter facebook linked in archives
Posted April 20, 2010

A nifty little problem with a simple, lightly-documented solution came up recently while I was working on a super-duper-top-secret-and-totally-awesome app for a client.

accepts_nested_attributes_for has been a boon in Rails 2.3 and a great replacement for the venerable attribute_fu. Under normal circumstances, you would use it like this to allow you to save an associated model thorough the parent model:

1 class Book < ActiveRecord::Base
2   has_many :chapters
3 
4   accepts_nested_attributes_for :chapters
5 end

In your forms, you can use fields_for to specify associated Chapter models. With a little JavaScript-fu, you can craft a form to create a Book with multiple Chapters all at once, without a lot of controller code. With a little more JavaScript and an option to accepts_nested_attributes_for you can enable deletions:

1 class Book < ActiveRecord::Base
2   has_many :chapters
3 
4   accepts_nested_attributes_for :chapters,
5     :allow_destroy => true,
6 end

At this point, you may want to detect and reject empty associated models like this:

1 class Book < ActiveRecord::Base
2   has_many :chapters
3 
4   accepts_nested_attributes_for :chapters,
5     :allow_destroy => true,
6     :reject_if => proc {|attrs| attrs['title'].blank? }
7 end

The wrinkle comes in if you also want to validate that you always have at least one associated model. You might think of doing the following, which is close, but does not work in all situations:

 1 class Book < ActiveRecord::Base
 2   has_many :chapters
 3 
 4   accepts_nested_attributes_for :chapters,
 5     :allow_destroy => true,
 6     :reject_if => proc {|attrs| attrs['title'].blank? }
 7 
 8   validate :must_have_one_chapter
 9 
10   def must_have_one_chapter
11     errors.add(:chapters, 'must have one chapter') if chapters_empty?
12   end
13 
14   def chapters_empty?
15     chapters.empty?
16   end
17 end

This works except when you are destroying an associated model; the destroy occurs after the validations have been run, making chapters_empty? true. The fix is to check the associated models to see if they are marked_for_destruction during the save, like so:

 1 class Book < ActiveRecord::Base
 2   has_many :chapters
 3 
 4   accepts_nested_attributes_for :chapters,
 5     :allow_destroy => true,
 6     :reject_if => proc {|attrs| attrs['title'].blank? }
 7 
 8   validate :must_have_one_chapter
 9 
10   def must_have_one_chapter
11     errors.add(:chapters, 'must have one chapter') if chapters_empty?
12   end
13 
14   def chapters_empty?
15     chapters.empty? or chapters.all? {|chapter| chapter.marked_for_destruction? }
16   end
17 end

Now the validation will fail as expected; you might check the associated models for destruction seperately to generate a more appropriate message.

Thanks to Tom Preston-Werner for the CSS layout, Webby for the blog renderer, and GitHub Pages for the blog hosting.