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.

Revisions

  1. chrisbloom7 created this gist Oct 16, 2025.
    331 changes: 331 additions & 0 deletions Active Storage Notes.md
    Original 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"]}
    ```