【Rails】ActiveStorage 使用時に、フォームでバリデーションエラーが起こっても画像の指定を維持したい
サマリ
- Rails はフォーム送信時に送信の成否に関わらず画像をアップロードするダイレクトアップロードと呼ばれる機能を用意してくれているが、これを設定しただけではバリデーションエラーでフォームを表示し直した際に画像の指定が外れてしまう
Hoge
モデルにhas_one_attached :fuga
とした際に定義されるHoge#fuga=
メソッドは文字列を引数にとることができ、 Rails はこれをsigned_id
として解釈するので、 フォームのhidden_field
を活用してvalue
にhoge.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
は説明を単純にするために使っているだけなのでここでは説明しませんが、要するに title
と cover_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
ここでようやく何が起こっているかわかってくるのですが、 attachable
が String
の際は ActiveStorage::Blob.find_signed!
が呼ばれ、これにより attachable
に signed_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::Blob
は signed_id
を使って検索をかけるべきものとして設計されているのだと解釈できます。今回のように hidden_field
を用いてレコードが取得できてしまう以上、 hidden_field
の値を操作しても別のレコードが取得できないようにしなければなりません。そこで、外的に操作されても問題のない値として signed_id
を採用したのだと思います(逆に言えば、いじられる心配のない値であれば signed_id
ではなくても問題なかったと想像しています)。
Rails は親切にも self.cover_image = signed_id
というインターフェースを用意してくれているので、これに乗っかってやれば良いというお話でした。