Skip to content

Commit

Permalink
Add an option to preprocessed AS variants
Browse files Browse the repository at this point in the history
ActiveStorage variants are processed on the fly when they are needed but
sometimes we're sure that they are accessed and want to processed them
upfront.

`preprocessed` option is added when declaring variants.

```
class User < ApplicationRecord
  has_one_attached :avatar do |attachable|
    attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
  end
end
```
  • Loading branch information
shouichi committed Jul 3, 2023
1 parent 5694f1e commit b1c544b
Show file tree
Hide file tree
Showing 11 changed files with 188 additions and 15 deletions.
18 changes: 18 additions & 0 deletions activestorage/CHANGELOG.md
@@ -1,3 +1,21 @@
* Add an option to preprocess variants

ActiveStorage variants are processed on the fly when they are needed but
sometimes we're sure that they are accessed and want to processed them
upfront.

`preprocessed` option is added when declaring variants.

```
class User < ApplicationRecord
has_one_attached :avatar do |attachable|
attachable.variant :thumb, resize_to_limit: [100, 100], preprocessed: true
end
end
```

*Shouichi Kamiya*

* Fix variants not included when eager loading multiple records containing a single attachment

When using the `with_attached_#{name}` scope for a `has_one_attached` relation,
Expand Down
12 changes: 12 additions & 0 deletions activestorage/app/jobs/active_storage/transform_job.rb
@@ -0,0 +1,12 @@
# frozen_string_literal: true

class ActiveStorage::TransformJob < ActiveStorage::BaseJob
queue_as { ActiveStorage.queues[:transform] }

discard_on ActiveRecord::RecordNotFound
retry_on ActiveStorage::IntegrityError, attempts: 10, wait: :exponentially_longer

def perform(blob, transformations)
blob.variant(transformations).process
end
end
16 changes: 11 additions & 5 deletions activestorage/app/models/active_storage/attachment.rb
Expand Up @@ -35,7 +35,7 @@ class ActiveStorage::Attachment < ActiveStorage::Record
delegate_missing_to :blob
delegate :signed_id, to: :blob

after_create_commit :mirror_blob_later, :analyze_blob_later
after_create_commit :mirror_blob_later, :analyze_blob_later, :transform_variants_later
after_destroy_commit :purge_dependent_blob_later

##
Expand Down Expand Up @@ -130,6 +130,12 @@ def mirror_blob_later
blob.mirror_later
end

def transform_variants_later
named_variants.each do |_name, named_variant|
blob.preprocessed(named_variant.transformations) if named_variant.preprocessed?(record)
end
end

def purge_dependent_blob_later
blob&.purge_later if dependent == :purge_later
end
Expand All @@ -138,18 +144,18 @@ def dependent
record.attachment_reflections[name]&.options&.fetch(:dependent, nil)
end

def variants
record.attachment_reflections[name]&.variants
def named_variants
record.attachment_reflections[name]&.named_variants
end

def transformations_by_name(transformations)
case transformations
when Symbol
variant_name = transformations
variants.fetch(variant_name) do
named_variants.fetch(variant_name) do
record_model_name = record.to_model.model_name.name
raise ArgumentError, "Cannot find variant :#{variant_name} for #{record_model_name}##{name}"
end
end.transformations
else
transformations
end
Expand Down
4 changes: 4 additions & 0 deletions activestorage/app/models/active_storage/blob/representable.rb
Expand Up @@ -98,6 +98,10 @@ def representable?
variable? || previewable?
end

def preprocessed(transformations) # :nodoc:
ActiveStorage::TransformJob.perform_later(self, transformations)
end

private
def default_variant_transformations
{ format: default_variant_format }
Expand Down
21 changes: 21 additions & 0 deletions activestorage/app/models/active_storage/named_variant.rb
@@ -0,0 +1,21 @@
# frozen_string_literal: true

class ActiveStorage::NamedVariant # :nodoc:
attr_reader :transformations, :preprocessed

def initialize(transformations)
@preprocessed = transformations[:preprocessed]
@transformations = transformations.except(:preprocessed)
end

def preprocessed?(record)
case preprocessed
when Symbol
record.send(preprocessed)
when Proc
preprocessed.call(record)
else
preprocessed
end
end
end
6 changes: 3 additions & 3 deletions activestorage/lib/active_storage/reflection.rb
Expand Up @@ -4,11 +4,11 @@ module ActiveStorage
module Reflection
class HasAttachedReflection < ActiveRecord::Reflection::MacroReflection # :nodoc:
def variant(name, transformations)
variants[name] = transformations
named_variants[name] = NamedVariant.new(transformations)
end

def variants
@variants ||= {}
def named_variants
@named_variants ||= {}
end
end

Expand Down
18 changes: 18 additions & 0 deletions activestorage/test/jobs/transform_job_test.rb
@@ -0,0 +1,18 @@
# frozen_string_literal: true

require "test_helper"
require "database/setup"

class ActiveStorage::TransformJobTest < ActiveJob::TestCase
setup { @blob = create_file_blob }

test "creates variant" do
transformations = { resize_to_limit: [100, 100] }

assert_changes -> { @blob.variant(transformations).processed? }, from: false, to: true do
perform_enqueued_jobs do
ActiveStorage::TransformJob.perform_later @blob, transformations
end
end
end
end
38 changes: 37 additions & 1 deletion activestorage/test/models/attached/many_test.rb
Expand Up @@ -824,7 +824,9 @@ def highlights
end

test "creating variation by variation name" do
@user.highlights_with_variants.attach fixture_file_upload("racecar.jpg")
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.highlights_with_variants.attach fixture_file_upload("racecar.jpg")
end
variant = @user.highlights_with_variants.first.variant(:thumb).processed

image = read_image(variant)
Expand Down Expand Up @@ -883,6 +885,40 @@ def highlights
assert_match(/Cannot find variant :unknown for User#highlights_with_variants/, error.message)
end

test "transforms variants later" do
blob = create_blob(filename: "funky.jpg")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [1, 1]] do
@user.highlights_with_preprocessed.attach blob
end
end

test "transforms variants later conditionally via proc" do
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.highlights_with_conditional_preprocessed.attach create_blob(filename: "funky.jpg")
end

blob = create_blob(filename: "funky.jpg")
@user.update(name: "transform via proc")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [2, 2]] do
@user.highlights_with_conditional_preprocessed.attach blob
end
end

test "transforms variants later conditionally via method" do
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.highlights_with_conditional_preprocessed.attach create_blob(filename: "funky.jpg")
end

blob = create_blob(filename: "funky.jpg")
@user.update(name: "transform via method")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [3, 3]] do
@user.highlights_with_conditional_preprocessed.attach blob
end
end

test "successfully attaches new blobs and destroys attachments marked for destruction via nested attributes" do
town_blob = create_blob(filename: "town.jpg")
@user.highlights.attach(town_blob)
Expand Down
38 changes: 37 additions & 1 deletion activestorage/test/models/attached/one_test.rb
Expand Up @@ -760,7 +760,9 @@ def avatar
end

test "creating preview by variation name" do
@user.avatar_with_variants.attach fixture_file_upload("report.pdf")
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.avatar_with_variants.attach fixture_file_upload("report.pdf")
end
preview = @user.avatar_with_variants.preview(:thumb).processed

image = read_image(preview.send(:variant))
Expand Down Expand Up @@ -798,4 +800,38 @@ def avatar

assert_match(/Cannot find variant :unknown for User#avatar_with_variants/, error.message)
end

test "transforms variants later" do
blob = create_blob(filename: "funky.jpg")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [1, 1]] do
@user.avatar_with_preprocessed.attach blob
end
end

test "transforms variants later conditionally via proc" do
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.avatar_with_conditional_preprocessed.attach create_blob(filename: "funky.jpg")
end

blob = create_blob(filename: "funky.jpg")
@user.update(name: "transform via proc")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [2, 2]] do
@user.avatar_with_conditional_preprocessed.attach blob
end
end

test "transforms variants later conditionally via method" do
assert_no_enqueued_jobs only: ActiveStorage::TransformJob do
@user.avatar_with_conditional_preprocessed.attach create_blob(filename: "funky.jpg")
end

blob = create_blob(filename: "funky.jpg")
@user.update(name: "transform via method")

assert_enqueued_with job: ActiveStorage::TransformJob, args: [blob, resize_to_limit: [3, 3]] do
@user.avatar_with_conditional_preprocessed.attach blob
end
end
end
10 changes: 5 additions & 5 deletions activestorage/test/models/reflection_test.rb
Expand Up @@ -14,7 +14,7 @@ class ActiveStorage::ReflectionTest < ActiveSupport::TestCase
assert_equal :local, reflection.options[:service_name]

reflection = User.reflect_on_attachment(:avatar_with_variants)
assert_instance_of Hash, reflection.variants
assert_instance_of Hash, reflection.named_variants
end

test "reflection on a singular attachment with the same name as an attachment on another model" do
Expand All @@ -33,14 +33,14 @@ class ActiveStorage::ReflectionTest < ActiveSupport::TestCase
assert_equal :local, reflection.options[:service_name]

reflection = User.reflect_on_attachment(:highlights_with_variants)
assert_instance_of Hash, reflection.variants
assert_instance_of Hash, reflection.named_variants
end

test "reflecting on all attachments" do
reflections = User.reflect_on_all_attachments.sort_by(&:name)
assert_equal [ User ], reflections.collect(&:active_record).uniq
assert_equal %i[ avatar avatar_with_variants cover_photo highlights highlights_with_variants intro_video name_pronunciation_audio vlogs ], reflections.collect(&:name)
assert_equal %i[ has_one_attached has_one_attached has_one_attached has_many_attached has_many_attached has_one_attached has_one_attached has_many_attached ], reflections.collect(&:macro)
assert_equal [ :purge_later, :purge_later, false, :purge_later, :purge_later, :purge_later, :purge_later, false ], reflections.collect { |reflection| reflection.options[:dependent] }
assert_equal %i[ avatar avatar_with_conditional_preprocessed avatar_with_preprocessed avatar_with_variants cover_photo highlights highlights_with_conditional_preprocessed highlights_with_preprocessed highlights_with_variants intro_video name_pronunciation_audio vlogs ], reflections.collect(&:name)
assert_equal %i[ has_one_attached has_one_attached has_one_attached has_one_attached has_one_attached has_many_attached has_many_attached has_many_attached has_many_attached has_one_attached has_one_attached has_many_attached ], reflections.collect(&:macro)
assert_equal [ :purge_later, :purge_later, :purge_later, :purge_later, false, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, :purge_later, false ], reflections.collect { |reflection| reflection.options[:dependent] }
end
end
22 changes: 22 additions & 0 deletions activestorage/test/test_helper.rb
Expand Up @@ -148,6 +148,15 @@ class User < ActiveRecord::Base
has_one_attached :avatar_with_variants do |attachable|
attachable.variant :thumb, resize_to_limit: [100, 100]
end
has_one_attached :avatar_with_preprocessed do |attachable|
attachable.variant :bool, resize_to_limit: [1, 1], preprocessed: true
end
has_one_attached :avatar_with_conditional_preprocessed do |attachable|
attachable.variant :proc, resize_to_limit: [2, 2],
preprocessed: ->(user) { user.name == "transform via proc" }
attachable.variant :method, resize_to_limit: [3, 3],
preprocessed: :should_preprocessed?
end
has_one_attached :intro_video
has_one_attached :name_pronunciation_audio

Expand All @@ -156,8 +165,21 @@ class User < ActiveRecord::Base
has_many_attached :highlights_with_variants do |attachable|
attachable.variant :thumb, resize_to_limit: [100, 100]
end
has_many_attached :highlights_with_preprocessed do |attachable|
attachable.variant :bool, resize_to_limit: [1, 1], preprocessed: true
end
has_many_attached :highlights_with_conditional_preprocessed do |attachable|
attachable.variant :proc, resize_to_limit: [2, 2],
preprocessed: ->(user) { user.name == "transform via proc" }
attachable.variant :method, resize_to_limit: [3, 3],
preprocessed: :should_preprocessed?
end

accepts_nested_attributes_for :highlights_attachments, allow_destroy: true

def should_preprocessed?
name == "transform via method"
end
end

class Group < ActiveRecord::Base
Expand Down

0 comments on commit b1c544b

Please sign in to comment.