Validating nested associations in Rails

Intro

Rails provide a wide range of options for creating rich forms for your models. This can be a simple form for one object, or the form for many related objects. Usually it is a parent-children relations. If you are not familiar with such terms as form_for or accepts_nested_attributes_for, I strongly recommend taking a look at this article Complex Rails Forms with Nested Attributes, written by Xavier Shay, which will show your how you can create complex forms with Rails.

An example

Suppose, we have a model called Company. And the company may have several offices. Lets define these two models.

class Company < ActiveRecord::Base
  attr_accessible :name, :offices_attributes
  validates :name, presence: true
  has_many :offices
  accepts_nested_attributes_for :offices, allow_destroy: true
end

class Company::Office < ActiveRecord::Base
  attr_accessible :company_id, :name
  validates :name, presence: true
  belongs_to :company
end

Both company and office have names. The company could have zero or more offices.

By including accepts_nested_attributes_for it becomes possible to access offices attributes inside our Company model.

> c = Company.create(name: 'Mars LLC')
>   => #<Company id: 1, name: "Mars LLC", created_at: "2012-10-08 19:16:44", updated_at: "2012-10-08 19:16:44">

# add two new offices
> c.offices_attributes = [{ name: 'North America' }, { name: 'Europe' }]
>   => [{:name=>"North America"}, {:name=>"Europe"}]
> c.save
> c.offices
>   => [#<Company::Office id: 1, company_id: 1, name: "North America", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:21:54">, #<Company::Office id: 2, company_id: 1, name: "Europe", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:21:54">]

# edit office in North America
> c.offices_attributes = [{ id: 1, name: "North America (it's cold out there)" }]
>   => [{:id=>1, :name=>"North America (it's cold out there)"}]
> c.save
> c.offices
>   => [#<Company::Office id: 1, company_id: 1, name: "North America (it's cold out there)", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:25:18">, #<Company::Office id: 2, company_id: 1, name: "Europe", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:21:54">]

# delete an office in Europe
> c.offices_attributes = [{ id: 2, _destroy: '1' }]
>   => [{:id=>2, :_destroy=>"1"}]
> c.save
> c.offices
>   => [#<Company::Office id: 1, company_id: 1, name: "North America (it's cold out there)", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:25:18">]

There are two basic options, that you should know when dealing with accepts_nested_attributes_for:

Take a look at the other supported options on apidock.com.

Validating nested attributes

Except the basic validation, you can use reject_if option to validate a nested object.

class Company < ActiveRecord::Base
  attr_accessible :name, :offices_attributes
  validates :name, presence: true
  has_many :offices
  accepts_nested_attributes_for :offices, allow_destroy: true, reject_if: :office_name_invalid

  private

    def office_name_invalid(attributes)
      # office name shouldn't start with underscore
      attributes['name'] =~ /\A_/
    end
end

The method should return either true (rejects the record) or false.

> c.offices_attributes = [{ id: 1, name: '_North America'}]
>   => [{:id=>1, :name=>"_North America"}]
> c.save
> c.offices # no changes
>   => [#<Company::Office id: 1, company_id: 1, name: "North America", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:46:22">]

We could use predefined :all_blank symbol.

> c.offices_attributes = [{ name: ''}]
>   => [{:name=>""}]
> c.save
> c.offices # no changes
>   => [#<Company::Office id: 1, company_id: 1, name: "North America", created_at: "2012-10-08 19:21:54", updated_at: "2012-10-08 19:46:22">]

Passing :all_blank instead of a Proc will create a proc that will reject a record where all the attributes are blank excluding any value for _destroy.

Validating count of the nested attributes

Lets add more complexity to our company model and say for example: it must have at least one office (we usually called it the main office).

class Company < ActiveRecord::Base
  OFFICES_COUNT_MIN = 1

  attr_accessible :name, :offices_attributes
  validates :name, presence: true
  validate do
    check_offices_number
  end
  has_many :offices
  accepts_nested_attributes_for :offices, allow_destroy: true

  private

    def offices_count_valid?
      offices.count >= OFFICES_COUNT_MIN
    end

    def check_offices_number
      unless offices_count_valid?
        errors.add(:base, :offices_too_short, :count => OFFICES_COUNT_MIN)
      end
    end
end

The problem here is that accepts_nested_attributes_for call destroy for child objects AFTER validation of the parent object. So the user is able to delete an office. Of course, later, when the user will try to edit a company, he/she will get an error - “Company should have at least one office.”.

Flowchart of the validation process

> c.offices_attributes = [{ id: 1, _destroy: '1' }]
>   => [{:id=>1, :_destroy=>"1"}]
> c.save
> c.offices
>   => []

You could try to use standard length validator (e.g. validates :offices, length: { minimum: OFFICES_COUNT_MIN }), and it actually works, but again, it does not take into account the fact that some of the records may be marked for destruction.

The things are getting a little tricky here. To sort out the problem, we need to understand what offices_attributes= method does.

# accepts_nested_attributes_for generates for us this method
def offices_attributes=(attributes)
  # @note the name of the method to call may vary depending on the type of association
  # @see https://github.com/rails/rails/blob/master/activerecord/lib/active_record/nested_attributes.rb#L285
  assign_nested_attributes_for_collection_association(:offices, attributes, mass_assignment_options)
end

def assign_nested_attributes_for_collection_association
  ...
  if !call_reject_if(association_name, attributes) # if the record passed
    # update a record with the attributes or marks it for destruction
    assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy], assignment_opts)
  end
  ...
end

As you can see from the code above, our method marks offices records (with _destroy attribute) for destruction. When the company validates offices count, the offices relation includes all the records. So, all we need to do is to select only those records not marked for destruction.

class Company < ActiveRecord::Base
  ...

  private

    def offices_count_valid?
      offices.reject(&:marked_for_destruction?).count >= OFFICES_COUNT_MIN
    end
end

Now we’ve got the actual number of the company’s offices. Therefore, we will get an error while trying to delete the last office in North America:

> c.offices_attributes = [{ id: 1, _destroy: '1' }]
>   => [{:id=>1, :_destroy=>"1"}]
> c.save
> c.errors
>   => #<ActiveModel::Errors:0x000000038fc840 @base=#<Company id: 1, name: "Mars LLC", created_at: "2012-10-08 19:16:44", updated_at: "2012-10-08 19:16:44">, @messages={:base=>["Company should have at least one office."]}>

Hopefully, in Rails 3 we are now able to write our own custom validators, so I’ve added one more for this case.

If you know a better solution, don’t hesitate to contact me or simply leave a comment below.

Validating presence of the parent object

The last thing I wanna share with you is how you can add presence validator to the parent association inside the nested model.

class Company::Office < ActiveRecord::Base
  attr_accessible :company_id, :name

  validates :name, presence: true
  # add validator to company
  validates :company, presence: true

  belongs_to :company
end

We want to be sure that the office always have a corresponding company. But this fails on creating a company.

> c = Company.create(name: 'Adidas America Inc', offices_attributes: [{ name: 'LS' }])
>   => #<Company id: nil, name: "Adidas America Inc", created_at: nil, updated_at: nil>
> c.errors
>   => #<ActiveModel::Errors:0x000000036387a8 @base=#<Company id: nil, name: "Adidas America Inc", created_at: nil, updated_at: nil>, @messages={:"offices.company"=>["can't be blank"]}>

The solution here is to use inverse_of option. See the options section in belongs_to, has_one or has_many documentation. Note: it does not work in combination with the polymorphic option.

class Company < ActiveRecord::Base
  ...
  has_many :offices, inverse_of: :company
  ...
end

class Company::Office < ActiveRecord::Base
  ...
  belongs_to :company, inverse_of: :offices
  ...
end

Now we are able to create a company instance:

> c = Company.create(name: 'Adidas America Inc', offices_attributes: [{ name: 'LS' }])
>   => #<Company id: 2, name: "Adidas America Inc", created_at: "2012-10-09 07:36:07", updated_at: "2012-10-09 07:36:07">
> c.offices
>   => #<Company::Office id: 6, company_id: 2, name: "LS", created_at: "2012-10-09 07:36:07", updated_at: "2012-10-09 07:36:07">

Comments

comments powered by Disqus