Rails7.1以降でも ActiveStorage の has_many_attached を 編集時既存ファイルを残す（上書きしない）挙動にする方法まとめ

## 超要約

ActiveStorage の `has_many_attached` に関する下記設定（Rails7.1 で廃止）の代替は
```ruby:config/application.rb
config.active_storage.replace_on_assign_to_many = false
```

下記のモデル拡張用モジュールを作成すれば（モジュール名は何でもよい）
```ruby:app/lib/attach_many_extension.rb
module AttachManyExtension
  module ClassMethods
    def has_many_attached(arg)
      super

      delete_ids_attr = "delete_#{arg.to_s.singularize}_ids"
      public_send(:attr_accessor, delete_ids_attr)

      define_method :del_attr_nm do delete_ids_attr end
      define_method :atch_attr_nm do arg end
      define_method :attachings do public_send(arg) end
    end
  end
  def self.included(base) = base.extend(ClassMethods)

  def update(params)
    params[del_attr_nm]&.each {|id| attachings.find_by_id(id)&.delete }
    params[atch_attr_nm]&.each {|item| attachings.attach(item) }
    super params.except(atch_attr_nm, del_attr_nm)
  end
end
```

あとは下記の1行で一括設定可能（上記で作成したモジュールを `include`）
```ruby:app/models/application_record.rb
include AttachManyExtension
```

但しその他の方法も幾つかあり、状況次第ではそれを選んだ方がよい場合も（以下詳述）


## 背景等

モデルへ画像等ファイルを複数個添付（attach）できるようにする ActiveStorage の `has_many_attached`。
編集（更新）操作で新たなファイルを添付した際

- 既存のファイルは残して新たなファイルを **追加**
- 既存のファイルは残さず新たなファイルで全て **上書き**　☆デフォルト

の2つの挙動があり、Rails7.0 までは下記のように設定することで

```ruby:config/application.rb
config.active_storage.replace_on_assign_to_many = false
```

全体一括で **追加** の挙動とすることも可能でした。[^1]
ところが Rails7.1 でこの設定は廃止・一括設定が可能な代替手段の提供もなく、対処法を探してもビューやコントローラーで個別に対応する術がパラパラ見つかる程度です…😟

> 新たなファイルを添付した際に既存のファイルは黙っていたら消えてしまうのは、論理的一貫性はあっても
実用上それが嬉しい状況というのが思いつかなく、個人的にはまあまあインパクトの大きい話だと思っているんですが
別段大きな話題にもなってなさそうなところを見ると影響を受けるような作りって意外にマイナーなんですかね…？

まとめということでそれらの方法も取り上げつつ、config での設定に近い手軽さ[^2]で **追加** の挙動に一括設定する方法も考案しましたので以下順に紹介します。


## ソース・環境

ソース全文・全履歴は下記になります。

https://github.com/k-yamauchi-1/attach_many_ex

ローカル環境は Windows 10 Pro、Ruby3.2.2、Rails7.1.2


## 下準備

:::note info
⑦までは何の変哲もないごく通常の手順、⑧も元々複数添付ファイルに対応していれば / ⑨も編集時は追加となる前提で実用に供しているアプリならば既に対応済であろうと思われる内容ですが、状況に即し必要な対応を適宜行ってください
:::

rails アプリとそのDBを作成し(①) Active Storage をインストール(②)、
モデルを作成[^3]してマイグレーションを実行(④)したのちコントローラーを作成(⑤)。
Viewのフォルダは⑤で作られますが個別のerbファイルは生成されなければ
（当方の環境ではされなかった）ビュー作成のコマンドも実行します(⑥)。

<details>
<summary>コマンド</summary>

```bash
$ rails new attach_many_ex  # ①
$ rails db:create  # ①
$ rails active_storage:install  # ②
$ rails g model Item name:string description:text price:integer  # ③
$ rails db:migrate  # ④
$ rails g controller Items  # ⑤
$ rails g erb:scaffold Item name description price  # ⑥
```
</details>

⑦生成されたコントローラ他各ファイルに[実装やルーティングの設定等](https://github.com/k-yamauchi-1/attach_many_ex/commit/c1bad8ceba892877930636d2589e5ad8a9a00a8f "Github コミット")を加え、

⑧モデルに [`has_many_attached` を記述し複数画像の添付に対応、
ビューにも複数画像の設定フォーム・表示タグを追加](https://github.com/k-yamauchi-1/attach_many_ex/commit/d8bd10350ed88648dc379b6363498deb4fdd4247 "Github コミット")します。
ストロングパラメータの追加もお忘れなく。

<details>
<summary>ソース</summary>

```diff_ruby:app/models/item.rb
  class Item < ApplicationRecord
+   has_many_attached :imgs
  end
```
```ruby:app/views/items/_item.html.erb
<% if item.imgs.attached? %>
  <% item.imgs.each do |img| %>
    <div><%= image_tag img %></div>
  <% end %>
<% end %>
```
```diff_ruby:app/views/items/_form.html.erb
  <%= form_with(model: item) do |form| %>
    <%# 略 %>

+   <div>
+     <%= form.label :imgs, style: "display: block" %>
+     <%= form.file_field :imgs, multiple: true, accept: 'image/jpeg,image/gif,image/png' %>
+   </div>
+ 
    <div>
      <%= form.submit %>
    </div>
  <% end %>
```
```diff_ruby:app/controllers/items_controller.rb
  class ItemsController < ApplicationController
    # 略

    def item_params
-     params.require(:item).permit(:name, :description, :price)
+     params.require(:item).permit(:name, :description, :price, imgs: [])
    end
  end
```
</details>

⑨複数添付ファイルに対応したモデル（エンティティ）を編集した際 新たに送信したファイルは **追加** となる前提であれば、
削除したい際に[能動的に削除できるUI（チェックボックス）/ 処理（コントローラ）も必要](https://github.com/k-yamauchi-1/attach_many_ex/commit/5c79224ae212ea6417a997b329dd21e0a1607f95 "Github コミット")なのでそれらも加えたら準備は完了です。

https://prograshi.com/framework/rails/active-storage/#toc21

https://qiita.com/yuji0809/items/8d6b08519d49facc9bf2

<details>
<summary>ソース</summary>

```diff_ruby:app/views/items/_form.html.erb
  <div>
    <%= form.label :imgs, style: "display: block" %>
    <%= form.file_field :imgs, multiple: true, accept: 'image/jpeg,image/gif,image/png' %>
+
+   <% if item.imgs.attached? %>
+     <p>添付済みの画像（削除する画像はチェック）</p>
+     <% item.imgs.each do |img| %>
+       <div> 
+         <%= form.check_box :delete_img_ids, { multiple: true }, img.id, false %>
+         <%= image_tag img %>
+       </div>
+     <% end %>
+   <% end %>
  </div>
```
```diff_ruby:app/controllers/items_controller.rb
  def update
+   if params[:item][:delete_img_ids].present?
+     params[:item][:delete_img_ids].each do |id|
+       @item.imgs.find_by_id(id)&.delete
+     end
+   end
    if @item.update(item_params)
    # 以下略
  end
```
</details>

Rails5.2 ならこれだけ、Rails6.* / 7.0 でも前述の config 設定を加えれば編集フォームから新たなファイルを追加した際も既存ファイルはキープされます（**追加**の挙動）が、
Rails7.1 以降では総上書きで既存のファイルはなくなってしまう（**上書き**の挙動）のでそれを回避する方法を次項から見ていきます。


## [View で対処する方法](https://github.com/k-yamauchi-1/attach_many_ex/commit/f430ef7ca2263e218b5f2157859683717635167d "Github コミット")

アプローチ（ざっくり）としては、編集画面のフォームからファイルを送ると更新後にはそのファイルだけしか反映（添付）されないなら 元々添付されていたファイルも改めてフォームから一緒に送信してしまえという発想。
実際にはファイルそのものは転送済みなので、そのIDを `hidden_field` から送る形になります。

ざっと検索しただけで日本語の情報も幾つか見つかりました。

https://patorash.hatenablog.com/entry/2020/12/02/182915

https://techracho.bpsinc.jp/hachi8833/2023_08_09/133020#1-3

具体的には下記のような実装になります。

```diff_ruby:app/views/items/_form.html.erb
  <% if item.imgs.attached? %>
    <p>添付済みの画像（削除する画像はチェック）</p>
    <% item.imgs.each do |img| %>
      <div> 
+       <%= form.hidden_field :imgs, multiple: true, value: img.signed_id %>
        <%= form.check_box :delete_img_ids, { multiple: true }, img.id, false %>
        <%= image_tag img %>
      </div>
    <% end %>
  <% end %>
```
:::note warn
`hidden_field` では `value` は `signed_id` にしないといけない（`id` だとエラーになる）ことに注意
挿入する箇所（行）は `each` ブロック内ならどこでもOK
:::

但しチェックボックスで選択した画像の削除機能（下準備の⑨）もある場合、これだけだとその削除機能がデグレってしまう（既存画像を無条件に再添付するため削除できない）ので、回避のためチェックボックスで選択された削除対象ファイルは再添付対象からは外すようコントローラーも修正が必要です。

```diff_ruby:app/controllers/items_controller.rb
  if params[:item][:delete_img_ids].present?
    params[:item][:delete_img_ids].each do |id|
      @item.imgs.find_by_id(id)&.delete
+     params[:item][:imgs].delete(id)
    end
  end
```

・・・これで意図した動きには確かになります。なりますが、末端であるビューの複数ファイル添付フォーム全てに変更が必要なので
該当するモデル・ビューが多数あり且つ config で **追加** の挙動に一括設定していたプロジェクトだと代替手段とするにはいささか厳しいのではないかと感じます[^4] [^5] [^6]。

と、いうことで違うアプローチも見ていきましょう。


## [Controller で対処する方法](https://github.com/k-yamauchi-1/attach_many_ex/commit/9907d5e20a65ee8f0a2de09da1aa5618fbbd7f6c "Github コミット")

コントローラーで対処する方法も探っていく中で見つかりました。
添付ファイル更新の際の Rails デフォルト挙動が制御できないならデフォルトの処理に到達させず自前の実装で処理しようという発想なので、前項のビューでの対処とは本質的に異なるアプローチになります。

https://stackoverflow.com/questions/59803964/what-is-the-correct-way-to-update-images-with-has-many-attached-in-rails-6

具体的には下記のような実装になります。
```diff_ruby:app/controllers/items_controller.rb
  def update
    if params[:item][:delete_img_ids].present?
      params[:item][:delete_img_ids].each do |id|
        @item.imgs.find_by_id(id)&.delete
      end
    end
+   if params[:item][:imgs].present?
+     params[:item][:imgs].each do |img|
+       @item.imgs.attach(img)
+     end
+   end
    if @item.update(item_params)
    # 以下略
  end
```

ちなみに所謂ぼっち演算子 `&.` を使えば `@item.update` 前は
<details><summary>2行で書けます（☆）</summary>

```ruby
params[:item][:delete_img_ids]&.each {|id| @item.imgs.find_by_id(id)&.delete }
params[:item][:imgs]&.each {|img| @item.imgs.attach(img) }
```
</details>

モデルの `update` メソッドに添付ファイルが渡らないようストロングパラメータも修正します。
```diff_ruby:app/controllers/items_controller.rb
  def item_params
-   params.require(:item).permit(:name, :description, :price, imgs: [])
+   params.require(:item).permit(:name, :description, :price)
  end
```

選択削除機能に対応していてもコントローラーの修正だけで完結しますし、複数のビュー（フォーム）から同じアクションを呼びだすこともなくはないので前項のビューでの対応よりは実装の集約度は若干上がります。
が、それでも `has_many_attached` を持つモデルやその編集処理を行うコントローラーが多数あれば要修正箇所もそれだけ多くなるので、一括設定にはまだまだと言わざるを得ません。

そこでこのアイデアをベースに、より修正箇所を減らす工夫を入れていきましょう。


## [Model で対処する方法](https://github.com/k-yamauchi-1/attach_many_ex/commit/b319f25f4d6381706f0d1b733fbee29d83e058e7 "Github コミット")

通常 `update` アクションの更新処理ではモデルの `update` メソッドを呼ぶので
まずはシンプルに、先程コントローラーに入れた実装をこちらへ移します。

選択削除機能についても、アクションによって要否が異なることは基本的にないでしょうし
不要な場合もあったとしても `delete_img_ids` パラメータを送らない限り副作用は特にないので一緒に移してしまいましょう。

```diff_ruby:app/models/item.rb
  class Item < ApplicationRecord
    has_many_attached :imgs
+
+   def update(params)
+     params[:delete_img_ids]&.each {|id| imgs.find_by_id(id)&.delete }
+     params[:imgs]&.each {|img| imgs.attach(img) }
+     super params.except(:imgs, :delete_img_ids)
+   end
  end
```
> ※前項☆印の2行実装をベースにしています

添付ファイル周り以外は Rails デフォルトの挙動でよいので継承元（ApplicationRecord）の `update` メソッドを呼びます（`super`）が
その際 添付ファイル周りの処理を走らせないよう関連パラメータを `params` から除外（`except`）して渡します。

コントローラー側は移した処理の削除[^7]と、加えてモデルへ移したことで `:img`, `:delete_img_ids` は再び通してやる必要があるので ストロングパラメータの許容対象に復活させます。

```diff_ruby:app/controllers/items_controller.rb
  def item_params
-   params.require(:item).permit(:name, :description, :price)
+   params.require(:item).permit(
+     :name, :description, :price, imgs: [], delete_img_ids: []
+   )
  end
```

これで同一のモデルは同じ挙動になり、実装集約度がもう一段上がりました。


## [一括設定の実現](https://github.com/k-yamauchi-1/attach_many_ex/commit/ddc86c1fb77fcb45a4bcaa661b766e5430b0cf09 "Github コミット")

モデル単位で挙動を統一するところまで来ましたが、複数モデルの `has_many_attached` をいずれも **追加** の挙動にしたい場合は似たような実装をそれぞれに加えることとなり、少々 DRY 感に欠けます。

そこでクラス拡張による実装共通化をさらに図ってみます。
モジュールを作ってモデルの `update` メソッドを再定義（オーバーライド）、加えてクラスメソッドである `has_many_attached` もパラメータ名生成処理を追加[^8]してオーバーライドし、モデルへMix-inして拡張する形です。
拡張クラスの場所・名前はなんでもよいですが、今回は app/lib 配下に `AttachManyExtension` モジュール（attach_many_extension.rb）を作る形にしてみました。

```ruby:app/lib/attach_many_extension.rb
module AttachManyExtension
  module ClassMethods
    # has_many_attached はクラスメソッドなので ClassMethods モジュールに定義して extend する
    def has_many_attached(arg)  # 引数 arg が添付ファイルの(疑似)カラム名
      super  # 基本の挙動は元のまま

      # 削除対象ファイルIDのパラメータ名は del_<引数の単数形>_id
      delete_ids_attr = "delete_#{arg.to_s.singularize}_ids"
      public_send(:attr_accessor, delete_ids_attr)
      define_method :del_attr_nm do delete_ids_attr end

      # 添付ファイルのパラメータ名は引数（複数形）そのまま
      define_method :atch_attr_nm do arg end

      # 名前が引数「arg」であるメソッドも定義（添付ファイル自体へのアクセスに必要）
      define_method :attachings do public_send(arg) end
    end
  end
  def self.included(base) = base.extend(ClassMethods)

  # update はほぼ前項の実装のままでOK
  # パラメータ名、添付ファイル自体へのアクセスメソッド名だけ↑で定義した名に変更する
  def update(params)
    params[del_attr_nm]&.each {|id| attachings.find_by_id(id)&.delete }
    params[atch_attr_nm]&.each {|item| attachings.attach(item) }
    super params.except(atch_attr_nm, del_attr_nm)
  end
end
```

これにより、`has_many_attached` を **追加** の挙動としたいモデルに対しては `include AttachManyExtension` の1行を追加するだけで変更可能になります。
この際対応する View は、パラメータ名（フォームヘルパーの引数：`params` に入ることになるフィールド名）を `has_many_attached` に指定した(疑似)カラム名に合わせるようにしてください。
例えば `has_many_attached :imgs` でなく `has_many_attached :pictures` とした場合、
<details>
<summary>View の実装はこうなります（一例）。</summary>

```ruby:app/views/items/_item.html.erb
<% if item.pictures.attached? %>
  <% item.pictures.each do |pic| %>
    <div><%= image_tag pic %></div>
  <% end %>
<% end %>
```
```ruby:app/views/items/_form.html.erb
<div>
  <%= form.label :pictures, style: "display: block" %>
  <%= form.file_field :pictures, multiple: true, accept: 'image/jpeg,image/gif,image/png' %>

  <% if item.pictures.attached? %>
    <p>添付済みの画像（削除する画像はチェック）</p>
    <% item.pictures.each do |pic| %>
      <div> 
        <%= form.check_box :delete_picture_ids, { multiple: true }, pic.id, false %>
        <%= image_tag pic %>
      </div>
    <% end %>
  <% end %>
</div>
```
</details>

全てのモデルの継承元である `ApplicationRecord` に上記の1行を追加すれば全モデルの挙動が変えられるので、これで所期の目的である一括挙動変更が実現できました。
```diff_ruby:app/models/application_record.rb
  class ApplicationRecord < ActiveRecord::Base
+   include AttachManyExtension
+
    primary_abstract_class
  end
```


### ちなみに

https://stackoverflow.com/questions/71990425/rails-active-storage-keep-existing-files-uploads

上記のGoodが少ない方のソリューションがアプローチとしてほぼ同等のように思えますのでこちらもご参考までに。
（Goodが多い方も手段は異なるがモデルへの集約を図る大枠の方向性は同じ）


[^1]: 正確には Rails5.2 では追加がデフォルトで、Rails6系から上書きがデフォルトになった
（激変緩和措置として本文記載の config 設定記述で5.2と同じ挙動にすることができた）
[^2]: 要修正ファイル数の観点。コード差分行数としては冒頭要約のように少々多め
[^3]: 今回は name:名称 と description:説明、price:単価 を属性に持つ `Item` モデルを例にとります
[^4]: パーシャルでDRY化もある程度可能だが、それでも結局パーシャル呼出コードの追加が必要な個所の数は変わらない
[^5]: 同じモデル・同じアクションでも画面によって追加か上書きか挙動を変えたい場合はもちろんこの方法で問題ない
（が、そうしたい状況というのがイメージできない。。）
[^6]: 選択削除機能もあると、加えてコントローラーも要修正なのでなおさら
[^7]: これで `update` アクションの典型的な if~else のみの構成に戻る
[^8]: `has_many_attached` の引数、即ち添付ファイルの(疑似)カラム名から動的に生成するため