Class: ActiveStorage::AwsRecord::Attachment

Inherits:
Object
  • Object
show all
Includes:
Item
Defined in:
lib/active_storage/aws_record/attachment.rb

Overview

The join item between an owner record and a blob. Stored under the owner’s partition (+ns#Owner#<record_type>#<record_id>+) with a sort key of ns#Attachment#<name>#<id>, so loading an owner’s attachments (the contract’s hot path) is a single base-table query. Creating/destroying an attachment also adjusts a strongly-consistent reference count on its blob, so a shared blob is only purged once no attachment references it.

Defined Under Namespace

Classes: NullBlobRelation

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Methods included from Item

#ns_key, #physical_key, #schema, #stamp_physical_keys!

Methods included from Persistence

#==, #changed?, #dynamodb_client, #hash, #mark_destroyed!, #mark_persisted!, #read_attribute, #write_attribute

Constructor Details

#initialize(attributes = {}) ⇒ Attachment

Returns a new instance of Attachment.



85
86
87
88
89
90
91
92
93
94
# File 'lib/active_storage/aws_record/attachment.rb', line 85

def initialize(attributes = {})
  super()
  attributes = attributes.dup
  record = attributes.delete(:record) || attributes.delete('record')
  blob = attributes.delete(:blob) || attributes.delete('blob')
  self.id ||= generate_uuid
  assign_attributes(attributes) if attributes.any?
  self.record = record if record
  self.blob = blob if blob
end

Instance Attribute Details

#immediate_variants_processedObject

Transient flags Active Storage’s change objects set on attachments; not persisted to DynamoDB.



26
27
28
# File 'lib/active_storage/aws_record/attachment.rb', line 26

def immediate_variants_processed
  @immediate_variants_processed
end

#pending_uploadObject

Transient flags Active Storage’s change objects set on attachments; not persisted to DynamoDB.



26
27
28
# File 'lib/active_storage/aws_record/attachment.rb', line 26

def pending_upload
  @pending_upload
end

Class Method Details

.attachment_prefix(name = nil) ⇒ Object

The sort-key prefix for an owner’s attachments, optionally narrowed to a single attachment name, with a trailing separator so begins_with cannot bleed across names.



72
73
74
75
# File 'lib/active_storage/aws_record/attachment.rb', line 72

def attachment_prefix(name = nil)
  base = name ? ns_key('Attachment', name) : ns_key('Attachment')
  "#{base}#{ActiveStorage::AwsRecord.config.separator}"
end

.blob_count_update(blob_id, delta) ⇒ Object

Atomic ADD on a blob’s reference count by blob_id, guarded on the blob item still existing (so a stale/purged blob is never resurrected by the ADD). Shared by the per-row path and the batched commit (which coalesces one update per distinct blob).



44
45
46
47
48
49
50
51
52
53
54
# File 'lib/active_storage/aws_record/attachment.rb', line 44

def blob_count_update(blob_id, delta)
  blob_keys = ActiveStorage::AwsRecord::Blob.logical_keys_for(blob_id)
  {
    table_name: ActiveStorage::AwsRecord::Blob.table_name,
    key: ActiveStorage::AwsRecord::Blob.physical_key(**blob_keys),
    update_expression: 'ADD #c :delta',
    condition_expression: 'attribute_exists(#h)',
    expression_attribute_names: { '#c' => 'as_attachments_count', '#h' => schema.partition_attr },
    expression_attribute_values: { ':delta' => delta },
  }
end

.find_by(attributes) ⇒ Object



56
57
58
# File 'lib/active_storage/aws_record/attachment.rb', line 56

def find_by(attributes)
  ActiveStorage::AwsRecord::Relation.new(self).find_by(attributes)
end

.none_for_blob(persisted) ⇒ Object

Empty relation used by Blob#attachments (see Blob): returns nothing for a non-persisted blob, and refuses to materialize on a persisted one (no blob→attachment index exists by design).



80
81
82
# File 'lib/active_storage/aws_record/attachment.rb', line 80

def none_for_blob(persisted)
  NullBlobRelation.new(persisted)
end

.owner_partition(record_type, record_id) ⇒ Object

The owner-adjacency partition key for a (record_type, record_id) pair.



65
66
67
# File 'lib/active_storage/aws_record/attachment.rb', line 65

def owner_partition(record_type, record_id)
  ns_key('Owner', record_type, record_id)
end

.transaction(&block) ⇒ Object

Open a real, fiber-local DynamoDB transaction (see Transaction). The generic has_many clear/replace/detach paths wrap their per-row destroys in this; buffering them into one transact_write_items makes a multi-attachment change atomic, instead of deleting some rows before a later one fails. Nested calls join the enclosing transaction. *Only destroys are buffered* — creates stay synchronous so Active Storage’s own failed-save cleanup still fires.



36
37
38
# File 'lib/active_storage/aws_record/attachment.rb', line 36

def transaction(&block)
  ActiveStorage::AwsRecord::Transaction.run(self, &block)
end

.where(attributes = nil) ⇒ Object



60
61
62
# File 'lib/active_storage/aws_record/attachment.rb', line 60

def where(attributes = nil)
  ActiveStorage::AwsRecord::Relation.new(self).where(attributes)
end

Instance Method Details

#as_json(options = nil) ⇒ Object



273
274
275
# File 'lib/active_storage/aws_record/attachment.rb', line 273

def as_json(options = nil)
  { id: id, name: name, record_type: record_type, record_id: record_id, blob_id: blob_id }.as_json(options)
end

#assign_attributes(attributes) ⇒ Object



96
97
98
99
100
101
102
103
# File 'lib/active_storage/aws_record/attachment.rb', line 96

def assign_attributes(attributes)
  attributes = attributes.dup
  record = attributes.delete(:record) || attributes.delete('record')
  blob = attributes.delete(:blob) || attributes.delete('blob')
  attributes.each { |name, value| public_send("#{name}=", value) }
  self.record = record if record
  self.blob = blob if blob
end

#blobObject



140
141
142
# File 'lib/active_storage/aws_record/attachment.rb', line 140

def blob
  @blob ||= ActiveStorage::AwsRecord::Blob.find(blob_id)
end

#blob=(blob) ⇒ Object



135
136
137
138
# File 'lib/active_storage/aws_record/attachment.rb', line 135

def blob=(blob)
  @blob = blob
  self.blob_id = blob&.id
end

#commit_destroy!Object

Flush a single buffered destroy: identical to a non-transactional destroy, so its idempotent duplicate-purge / orphaned-blob recovery is preserved (the batched ≥2 path cannot offer per-row recovery).



208
209
210
# File 'lib/active_storage/aws_record/attachment.rb', line 208

def commit_destroy!
  transactional_destroy!
end

#deleteObject

Like #destroy (it also decrements the blob count, so the count never drifts), but used by replace/detach paths that manage the blob themselves; skips touch/blob cleanup.



193
194
195
196
197
# File 'lib/active_storage/aws_record/attachment.rb', line 193

def delete
  @previously_persisted = persisted?
  enqueue_or_destroy if @previously_persisted
  true
end

#delete_transact_itemObject

The Delete action (without the { delete: } wrapper) for this attachment’s row, guarded so a row that already vanished cancels the transaction rather than silently masking a concurrent change.



215
216
217
218
219
220
221
222
# File 'lib/active_storage/aws_record/attachment.rb', line 215

def delete_transact_item
  {
    table_name: self.class.table_name,
    key: physical_key,
    condition_expression: 'attribute_exists(#h)',
    expression_attribute_names: { '#h' => schema.partition_attr },
  }
end

#destroyObject

Delete the join item and decrement its blob’s reference count atomically. Must complete or raise (never return false) for the generic dependent-destroy path; a failure maps to RecordNotDestroyed.



179
180
181
182
183
184
185
186
187
188
# File 'lib/active_storage/aws_record/attachment.rb', line 179

def destroy
  @previously_persisted = persisted?
  enqueue_or_destroy if @previously_persisted
  true
rescue Aws::DynamoDB::Errors::ServiceError => e
  # ServiceError, not Errors::Error: every DynamoDB error (throttling,
  # conditional failure, a re-raised cancellation) subclasses ServiceError,
  # while Errors::Error has no subclasses — rescuing it would catch nothing.
  raise ActiveStorage::RecordNotDestroyed.new("Failed to destroy attachment: #{e.message}", self)
end

#logical_keysObject



105
106
107
108
109
110
111
# File 'lib/active_storage/aws_record/attachment.rb', line 105

def logical_keys
  {
    h: self.class.owner_partition(record_type, record_id),
    r: ns_key('Attachment', name, id),
    item_id: ns_key('Attachment', id),
  }
end

#preview(transformations) ⇒ Object



265
266
267
# File 'lib/active_storage/aws_record/attachment.rb', line 265

def preview(transformations)
  blob.preview(transformations_by_name(transformations))
end

#previously_persisted?Boolean

Returns:

  • (Boolean)


199
200
201
# File 'lib/active_storage/aws_record/attachment.rb', line 199

def previously_persisted?
  @previously_persisted
end

#purgeObject

Purge the attachment and (if unreferenced) its blob. Assumes no ambient Attachment.transaction is open: the destroy here commits at the end of its own block, so the refcount is decremented before blob.purge checks it. Nested inside an outer transaction the decrement would not have committed yet, so the blob’s foreign-key guard would (harmlessly) refuse the purge — the contract never calls purge from inside a transaction.



230
231
232
233
234
235
236
# File 'lib/active_storage/aws_record/attachment.rb', line 230

def purge
  self.class.transaction do
    destroy
    touch_record
  end
  blob&.purge
end

#purge_laterObject

See #purge: assumes no ambient Attachment.transaction.



239
240
241
242
243
244
245
# File 'lib/active_storage/aws_record/attachment.rb', line 239

def purge_later
  self.class.transaction do
    destroy
    touch_record
  end
  blob&.purge_later
end

#recordObject

Resolve the owner from the stored (record_type, record_id). Prefer the active_storage_find hook (ActiveStorage::AwsRecord::Attachable/Owner owners define it so an aws-record model with a key-hash #find resolves correctly), and fall back to the generic contract’s bare-id find(record_id) (the gem’s own Blob/VariantRecord owners override that).



124
125
126
127
128
129
130
131
132
133
# File 'lib/active_storage/aws_record/attachment.rb', line 124

def record
  @record ||= begin
    owner_class = record_type.constantize
    if owner_class.respond_to?(:active_storage_find)
      owner_class.active_storage_find(record_id)
    else
      owner_class.find(record_id)
    end
  end
end

#record=(record) ⇒ Object



113
114
115
116
117
# File 'lib/active_storage/aws_record/attachment.rb', line 113

def record=(record)
  @record = record
  self.record_type = ActiveStorage::Attached::Changes.polymorphic_name(record)
  self.record_id = record.id.to_s
end

#representation(transformations) ⇒ Object



269
270
271
# File 'lib/active_storage/aws_record/attachment.rb', line 269

def representation(transformations)
  blob.representation(transformations_by_name(transformations))
end

#save(opts = {}) ⇒ Object



169
170
171
172
173
174
# File 'lib/active_storage/aws_record/attachment.rb', line 169

def save(opts = {})
  save!(opts)
  true
rescue ActiveStorage::RecordNotSaved
  false
end

#save!(opts = {}) ⇒ Object

Persist the join item. On create, atomically (DynamoDB transaction) put the attachment and increment its blob’s reference count — guarding the increment on the blob still existing so a purged blob is never resurrected.



151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/active_storage/aws_record/attachment.rb', line 151

def save!(opts = {})
  self.created_at ||= ActiveStorage::AwsRecord::Blob.current_timestamp
  if new_record?
    stamp_physical_keys!
    transactional_create!
  end
  # A persisted attachment join row is immutable (record/blob/name are
  # fixed at creation), so a re-save is a deliberate no-op. Calling super
  # here would invoke aws-record's #save!, which delegates to #save — and
  # because we override #save to call #save!, that recurses infinitely
  # (SystemStackError). Active Storage re-saves already-persisted
  # attachments during its attach flow, so this path is hot.
  self
rescue Aws::DynamoDB::Errors::TransactionCanceledException, Aws::DynamoDB::Errors::ConditionalCheckFailedException,
       Aws::Record::Errors::ConditionalWriteFailed, Aws::Record::Errors::ValidationError => e
  raise ActiveStorage::RecordNotSaved.new(e.message, self)
end

#signed_idObject



144
145
146
# File 'lib/active_storage/aws_record/attachment.rb', line 144

def signed_id
  blob.signed_id
end

#uploaded(io:) ⇒ Object

Upload the io to the service and (optionally) analyze, mirroring the reference backend’s attachment upload lifecycle.



249
250
251
252
253
254
255
256
257
258
259
# File 'lib/active_storage/aws_record/attachment.rb', line 249

def uploaded(io:)
  blob.local_io = io
  blob.analyze_without_saving unless blob.analyzed? || skip_later_analysis?
  io.rewind if io.respond_to?(:rewind)
  blob.upload_without_unfurling(io)
  blob.save! if blob.persisted?
  blob.mirror_later
  blob.analyze_later unless blob.analyzed? || skip_later_analysis?
ensure
  blob.local_io = nil
end

#variant(transformations) ⇒ Object



261
262
263
# File 'lib/active_storage/aws_record/attachment.rb', line 261

def variant(transformations)
  blob.variant(transformations_by_name(transformations))
end