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
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
class Story < ApplicationRecord def readonly? !new_record? end end
If we create a
Story record and then try to update it, we’ll get the following exception:
2.5.1 :001 > my_story = Story.new(title: 'Story Title') => #<Story id: nil, title: "Story Title", created_at: nil, updated_at: nil> 2.5.1 :002 > my_story.save! => true 2.5.1 :003 > my_story.title = 'New Title' => "New Title" 2.5.1 :004 > my_story.save! Traceback (most recent call last): 1: from (irb): 4 ActiveRecord::ReadOnlyRecord (Story is marked as readonly)
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:
def readonly? !accepted_at_changed? && accepted_at.present? end
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:
class Project < ApplicationRecord has_many :stories, dependent: :destroy end
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
def destroy _raise_readonly_record_error if readonly? destroy_associations
And there it was in the second line, right below the read-only exception that I wanted to override 🙁
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:
def readonly? return false if destroyed_by_association !accepted_at_changed? && accepted_at.present? end
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.
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.
Thanks to Thiago Araújo Silva.