Given a model defining an ActiveStorage attachment:
class TermsOfService < ApplicationRecord
has_one_attached :file
endThen the following behavior can be observed
(Observations limited to the has_one_attached type; the other type is has_many_attached)
# 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::Baseterms_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
=> nilterms_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
=> {}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
=> {}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"]}