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:

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 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:

  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

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:

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 🙁

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:

  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.

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.

Thanks to Thiago Araújo Silva.