Repository navigation
Store newly-uploaded files on save rather than assignment - #33303
Conversation
40c9962 to
5f1d25b
Compare
b8fbd4c to
37ab8f3
Compare
37ab8f3 to
62856d1
Compare
kaspth
left a comment
There was a problem hiding this comment.
Nice! I attempted a cursory review 😄
| CODE | ||
|
|
||
| has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: false | ||
| has_one :"#{name}_attachment", -> { where(name: name) }, class_name: "ActiveStorage::Attachment", as: :record, inverse_of: :record, dependent: :destroy |
There was a problem hiding this comment.
Does this need a deprecation cycle?
There was a problem hiding this comment.
No, it doesn’t change behavior. We did this before by calling purge_later or detach on destroy.
There was a problem hiding this comment.
Got it, so it's just more obvious now.
|
|
||
| after_commit(on: [:create, :update]) do | ||
| begin | ||
| public_send("#{name}_change").try(:upload) |
There was a problem hiding this comment.
Why not use &.upload like above?
There was a problem hiding this comment.
The Delete* changes don’t respond to upload. They could implement upload and do nothing, and I had them do that originally, but it seemed odd to me.
| # they are all available at the model level. | ||
| def attachment | ||
| record.public_send("#{name}_attachment") | ||
| change.present? ? change.attachment : record.public_send("#{name}_attachment") |
There was a problem hiding this comment.
change&.attachment || record.public_send("#{name}_attachment")?
There was a problem hiding this comment.
Tricky: we don’t want to fall back to the attachment in the DB when a Delete* change is pending.
|
|
||
| blob_was.purge_later if blob_was && dependent == :purge_later | ||
| if !attached? || new_blob != blob | ||
| write_attachment build_attachment(blob: new_blob) |
There was a problem hiding this comment.
Should this write a "change" to the accessor? I don't understand how calling attach would now ensure the Attachment got uploaded?
There was a problem hiding this comment.
We can explore implementing attach in terms of the writer in the future. I chose to preserve its existing behavior for now. It uses create_blob_from, which uploads as necessary.
There was a problem hiding this comment.
Ah, got it 👍 There's a fair amount of different flows here 😄
|
|
||
| def build_attachment(blob:) | ||
| ActiveStorage::Attachment.new(record: record, name: name, blob: blob) | ||
| Attachment.new(record: record, name: name, blob: blob) |
There was a problem hiding this comment.
Could also strip ActiveStorage:: from the change classes.
| delegate_missing_to :blob | ||
|
|
||
| after_create_commit :analyze_blob_later, :identify_blob | ||
| after_destroy_commit :purge_dependent_blob_later |
There was a problem hiding this comment.
We're switching to delete below because of this, right? Is this callback just to ensure that we always honor the dependent: :destroy from the macros?
There was a problem hiding this comment.
Yes. This, along with the switch to dependent: :destroy on attachment associations, keeps changes from having to track removed attachments and purge them on commit: they can just replace the associated attachments and let AR destroy the removed ones.
| end | ||
|
|
||
| def clear_#{name}_change | ||
| @active_storage_attached_#{name}_change = nil |
There was a problem hiding this comment.
It might be nice to replace these ivars with a private/nodoc'ed attachment_changes hash in the vein of Active Model's changes. Then the change classes could use that and skip the need for public_send.
There was a problem hiding this comment.
Agree. I'll hook that up.
c416740 to
5ff2904
Compare
|
|
||
| def upload | ||
| case attachable | ||
| when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile |
There was a problem hiding this comment.
Shouldn't we first check if these constants are defined? Are we sure they're guaranteed to be required by now?
There was a problem hiding this comment.
Are we sure they're guaranteed to be required by now?
Yes. They’re required in lib/active_storage/attached.rb.
|
Thanks! |
|
@georgeclaghorn That is some excellent work to improve ActiveStorage. It looks like this would potentially fix issue #31985. If this fixes the issue I mentioned before is there any way to benefit from this fix prior to Rails 6? |
In Rails < 6, attachment changes are persisted as soon as they are assigned, regardless of whether the record they’re attached to is successfully saved. That’s why Rails < 6 doesn’t support validation of attachments. We need to, but the model is left in an inconsistent state if validation fails: the old blob is deleted as soon as the new attachment is assigned, but if validation fails the old attachment is preserved, and points to a dead blob. Therefore, we’re monkey patching rails to proactively delete any extant attachments that point to a blob before purging it. This will only be necessary until Rails 6 comes out, because of rails/rails#33303
In Rails updating an Active Storage relation will now replace the entire association instead of merely adding to it. #35817 (comment) Fixes #35817 cc @georgeclaghorn
| # All methods called on this proxy object that aren't listed here will automatically be delegated to +attachments+. | ||
| def attachments | ||
| record.public_send("#{name}_attachments") | ||
| change.present? ? change.attachments : record.public_send("#{name}_attachments") |
There was a problem hiding this comment.
this either returns an Array or ActiveStorage::Attachment::ActiveRecord_Associations_CollectionProxy which makes the interface quite a moving target. with the latter i can do something like attachments.where(...) whereas that breaks when getting an array.
Store newly-uploaded files on save rather than assignment rails/rails#33303
Store newly-uploaded files on save rather than assignment rails/rails#33303
|
@georgeclaghorn Is there any plan to add this fix/feature to Rails 5.2 as well? it would be really appreciated 🙏 |
|
This PR broke the usual cycle of validation before save. The justification appears to be cost optimisation for folks like Github and Basecamp. There is now no official way to access content prior to upload. I’m now seeing repeated “wtf?”-level questions as a result in a variety of public and private fora. There’s a job to finish here, because out in meat-and-potatoes business apps land where we just want to validate content before save as normal, programmer happiness has been diminished. |
This PR made it possible to validate file uploads at all. Previously, files were indiscriminately uploaded before validation.
No.
There wasn’t a way to access pre-upload file data in 5.2, either.
Edit: I misread Josh’s comment as a demand directed at me. I apologize for reacting so defensively. |
Just to be clear, since there might be a terminology misunderstanding here:
In 5.2.3, there was no moment of "pre-upload" at all. As you say, attachments are always uploaded straight away. As a result, class ImageValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
unless value.attached?
record.errors.add attribute, :blank
return
end
if options[:size]
record.errors.add attribute, :size unless options[:size] === value.byte_size
end
if options[:content]
record.errors.add attribute, :content unless options[:content] === value.download
end
record.errors.add attribute, :unrepresentable unless value.representable?
end
end
class User < ApplicationRecord
class ContentCheck
# Dummy content check for demonstration purposes.
def ===(content)
Digest::SHA256.hexdigest(content) =~ /\A[a-f]/
end
end
has_one_attached :avatar
validates :avatar, image: { size: ...1.megabyte, content: ContentCheck.new }
#...
endIn 6.0.0, this throws an exception in the validator when it hits To put it simply, I am only concerned about having access to the content, whether it is in its final resting place or not, and perceive the loss of that as a regression. It is the model's validation-before-save that matters to us, and not whether or not a loose blob made it to S3 and needs cleaning up later. I can work around this in a few ways; the simplest, especially for those doing a 5.2 -> 6.0 upgrade, seems to be by using an undocumented internal API, c.f. #37005. |
George, my remark was evidently ambiguous in that regard and I apologise both for the miscommunication and that it stung you. |
|
It would have been helpful for this information to be in the guides if not the changelog / upgrade guide itself. These changes were breaking and had no deprecation cycle. The work here is beneficial for many I'm sure, but the loop isn't closed on this one. In my case, validations included analyzing the attached blob. Like other issues linked here, a |
|
@jbielick please do consider improving the documentation if you're able! https://guides.rubyonrails.org/v5.1/contributing_to_ruby_on_rails.html#contributing-to-the-rails-documentation |
|
@trevorturk The last PR to add some information about this was closed #37005 I think closing the loop in the sense that I meant it would be providing a public API to retain existing behavior. Is there a workaround you know could be documented? |
|
No, I was just reacting to you saying "it would have been helpful for this information to be in the guides" and suggesting you improve the documentation if you're able. If you think changes to the code should be made, you should probably consider opening a new issue referencing this one, or ideally a pull request with your proposed changes. |
At present, when an uploaded file is assigned to a record via a writer method added by
has_one_attachedorhas_many_attached, it’s persisted to storage immediately. For example, the following causes an uploaded file inparams[:avatar]to be stored:We’d like to support validating blobs attached to records, but for validations to be effective, they ought to prevent invalid files from being stored. It’s of little use to identify an invalid file after it’s already been shipped off to storage: you might use a size validation to limit the cost that a single file can add to your AWS bill, but if the file is stored before validations run, you incur its cost regardless.
This PR changes Active Storage to store attachables assigned to a record after the record is saved rather than immediately. To that end, it introduces change tracking for Active Storage attachments. When you use a writer method added by
has_one_attachedorhas_many_attached, Active Storage tracks a pending change to the record’s attachments. The change is persisted in the application’s database when the record is saved, and the attachables involved are uploaded to storage only after the save transaction commits.Included are supplementary reorganization and expansion of the attachment API’s tests.