Rails: Prevent the destruction of Child object when Parent requires its presence

Let's say we're building a Rails app where the Location model has_one Address, has_one MailAddress, and requires the presence of at least one of them. To ensure that a Location cannot be created without any type of address, we can add the following lines to the Location model:

validates(
  :address,
  presence: { message: 'A location must have at least one address type.' },
  unless: proc { |loc| loc.mail_address.present? }
)

validates(
  :mail_address,
  presence: { message: 'A location must have at least one address type.' },
  unless: proc { |loc| loc.address.present? }
)

The unless option tells the app to skip the presence validation if the other address type already exists.

To be able to create a Location and an Address and/or MailAddress at the same time, we need to add the following lines to our Location model:

attr_accessible :address_attributes, :mail_address_attributes

accepts_nested_attributes_for :address, :mail_address

This will allow us to add an Address to a Location like this:

Location.create!(
  address_attributes: {
    street: '123 Main Street',
    city: 'Belmont',
    state: 'CA',
    zip: '94403'
  }
)

Because Address and MailAddress are separate models, Active Record requires the _attributes suffix. If we were using MongoDB with Mongoid, we wouldn't need to add the suffix.

Now let's say we want our app to allow users to update a Location and its address types, including deleting one or the other. How do we make sure that a Location can't be left without any type of address at all?

You would think that the presence validations that we defined in the Location model would be enough to protect us, but we'd be in for a bad surprise if we used the typical destroy controller action that calls .destroy on the object. Let's try it in the console:

rails c

2.1.1 :001 > l = Location.find(1)
=> #<Location id: 1, created_at: "2014-04-11 20:16:42", updated_at: "2014-04-11 20:16:42">

2.1.1 :002 > l.address
=> #<Address id: 1, street: "123 Main Street", city: "Belmont", state: "CA", zip: "94403", created_at: "2014-04-11 20:16:43", updated_at: "2014-04-11 20:16:43">

2.1.1 :003 > l.mail_address
=> nil

2.1.1 :004 > l.address.destroy
=> #<Address id: 1, street: "123 Main Street", city: "Belmont", state: "CA", zip: "94403", created_at: "2014-04-11 20:16:43", updated_at: "2014-04-11 20:16:43">

2.1.1 :005 > l.reload.address
=> nil

Oops!

This actually feels like a Rails bug to me, especially given this feature that was added 2 years ago, which, if I understand it correctly, should be preventing this scenario.

One way to keep a Location from losing both of its addresses is to use nested forms. If you're not familiar with nested forms, I recommend watching Railscasts #196. Basically, instead of having a separate controller for each address type, and deleting an Address via its own form by calling .destroy in the controller, you would just have one locations_controller.rb, and one form that contained fields for the Location, as well as its Address and MailAddress.

Any updates to the Address and/or MailAddress, including their removal, would be processed by the update action in locations_controller.rb.

To delete an address, you could add a checkbox with a :_destroy parameter, like this:

%fieldset
  = f.label :street, 'Street'
  = f.text_field :street
  = f.label :city, 'City'
  = f.text_field :city
  = f.label :state, 'State'
  = f.text_field :state
  = f.label :zip, 'ZIP code'
  = f.text_field :zip
  = f.check_box :_destroy
  = f.label :_destroy, 'Remove Address'

You would also have to add allow_destroy: true to the accepts_nested_attributes line in the Location model:

accepts_nested_attributes_for :address, allow_destroy: true

Now if you tried to remove both the Address and MailAddress from a Location, you would get an error, as expected. But what if we didn't want to use nested forms, or what if we were building a RESTful API that would allow clients to delete an Address via a separate endpoint?

The key is to understand what Rails does when you remove an object via a nested form. If you looked at the server logs after submitting the nested form, you would see something like this:

Started PATCH "/locations/1" for 127.0.0.1 at 2014-04-27 17:29:32 -0400
Processing by LocationsController#update as HTML
  Parameters: {
    "utf8"=>"✓",
    "authenticity_token"=>"pK3dD4OSjZfQURAauI6VaGMx7zpzxbFLrzCWoQXz9E8=",
    "location"=>{
      "address_attributes"=>{
        "street"=>"123 Main Street",
        "city"=> "Belmont",
        "state"=>"CA",
        "zip"=>"94403",
        "_destroy"=>"1",
        "id"=>"1"
      }
    },
    "commit"=>"Update Location", "id"=>"1"
  }

This tells you that another way to destroy a Parent's child object is to include a _destroy key with a truthy value, like 1, inside the child_attributes hash. For more details, read the documentation for Active Record Nested Attributes.

So, instead of calling .destroy in our controller action, we would do this instead:

@address = Address.find(params[:id])
location = @address.location
if location.update(address_attributes: { id: @address.id, _destroy: '1' })
  redirect_to locations_path,
              notice: "Successfully deleted address from #{location.name}."
else
  redirect_to edit_address_path(@address),
              alert: 'A location must have at least one address type!'
end

While researching this issue, I found a few Stack Overflow entries where people were trying to solve it by adding a before_destroy callback to the Child model. In our case, the callback would check to see if the other type of address is present. Something like this:

# In app/models/address.rb

before_destroy :check_mail_address_presence

def check_mail_address_presence
  return false if location.mail_address.blank?
end

If a Location doesn't have a MailAddress, trying to destroy its Address will return false and will stop the destruction. While this solution works, even when calling .destroy, it has a major flaw that the Stack Overflow users quickly ran into:

I tried modifying the before_destroy callback to check if the Location was marked for destruction, but that still didn't allow the Location to be destroyed:

def check_mail_address_presence
  if !location.marked_for_destruction? && location.mail_address.blank?
    return false
  end
end

To recap, using nested attributes and the _destroy key is the best solution in the following scenarios:

If you want to prevent the deletion of any child (unless the Parent is being deleted), just make sure you don't add the allow_destroy: true option. By default, Rails protects associated records from being destroyed.