Search code examples
ruby-on-railsactiverecordruby-on-rails-5nested-formsnested-attributes

Why Rails 5 is not saving nested attributes because parent model is not saving first


I am using Rails 5 and everything at its newest stable versions. So I get the following :

You have your association set to required but it's missing. Associations are set to required by default in rails 5 so if you want to keep one empty you need to set optional:true on your association in mode

This is great and I understand what is going on however for the life of me I cannot figure out how to get the parent model to save first so the user_id is translated the nested models record. I see the same answer above everywhere however no one explains a work around other than turning the default in the initializer from true to false. THIS DOES NOT SOLVE THE PROBLEM, because the record sure does save but it does not include the user_id.

Below is what I have for my code base, I would ask rather than responding with the above quote, could someone enlighten me on HOW to get the USER_ID field into the nested attributes while saving. I refuse to disable validation and manually handle the insertion, as this is not the ruby way and breaks from standards! Thanks in advance for anyone who can answer this question directly and without vague explanations that digress from the ruby way of things!

###Models
#Users
class User < ApplicationRecord
  has_one :profile, inverse_of: :user
  accepts_nested_attributes_for :profile, allow_destroy: true
end

#Profiles
class Profile < ApplicationRecord
  belongs_to :user, inverse_of: :profile
end

###Controller
class UsersController < ApplicationController
  before_action :set_user, only: [:show, :edit, :update, :destroy]

  # GET /users
  # GET /users.json
  def index
    @users = User.all
  end

  # GET /users/1
  # GET /users/1.json
  def show
  end

  # GET /users/new
  def new
    @user = User.new
    @user.build_profile
  end

  # GET /users/1/edit
  def edit
    @user.build_profile
  end

  # POST /users
  # POST /users.json
  def create
    @user = User.new(user_params)

    respond_to do |format|
      if @user.save
        format.html { redirect_to @user, notice: 'User was successfully created.' }
        format.json { render :show, status: :created, location: @user }
      else
        format.html { render :new }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # PATCH/PUT /users/1
  # PATCH/PUT /users/1.json
  def update
    respond_to do |format|
      if @user.update(user_params)
        format.html { redirect_to @user, notice: 'User was successfully updated.' }
        format.json { render :show, status: :ok, location: @user }
      else
        format.html { render :edit }
        format.json { render json: @user.errors, status: :unprocessable_entity }
      end
    end
  end

  # DELETE /users/1
  # DELETE /users/1.json
  def destroy
    @user.destroy
    respond_to do |format|
      format.html { redirect_to users_url, notice: 'User was successfully destroyed.' }
      format.json { head :no_content }
    end
  end

  private
    # Use callbacks to share common setup or constraints between actions.
    def set_user
      @user = User.find(params[:id])
    end

    # Never trust parameters from the scary internet, only allow the white list through.
    def user_params
      params.require(:user).permit(:username, :password, :user_type_id, profile_attributes: [:user_id, :first_name, :middle_name, :last_name, :phone_number, :cell_number, :email])
    end
end

##View
<%= form_for(@user) do |f| %>
  <% if user.errors.any? %>
    <div id="error_explanation">
      <h2><%= pluralize(user.errors.count, "error") %> prohibited this user from being saved:</h2>

      <ul>
      <% user.errors.full_messages.each do |message| %>
        <li><%= message %></li>
      <% end %>
        <!--<li><%= debug f %></li>-->
      </ul>
    </div>
  <% end %>

  <div class="field">
    <%= f.label :username %>
    <%= f.text_field :username %>
  </div>

  <div class="field">
    <%= f.label :password %>
    <%= f.text_field :password %>
  </div>

  <div class="field">
    <% if params[:trainer] == "true" %>
      <%= f.label :user_type_id %>
      <%= f.text_field :user_type_id, :readonly => true, :value => '2' %>
    <% else %>
      <%= f.label :user_type_id %>
      <%= f.text_field :user_type_id, :readonly => true, :value => '1'  %>
    <% end %>
  </div>
    <h2>Account Profile</h2>
    <%= f.fields_for :profile do |profile| %>
      <%#= profile.inspect %>
        <div>
          <%= profile.label :first_name %>
          <%= profile.text_field :first_name %>
        </div>
        <div>
          <%= profile.label :middle_name %>
          <%= profile.text_field :middle_name %>
        </div>
        <div>
          <%= profile.label :last_name %>
          <%= profile.text_field :last_name %>
        </div>
        <div>
          <%= profile.label :email %>
          <%= profile.text_field :email %>
        </div>
        <div>
          <%= profile.label :phone_number %>
          <%= profile.telephone_field :phone_number %>
        </div>
        <div>
          <%= profile.label :cell_phone %>
          <%= profile.telephone_field :cell_number %>
        </div>
    <% end %>
  <div class="actions">
    <%= f.submit %>
  </div>
    <%= debug params %>
    <%= debug user %>
    <%= debug user.profile %>
<% end %>

UPDATE For starters I have figured out that you need to include autosave: true to the relationship like so

class User < ApplicationRecord
  has_one :profile, inverse_of: :user, autosave: true
  accepts_nested_attributes_for :profile, allow_destroy: true
end

Then the parent record gets saved before the child. Now comes another gotcha that I am just not sure about and is odd when the form is submitted you will notice in the console output I pasted below that the INSERT INTO profiles statement includes the user_id column and the value of 1. It passees validation and looks like it runs properly from the output, however the user_id column in the profiles table is still null. I am going to keep digging, hopefuly one of my fellow rubyiests out there will see this and have some ideas on how to finish fixing this. I love Rails 5 improvements so far but it wouldn't be ROR without small interesting gotchas! Thanks again in advance!

Started POST "/users" for 192.168.0.31 at 2017-03-12 22:28:14 -0400
Cannot render console from 192.168.0.31! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
Processing by UsersController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"YA7kQnScvlIBy5OiT+BmOQ2bR7J00ANXId38FqNwX37Cejd+6faUyD3rMF4y0qJNKBUYGaxrRZqcLrXonL6ymA==", "user"=>{"username"=>"john", "password"=>"[FILTERED]", "user_type_id"=>"1", "profile_attributes"=>{"first_name"=>"john", "middle_name"=>"r", "last_name"=>"tung", "email"=>"[email protected]", "phone_number"=>"8033207677", "cell_number"=>"8033207677"}}, "commit"=>"Create User"}
   (0.1ms)  BEGIN
  SQL (0.3ms)  INSERT INTO `users` (`username`, `password`, `user_type_id`, `created_at`, `updated_at`) VALUES ('john', '0000', 1, '2017-03-13 02:28:14', '2017-03-13 02:28:14')
  SQL (0.4ms)  INSERT INTO `profiles` (`user_id`, `email`, `first_name`, `middle_name`, `last_name`, `phone_number`, `cell_number`, `created_at`, `updated_at`) VALUES (1, '[email protected]', 'john', 'r', 'tung', '8033207677', '8033207677', '2017-03-13 02:28:14', '2017-03-13 02:28:14')
   (10.8ms)  COMMIT
Redirected to http://192.168.0.51:3000/users/1
Completed 302 Found in 24ms (ActiveRecord: 11.5ms)

Solution

  • Ok, I am answering my own question because I know many people are struggling with this and I actually have the answer and not a vague response to the documentation.

    First we will just be using a one to one relationship for this example. When you create your relationships you need to make sure that the parent model has the following

    1. inverse_of:
    2. autosave: true
    3. accepts_nested_attributes_for :model, allow_destroy:true

    Here is the Users model then I will explain,

    class User < ApplicationRecord
      has_one :profile, inverse_of: :user, autosave: true
      accepts_nested_attributes_for :profile, allow_destroy: true
    end
    

    in Rails 5 you need inverse_of: because this tells Rails that there is a relationship through foreign key and that it needs to be set on the nested model when saving your form data.

    Now if you were to leave autosave: true off from the relationship line you are left with the user_id not saving to the profiles table and just the other columns, unless you have validations off and then it won't error out it will just save it without the user_id.

    What is going on here is autosave: true is making sure that the user record is saved first so that it has the user_id to store in the nested attributes for the profile model.

    That is it in a nutshell why the user_id was not traversing to the child and it was rolling back rather than committing.

    Also one last gotcha is there are some posts out there telling you in your controller for the edit route you should add @user.build_profile like I have in my post. DO NOT DO IT THEY ARE DEAD WRONG, after assessing the console output it results in

    Started GET "/users/1/edit" for 192.168.0.31 at 2017-03-12 22:38:17 -0400
    Cannot render console from 192.168.0.31! Allowed networks: 127.0.0.1, ::1, 127.0.0.0/127.255.255.255
    Processing by UsersController#edit as HTML
      Parameters: {"id"=>"1"}
      User Load (0.4ms)  SELECT  `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
      Profile Load (0.5ms)  SELECT  `profiles`.* FROM `profiles` WHERE `profiles`.`user_id` = 1 LIMIT 1
       (0.1ms)  BEGIN
      SQL (0.5ms)  UPDATE `profiles` SET `user_id` = NULL, `updated_at` = '2017-03-13 02:38:17' WHERE `profiles`.`id` = 1
       (59.5ms)  COMMIT
      Rendering users/edit.html.erb within layouts/application
      Rendered users/_form.html.erb (44.8ms)
      Rendered users/edit.html.erb within layouts/application (50.2ms)
    Completed 200 OK in 174ms (Views: 98.6ms | ActiveRecord: 61.1ms)
    

    If you look it is rebuilding the profile from scratch and resetting the user_id to null for the record that matches the current user you are editing.

    So be very careful of this as I have seen tons of posts making this suggestion and it cost me DAYS of research to find a solution!