activestorage-aws-record 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +55 -0
- data/LICENSE +21 -0
- data/PLAN.md +413 -0
- data/README.md +287 -0
- data/lib/active_storage/aws_record/attachable.rb +104 -0
- data/lib/active_storage/aws_record/attachment.rb +426 -0
- data/lib/active_storage/aws_record/blob.rb +417 -0
- data/lib/active_storage/aws_record/configuration.rb +62 -0
- data/lib/active_storage/aws_record/item.rb +86 -0
- data/lib/active_storage/aws_record/owner.rb +82 -0
- data/lib/active_storage/aws_record/persistence.rb +89 -0
- data/lib/active_storage/aws_record/railtie.rb +53 -0
- data/lib/active_storage/aws_record/relation.rb +163 -0
- data/lib/active_storage/aws_record/schema.rb +148 -0
- data/lib/active_storage/aws_record/tables.rb +82 -0
- data/lib/active_storage/aws_record/tasks.rake +15 -0
- data/lib/active_storage/aws_record/transaction.rb +132 -0
- data/lib/active_storage/aws_record/variant_record.rb +208 -0
- data/lib/active_storage/aws_record/version.rb +7 -0
- data/lib/active_storage/aws_record.rb +148 -0
- data/lib/activestorage-aws-record.rb +4 -0
- metadata +166 -0
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
require 'active_storage/aws_record/item'
|
|
2
|
+
require 'active_storage/aws_record/owner'
|
|
3
|
+
|
|
4
|
+
module ActiveStorage
|
|
5
|
+
module AwsRecord
|
|
6
|
+
# The blob metadata item. Mirrors the behavior of the default Active Record
|
|
7
|
+
# blob (and the in-memory reference backend) but persists through aws-record.
|
|
8
|
+
# Bytes still live in the configured Active Storage Service; only metadata is
|
|
9
|
+
# here. It is also an attachment owner (for +preview_image+) and carries a
|
|
10
|
+
# strongly-consistent +attachments_count+ used by #destroy to protect shared
|
|
11
|
+
# blobs.
|
|
12
|
+
#
|
|
13
|
+
# Single-table layout: the blob and all of its variant records share one
|
|
14
|
+
# partition (+ns#Blob#<id>+); the blob's own item is the collection root.
|
|
15
|
+
class Blob
|
|
16
|
+
include ActiveStorage::AwsRecord::Item
|
|
17
|
+
include ActiveStorage::AwsRecord::Owner
|
|
18
|
+
include ActiveStorage::Servable
|
|
19
|
+
|
|
20
|
+
MINIMUM_TOKEN_LENGTH = 28
|
|
21
|
+
|
|
22
|
+
# Every non-key attribute carries an explicit, namespaced DynamoDB name so
|
|
23
|
+
# it can never collide with the table's (app-chosen) key attribute names.
|
|
24
|
+
string_attr :id, database_attribute_name: 'as_id'
|
|
25
|
+
string_attr :key, database_attribute_name: 'as_key'
|
|
26
|
+
string_attr :filename, database_attribute_name: 'as_filename'
|
|
27
|
+
string_attr :content_type, database_attribute_name: 'as_content_type'
|
|
28
|
+
integer_attr :byte_size, database_attribute_name: 'as_byte_size'
|
|
29
|
+
string_attr :checksum, database_attribute_name: 'as_checksum'
|
|
30
|
+
# Fiber/thread-safe default: a lambda yields a fresh hash per instance
|
|
31
|
+
# instead of sharing one mutable hash across all records.
|
|
32
|
+
map_attr :metadata, database_attribute_name: 'as_metadata', default_value: -> { {} }
|
|
33
|
+
string_attr :service_name, database_attribute_name: 'as_service_name'
|
|
34
|
+
string_attr :created_at, database_attribute_name: 'as_created_at'
|
|
35
|
+
integer_attr :attachments_count, database_attribute_name: 'as_attachments_count', default_value: 0
|
|
36
|
+
string_attr :entity, database_attribute_name: 'as_entity', default_value: 'Blob'
|
|
37
|
+
|
|
38
|
+
attr_accessor :local_io
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
def services
|
|
42
|
+
ActiveStorage::Services.registry
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def services=(registry)
|
|
46
|
+
ActiveStorage::Services.registry = registry
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def service
|
|
50
|
+
ActiveStorage::Services.default
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def service=(service)
|
|
54
|
+
ActiveStorage::Services.default = service
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Microsecond-precision, fixed-width UTC ISO8601 — lexically sortable (so
|
|
58
|
+
# has_many ordering by created_at is correct) and parseable back to a Time.
|
|
59
|
+
def current_timestamp
|
|
60
|
+
Time.now.utc.iso8601(6)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def generate_unique_secure_token(length: MINIMUM_TOKEN_LENGTH)
|
|
64
|
+
SecureRandom.base36(length)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Contract +find(id)+: GetItem on the blob's collection-root key.
|
|
68
|
+
def find(id)
|
|
69
|
+
keys = logical_keys_for(id)
|
|
70
|
+
get_item(**keys) ||
|
|
71
|
+
raise(ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}")
|
|
72
|
+
rescue ArgumentError, Aws::Record::Errors::KeyMissing
|
|
73
|
+
raise ActiveStorage::RecordNotFound, "Couldn't find #{name} with id=#{id.inspect}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Owner resolution for Active Storage (Blob is itself an attachment owner
|
|
77
|
+
# via +preview_image+). Delegate to the composite-key +#find+: {Attachable}'s
|
|
78
|
+
# default +find_with_opts(hash_key => id)+ adapter cannot address the
|
|
79
|
+
# +ns#Blob#<id>+ key.
|
|
80
|
+
def active_storage_find(id)
|
|
81
|
+
find(id)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Logical keys for a blob id without an instance (used by the attachment
|
|
85
|
+
# refcount transaction). The blob and its variants share one partition.
|
|
86
|
+
def logical_keys_for(blob_id)
|
|
87
|
+
root = ns_key('Blob', blob_id)
|
|
88
|
+
{ h: root, r: root, item_id: root }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def build_after_unfurling(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
|
|
92
|
+
new(key: key, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name).tap do |blob|
|
|
93
|
+
blob.unfurl(io, identify: identify)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def create_after_unfurling!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
|
|
98
|
+
build_after_unfurling(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, record: record).tap(&:save!)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def create_and_upload!(key: nil, io:, filename:, content_type: nil, metadata: nil, service_name: nil, identify: true, record: nil)
|
|
102
|
+
create_after_unfurling!(key: key, io: io, filename: filename, content_type: content_type, metadata: metadata, service_name: service_name, identify: identify, record: record).tap do |blob|
|
|
103
|
+
blob.upload_without_unfurling(io)
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def create_before_direct_upload!(key: nil, filename:, byte_size:, checksum:, content_type: nil, metadata: nil, service_name: nil, record: nil)
|
|
108
|
+
metadata = ActiveStorage.filter_blob_metadata(metadata || {})
|
|
109
|
+
new(key: key, filename: filename, byte_size: byte_size, checksum: checksum, content_type: content_type, metadata: metadata, service_name: service_name).tap(&:save!)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def find_signed(id, record: nil, purpose: :blob_id)
|
|
113
|
+
find_signed!(id, record: record, purpose: purpose)
|
|
114
|
+
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveStorage::RecordNotFound
|
|
115
|
+
nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def find_signed!(id, record: nil, purpose: :blob_id)
|
|
119
|
+
find(ActiveStorage.verifier.verify(id, purpose: purpose.to_s))
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def scope_for_strict_loading
|
|
123
|
+
self
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def initialize(attributes = {})
|
|
128
|
+
super
|
|
129
|
+
self.id ||= generate_uuid
|
|
130
|
+
self.key ||= self.class.generate_unique_secure_token
|
|
131
|
+
self.metadata ||= {}
|
|
132
|
+
self.service_name ||= self.class.service&.name&.to_s
|
|
133
|
+
self.created_at ||= self.class.current_timestamp
|
|
134
|
+
self.attachments_count ||= 0
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Logical keys: the blob and its variants share the +ns#Blob#<id>+ partition;
|
|
138
|
+
# the blob's own sort key mirrors the partition (the collection root).
|
|
139
|
+
def logical_keys
|
|
140
|
+
self.class.logical_keys_for(id)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def signed_id(purpose: :blob_id, expires_in: nil, expires_at: nil)
|
|
144
|
+
ActiveStorage.verifier.generate(id, purpose: purpose.to_s, expires_in: expires_in, expires_at: expires_at)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Returns a Time (not the stored String), because Active Storage's proxy
|
|
148
|
+
# controller passes +blob.created_at+ to +http_cache_forever+, which calls
|
|
149
|
+
# +.utc+ on it for the Last-Modified header.
|
|
150
|
+
def created_at
|
|
151
|
+
raw = read_attribute(:created_at)
|
|
152
|
+
return raw unless raw.is_a?(String)
|
|
153
|
+
|
|
154
|
+
Time.iso8601(raw)
|
|
155
|
+
rescue ArgumentError
|
|
156
|
+
nil
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def filename
|
|
160
|
+
ActiveStorage::Filename.new(read_attribute(:filename).to_s)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def filename=(value)
|
|
164
|
+
write_attribute(:filename, value&.to_s)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def custom_metadata
|
|
168
|
+
indifferent_metadata[:custom] || {}
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def identified = !!indifferent_metadata[:identified]
|
|
172
|
+
def identified?(*) = identified
|
|
173
|
+
def identified=(value)
|
|
174
|
+
metadata_set(:identified, value)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def analyzed = !!indifferent_metadata[:analyzed]
|
|
178
|
+
def analyzed?(*) = analyzed
|
|
179
|
+
def analyzed=(value)
|
|
180
|
+
metadata_set(:analyzed, value)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def composed = !!indifferent_metadata[:composed]
|
|
184
|
+
def composed=(value)
|
|
185
|
+
metadata_set(:composed, value)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def identify_without_saving
|
|
189
|
+
return if identified?
|
|
190
|
+
|
|
191
|
+
self.content_type ||= 'application/octet-stream'
|
|
192
|
+
self.identified = true
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def analyze_without_saving
|
|
196
|
+
metadata_set(:analyzed, true)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def analyze
|
|
200
|
+
analyze_without_saving
|
|
201
|
+
save!
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def analyze_later
|
|
205
|
+
ActiveStorage::AnalyzeJob.perform_later(self)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def upload(io, identify: true)
|
|
209
|
+
unfurl(io, identify: identify)
|
|
210
|
+
upload_without_unfurling(io)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def unfurl(io, identify: true)
|
|
214
|
+
self.checksum = service.compute_checksum(io)
|
|
215
|
+
self.content_type = Marcel::MimeType.for(io, name: filename.to_s, declared_type: content_type) if content_type.nil? || identify
|
|
216
|
+
self.byte_size = io.size
|
|
217
|
+
self.identified = true
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def upload_without_unfurling(io)
|
|
221
|
+
service.upload(key, io, checksum: checksum, content_type: content_type)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def download(&block)
|
|
225
|
+
service.download(key, &block)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def download_chunk(range)
|
|
229
|
+
service.download_chunk(key, range)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def open(tmpdir: nil, &block)
|
|
233
|
+
if local_io
|
|
234
|
+
open_local_io(tmpdir: tmpdir, &block)
|
|
235
|
+
else
|
|
236
|
+
service.open(key, checksum: checksum, verify: !composed,
|
|
237
|
+
name: ["ActiveStorage-#{id}-", filename.extension_with_delimiter], tmpdir: tmpdir, &block)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def url(expires_in: ActiveStorage.service_urls_expire_in, disposition: :inline, filename: nil, **options)
|
|
242
|
+
service.url(key, expires_in: expires_in, filename: ActiveStorage::Filename.wrap(filename || self.filename),
|
|
243
|
+
content_type: content_type_for_serving, disposition: forced_disposition_for_serving || disposition, **options)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def service_url_for_direct_upload(expires_in: ActiveStorage.service_urls_expire_in)
|
|
247
|
+
service.url_for_direct_upload(key, expires_in: expires_in, content_type: content_type,
|
|
248
|
+
content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def service_headers_for_direct_upload
|
|
252
|
+
service.headers_for_direct_upload(key, filename: filename, content_type: content_type,
|
|
253
|
+
content_length: byte_size, checksum: checksum, custom_metadata: custom_metadata)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def content_type_for_serving = super
|
|
257
|
+
def forced_disposition_for_serving = super
|
|
258
|
+
|
|
259
|
+
def image? = content_type&.start_with?('image')
|
|
260
|
+
def audio? = content_type&.start_with?('audio')
|
|
261
|
+
def video? = content_type&.start_with?('video')
|
|
262
|
+
def text? = content_type&.start_with?('text')
|
|
263
|
+
|
|
264
|
+
def variable?
|
|
265
|
+
ActiveStorage.variable_content_types.include?(content_type)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def previewable?
|
|
269
|
+
ActiveStorage.previewers.any? { |klass| klass.accept?(self) }
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def representable?
|
|
273
|
+
variable? || previewable?
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def variant(transformations)
|
|
277
|
+
raise ActiveStorage::InvariableError unless variable?
|
|
278
|
+
|
|
279
|
+
variant_class.new(self, ActiveStorage::Variation.wrap(transformations).default_to(default_variant_transformations))
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def preview(transformations)
|
|
283
|
+
raise ActiveStorage::UnpreviewableError unless previewable?
|
|
284
|
+
|
|
285
|
+
ActiveStorage::Preview.new(self, transformations)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def representation(transformations)
|
|
289
|
+
case
|
|
290
|
+
when previewable? then preview(transformations)
|
|
291
|
+
when variable? then variant(transformations)
|
|
292
|
+
else
|
|
293
|
+
raise ActiveStorage::UnrepresentableError
|
|
294
|
+
end
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# The blob→attachment reverse lookup is intentionally unsupported (it is the
|
|
298
|
+
# one access pattern that would force a secondary index; the generic path
|
|
299
|
+
# only ever reaches it for a non-persisted blob, which has no rows). Returns
|
|
300
|
+
# an empty, no-op relation; materializing it on a persisted blob raises.
|
|
301
|
+
def attachments
|
|
302
|
+
ActiveStorage::AwsRecord::Attachment.none_for_blob(persisted?)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def service
|
|
306
|
+
self.class.services.fetch(service_name)
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def mirror_later
|
|
310
|
+
service.mirror_later(key, checksum: checksum) if service.respond_to?(:mirror_later)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def delete
|
|
314
|
+
service.delete(key)
|
|
315
|
+
service.delete_prefixed("variants/#{key}/") if image?
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Delete the metadata item and, when variant tracking is on, its variant
|
|
319
|
+
# records. A still-referenced blob (attachments_count > 0) raises
|
|
320
|
+
# ForeignKeyViolation via a strongly-consistent conditional delete.
|
|
321
|
+
def destroy
|
|
322
|
+
@previously_persisted = persisted?
|
|
323
|
+
destroyed = false
|
|
324
|
+
run_callbacks(:destroy) do
|
|
325
|
+
# Guard on persisted? so a new/stamped blob object with a colliding id
|
|
326
|
+
# cannot delete the stored blob's metadata.
|
|
327
|
+
if persisted?
|
|
328
|
+
delete_with_foreign_key_guard!
|
|
329
|
+
destroyed = true
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
if destroyed
|
|
333
|
+
sweep_variant_records if ActiveStorage.track_variants
|
|
334
|
+
run_callbacks(:commit)
|
|
335
|
+
end
|
|
336
|
+
destroyed
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def purge
|
|
340
|
+
destroy
|
|
341
|
+
delete if previously_persisted?
|
|
342
|
+
rescue ActiveStorage::ForeignKeyViolation
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def purge_later
|
|
347
|
+
ActiveStorage::PurgeJob.perform_later(self)
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def previously_persisted?
|
|
351
|
+
@previously_persisted
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
private
|
|
355
|
+
|
|
356
|
+
# Conditional delete on the blob's own item: succeeds only when no
|
|
357
|
+
# attachment references it. The count lives on this item and is mutated
|
|
358
|
+
# atomically with each attachment write, so the guard is strong.
|
|
359
|
+
def delete_with_foreign_key_guard!
|
|
360
|
+
delete!(
|
|
361
|
+
condition_expression: 'attribute_not_exists(#c) OR #c = :zero',
|
|
362
|
+
expression_attribute_names: { '#c' => 'as_attachments_count' },
|
|
363
|
+
expression_attribute_values: { ':zero' => 0 }
|
|
364
|
+
)
|
|
365
|
+
rescue Aws::DynamoDB::Errors::ConditionalCheckFailedException
|
|
366
|
+
raise ActiveStorage::ForeignKeyViolation
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Destroy the blob's variant records. In Mode A this is a strong base-table
|
|
370
|
+
# query, so the sweep sees every variant. In Mode B (numeric range key) the
|
|
371
|
+
# listing comes from the eventually-consistent GSI — a variant created
|
|
372
|
+
# within the GSI's propagation window of this purge may be missed (a benign,
|
|
373
|
+
# documented limitation of the numeric-range fallback; the H2 ConditionCheck
|
|
374
|
+
# still prevents creating a variant *after* the blob row is gone).
|
|
375
|
+
def sweep_variant_records
|
|
376
|
+
ActiveStorage::AwsRecord::VariantRecord.where_blob(id).each(&:destroy)
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def indifferent_metadata
|
|
380
|
+
(metadata || {}).each_with_object({}) { |(k, v), acc| acc[k.to_sym] = v }
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def metadata_set(key, value)
|
|
384
|
+
h = (metadata || {}).each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
|
|
385
|
+
h[key.to_s] = value
|
|
386
|
+
self.metadata = h
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
def open_local_io(tmpdir:)
|
|
390
|
+
Tempfile.open(["ActiveStorage-#{id}-", filename.extension_with_delimiter], tmpdir) do |file|
|
|
391
|
+
file.binmode
|
|
392
|
+
local_io.rewind if local_io.respond_to?(:rewind)
|
|
393
|
+
IO.copy_stream(local_io, file)
|
|
394
|
+
local_io.rewind if local_io.respond_to?(:rewind)
|
|
395
|
+
file.rewind
|
|
396
|
+
yield file
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def default_variant_transformations
|
|
401
|
+
{ format: default_variant_format }
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def default_variant_format
|
|
405
|
+
if ActiveStorage.web_image_content_types.include?(content_type)
|
|
406
|
+
filename.extension.presence || :png
|
|
407
|
+
else
|
|
408
|
+
:png
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def variant_class
|
|
413
|
+
ActiveStorage.track_variants ? ActiveStorage::VariantWithRecord : ActiveStorage::Variant
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
module ActiveStorage
|
|
2
|
+
module AwsRecord
|
|
3
|
+
# Raised when the gem cannot adapt to the configured DynamoDB table (e.g. a
|
|
4
|
+
# numeric partition key, a missing range key, or a key-attribute name that
|
|
5
|
+
# collides with one of the gem's own stored attributes).
|
|
6
|
+
class ConfigurationError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Holds the gem's settings. In a Rails app these are populated from
|
|
9
|
+
# +config.activestorage_aws_record+ by the Railtie; outside Rails (the gem's
|
|
10
|
+
# own test suite) they are set directly via {AwsRecord.configure}.
|
|
11
|
+
#
|
|
12
|
+
# The guiding principle is *assume as little as possible*: the only thing the
|
|
13
|
+
# gem truly needs is the +table_name+. The partition/sort key attribute names
|
|
14
|
+
# and types are discovered from the live table at boot (see {Schema}); set
|
|
15
|
+
# {#partition_key}/{#sort_key} only to override that discovery.
|
|
16
|
+
class Configuration
|
|
17
|
+
# Name of the single DynamoDB table that holds every Active Storage item
|
|
18
|
+
# (blob, attachment, and variant-record metadata). Single Table Design: the
|
|
19
|
+
# gem never creates entity-specific tables. The partition/sort key attribute
|
|
20
|
+
# names and types are auto-detected from this table at boot (see {Schema}).
|
|
21
|
+
attr_accessor :table_name
|
|
22
|
+
|
|
23
|
+
# First segment of every key the gem writes, isolating Active Storage items
|
|
24
|
+
# from the application's own items in the shared table. Make it unique if
|
|
25
|
+
# the default ("ActiveStorage") could collide with application keys.
|
|
26
|
+
attr_accessor :namespace
|
|
27
|
+
|
|
28
|
+
# Delimiter between key segments (the "#" pattern). Must not appear in a
|
|
29
|
+
# +record_type+, +record_id+, or attachment +name+.
|
|
30
|
+
attr_accessor :separator
|
|
31
|
+
|
|
32
|
+
# Hash of options forwarded to +Aws::DynamoDB::Client.new+ (e.g. +:region+,
|
|
33
|
+
# +:endpoint+, +:credentials+). An +:endpoint+ of "http://localhost:8000"
|
|
34
|
+
# targets DynamoDB Local.
|
|
35
|
+
attr_accessor :client_options
|
|
36
|
+
|
|
37
|
+
# An explicit +Aws::DynamoDB::Client+. When set it is used as-is and
|
|
38
|
+
# +client_options+ is ignored. Handy for tests and dependency injection.
|
|
39
|
+
attr_accessor :client
|
|
40
|
+
|
|
41
|
+
# When +true+, the gem will create the table (and, in Mode B, the index) if
|
|
42
|
+
# missing. Defaults to +false+: production tables are application-managed.
|
|
43
|
+
attr_accessor :manage_table
|
|
44
|
+
|
|
45
|
+
# Name of the string-keyed GSI used only when the table's range key is
|
|
46
|
+
# numeric (Mode B); identifies which GSI carries the adjacency keys when a
|
|
47
|
+
# table has several. Its key attribute names are auto-detected. Ignored in
|
|
48
|
+
# Mode A (string range key, no GSI).
|
|
49
|
+
attr_accessor :index_name
|
|
50
|
+
|
|
51
|
+
def initialize
|
|
52
|
+
@table_name = 'active_storage'
|
|
53
|
+
@namespace = 'ActiveStorage'
|
|
54
|
+
@separator = '#'
|
|
55
|
+
@client_options = {}
|
|
56
|
+
@client = nil
|
|
57
|
+
@manage_table = false
|
|
58
|
+
@index_name = 'active_storage_index'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module ActiveStorage
|
|
2
|
+
module AwsRecord
|
|
3
|
+
# Single-table key machinery, mixed into the gem's three entities only
|
|
4
|
+
# (+Blob+, +Attachment+, +VariantRecord+) — never into application owner
|
|
5
|
+
# models. It turns each entity's *logical* keys into the *physical* DynamoDB
|
|
6
|
+
# keys for the resolved {Schema} mode, and stamps them before a write.
|
|
7
|
+
#
|
|
8
|
+
# Including classes must implement +#logical_keys+ returning
|
|
9
|
+
# <tt>{ h:, r:, item_id: }</tt>:
|
|
10
|
+
# * +h+ — the partition adjacency key (the +ns#…#…+ string),
|
|
11
|
+
# * +r+ — the sort adjacency key,
|
|
12
|
+
# * +item_id+— a globally-unique id string (the base-table partition in Mode B).
|
|
13
|
+
module Item
|
|
14
|
+
extend ActiveSupport::Concern
|
|
15
|
+
|
|
16
|
+
include Persistence
|
|
17
|
+
|
|
18
|
+
class_methods do
|
|
19
|
+
# @return [Schema] the resolved table layout.
|
|
20
|
+
def schema
|
|
21
|
+
ActiveStorage::AwsRecord.schema
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Namespaced +#+-key: prepends the configured namespace to +parts+.
|
|
25
|
+
def ns_key(*parts)
|
|
26
|
+
ActiveStorage::AwsRecord.key(ActiveStorage::AwsRecord.config.namespace, *parts)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# aws-record key hash (Ruby attribute symbols → values) for +find_with_opts+.
|
|
30
|
+
def aws_record_key(h:, r:, item_id:)
|
|
31
|
+
if schema.range_mode?
|
|
32
|
+
{ dynamo_partition_key: h, dynamo_range_key: r }
|
|
33
|
+
else
|
|
34
|
+
{ dynamo_partition_key: item_id, dynamo_range_key: 0 }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Raw DynamoDB key (DB attribute names → values) for transact/raw calls.
|
|
39
|
+
def physical_key(h:, r:, item_id:)
|
|
40
|
+
if schema.range_mode?
|
|
41
|
+
{ schema.partition_attr => h, schema.sort_attr => r }
|
|
42
|
+
else
|
|
43
|
+
{ schema.partition_attr => item_id, schema.sort_attr => 0 }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Fetch one item by logical keys (strongly consistent by default).
|
|
48
|
+
# @return [Aws::Record, nil]
|
|
49
|
+
def get_item(h:, r:, item_id:, consistent: true)
|
|
50
|
+
find_with_opts(key: aws_record_key(h:, r:, item_id:), consistent_read: consistent)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def schema
|
|
55
|
+
ActiveStorage::AwsRecord.schema
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ns_key(*parts)
|
|
59
|
+
self.class.ns_key(*parts)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The raw key for this instance (DB attribute names → values).
|
|
63
|
+
def physical_key
|
|
64
|
+
self.class.physical_key(**logical_keys)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Stamp the physical key attributes from this item's logical keys. Only on
|
|
68
|
+
# create: a key attribute cannot be updated in DynamoDB, and re-stamping
|
|
69
|
+
# would mark it dirty and break the next +update_item+.
|
|
70
|
+
def stamp_physical_keys!
|
|
71
|
+
return unless new_record?
|
|
72
|
+
|
|
73
|
+
keys = logical_keys
|
|
74
|
+
if schema.range_mode?
|
|
75
|
+
self.dynamo_partition_key = keys[:h]
|
|
76
|
+
self.dynamo_range_key = keys[:r]
|
|
77
|
+
else
|
|
78
|
+
self.dynamo_partition_key = keys[:item_id]
|
|
79
|
+
self.dynamo_range_key = 0
|
|
80
|
+
self.dynamo_index_partition = keys[:h]
|
|
81
|
+
self.dynamo_index_sort = keys[:r]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
require 'active_storage/aws_record/attachable'
|
|
2
|
+
|
|
3
|
+
module ActiveStorage
|
|
4
|
+
module AwsRecord
|
|
5
|
+
# Make a **greenfield** aws-record model an Active Storage attachment owner:
|
|
6
|
+
# {Attachable}'s contract glue *plus* an aws-record persistence implementation
|
|
7
|
+
# that runs Active Storage's callback chains. Use this when the model has no
|
|
8
|
+
# persistence of its own; if it already defines +save+/+destroy+ (versioning,
|
|
9
|
+
# events, search), include {Attachable} instead so this does not clobber them.
|
|
10
|
+
#
|
|
11
|
+
# class Message
|
|
12
|
+
# include Aws::Record
|
|
13
|
+
# include ActiveStorage::AwsRecord::Owner
|
|
14
|
+
# string_attr :id, hash_key: true
|
|
15
|
+
# has_one_attached :avatar
|
|
16
|
+
# has_many_attached :images
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# Include +Aws::Record+ *before* +Owner+ (as above) so +Owner+'s +save+/+destroy+
|
|
20
|
+
# sit above aws-record in the ancestor chain and their +super+ reaches it.
|
|
21
|
+
module Owner
|
|
22
|
+
extend ActiveSupport::Concern
|
|
23
|
+
|
|
24
|
+
included do
|
|
25
|
+
include ActiveStorage::AwsRecord::Persistence
|
|
26
|
+
include ActiveStorage::AwsRecord::Attachable
|
|
27
|
+
|
|
28
|
+
# Owner controls persistence, so it runs a real :commit chain (deferred
|
|
29
|
+
# purges flush on commit) and a :rollback chain so they can be cancelled.
|
|
30
|
+
# ({Attachable} defines the :save/:destroy/:validation chains.)
|
|
31
|
+
define_model_callbacks :commit unless respond_to?(:_commit_callbacks, true)
|
|
32
|
+
define_model_callbacks :rollback unless respond_to?(:_rollback_callbacks, true)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Persist via aws-record inside the :save (then :commit) chains. +super+ is
|
|
36
|
+
# aws-record's terminal +save+ (not its +save!+), so this never re-enters.
|
|
37
|
+
def save(opts = {})
|
|
38
|
+
saved = false
|
|
39
|
+
run_callbacks(:save) do
|
|
40
|
+
stamp_physical_keys! if respond_to?(:stamp_physical_keys!)
|
|
41
|
+
saved = super(opts)
|
|
42
|
+
# A failed (invalid) save must not run after_save / flush attachments.
|
|
43
|
+
throw :abort unless saved
|
|
44
|
+
saved
|
|
45
|
+
end
|
|
46
|
+
run_callbacks(:commit) if saved
|
|
47
|
+
saved
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Bang variant. Delegates to #save (one callback run) rather than calling
|
|
51
|
+
# aws-record's #save! — which delegates back to #save and would run the
|
|
52
|
+
# :save/:commit chains twice. A falsy #save is either a validation failure
|
|
53
|
+
# (surface the messages) or a before_save +throw :abort+.
|
|
54
|
+
def save!(opts = {})
|
|
55
|
+
return self if save(opts)
|
|
56
|
+
|
|
57
|
+
message = errors.any? ? errors.full_messages.to_sentence : 'Save halted by a before_save callback'
|
|
58
|
+
raise ActiveStorage::RecordNotSaved.new(message, self)
|
|
59
|
+
rescue Aws::Record::Errors::ConditionalWriteFailed => e
|
|
60
|
+
raise ActiveStorage::RecordNotSaved.new(e.message, self)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Remove the backend record *inside* the destroy callbacks so Active
|
|
64
|
+
# Storage's +after_destroy+ cleanup sees +persisted? == false+, and only run
|
|
65
|
+
# :commit when the destroy actually happened. The +delete!+ is guarded on
|
|
66
|
+
# +persisted?+ so destroying a never-saved owner whose id collides with a
|
|
67
|
+
# stored row cannot delete the stored row.
|
|
68
|
+
def destroy(opts = {})
|
|
69
|
+
destroyed = false
|
|
70
|
+
run_callbacks(:destroy) do
|
|
71
|
+
if persisted?
|
|
72
|
+
delete!(opts)
|
|
73
|
+
destroyed = true
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
run_callbacks(:commit) if destroyed
|
|
77
|
+
destroyed
|
|
78
|
+
end
|
|
79
|
+
alias_method :destroy!, :destroy
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|