ActiveStorage is by far the feature I am most excited about in Rails 5.2 (it was also the source of my first bit of code in Rails!). Now that 5.2 stable is out, I have been implementing it in a few apps, but already tripping over a few things, specifically, how to handle deleting attachments.

How to delete attachments

At the time of this writing, the Rails Edge Guide detailing removing attachments is all of two sentences and two methods long. You can purge them now or you can purge them later. Note, for this post we will only talk about purge rather than detatch which calls destroy_all the attachments, but leaves the blob(s) intact.

For has_many_attached the default behavior of purge is to delete all the records (obviously the same is true of has_one_attached), however, you can target them for specific removal with any query. To get a better idea of how the objects are shaped, here is a Resource object which has two :files attached

Resource.last.files
#<ActiveStorage::Attached::Many:0x00007f8ff866e470 @name="files", @record=#<Resource id: 1, title: "Testing", created_at: "2018-04-17 16:30:29", updated_at: "2018-04-17 16:30:29">, @dependent=:purge_later>

Resource.last.files.first
#<ActiveStorage::Attachment id: 1, name: "files", record_type: "Resource", record_id: 1, blob_id: 1, created_at: "2018-04-17 16:30:29"> 

Resource.last.files.last
#<ActiveStorage::Attachment id: 2, name: "files", record_type: "Resource", record_id: 1, blob_id: 2, created_at: "2018-04-17 16:30:29">

The name assigned to each record is the one that you set in the model and will be the same for all attachments on that model. The specifics about the file itself are in the blob

Resource.last.files.first.blob
#<ActiveStorage::Blob id: 2, key: "9BG2pTG3S7Ms4jfVa9ysj33F", filename: "Test_file.PDF", content_type: "application/pdf", metadata: {"identified"=>true, "analyzed"=>true}, byte_size: 78618, checksum: "kG/rsqFbw/BkHSSPYItqAg==", created_at: "2018-04-19 19:17:27"> 

To remove all the attachments from either a has_one or has_many model, just target the attachment and call .purge on it. To get rid of a single attachment from a has_many_attached record, you can use a query:

# Directly
ActiveStorage::Attachment.find(1).purge

# Through a resource
@resource.files.find(params[:attachment_id]).purge

What to do about controllers

For me, the above discussion is interesting, but only the start. Yes, you know can target attachments and purge them, but as soon as you sit down to wire this up to a user action you will be confronted with where exactly to put it.

Interestingly, this is not an issue that the other actions have. Creating attachments doesn't require anything extra on the create action. Updating attachments will work great on the update action as long as you remember that updating a has_one_attached relationship will destroy and replace the old file and updating on a has_many_attached will create a new attachment. Neither of those requires anything explicit to work for ActiveStorage other than whitelisting your attachment name.

Here are three options I see on where you can locate your attachment deletion responsibilities:

  • a global attachments controller that handles all delete requests
  • a non-restful endpoint and method on the resource controller
  • a switch on the resources destroy action

I think each has their pluses and minuses depending on your application and how closely you want to hew to REST.

A global attachments controller

A global attachments controller keeps things nice and clean. For example, you send your deletes to /attachments/:id, dust off your hands and call it day. For many apps I can see this working. In some ways it kind of feels like this is a missing controller for ActiveStorage.

One disadvantage that comes to mind, however is that now the destroy method is separated from any before filters, authorizations or controller logic specific to the resource controller. This may or may not matter to you, depending on your use of callbacks.

A second disadvantage is that it's not quite as simple as just passing in the ID in the case where you want to purge only one record from as has_many_attached model. You could handle this in a number of ways, but it seems certain to involve some conditional logic.

A non-RESTful endpoint

This was the first tool I pulled for when thinking about this issue. It makes your routes a little messier and there's another method in the controller, but all-in-all, it's not so bad. And, if you're making one endpoint, you might as well make 2 and then you can handle purging all attachments and selectively purging attachments without conditionals. This way you also get any controller specific callbacks and methods.

The reason I moved away from this, and I think it's primary disadvantage if you aren't worried about being completely RESTful, is that I had a lot or models that were handling files and building in this endpoint to each one was feeling like a lot of repeated work. I could still see it being worth it if you are doing a lot of controller specific work depending on where the delete was coming from, but in my case, all the destroy actions were the same.

Switch on the resource's destroy method

For this approach, you don't have to mess with your routes or manage additional methods. I built mine to pick up on an [:attachment_id] param or a [:purge] param. If it's present, it handles the attachment(s), if not, it destroys the resource just like always.

Here is an example of a has_many_attached model:

# handle selective purge
if params[:attachment_id]
  @resource.files.find_by_id(params[:attachment_id]).purge
  ...
# handle purge all
elsif params[:purge]
  @resource.files.purge
  ...
# handle destroy resource
else
  @resource.destroy
  ...
end

The disadvantage is you need to deal with a conditional and it feels a bit like hijacking the destroy method, but for my current use case it's been pretty manageable.