LoginSignup
0
0
お題は不問!Qiita Engineer Festa 2024で記事投稿!
Qiita Engineer Festa20242024年7月17日まで開催中!

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

Last updated at Posted at 2024-06-10

超要約

ActiveStorage の has_many_attached に関する下記設定(Rails7.1 で廃止)の代替は

config/application.rb
config.active_storage.replace_on_assign_to_many = false

下記のモデル拡張用モジュールを作成すれば(モジュール名は何でもよい)

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

app/models/application_record.rb
include AttachManyExtension

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

背景等

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

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

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

config/application.rb
config.active_storage.replace_on_assign_to_many = false

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

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

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

ソース・環境

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

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

下準備

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

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

コマンド
$ 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  # ⑥

⑦生成されたコントローラ他各ファイルに実装やルーティングの設定等を加え、

⑧モデルに has_many_attached を記述し複数画像の添付に対応、
ビューにも複数画像の設定フォーム・表示タグを追加
します。
ストロングパラメータの追加もお忘れなく。

ソース
app/models/item.rb
  class Item < ApplicationRecord
+   has_many_attached :imgs
  end
app/views/items/_item.html.erb
<% if item.imgs.attached? %>
  <% item.imgs.each do |img| %>
    <div><%= image_tag img %></div>
  <% end %>
<% end %>
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 %>
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

⑨複数添付ファイルに対応したモデル(エンティティ)を編集した際 新たに送信したファイルは 追加 となる前提であれば、
削除したい際に能動的に削除できるUI(チェックボックス)/ 処理(コントローラ)も必要なのでそれらも加えたら準備は完了です。

ソース
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>
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

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

View で対処する方法

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

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

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

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

hidden_field では valuesigned_id にしないといけない(id だとエラーになる)ことに注意
挿入する箇所(行)は each ブロック内ならどこでもOK

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

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 で対処する方法

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

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

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 前は

2行で書けます(☆)
params[:item][:delete_img_ids]&.each {|id| @item.imgs.find_by_id(id)&.delete }
params[:item][:imgs]&.each {|img| @item.imgs.attach(img) }

モデルの update メソッドに添付ファイルが渡らないようストロングパラメータも修正します。

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 で対処する方法

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

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

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 は再び通してやる必要があるので ストロングパラメータの許容対象に復活させます。

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

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

一括設定の実現

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

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

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 とした場合、

View の実装はこうなります(一例)。
app/views/items/_item.html.erb
<% if item.pictures.attached? %>
  <% item.pictures.each do |pic| %>
    <div><%= image_tag pic %></div>
  <% end %>
<% end %>
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>

全てのモデルの継承元である ApplicationRecord に上記の1行を追加すれば全モデルの挙動が変えられるので、これで所期の目的である一括挙動変更が実現できました。

app/models/application_record.rb
  class ApplicationRecord < ActiveRecord::Base
+   include AttachManyExtension
+
    primary_abstract_class
  end

ちなみに

上記の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 の引数、即ち添付ファイルの(疑似)カラム名から動的に生成するため

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0