Associated models and a nested form with validation not working

1.5k Views Asked by At

Update2: I've cleaned up the code, which seems to have solved some of the problems. I've posted the new code as a new question here.

Update: Organization and User have a 1:many relationship. My question concerns a joined signup form where both an organization and user are required. After maxcal's help on the original post, I've written a new create method for my nested form ("organization has many users"), as shown below. Also I added begin...rescue...end to the create method. The situation/problem now:

  • Submitted with all valid info it works correctly.
  • Submitted with invalid info for organization (doesn't matter if user is also invalid or not), it renders the page with the error messages, as we want it to, but it only shows errors for the organization details. Also, for the user details it has then emptied all the fields, which it shouldn't.
  • Submitted with invalid info only for user, it renders the form again but without any error messages and all fields for user have been emptied.

Anyone got an idea what is wrong with the code? The problem seems to be more with the nested user than with organization (the parent). Also, users_attributes.empty? doesn't work, since an empty submitted form still includes such attributes, according to the log:

Parameters: {"utf8"=>"✓", "authenticity_token"=>"***", "organization"=>{"name"=>"", "bag"=>"", "users_attributes"=>{"0"=>{"email"=>"", "username"=>"", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]", "usertype"=>"2", "admin"=>"true"}}}, "commit"=>"Register"}

.

  def create
    @organization = Organization.new(new_params.except(:users_attributes))
begin
    if users_attributes.empty?
        @organisation.errors.add(:users, 'No user provided')
    end
    @organization.transaction do
      @organization.save!
      if users_attributes.any?
        @organization.users.create!(users_attributes)
      end
    end
rescue ActiveRecord::RecordInvalid => invalid
    if @organization.persisted?
      if @organization.users.any?
        @organization.users.each do |single_user|
          single_user.send_activation_email
        end
      end
      flash[:success] = "Confirmation email sent."
      redirect_to root_url
    else
      @organization.users.build if @organization.users.blank? 
      render :new
    end
end
  end

private
  # converts the hash of nested attributes hashes to an array
  def users_attributes
     new_params[:users_attributes].values
  end
end


Original question: I have two associated models and a nested form with validation. Unfortunately, it’s not working. 1) On seeding it generates the error Validation failed: Users organization can't be blank. I previously posted a question about this and prematurely concluded it had solved it. It hasn’t. 2) Submitting my nested signup form with all fields filled in correctly, produces the flash error message The form contains 1 error. Users organization can't be blank.

How should I adjust my code to solve these issues?

Model files:

#User model
belongs_to :organization, inverse_of: :users
validates_presence_of :organization_id, :unless => 'usertype == 1'

# Organization model
has_many :users, dependent: :destroy
accepts_nested_attributes_for :users, :reject_if => :all_blank, :allow_destroy => true

validate  :check_user
private
  def check_user
    if users.empty?
      errors.add(:base, 'User not present')
    end
  end

Organization Controller methods

  def new
    @organization = Organization.new
    @user = @organization.users.build
  end

  def create
    @organization = Organization.new(new_params)
    if @organization.save
      @organization.users.each do |single_user|
        single_user.send_activation_email                 # Method in user model file.
      end
      flash[:success] = "Confirmation email sent."
      redirect_to root_url
    else
      @organization.users.build if @organization.users.blank?
      render 'new'
    end
  end

def new_params
  params.require(:organization).permit(:name, :bag,
             users_attributes: [:email, :username, :usertype, :password, :password_confirmation])
end

The form:

  <%= form_for @organization, url: organizations_path do |f| %>
    <%= render 'shared/error_messages', object: f.object %>
    <%= f.text_field :name %>
    <%= f.text_field :bag %>
    <%= f.fields_for :users do |p| %>
      <%= p.email_field :email %>
      <%= p.text_field :username %>
      <%= p.text_field :fullname %>
      <%= p.password_field :password %>
      <%= p.password_field :password_confirmation %>
      <%= p.hidden_field :usertype, value: 2 %>
    <% end %>

In my Seeds file I have:

Organization.create!(name: "Fictious business",
                     address: Faker::Address.street_address,
                     city: Faker::Address.city,
  users_attributes: [email: "[email protected]",
                     username: "helpyzghtst", 
                     usertype: 2,
                     password: "foobar", 
                     password_confirmation: "foobar"])

The log on the error on submitting the signup form:

Started POST "/organizations" 
Processing by OrganizationsController#create as HTML
  Parameters: {"utf8"=>"✓", "authenticity_token"=>"0cR***Nnx4iReMiePg==", "organization"=>{"name"=>"test21", "bag"=>"tes21", "users_attributes"=>{"0"=>{"email"=>"[email protected]", "username"=>"test21", "password"=>"[FILTERED]", "password_confirmation"=>"[FILTERED]", "usertype"=>"2"}}}, "commit"=>"Register"}
   (0.2ms)  BEGIN
  User Exists (1.1ms)  SELECT  1 AS one FROM "users" WHERE LOWER("users"."email") = LOWER('[email protected]') LIMIT 1
   (0.7ms)  SELECT "users"."email" FROM "users"  ORDER BY "users"."username" ASC
  User Exists (0.3ms)  SELECT  1 AS one FROM "users" WHERE LOWER(users"."username") = LOWER('test21') LIMIT 1
  Organization Exists (0.6ms)  SELECT  1 AS one FROM "organizations" WHERE LOWER("organizations"."name") = LOWER('test21') LIMIT 1
  Organization Exists (0.4ms)  SELECT  1 AS one FROM "organizations" WHERE LOWER("organizations"."bag") = LOWER('tes21') LIMIT 1
   (0.2ms)  ROLLBACK
1

There are 1 best solutions below

9
On BEST ANSWER

Your validation does not work due to a Catch-22

To apply for this job, you would have to be insane; but if you are insane, you are unacceptable.

ActiveRecord models get their ID from the database when they are saved. But the validation on the nested user runs before the organization is inserted into the the database.

You would guess that just checking validates_presence_of instead would pass:

validates_presence_of :organization, unless: -> { usertype == 1 }

Unfortunatly not. In order for validates_presence_of :organization to pass the organization must be persisted to the database. Catch-22 again.

In order for the validation to pass we would need to split creating the organization and user into two steps:

org = Organization.create(name: 'M & M Enterprises')
user = org.users.build(username: 'milo_minderbinder', ...)
user.valid? 

Unfortunatly the means that you cannot use accepts_nested_attributes_for :users - well at least not straight off the bat.

By using a transaction we can insert the organization into the the database and and roll back if the user is not valid.

def create
  @organization = Organization.new(new_params.except(:users_attributes))
  @organization.transaction do
    @organization.save!
    if new_params[:users_attributes].any?
      @organization.users.create!(new_params[:users_attributes])
    end
  end
  if @organization.persisted?
    # ...
    if @organization.users.any?
      # send emails ... 
    end
  else
    @organization.users.build if @organization.users.blank? 
    render :new
  end
end

Followup questions

We use @organization.persisted? since we presumably want to redirect to the newly created organisation no matter if the there is a User record created.

because the emails are sent to users? It shouldn't matter since organization is rolled back if no user is created.

The transaction is not rolled back if there is no user created. Only if the user(s) fails to save due to invalid parameters. This is based on your requirement:

But an organization can also (temporarily) have no users.

If you need the @organisation to be invalid without users you could do:

  @organisation.errors.add(:users, 'No users provided') unless new_params[:users_attributes].any?
  @organization.transaction do
    @organization.save!
    if new_params[:users_attributes].any?
      @organization.users.create!(new_params[:users_attributes])
    end
  end

You would use @organization.users.any? to check if there are any users. @organization.users.persisted? will not work since .persisted? is a method on model instances - not collections.

On a different note, I assume it's not possible to overwrite/update an existing organization/user with this method (which shouldn't be) instead of always creating a new record?

Right, since this will always issue two SQL insert statements it will not alter existing records.

It is up to you however to create validations that guarantee the uniqueness of the database columns (IE you don't want several records with the same user.email or organiation.name).

On the plus side is that none of these caveats apply when updating an existing organization:

def update
  @organisation.update(... params for org and and users ...)
end

Since you don't get the whole chicken or egg dilemma when validating the users.