Rails Active Storage - Keep Existing Files / Uploads?
Asked Answered
C

3

8

I have a Rails model with:

has_many_attached :files

When uploading via Active Storage by default if you upload new files it deletes all the existing uploads and replaces them with the new ones.

I have a controller hack from this which is less than desirable for many reasons:

What is the correct way to update images with has_many_attached in Rails 6

Is there a way to configure Active Storage to keep the existing ones?

Cianca answered 24/4, 2022 at 16:43 Comment(0)
A
22

Looks like there is a configuration that does exactly that

config.active_storage.replace_on_assign_to_many = false

Unfortunately it is deprecated according to current rails source code and it will be removed in Rails 7.1

config.active_storage.replace_on_assign_to_many is deprecated and will be removed in Rails 7.1. Make sure that your code works well with config.active_storage.replace_on_assign_to_many set to true before upgrading. To append new attachables to the Active Storage association, prefer using attach. Using association setter would result in purging the existing attached attachments and replacing them with new ones.

It looks like explicite usage of attach will be the only way forward.

So one way is to set everything in the controller:

def update
  ...
  if model.update(model_params)
    model.files.attach(params[:model][:files]) if params.dig(:model, :files).present?
  else
    ...
  end
end

If you don't like to have this code in controller. You can for example override default setter for the model eg like this:

class Model < ApplicationModel
  has_many_attached :files

  def files=(attachables)
    files.attach(attachables)
  end
end

Not sure if I'd suggest this solution. I'd prefer to add new method just for appending files:

class Model < ApplicationModel
  has_many_attached :files

  def append_files=(attachables)
    files.attach(attachables)
  end
end

and in your form use

  <%= f.file_field :append_files %>

It might need also a reader in the model and probably a better name, but it should demonstrate the concept.

Azide answered 24/4, 2022 at 17:45 Comment(6)
This is an excellent answer! I actually came across the controler solution but the new method idea is WAY better. πŸ‘ – Cianca
If I do this in the controller, then all my new files will be added twice. I am guessing one with the .attach method, and also with the model.update method. Do we also need to make the params[:model][:files] = nil to prevent that? – Parricide
Of course you cannot pass both. If you want to use attach explicitly you have to avoid passing params[:model][:files] to the model.update. You can do it eg by removing :files from the permitted params or by using different attribute. If you set params[:model][:files] to nil you have to be careful and do that after calling attach method and before attributes are passed to model.update. – Azide
not sure not working on my side, it will cause "stack level too deep" – Orle
@MadaAryakusumah I had the same issue, i've added my solution below: https://mcmap.net/q/1243264/-rails-active-storage-keep-existing-files-uploads – Prudenceprudent
There's also this new configuration option, which may help. edgeguides.rubyonrails.org/… – Allophone
P
6

The solution suggested for overwriting the writer by @edariedl DOES NOT WORK because it causes a stack level too deep

1st solution

Based on ActiveStorage source code at this line

You can override the writer for the has_many_attached like so:

class Model < ApplicationModel
  has_many_attached :files

  def files=(attachables)
     attachables = Array(attachables).compact_blank

    if attachables.any?
      attachment_changes["files"] =
        ActiveStorage::Attached::Changes::CreateMany.new("files", self, files.blobs + attachables)
    end
  end
end

Refactor / 2nd solution

You can create a model concern that will encapsulate all this logic and make it a bit more dynamic, by allowing you to specify the has_many_attached fields for which you want the old behaviour, while still maintaining the new behaviour for newer has_many_attached fields, should you add any after you enable the new behaviour.

in app/models/concerns/append_to_has_many_attached.rb

module AppendToHasManyAttached
  def self.[](fields)
    Module.new do
      extend ActiveSupport::Concern

      fields = Array(fields).compact_blank # will always return an array ( worst case is an empty array)

      fields.each do |field|
        field = field.to_s # We need the string version
        define_method :"#{field}=" do |attachables|
          attachables = Array(attachables).compact_blank

          if attachables.any?
            attachment_changes[field] =
              ActiveStorage::Attached::Changes::CreateMany.new(field, self, public_send(field).public_send(:blobs) + attachables)
          end
        end
      end
    end
  end
end

and in your model :

class Model < ApplicationModel
  include AppendToHasManyAttached['files'] # you can include it before or after, order does not matter, explanation below

  has_many_attached :files
end

NOTE: It does not matter if you prepend or include the module because the methods generated by ActiveStorage are added inside this generated module which is called very early when you inherit from ActiveRecord::Base here

==> So your writer will always take precedence.

Alternative/Last solution:

If you want something even more dynamic and robust, you can still create a model concern, but instead you loop inside the attachment_reflections of your model like so :

reflection_names = Model.reflect_on_all_attachments.filter { _1.macro == :has_many_attached }.map { _1.name.to_s } # we filter to exclude `has_one_attached` fields
# => returns ['files']
reflection_names.each do |name|
  define_method :"#{name}=" do |attachables|
  # ....
  end
end

However I believe for this to work, you need to include this module after all the calls to your has_many_attached otherwise it won't work because the reflections array won't be fully populated ( each call to has_many_attached appends to that array)

Prudenceprudent answered 26/10, 2022 at 12:7 Comment(1)
Dude you are awesome - solution 2 works like a charm - elegant and simple for multiple models using has_many_attached – Gorski
A
0

As of Rails 7, you can call include_hidden: false in your form to stop this happening.

<%= form.file_field :images, multiple: true, include_hidden: false %>

More details on the file_field documentation.

:include_hidden - When multiple: true and include_hidden: true, the field will be prefixed with an field with an empty value to support submitting an empty collection of files.

https://edgeapi.rubyonrails.org/classes/ActionView/Helpers/FormBuilder.html#method-i-file_field

Allophone answered 3/7, 2023 at 15:51 Comment(1)
Oh wait, the documentation says to use include_hidden: true, but that does the opposite of that we want. – Allophone

© 2022 - 2024 β€” McMap. All rights reserved.