Skip to content

Commit 1cb6db4

Browse files
authored
Merge pull request rails#41544 from gmcgibbon/active_storage_compose
Add ActiveStorage::Blob.compose
2 parents b261869 + 79a5e0b commit 1cb6db4

File tree

13 files changed

+140
-7
lines changed

13 files changed

+140
-7
lines changed

activestorage/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
* `Add ActiveStorage::Blob.compose` to concatenate multiple blobs.
2+
3+
*Gannon McGibbon*
4+
15
* Setting custom metadata on blobs are now persisted to remote storage.
26

37
*joshuamsager*

activestorage/app/models/active_storage/blob.rb

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
3939
MINIMUM_TOKEN_LENGTH = 28
4040

4141
has_secure_token :key, length: MINIMUM_TOKEN_LENGTH
42-
store :metadata, accessors: [ :analyzed, :identified ], coder: ActiveRecord::Coders::JSON
42+
store :metadata, accessors: [ :analyzed, :identified, :composed ], coder: ActiveRecord::Coders::JSON
4343

4444
class_attribute :services, default: {}
4545
class_attribute :service, instance_accessor: false
@@ -59,6 +59,7 @@ class ActiveStorage::Blob < ActiveStorage::Record
5959
end
6060

6161
validates :service_name, presence: true
62+
validates :checksum, presence: true, unless: :composed
6263

6364
validate do
6465
if service_name_changed? && service_name.present?
@@ -145,6 +146,20 @@ def scope_for_strict_loading # :nodoc:
145146
all
146147
end
147148
end
149+
150+
# Concatenate multiple blobs into a single "composed" blob.
151+
def compose(filename:, blobs:, content_type: nil, metadata: nil)
152+
unless blobs.all?(&:persisted?)
153+
raise(ActiveRecord::RecordNotSaved, "All blobs must be persisted.")
154+
end
155+
156+
content_type ||= blobs.pluck(:content_type).compact.first
157+
158+
new(filename: filename, content_type: content_type, metadata: metadata, byte_size: blobs.sum(&:byte_size)).tap do |combined_blob|
159+
combined_blob.compose(*blobs.pluck(:key))
160+
combined_blob.save!
161+
end
162+
end
148163
end
149164

150165
# Returns a signed ID for this blob that's suitable for reference on the client-side without fear of tampering.
@@ -255,6 +270,11 @@ def upload_without_unfurling(io) # :nodoc:
255270
service.upload key, io, checksum: checksum, **service_metadata
256271
end
257272

273+
def compose(*keys) # :nodoc:
274+
self.composed = true
275+
service.compose(*keys, key, **service_metadata)
276+
end
277+
258278
# Downloads the file associated with this blob. If no block is given, the entire file is read into memory and returned.
259279
# That'll use a lot of RAM for very large files. If a block is given, then the download is streamed and yielded in chunks.
260280
def download(&block)
@@ -280,8 +300,14 @@ def download_chunk(range)
280300
#
281301
# Raises ActiveStorage::IntegrityError if the downloaded data does not match the blob's checksum.
282302
def open(tmpdir: nil, &block)
283-
service.open key, checksum: checksum,
284-
name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ], tmpdir: tmpdir, &block
303+
service.open(
304+
key,
305+
checksum: checksum,
306+
verify: !composed,
307+
name: [ "ActiveStorage-#{id}-", filename.extension_with_delimiter ],
308+
tmpdir: tmpdir,
309+
&block
310+
)
285311
end
286312

287313
def mirror_later # :nodoc:

activestorage/db/migrate/20170806125915_create_active_storage_tables.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ def change
1010
t.text :metadata
1111
t.string :service_name, null: false
1212
t.bigint :byte_size, null: false
13-
t.string :checksum, null: false
13+
t.string :checksum
1414

1515
if connection.supports_datetime_with_precision?
1616
t.datetime :created_at, precision: 6, null: false
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class RemoveNotNullOnActiveStorageBlobsChecksum < ActiveRecord::Migration[6.0]
2+
def change
3+
change_column_null(:active_storage_blobs, :checksum, true)
4+
end
5+
end

activestorage/lib/active_storage/downloader.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,10 @@ def initialize(service)
88
@service = service
99
end
1010

11-
def open(key, checksum:, name: "ActiveStorage-", tmpdir: nil)
11+
def open(key, checksum: nil, verify: true, name: "ActiveStorage-", tmpdir: nil)
1212
open_tempfile(name, tmpdir) do |file|
1313
download key, file
14-
verify_integrity_of file, checksum: checksum
14+
verify_integrity_of(file, checksum: checksum) if verify
1515
yield file
1616
end
1717
end

activestorage/lib/active_storage/service.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ def open(*args, **options, &block)
9090
ActiveStorage::Downloader.new(self).open(*args, **options, &block)
9191
end
9292

93+
# Concatenate multiple files into a single "composed" file. Returns the checksum of the composed file.
94+
def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
95+
raise NotImplementedError
96+
end
97+
9398
# Delete the file at the +key+.
9499
def delete(key)
95100
raise NotImplementedError

activestorage/lib/active_storage/service/azure_storage_service.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ def headers_for_direct_upload(key, content_type:, checksum:, filename: nil, disp
107107
{ "Content-Type" => content_type, "Content-MD5" => checksum, "x-ms-blob-content-disposition" => content_disposition, "x-ms-blob-type" => "BlockBlob", **custom_metadata_headers(custom_metadata) }
108108
end
109109

110+
def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
111+
content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
112+
113+
client.create_append_blob(
114+
container,
115+
destination_key,
116+
content_type: content_type,
117+
content_disposition: content_disposition,
118+
metadata: custom_metadata,
119+
).tap do |blob|
120+
source_keys.each do |source_key|
121+
stream(source_key) do |chunk|
122+
client.append_blob_block(container, blob.name, chunk)
123+
end
124+
end
125+
end
126+
end
127+
110128
private
111129
def private_url(key, expires_in:, filename:, disposition:, content_type:, **)
112130
signer.signed_uri(

activestorage/lib/active_storage/service/disk_service.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,16 @@ def path_for(key) # :nodoc:
100100
File.join root, folder_for(key), key
101101
end
102102

103+
def compose(*source_keys, destination_key, **)
104+
File.open(make_path_for(destination_key), "w") do |destination_file|
105+
source_keys.each do |source_key|
106+
File.open(path_for(source_key), "rb") do |source_file|
107+
IO.copy_stream(source_file, destination_file)
108+
end
109+
end
110+
end
111+
end
112+
103113
private
104114
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
105115
generate_url(key, expires_in: expires_in, filename: filename, content_type: content_type, disposition: disposition)

activestorage/lib/active_storage/service/gcs_service.rb

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,14 @@ def headers_for_direct_upload(key, checksum:, filename: nil, disposition: nil, c
134134
headers
135135
end
136136

137+
def compose(*source_keys, destination_key, filename: nil, content_type: nil, disposition: nil, custom_metadata: {})
138+
bucket.compose(source_keys, destination_key).update do |file|
139+
file.content_type = content_type
140+
file.content_disposition = content_disposition_with(type: disposition, filename: filename) if disposition && filename
141+
file.metadata = custom_metadata
142+
end
143+
end
144+
137145
private
138146
def private_url(key, expires_in:, filename:, content_type:, disposition:, **)
139147
args = {

activestorage/lib/active_storage/service/mirror_service.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class Service::MirrorService < Service
1414
attr_reader :primary, :mirrors
1515

1616
delegate :download, :download_chunk, :exist?, :url,
17-
:url_for_direct_upload, :headers_for_direct_upload, :path_for, to: :primary
17+
:url_for_direct_upload, :headers_for_direct_upload, :path_for, :compose, to: :primary
1818

1919
# Stitch together from named services.
2020
def self.build(primary:, mirrors:, name:, configurator:, **options) # :nodoc:

0 commit comments

Comments
 (0)