Destroying an Association Marked as Read-Only in Rails
Read-only is a boolean setting. When true
, it prevents an Active Record instance to be updated or destroyed. Although very helpful, there are a few cases where you want to be able to destroy or modify a read-only record. In this article, I’m going to show you a case where overriding that rule makes sense, and how I did it.
So, I’m an intern in a project called CM42 Central. It is an open source backlog management system based on Pivotal Tracker that’s used to control teams, projects, and tasks of an organization. It allows you to create and estimate stories with points which determine your team’s velocity through iterations. Stories can be features, bugs, chores, or releases. After created, they can be started, finished, or delivered (when your feature goes to production), and can also be accepted or declined by the project manager. Once a story is accepted, it becomes read-only to prevent further modification.
The readonly? method
The readonly?
method prevents a record to be modified or destroyed. It can be implemented by overriding the ActiveRecord::Base#readonly? method in a given model.
Say we have a Story
model:
If we create a Story
record and then try to update it, we’ll get the following exception:
The problem
The implementation of readonly?
I’m about to show you is pretty simple. If the accepted_at
attribute is set in the Story
, it becomes a read-only record:
In other words, if a story has already been accepted, then it cannot be destroyed or modified. But what if I want to destroy a project with an accepted story? Suppose the Project#stories
association is set like so:
When running project.destroy!
, I get the following error:
And this is a big problem because the story should be destroyable through the project. From this point on, I started my journey in StackOverflow and GitHub in an attempt to fix this issue.
Attempts to solve this
In the first attempt, I tried to set the readonly
attribute of the object:
story.update_attribute :readonly, false
As you’d imagine, this doesn’t work. It doesn’t even make sense because readonly?
is a method and not an attribute. But since I’m a Rails newcomer and interns are good to be interns, such a thing came across my mind anyway.
In the second attempt, I tried to override the readonly?
setting in the story’s before_destroy
callback, which also doesn’t work because I don’t want to destroy the story if it is already accepted. I want to destroy it only if I’m destroying the project.
After some time trying out different approaches and thus deepening my understanding, I claimed for help in the organization’s chat and someone told me I could use destroy_associations
. So I looked this method up and found out it’s a callback called by Active Record’s destroy
method, and it can be overriden to do something before it destroys an associated record. It seems OK at first look, but this time I decided to inspect the original method’s implementation. I entered GitHub, opened the Rails source code, and seeked the destroy
method:
And there it was in the second line, right below the read-only exception that I wanted to override :(
The solution
So after a few hours stuck on this issue, and the red exception on my computer screen telling me that I’ll never be a programmer and I’m losing to a simple bug, I decided to look back into previous attempts. When I was reviewing the second one, it finally hit me:
Is there a way to discover if my object is being destroyed by its parent?
It turns out there is. The ActiveRecord::AutosaveAssociation
class has a method called destroyed_by_association
, which returns the parent object that’s destroying the current record. In practice, it is used to avoid updating the counter cache (if any) unnecessarily. So I changed my code to this:
And it worked perfectly. So now if I try to send a destroy
message to an accepted story, this method will respond that the story is read-only and cannot be destroyed. But if I send the same message through the Project
, readonly?
will return false and the Story
will therefore be destroyed.
Conclusion
There might be better, alternative workarounds for this issue, but the destroyed_by_association
method worked like a charm for me, and, who knows, it might work for other developers as well.
If you know about other alternatives, please leave it up in the comments.