20

Using Rails 2.3.8

Goal is to create a Blogger while simultaneously updating the nested User model (in case info has changed, etc.), OR create a brand new user if it doesn't exist yet.

Model:

class Blogger < ActiveRecord::Base
  belongs_to :user
  accepts_nested_attributes_for :user
end

Blogger controller:

def new
  @blogger = Blogger.new
  if user = self.get_user_from_session
    @blogger.user = user
  else
    @blogger.build_user
  end
  # get_user_from_session returns existing user 
  # saved in session (if there is one)
end

def create
  @blogger = Blogger.new(params[:blogger])
  # ...
end

Form:

<% form_for(@blogger) do |blogger_form| %>
  <% blogger_form.fields_for :user do |user_form| %>
    <%= user_form.label :first_name %>
    <%= user_form.text_field :first_name %>
    # ... other fields for user
  <% end %>
  # ... other fields for blogger
<% end %>

Works fine when I'm creating a new user via the nested model, but fails if the nested user already exists and has and ID (in which case I'd like it to simply update that user).

Error:

Couldn't find User with ID=7 for Blogger with ID=

This SO question deals with a similar issue, and only answer suggests that Rails simply won't work that way. The answer suggests simply passing the ID of the existing item rather than showing the form for it -- which works fine, except I'd like to allow edits to the User attributes if there are any.

Deeply nested Rails forms using belong_to not working?

Suggestions? This doesn't seem like a particularly uncommon situation, and seems there must be a solution.

Community
  • 1
  • 1
Jase
  • 543
  • 5
  • 12

3 Answers3

51

I'm using Rails 3.2.8 and running into the exact same problem.

It appears that what you are trying to do (assign/update an existing saved record to a belongs_to association (user) of a new unsaved parent model (Blogger) is simply not possible in Rails 3.2.8 (or Rails 2.3.8, for that matter, though I hope you've upgraded to 3.x by now)... not without some workarounds.

I found 2 workarounds that appear to work (in Rails 3.2.8). To understand why they work, you should first understand the code where it was raising the error.

Understanding why ActiveRecord is raising the error...

In my version of activerecord (3.2.8), the code that handles assigning nested attributes for a belongs_to association can be found in lib/active_record/nested_attributes.rb:332 and looks like this:

def assign_nested_attributes_for_one_to_one_association(association_name, attributes, assignment_opts = {})
  options = self.nested_attributes_options[association_name]
  attributes = attributes.with_indifferent_access

  if (options[:update_only] || !attributes['id'].blank?) && (record = send(association_name)) &&
      (options[:update_only] || record.id.to_s == attributes['id'].to_s)
    assign_to_or_mark_for_destruction(record, attributes, options[:allow_destroy], assignment_opts) unless call_reject_if(association_name, attributes)

  elsif attributes['id'].present? && !assignment_opts[:without_protection]
    raise_nested_attributes_record_not_found(association_name, attributes['id'])

  elsif !reject_new_record?(association_name, attributes)
    method = "build_#{association_name}"
    if respond_to?(method)
      send(method, attributes.except(*unassignable_keys(assignment_opts)), assignment_opts)
    else
      raise ArgumentError, "Cannot build association #{association_name}. Are you trying to build a polymorphic one-to-one association?"
    end
  end
end

In the if statement, if it sees that you passed a user ID (!attributes['id'].blank?), it tries to get the existing user record from the blogger's user association (record = send(association_name) where association_name is :user).

But since this is a newly built Blogger object, blogger.user is going to initially be nil, so it won't get to the assign_to_or_mark_for_destruction call in that branch that handles updating the existing record. This is what we need to work around (see the next section).

So it moves on to the 1st else if branch, which again checks if a user ID is present (attributes['id'].present?). It is present, so it checks the next condition, which is !assignment_opts[:without_protection].

Since you are initializing your new Blogger object with Blogger.new(params[:blogger]) (that is, without passing as: :role or without_protection: true), it uses the default assignment_opts of {}. !{}[:without_protection] is true, so it proceeds to raise_nested_attributes_record_not_found, which is the error that you saw.

Finally, if neither of the other 2 if branches were taken, it checks if it should reject the new record and (if not) proceeds to build a new record. This is the path it follows in the "create a brand new user if it doesn't exist yet" case you mentioned.


Workaround 1 (not recommended): without_protection: true

The first workaround I thought of -- but wouldn't recommend -- was be to assign the attributes to the Blogger object using without_protection: true (Rails 3.2.8).

Blogger.new(params[:blogger], without_protection: true)

This way it skips the 1st elsif and goes to the last elsif, which builds up a new user with all the attributes from the params, including :id. Actually, I don't know if that will cause it to update the existing user record like you were wanting (probably not—haven't really tested that option much), but at least it avoids the error... :)

Workaround 2 (recommended): set self.user in user_attributes=

But the workaround that I would recommend more than that is to actually initialize/set the user association from the :id param so that the first if branch is used and it updates the existing record in memory like you want...

  accepts_nested_attributes_for :user
  def user_attributes=(attributes)
    if attributes['id'].present?
      self.user = User.find(attributes['id'])
    end
    super
  end

In order to be able to override the nested attributes accessor like that and call super, you'll need to either be using edge Rails or include the monkey patch that I posted at https://github.com/rails/rails/pull/2945. Alternatively, you can just call assign_nested_attributes_for_one_to_one_association(:user, attributes) directly from your user_attributes= setter instead of calling super.


If you want to make it always create a new user record and not update existing user...

In my case, I ended up deciding that I didn't want people to be able to update existing user records from this form, so I ended up using a slight variation of the workaround above:

  accepts_nested_attributes_for :user
  def user_attributes=(attributes)
    if user.nil? && attributes['id'].present?
      attributes.delete('id')
    end
    super
  end

This approach also prevents the error from occurring, but does so a little differently.

If an id is passed in the params, instead of using it to initialize the user association, I just delete the passed-in id so that it will fall back to building a new user from the rest of the submitted user params.

Tyler Rick
  • 8,396
  • 6
  • 52
  • 56
  • 1
    Your "Workaround 2" is good, in that it gets Rails out of the way, so we can get to our 'create' block code, where we can handle the nested model. I found the OPs error occurred before even getting to the create block - maddening. In my case if it is existing, update the parent-model's belongs_to id-field and strip out the nested-params with a '.except(:nested_model)' - or create as a new instance, in which case the save is fine with the params nested. – JosephK Feb 06 '16 at 08:56
  • This solution is great, it helped me solving my problem. However, I was wondering how could I implement a similar approach to handle many to many relationship... Without overriding i always get to a point where the parent object (that I'm creating for the first time) cannot create the nested relationship when some of it's children already have a previous id (since it's many to many, they can have "many parents"), getting a 404 not found on the association table. If I override with proper find method for array, I get a DB error (PG in my case) since in the association table parent_id is nil. – Stefano Mondino May 22 '16 at 14:50
  • Has this been added to the latest version of Rails? – varagrawal Aug 31 '16 at 17:54
  • @StefanoMondino i think you can do something similar to this https://github.com/rails/rails/issues/7256#issuecomment-249735086 – brayancastrop Sep 27 '16 at 00:25
  • turned out I was using a has_one relationship on parent object instead of a belongs_to in the child object. The difference is the table holding the relationship's ID – Stefano Mondino Sep 28 '16 at 15:45
  • 1
    For the late visitors workaround #1 is no longer an option and was removed with [commit 2d7ae1b08ee2a10b12cbfeef3a6cc6da55b57df6](https://github.com/rails/rails/commit/2d7ae1b08ee2a10b12cbfeef3a6cc6da55b57df6) (Rails 4.0.0). – 3limin4t0r Sep 12 '18 at 11:57
2

I ran into the same error in rails 3.2. The error occurred when using a nested form to create a new object with a belongs to relationship for an existing object. Tyler Rick's approach did not work for me. What I found to work was to set the relationship following the initialization of the object and then setting the objects attributes. An example of this is as follows ...

@report = Report.new()
@report.user = current_user
@report.attributes = params[:report] 

assuming params looks something like ... {:report => { :name => "name", :user_attributes => {:id => 1, { :things_attributes => { "1" => {:name => "thing name" }}}}}}

bhattamer
  • 121
  • 5
0

Try adding a hidden field for the user's id in the nested form:

<%=user_form.hidden_field :id%>

The nested save will use this to determine if it is a create or an update for the User.

Brian Glick
  • 2,101
  • 15
  • 19
  • 1
    Rails actually already does this, essentially. If the User object already exists, it adds a hidden `blogger[user_attributes][id]` form field (not present if it's a **new** user). As the error message in my original notes, Rails knows the ID of the nested model. – Jase Jun 14 '11 at 21:54
  • 1
    Yes, definitely not a problem with the ID of the nested object being passed. In the error I pasted in original, it knows the ID of the nested object (User) is 7 and the outer object (Blogger) doesn't have an ID yet (because it's brand new). It just apparently freaks out in that situation. – Jase Jul 06 '11 at 18:43