Skip to content

Instantly share code, notes, and snippets.

@chrisbloom7
Created October 16, 2025 20:01
Show Gist options
  • Save chrisbloom7/30de742fcd0523fb597ad27c01a74b1e to your computer and use it in GitHub Desktop.
Save chrisbloom7/30de742fcd0523fb597ad27c01a74b1e to your computer and use it in GitHub Desktop.
Notes about how change tracking (aka dirty attributes) work on Active Storage attachments in Rails 7.x

Active Storage Change Tracking Notes

Given a model defining an ActiveStorage attachment:

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

# 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

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

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

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

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"]}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment