accepts_nested_attributes_for with Has-Many-Through Relations

accepts_nested_attributes_for with Has-Many-Through Relations

If you find yourself getting validation errors when using accepts_nested_attributes_for with has-many-through relations, the answer may be to add an inverse_of option.

The inverse_of option allows you to tell Rails when two model relations describe the same relationship, but from opposite directions. For example, if a User has_many :posts and a Post belongs_to :user, you can tell Rails that the :user relation on Post is the inverse of the :posts relation on User.

This option is usually not required, but there are cases where it matters. One such case is when using accepts_nested_attributes_for with a has-many-through relation. This will eventually lead to a collection= assignment which is only possible if Rails knows that one relation is the inverse of another.

Example

In our case, we had the following three models:

class Notice < ActiveRecord::Base

  # attribute :title

  has_many :entity_roles
  has_many :entities, through: :entity_roles

  accepts_nested_attributes_for :entity_roles

end

class EntityRole < ActiveRecord::Base

  # attribute :name

  belongs_to :entity
  belongs_to :notice

  validates_presence_of :entity
  validates_presence_of :notice

  accepts_nested_attributes_for :entity

end

class Entity < ActiveRecord::Base

  # attribute :name
  # attribute :address

  has_many :entity_roles
  has_many :notices, through: :entity_roles

end

We wanted the form to create two related entities for the notice, each of a specific role.

The controller looks like this:

class NoticesController < ApplicationController

  def new
    @notice = Notice.new
    @notice.entity_roles.build(name: 'submitter').build_entity
    @notice.entity_roles.build(name: 'recipient').build_entity
  end

end

Using Simple Form, the view looks like this:

<%= simple_form_for(@notice) do |form| %>
  <%= form.input :title %>

  <%= form.simple_fields_for(:entity_roles) do |roles_form| %>
    <% role = roles_form.object.name.titleize %>
    <%= roles_form.input :name, as: :hidden %>
    <%= roles_form.simple_fields_for(:entity) do |entity_form| %>
      <%= entity_form.input :name, label: "#{role} Name" %>
      <%= entity_form.input :address, label: "#{role} Address" %>
    <% end %>
  <% end %>

  <%= form.submit "Submit" %>
<% end %>

The only clever bit here is that we use each role’s name to intelligently affect the entity form’s labels each time it’s rendered. Aside from that, it’s pretty standard accepts_nested_attributes stuff.

On POST, we found validation errors on the entity_role objects:

["notice", "can't be blank"]

We were confused.

The controller’s #create action is effectively doing this:

notice = Notice.new(
  title: "...",
  entity_roles_attributes: [
    { name: "submitter", entity_attributes: { ... } },
    { name: "recipient", entity_attributes: { ... } }
  ]
)

notice.save

Which, as far as we knew, should work.

It seemed Rails was not setting the notice attribute on the EntityRole before attempting to save it, triggering the validation errors. This is a bit surprising as other has_many relations (omitted in this blog post) should have the same save mechanics and were working just fine.

In an act of experimentation, we added inverse_of:

class Notice < ActiveRecordBase

  has_many :entity_roles, inverse_of: :notice

end

And suddenly, it all worked.

Only after the fact, when we knew to include “inverse_of” in our search queries, did we find some information on this issue. You can read the details here, here, and here if you're interested.

When you use collection= assignment with a has-many-through (as accepts_nested_attributes_for does), you have to specify inverse_of for Rails to save everything correctly.

Pat Brisbin Developer