4

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?

Dan Tappin
  • 2,692
  • 3
  • 37
  • 77

3 Answers3

16

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.

edariedl
  • 3,234
  • 16
  • 19
  • 1
    This is an excellent answer! I actually came across the controler solution but the new method idea is WAY better. – Dan Tappin Apr 29 '22 at 04:27
  • 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? – viktorsmari Jul 21 '22 at 14:11
  • 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`. – edariedl Jul 22 '22 at 09:50
  • 1
    not sure not working on my side, it will cause "stack level too deep" – Mada Aryakusumah Jul 26 '22 at 01:29
  • @MadaAryakusumah I had the same issue, i've added my solution below: https://stackoverflow.com/a/74207496/2728491 – chedli Oct 26 '22 at 12:14
  • There's also this new configuration option, which may help. https://edgeguides.rubyonrails.org/configuring.html#config-active-storage-multiple-file-field-include-hidden – alex Jul 03 '23 at 15:41
3

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)

chedli
  • 191
  • 1
  • 4
  • 1
    Dude you are awesome - solution 2 works like a charm - elegant and simple for multiple models using has_many_attached – Mark Oct 31 '22 at 04:18
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

alex
  • 1,042
  • 4
  • 18
  • 33
  • Oh wait, the documentation says to use `include_hidden: true`, but that does the opposite of that we want. – alex Jul 04 '23 at 16:29