開発
Rails
SonicGarden Gym

【Rails】ActiveStorage 使用時に、フォームでバリデーションエラーが起こっても画像の指定を維持したい

created: 2022-03-06 / updated: 2022-03-08
1865 views

サマリ

  • Rails はフォーム送信時に送信の成否に関わらず画像をアップロードするダイレクトアップロードと呼ばれる機能を用意してくれているが、これを設定しただけではバリデーションエラーでフォームを表示し直した際に画像の指定が外れてしまう
  • Hoge モデルに has_one_attached :fuga とした際に定義される Hoge#fuga= メソッドは文字列を引数にとることができ、 Rails はこれを signed_id として解釈するので、 フォームの hidden_field を活用して valuehoge.fuga.signed_id を入れてあげれば、すでにアップロードした画像を見ることができる

状況設定

読んだ本を登録するようなWebアプリケーションを構築しているとします。 books テーブルには title カラムがあり、 Book モデルから has_one_attached によって表紙画像を登録できるようにしています。

# app/models/book.rb

class Book < ApplicationRecord
  has_one_attached :cover_image

  validates :title, presence: true
end
# app/controllers/books_controller.rb

class BooksController < ApplicationController
  # 各種アクションは省略

  private

  def book_params
    params.require(:book).permit(:title, :cover_image)
  end
end

本の登録フォームは、 new と edit から共通して参照される partial として、このようにしています。

# app/views/books/_form.html.haml

= simple_form_for book do |f|
  = f.input :title, autofocus: true
  = f.input :cover_image, as: :file, input_html: { data: { direct_upload_url: rails_direct_uploads_path } }
  = f.button :submit, class: 'btn-primary'

simple_form は説明を単純にするために使っているだけなのでここでは説明しませんが、要するに titlecover_image のためのインプット欄が作られていると理解してください。

f.input :cover_image には input_html: { data: { direct_upload_url: rails_direct_uploads_path } } が指定されており、ここでダイレクトアップロードができるようになっています。ダイレクトアップロードとは Rails サーバーを介さずにファイルをアップロードする機能で、これを設定することで、フォームの送信成否によらずにファイルをアップロードできるようになります。詳しくはRails ガイドを参照してください。

この状態で、 title をわざと空にしてフォームを送信するとバリデーションエラーで戻されるわけですが、その際、登録した表紙画像の指定も解除されてしまいます。

やること

結論から言うと、このように変更してあげれば画像の指定が保持されるようになります。

# app/models/book.rb

  class Book < ApplicationRecord
    has_one_attached :cover_image

+   attribute :cover_cache, :string

    validates :title, presence: true

+   before_validation :set_cover_cache

+   private
+ 
+   def set_cover_cache
+     if cover_cache.present? && cover_image.blank?
+       self.cover_image = cover_cache
+     end
+   end
  end
# app/controllers/books_controller.rb

  class BooksController < ApplicationController
    # new アクションや edit アクションなどは省略

    private

    def book_params
-     params.require(:book).permit(:title, :cover_image)
+     params.require(:book).permit(:title, :cover_image, :cover_cache)
    end
  end
# app/views/books/_form.html.haml

  = simple_form_for book do |f|
    = f.input :title, autofocus: true
    = f.input :cover_image, as: :file, input_html: { data: { direct_upload_url: rails_direct_uploads_path } }
+   = f.hidden_field :cover_cache, value: book.cover_image.signed_id if book.cover_image.attached?
    = f.button :submit, class: 'btn-primary'

解説

Rails のコードを読み進めていって、何が起こっているのかを見てみたいと思います。

まず、 has_one_attached :cover_image とすることで、 cover_image= メソッドが定義されます。
https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/model.rb#L60

def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
  ...

    def #{name}=(attachable)
      attachment_changes["#{name}"] =
        if attachable.nil?
          ActiveStorage::Attached::Changes::DeleteOne.new("#{name}", self)
        else
          ActiveStorage::Attached::Changes::CreateOne.new("#{name}", self, attachable)
        end
    end

  ...
end

ここでは、 attachment_changes というインスタンス変数のハッシュに cover_image というキーで値を入れています。入れている値は ActiveStorage::Attached::Changes::CreateOne のインスタンスで、紐づけられるべき値はコンストラクタの第3引数 attachable にあたることがわかります。

has_one_attached には after_save コールバックも定義されていて、先ほど attachment_changes に入れた ActiveStorage::Attached::Changes::CreateOne のインスタンスに対して save を呼んでいます。
https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/model.rb#L75

def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
  ...
  after_save { attachment_changes[name.to_s]&.save }
  ...
end

ActiveStorage::Attached::Changes::CreateOne#save の定義は以下のようになっていて、 book.cover_image_blob = blob が実行されます。https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/changes/create_one.rb#L32

def save
  record.public_send("#{name}_attachment=", attachment)
  record.public_send("#{name}_blob=", blob)
end

cover_image_blob= メソッドは、 has_one_attached :cover_image によって has_one :cover_image_blob が定義されることにより存在しています。
https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/model.rb#L71

def has_one_attached(name, dependent: :purge_later, service: nil, strict_loading: false)
  ...
  has_one :"#{name}_blob", through: :"#{name}_attachment", class_name: "ActiveStorage::Blob", source: :blob, strict_loading: strict_loading
  ...
end

一方で引数となる blob もメソッドになっていて、これは実質的に find_or_build_blob メソッドです。
https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/changes/create_one.rb#L19

def blob
  @blob ||= find_or_build_blob
end

find_or_build_blob は以下のようになっています。
https://github.com/rails/rails/blob/2a98e28a31d3b9a720c4a9571c23b40433a035df/activestorage/lib/active_storage/attached/changes/create_one.rb#L52

def find_or_build_blob
  case attachable
  when ActiveStorage::Blob
    attachable
  when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
    ActiveStorage::Blob.build_after_unfurling(
      io: attachable.open,
      filename: attachable.original_filename,
      content_type: attachable.content_type,
      record: record,
      service_name: attachment_service_name
    )
  when Hash
    ActiveStorage::Blob.build_after_unfurling(
      **attachable.reverse_merge(
        record: record,
        service_name: attachment_service_name
      ).symbolize_keys
    )
  when String
    ActiveStorage::Blob.find_signed!(attachable, record: record)
  else
    raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
  end
end

ここでようやく何が起こっているかわかってくるのですが、 attachableString の際は ActiveStorage::Blob.find_signed! が呼ばれ、これにより attachablesigned_id を入れることで、最初にダイレクトアップロードした active_storage_blobs のレコードが取得できることがわかります。

ちなみに、 signed_id とはなんなのかについては、 ActiveStorage::Blob#find_signed のドキュメントがヒントになりそうです。
https://api.rubyonrails.org/classes/ActiveStorage/Blob.html#method-c-find_signed

You can use the signed ID of a blob to refer to it on the client side without fear of tampering. This is particularly helpful for direct uploads where the client-side needs to refer to the blob that was created ahead of the upload itself on form submission.

ActiveStorage::Blob のレコードをDBから探してくる時は signed_id を使うのが良いよ、これはダイレクトアップロードの時に特に有用だよ、と書いてあります。
つまり、 ActiveStorage::Blobsigned_id を使って検索をかけるべきものとして設計されているのだと解釈できます。今回のように hidden_field を用いてレコードが取得できてしまう以上、 hidden_field の値を操作しても別のレコードが取得できないようにしなければなりません。そこで、外的に操作されても問題のない値として signed_id を採用したのだと思います(逆に言えば、いじられる心配のない値であれば signed_id ではなくても問題なかったと想像しています)。
Rails は親切にも self.cover_image = signed_id というインターフェースを用意してくれているので、これに乗っかってやれば良いというお話でした。