Update 12/01/2016: I've received reports that this behavior has changed in Rails 5, which no longer relies on exceptions for rollback behavior. I have yet to confirm, and have not built a project with Rails 5 yet. I'd love to hear from more people who can confirm this behavior.

I’ve been doing a lot of Ruby on Rails work for my day job lately, and ran across an interesting problem the other day. In one case, I had to disallow a record from being deleted if some other records associated with it exist.

Consider the following model class:

class Project < ActiveRecord::Base
  has_many :memberships, dependent: :destroy
  has_many :tasks, dependent: :restrict_with_errors
end

Notice the use of ActiveRecord’s handy :restrict_with_errors option. From the Rails docs:

:restrict_with_error causes an error to be added to the owner if there are any associated objects

The idea is that when a project is deleted, we want to also delete its associated memberships. But if a project has tasks, we want to prevent the project from being deleted. So when we do the following, the project should not be deleted, and should get an error message attached to it.

project = Project.create!(name: 'Test Project')
project.memberships.create!
project.tasks.create!
project.destroy
# => false
project.errors.full_messages
# => ["Cannot delete record because dependent tasks exist"]

Can you spot the bug? That’s right—even though our project hasn’t been deleted, all of its memberships have!

In order to understand the issue, we need to know a couple things about ActiveRecord associations and the dependent option.

  • For associations with dependent: :destroy, the associated objects are destroyed before the main object. This is to prevent any foreign key constraints from complaining.
  • destroy operations run within a transaction. Associated records with dependent: :destroy are also removed within that transaction.

So the issue here is that simply adding an error to the project doesn’t trigger a rollback, and the associations that have been destroyed before the operation was halted by the restrict_with_error stay destroyed.

The Solution

Barring a patch to Rails core, there are two solutions to this issue:

Firstly, we could change the order of the associations so that the restrict_with_errors halts the operation before the other associations are destroyed:

class Project < ActiveRecord::Base
  has_many :tasks, dependent: :restrict_with_errors
  has_many :memberships, dependent: :destroy
end

In this case, the operation will bail before the memberships association is destroyed.

Otherwise, we could use the restict_with_exception option instead:

class Project < ActiveRecord::Base
  has_many :memberships, dependent: :destroy
  has_many :tasks, dependent: :restrict_with_exception
end

Here, when we attempt to destroy the project, an exception will be thrown that will roll back the entire delete operation.

The first solution has some problems. For one, our associations are now order-dependent. If we were going to go with this option, we’d better surround that thing will a wall of tests so that no one (ourselves included) messes it up in the future. The second one seems much less brittle to me, so I ended up rolling with that.

Hope this helps!