Created
October 16, 2025 20:01
-
-
Save chrisbloom7/30de742fcd0523fb597ad27c01a74b1e to your computer and use it in GitHub Desktop.
Revisions
-
chrisbloom7 created this gist
Oct 16, 2025 .There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode charactersOriginal file line number Diff line number Diff line change @@ -0,0 +1,331 @@ # Active Storage Change Tracking Notes **Given** a model defining an ActiveStorage attachment: ```ruby class TermsOfService < ApplicationRecord has_one_attached :file end ``` **Then** the following behavior can be observed _(Observations limited to the `has_one_attached` type; the other type is `has_many_attached`)_ ## Attached Files ```ruby # After adding a new attachment, before the record is saved, the attached attribute (i.e. `file`) references # the type of attachment, e.g. `Attached::One` or `Attached::Many`. # After the record is saved, the attribute will behave like an alias to the associated `attachment` record terms_of_service.file => #<ActiveStorage::Attached::One:0x000000012e77efc8 @name="file", @record= #<TermsOfService:0x000000012e8d8450 id: 1454, created_at: "2025-10-04 16:24:12.679698000 +0000", in_effect_at: "2025-09-20 16:24:13.057122000 +0000", name: "tos-1.pdf", status: "active", updated_at: "2025-10-04 16:24:12.696136000 +0000">> # the attached attribute has a `record` attribute pointing back to the attaching record terms_of_service.file.record == terms_of_service => true # we can access the associated `attachment` record (unsaved) terms_of_service.file.attachment => #<ActiveStorage::Attachment:0x000000013df71410 id: nil, blob_id: nil, created_at: nil, name: "file", record_id: 1454, record_type: "TermsOfService"> # as well as the associated `blob` record (also unsaved) terms_of_service.file.blob => #<ActiveStorage::Blob:0x000000013d5ba7a0 id: nil, byte_size: 53117, checksum: "IJVjRUvsEGdqcnJPxE6/KA==", content_type: "application/pdf", created_at: nil, filename: "tos-1.pdf", key: "h9zb5wi1hje7mh0b74hpvv5nl759", metadata: {"identified" => true}, service_name: "test"> # `blob` is an alias to `attachment.blob` terms_of_service.file.attachment.blob == terms_of_service.file.blob => true # both associations are subclasses of `ActiveStorage::Record` terms_of_service.file.blob.is_a?(ActiveStorage::Record) => true terms_of_service.file.attachment.is_a?(ActiveStorage::Record) => true # `ActiveStorage::Record` inherits from `ActiveRecord::Base` ActiveStorage::Record.superclass => ActiveRecord::Base ``` ### Removing an attached file ```ruby terms_of_service.save => true # Since the attribute is an alias to the associated `attachment` record, setting the attribute to nil sets the # `attachment` record to nil terms_of_service.file = nil => nil # Pending attachment changes _for all attached attributes_ are tracked in a hash on the attaching instance. # Note that the attachment type is a `Attached::Changes::DeleteOne`, which will delete the underlying `attachment` # and `blob` records after commit. terms_of_service.attachment_changes["file"] => #<ActiveStorage::Attached::Changes::DeleteOne:0x000000014403c358 @name="file", @record= #<TermsOfService:0x0000000133e3fd80 id: 1456, created_at: "2025-10-04 16:44:23.094808000 +0000", in_effect_at: "2025-09-20 16:44:23.189444000 +0000", name: "miscarriage_freckle/unde.mp4", status: "active", updated_at: "2025-10-04 16:44:23.107215000 +0000">> # Association record level changes are not reflected on delete terms_of_service.file.blob_changes => nil terms_of_service.file.attachment_changes => nil # ActiveStorage offers two ways to access the associated `blob`: # # 1. `<attachment_attribute_name><underscore>blob` - always returns the currently attached `blob` record terms_of_service.file_blob => #<ActiveStorage::Blob:0x0000000133cd41f8 id: 1367, byte_size: 81469, checksum: "RfHKIldRrvFez0klCSOTLQ==", content_type: "application/pdf", created_at: "2025-10-04 16:44:23.102780000 +0000", filename: "tos-1.pdf", key: "wi55etab8kdn2migovrh6hu4ttta", metadata: {"identified" => true, "analyzed" => true}, service_name: "test"> # 2. `<attachment_attribute_name><dot>blob` # - if the attachment has pending changes, `<attachment_attribute_name><dot>blob` will show the new (unsaved) blob # - otherwise, it behaves as an alias to `<attachment_attribute_name><underscore>blob` terms_of_service.file.blob => nil ``` ### Attaching a new file when none previously exited ```ruby terms_of_service.save => true # You can attach any IO-like object terms_of_service.file = File.open(Rails.root.join("spec/fixtures/files/terms_of_service/tos-2.pdf"), "rb") => #<File:/Users/chris.bloom/src/huntresslabs/portal/spec/fixtures/files/terms_of_service/tos-2.pdf> # Adding an attachment creates a `Attached::Changes` subclass instance depending on the type of attachment # (e.g. `Attached::One`, `Attached::Many`) terms_of_service.attachment_changes["file"] => #<ActiveStorage::Attached::Changes::CreateOne:0x0000000134b52630 @attachable=#<File:/Users/chris.bloom/src/huntresslabs/portal/spec/fixtures/files/terms_of_service/tos-2.pdf>, @blob= #<ActiveStorage::Blob:0x00000001369fec50 id: nil, byte_size: 53117, checksum: "IJVjRUvsEGdqcnJPxE6/KA==", content_type: "application/pdf", created_at: nil, filename: "tos-2.pdf", key: "5sozow0fdiv8h5ytgjp8yq0dxcur", metadata: {"identified" => true}, service_name: "test">, @name="file", @record=#<TermsOfService:0x00000001363d17d8 id: nil, created_at: nil, in_effect_at: nil, name: nil, status: nil, updated_at: nil>> # Each attachment association does have dirty attribute tracking before saving (but see caveat below under replacing a file) terms_of_service.file.blob.changes => {"byte_size" => [nil, 53117], "checksum" => [nil, "IJVjRUvsEGdqcnJPxE6/KA=="], "content_type" => [nil, "application/pdf"], "filename" => [nil, "tos-2.pdf"], "key" => [nil, "5sozow0fdiv8h5ytgjp8yq0dxcur"], "metadata" => [{}, {"identified" => true}], "service_name" => [nil, "test"]} terms_of_service.file.attachment.changes => {"name" => [nil, "file"], "record_type" => [nil, "TermsOfService"]} terms_of_service.file.changes # alias to `terms_of_service.file.attachment.changes` => {"name" => [nil, "file"], "record_type" => [nil, "TermsOfService"]} terms_of_service.file.changed? => true # Changes to these associations cannot be accessed like typical attributes on the attachment terms_of_service.file.attachment_changes => {} # As noted above, `<attachment_attribute_name><dot>blob` shows the new (unsaved) blob terms_of_service.file.blob => #<ActiveStorage::Blob:0x00000001369fec50 id: nil, byte_size: 53117, checksum: "IJVjRUvsEGdqcnJPxE6/KA==", content_type: "application/pdf", created_at: nil, filename: "tos-2.pdf", key: "5sozow0fdiv8h5ytgjp8yq0dxcur", metadata: {"identified" => true}, service_name: "test"> # `<attachment_attribute_name><underscore>blob` shows the currently attached blob (in this case, `nil`) terms_of_service.file_blob => nil # Attachment changes do not affect dirty attribute tracking on the attaching record terms_of_service.changed? => false terms_of_service.changes => {} ``` ### Replacing an existing attached file ```ruby terms_of_service.save => true # `attach` is an alternative interface to direct assignment. Under the hood, `attach` uses the direct assignment method new_file = File.open(Rails.root.join("spec/fixtures/files/terms_of_service/tos-3.pdf") terms_of_service.file.attach(io: new_file, filename: "tos-3.pdf") # Because attachment records are never updated (only ever created, replaced, or deleted), we still get a # `Attached::Changes::CreateOne` even when replacing an existing file. terms_of_service.attachment_changes["file"] => #<ActiveStorage::Attached::Changes::CreateOne:0x000000013ebfad30 @attachable= {io: #<Rack::Test::UploadedFile:0x00000001319ce618 @content_type="application/pdf", @original_filename="tos-3.pdf", @tempfile=#<File:/var/folders/v2/fn2_lj8n6ml4jxxm8z6_35780000gn/T/tos-220251004-72114-n4vdpp.pdf>>, filename: "tos-3.pdf"}, @attachment=#<ActiveStorage::Attachment:0x000000013d2f4110 id: nil, blob_id: nil, created_at: nil, name: "file", record_id: 1458, record_type: "TermsOfService">, @blob= #<ActiveStorage::Blob:0x000000013c813e80 id: nil, byte_size: 53117, checksum: "IJVjRUvsEGdqcnJPxE6/KA==", content_type: "application/pdf", created_at: nil, filename: "tos-2.pdf", key: "8fhcpu5ozuc70ekqbe8kn69jqjy5", metadata: {"identified" => true}, service_name: "test">, @name="file", @record= #<TermsOfService:0x000000012ee87bd0 id: 1458, created_at: "2025-10-05 03:28:31.946738000 +0000", in_effect_at: "2025-09-21 03:28:32.272134000 +0000", name: "fruit-bless/sint.key", status: "active", updated_at: "2025-10-05 03:28:31.959954000 +0000">> # `<attachment_attribute_name><dot>blob` - the new (unsaved) blob terms_of_service.file.blob => #<ActiveStorage::Blob:0x000000013c813e80 id: nil, byte_size: 53117, checksum: "IJVjRUvsEGdqcnJPxE6/KA==", content_type: "application/pdf", created_at: nil, filename: "tos-3.pdf", key: "8fhcpu5ozuc70ekqbe8kn69jqjy5", metadata: {"identified" => true}, service_name: "test"> # `<attachment_attribute_name><underscore>blob` - the currently attached blob (pending save) terms_of_service.file_blob => #<ActiveStorage::Blob:0x000000012f008ef0 id: 1369, byte_size: 81469, checksum: "RfHKIldRrvFez0klCSOTLQ==", content_type: "application/pdf", created_at: "2025-10-05 03:28:31.954845000 +0000", filename: "tos-2.pdf", key: "d1ha2iw08bei06yp082cd7p8pulv", metadata: {"identified" => true, "analyzed" => true}, service_name: "test"> # As noted above, attachment records are never updated so changes will always show an initial `nil` state, # i.e. previous values are never reflected in the `changes` hash terms_of_service.file.blob.changes => {"byte_size" => [nil, 53117], "checksum" => [nil, "IJVjRUvsEGdqcnJPxE6/KA=="], "content_type" => [nil, "application/pdf"], "filename" => [nil, "tos-3.pdf"], "key" => [nil, "8fhcpu5ozuc70ekqbe8kn69jqjy5"], "metadata" => [{}, {"identified" => true}], "service_name" => [nil, "test"]} terms_of_service.file.attachment.changes => {"name" => [nil, "file"], "record_id" => [nil, 1458], "record_type" => [nil, "TermsOfService"]} terms_of_service.save => true # Unlike other models, ActiveStorage::Record does not capture the saved `changes` as `previous_changes` terms_of_service.file.previous_changes => {} ``` ## Recreating `changes` before save With these observations in hand, we can manually generate a collated set of changes ```ruby terms_of_service.file = File.open(Rails.root.join("spec/fixtures/files/terms_of_service/tos-4.pdf"), "rb") # `<attachment_attribute_name><dot>blob` incoming_file_attributes = terms_of_service.file.blob.attributes.except("id", "created_at", "updated_at") => {"byte_size" => 53117, "checksum" => "IJVjRUvsEGdqcnJPxE6/KA==", "content_type" => "application/pdf", "filename" => "tos-4.pdf", "key" => "xcrxmv6vzqpqgw73082v341ttbt8", "metadata" => {"identified" => true}, "service_name" => "test"} # `<attachment_attribute_name><underscore>blob` current_file_attributes = terms_of_service.file_blob.attributes.except("id", "created_at", "updated_at") => {"byte_size" => 81469, "checksum" => "RfHKIldRrvFez0klCSOTLQ==", "content_type" => "application/pdf", "filename" => "tos-3.pdf", "key" => "hoxihcz6lvdqogae4392k49kaecj", "metadata" => {"identified" => true, "analyzed" => true}, "service_name" => "test"} blob_attributes = ActiveStorage::Blob.new.attributes.except("id", "created_at", "updated_at").keys => ["byte_size", "checksum", "content_type", "filename", "key", "metadata", "service_name"] file_blob_changes = blob_attributes.zip(current_file_attributes.values.zip(incoming_file_attributes.values)).to_h => {"byte_size" => [81469, 53117], "checksum" => ["RfHKIldRrvFez0klCSOTLQ==", "IJVjRUvsEGdqcnJPxE6/KA=="], "content_type" => ["application/pdf", "application/pdf"], "filename" => ["galaxy_minister/et.html", "minister_corn/magnam.pptx"], "key" => ["hoxihcz6lvdqogae4392k49kaecj", "xcrxmv6vzqpqgw73082v341ttbt8"], "metadata" => [{"identified" => true, "analyzed" => true}, {"identified" => true}], "service_name" => ["test", "test"]} ```