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_errorcauses 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
- For associations with
dependent: :destroy, the associated objects are destroyed before the main object. This is to prevent any foreign key constraints from complaining.
destroyoperations run within a transaction. Associated records with
dependent: :destroyare 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.
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
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
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!