Ruby on Rails: How to validate nested attributes on certain condition?

33,210

First of all, I want to be sure that you mean blank? rather than present?. Typically, I see this:

validate :address, :presence_of => true, :if => 'organisation.present?'

Meaning, you only want to validate address if organisation is also present.

Regarding, :accepts_nested_attributes_for, are you using this feature by passing in nested form attributes, or some such thing? I just want to make sure you absolutely need to use this functionality. If you are not actually dealing with nested form attributes, you can implement cascading validation using:

validates_associated :address

If you do need to use :accepts_nested_attributes, be sure to check out the :reject_if parameter. Basically, you can reject adding an attribute (and it's descendants) altogether if certain conditions apply:

accepts_nested_attributes_for :address, :allow_destroy => true, :reject_if => :no_organisation

def no_organisation(attributes)
  attributes[:organisation_id].blank?
end

Now, if none of the above apply, let's take a look at your syntax:

It should work, :if/:unless take symbols, strings and procs. You don't need to point to the foreign_key, but can simplify by pointing to:

:if => "organisation.blank?"

You have other validations in the Address model, correct? Is Address being validated when you don't want it to? Or is Address not being validated? I can help you test it out in the console if you can give me some additional details.


  1. To make things easier for myself re: mass-assignment, I changed the rails config: config.active_record.whitelist_attributes = false
  2. I created a gist for you to follow along
  3. I have a sample project as well. Let me know if you are interested.

    Basic points:

  4. Added the following to Person to ensure that either Org or Address are valid:

    validates_presence_of :organisation, :unless => "address.present?" validates_associated :address, :unless => "organisation.present?"

  5. Added validation to Address to trigger errors when Org is not present: validates_presence_of :line1, :line2, :city, :zip

    I was able to produce the requirements you are seeking. Please look at the gist I created where I have a full console test plan.


I added a controller file to the previous gist.

Overview:

  1. All you should need to create the person is: @person = current_user.people.build(params[:person])
  2. :organisation_id will always be found off of the :person param node, like so: params[:person][:organisation_id] So you're if will never be true.

I updated the gist with the necessary changes to the controller, the model and the form.

Overview:

  1. You need to cleanup your controller. You are using accepts_nested_attribute, so in the :create, you only care about params[:person]. Additionally, in the render :new, you need to setup any instance variables that the partial will use. This does NOT go back through the :new action. The :new and :edit actions also need to be simplified.
  2. Your Person model needs to use the :reject_if argument because the Address fields are coming back to the :create action as :address_attributes => {:line1 => '', :line2 => '', etc}. you only want to create the association if any have values. Then your validates_presence_of for :organisation will work just fine.
  3. Your form needs to pass the organisation id to the controller, rather than the organisation names

    It's all in the gist


Should be the final gist.

Overview:

  1. Add the following to your edit action right after building the @person:

    @person.build_address if @person.address.nil? This ensure that you have the address inputs, even if the @person.address does not exist. It doesn't exist, because of the :reject_if condition on accepts_nested_attributes

  2. I DRYed up the :reject_if as follows. It's a little hacky, but has some utility:

    accepts_nested_attributes_for :address, :allow_destroy => true, :reject_if => :attributes_blank?
    
    def attributes_blank?(attrs)  
      attrs.except('id').values.all?(&:blank?)  
    end  
    

    a. attrs -> the result of params[:person][:address]
    b. .except('id') -> return all key-values except for 'id'
    c. .values -> return all values from a hash as an array
    d. .all? -> do all elements in the array satisfy the following check
    e. &:blank -> ruby shorthand for a block, like this: all?{ |v| v.blank? }

Share:
33,210
Tintin81
Author by

Tintin81

Updated on July 05, 2022

Comments

  • Tintin81
    Tintin81 almost 2 years

    I have these models:

    class Organisation < ActiveRecord::Base
    
      has_many    :people
      has_one     :address, :as         => :addressable,
                            :dependent  => :destroy
      accepts_nested_attributes_for :address, :allow_destroy => true
    
    end
    
    class Person < ActiveRecord::Base
    
      attr_accessible :first_name, :last_name, :email, :organisation_id, :address_attributes
    
      belongs_to  :user
      belongs_to  :organisation
      has_one     :address, :as         => :addressable,
                            :dependent  => :destroy
      accepts_nested_attributes_for :address, :allow_destroy => true
    
      # These two methods seem to have no effect at all!
      validates_presence_of :organisation,  :unless => "address.present?"
      validates_associated  :address,       :unless => "organisation.present?"
    
    end
    
    class Address < ActiveRecord::Base
    
      belongs_to :addressable, :polymorphic => true
    
      validates_presence_of :line1, :line2, :city, :zip
    
    end
    

    ...and these views:

    _fields.html.erb:

    <%= render 'shared/error_messages', :object => f.object %>
    <fieldset>
    <div class="left">
        <%= f.label :first_name %><br/>
        <%= f.text_field :first_name %>
    </div>
    <div>
        <%= f.label :last_name %><br/>
        <%= f.text_field :last_name %>
    </div>
    <div>
        <%= f.label :email %><br/>
        <%= f.text_field :email %>
    </div>
    <div>
        <%= f.label :organisation_id %><br/>
        <%= f.select(:organisation_id, current_user.organisation_names, {:include_blank => "--- None ---"}, :id => 'organisation_select') %>
    </div>
    </fieldset>
    
    <%= f.fields_for :address do |address| %>
      <%= render 'shared/address', :f => address %>
    <% end %>
    

    _address.html.erb:

    <fieldset id="address_fields">
    <div>
        <%= f.label :line1 %>
        <%= f.text_field :line1 %>
    </div>
    <div>
        <%= f.label :line2 %>
        <%= f.text_field :line2 %>
    </div>
    <div>
        <%= f.label :zip %>
        <%= f.text_field :zip %>
    </div>  
    <div>
        <%= f.label :city %>
        <%= f.text_field :city %>
    </div>  
    </fieldset>
    

    people_controller.rb:

    def new
      puts params.inspect
      @person = Person.new(:organisation_id => params[:organisation_id])
      @person.build_address
      @title = "New person"
    end
    
    {"action"=>"new", "controller"=>"people"}
    
    def edit
      puts params.inspect
      @title = @person.name
    end
    
    {"action"=>"edit", "id"=>"69", "controller"=>"people"}
    
    def create
      puts params.inspect
      if params[:organisation_id]
        @person = current_user.organisations.build_person(params[:person])
      else
        @person = current_user.people.build(params[:person])
      end
      if @person.save
        flash[:success] = "Person created."
        redirect_to people_path
      else
        render :action => "new"
      end
    end
    
    {"commit"=>"Create", "action"=>"create", "person"=>{"last_name"=>"Doe", "organisation_id"=>"9", "email"=>"[email protected]", "first_name"=>"John", "address_attributes"=>{"city"=>"Chicago", "zip"=>"12345", "line2"=>"Apt 1", "line1"=>"1 Main Street"}}, "authenticity_token"=>"Jp3XVLbA3X1SOigPezYFfEol0FGjcMHRTy6jQeM1OuI=", "controller"=>"people", "utf8"=>"✓"}
    

    Inside my Person model I need to make sure that only if a person's organisation_id is blank, that person's address fields have to be present.

    I tried something like this:

    validates :address, :presence => true, :if => "organisation_id.blank?"
    

    But it's not working.

    How can this be done?

    Thanks for any help.

  • Matt Dressel
    Matt Dressel over 11 years
    By the way, I kept typing 'organisation' as 'organization', so there might be some typos.
  • Tintin81
    Tintin81 over 11 years
    This doesn't work either. I get an undefined method key? for nil:NilClass error.
  • Tintin81
    Tintin81 over 11 years
    Hello Matt, thanks for your kind help. I extended my initial answer above a bit and also posted the view code. Indeed I am using :accepts_nested_attributes_for in the Person model, so that it also accepts the address data for the person in question. Is that not a good idea? To be honest I failed to get your code working, even though it's definitely a lot better than mine.
  • Tintin81
    Tintin81 over 11 years
    Basically, when a new person is created that person must belong to an organisation. Only if is does not (i.e. if no option is selected in the organisation select box), that person's nested address attributes will have to be filled in by the user. Unfortunately, I find this really difficult to realise, so I hope you can help me with it.
  • varatis
    varatis over 11 years
    Yeah sorry, it would be the negation of that (!organisation_id.nil). Regardless I would go with Matt's answer
  • Tintin81
    Tintin81 over 11 years
    Hello Matt. Thanks a lot!! I was able to get your code working on my console and learned a lot just by doing that. I only made some slight changes to it because my Person and Organisation models both required a user_id as well. The 5 tests you created all pass as expected. p, p2, p3 return true while p4 and p5 return false. I guess that's the outcome that you expected?
  • Tintin81
    Tintin81 over 11 years
    The problem now is that except of the first one, none of the scenarios work like that when I create a new person in the browser. I get an error message whenever I leave the address fields empty, no matter if an organisation is selected in the dropdown menu or not. So does this mean that my controller is faulty? I wonder if the @person.build_address line causes the validation to run every time? But I also kind of need it because else no address fields will be displayed in my form at all.
  • Matt Dressel
    Matt Dressel over 11 years
    Can you add the following line at the top of you controller action and add the output in your post: puts params.inspect
  • Matt Dressel
    Matt Dressel over 11 years
    Yep. I'll have a solution for you shortly.
  • Tintin81
    Tintin81 over 11 years
    Hello Matt. Thanks for your help. Indeed my controller code was a bit silly; I changed it accordingly. I studied your code very carefully and also ran the test_person test. I cannot find anything wrong with it though. Everything behaves as it should, except for the Address model whose validation cannot be skipped. It seems that due to the line @person.build_address in my new action, Rails considers the address as being present and thus the validation method validates_associated :address, :unless => "organisation.present?" doesn't have any effect.
  • Tintin81
    Tintin81 over 11 years
    Actually, I spent hours this morning trying to get the two validation methods in the Person model to work. I also tried the lambda and Proc syntax, but to no avail. The validation methods inside the Address model do always get fired, no matter what I do in the Person model. This is really weird...
  • Tintin81
    Tintin81 over 11 years
    OK, I just updated the Model code in my initial question above, just to provide some more info on what I have.
  • Matt Dressel
    Matt Dressel over 11 years
    Their are a few things that I see that are wrong. I will update in a bit.
  • Tintin81
    Tintin81 over 11 years
    OK, it works now. This line did the trick: accepts_nested_attributes_for :address, :allow_destroy => true, :reject_if => lambda { |a| a[:line1].blank? && a[:line2].blank? && a[:city].blank? && a[:zip].blank? } I also created a new gist.
  • Tintin81
    Tintin81 over 11 years
    One little problem that's left is that when I edit a previously created person and remove all that person's address fields, all fields appear filled in again after hitting the Update button. But that should be relatively easy to fix in my controller code. By the way, have I said "Thanks" yet? You really saved me an awful lot of work and I learned a lot about Rails along the way.
  • Matt Dressel
    Matt Dressel over 11 years
    No problem. It's my pleasure. I have an additional gist for you with some notes.
  • ocodo
    ocodo almost 10 years
    @MattDressel I have a related problem, maybe you can give your 2 cents? stackoverflow.com/questions/23867452/…