Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add lazy option to outputs to delay execution of output if not needed #76

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ class SignupOp < ::Subroutine::Op

outputs :user
outputs :business, type: Business # validate that output type is an instance of Business
outputs :heavy_operation, lazy: true # delay the execution of the output until accessed

protected

Expand All @@ -33,6 +34,7 @@ class SignupOp < ::Subroutine::Op

output :user, u
output :business, b
output :heavy_operation, -> { some_heavy_operation }
end

def create_user!
Expand All @@ -41,7 +43,11 @@ class SignupOp < ::Subroutine::Op

def create_business!(owner)
Business.create!(company_name: company_name, owner: owner)
end
end

def some_heavy_operation
# ...
end

def deliver_welcome_email(u)
UserMailer.welcome(u.id).deliver_later
Expand Down
72 changes: 58 additions & 14 deletions lib/subroutine/outputs.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,27 @@ module Outputs

extend ActiveSupport::Concern

class LazyExecutor
def initialize(value)
@value_block = value
@executed = false
end

def value
return @value if @executed
@value = @value_block.respond_to?(:call) ? @value_block.call : @value
@executed = true
@value
end

def executed?
@executed
end
end

included do
class_attribute :output_configurations
self.output_configurations = {}

attr_reader :outputs
end

module ClassMethods
Expand All @@ -39,42 +55,70 @@ def setup_outputs
@outputs = {} # don't do with_indifferent_access because it will turn provided objects into with_indifferent_access objects, which may not be the desired behavior
end

def outputs
unless @outputs_executed
@outputs.each_pair do |key, value|
@outputs[key] = value.is_a?(LazyExecutor) ? value.value : value
end
@outputs_executed = true
end

@outputs
end

def output(name, value)
name = name.to_sym
unless output_configurations.key?(name)
raise ::Subroutine::Outputs::UnknownOutputError, name
end

outputs[name] = value
@outputs[name] = output_configurations[name].lazy? ? LazyExecutor.new(value) : value
end

def get_output(name)
name = name.to_sym
raise ::Subroutine::Outputs::UnknownOutputError, name unless output_configurations.key?(name)

outputs[name]
output = @outputs[name]
unless output.is_a?(LazyExecutor)
output
else
# if its not executed, validate the type
unless output.executed?
@outputs[name] = output.value
ensure_output_type_valid!(name)
end

@outputs[name]
end
end

def validate_outputs!
output_configurations.each_pair do |name, config|
if config.required? && !output_provided?(name)
raise ::Subroutine::Outputs::OutputNotSetError, name
end
unless valid_output_type?(name)
name = name.to_sym
raise ::Subroutine::Outputs::InvalidOutputTypeError.new(
name: name,
actual_type: outputs[name].class,
expected_type: output_configurations[name][:type]
)
unless output_configurations[name].lazy?
ensure_output_type_valid!(name)
end
end
end

def ensure_output_type_valid!(name)
return if valid_output_type?(name)

name = name.to_sym
raise ::Subroutine::Outputs::InvalidOutputTypeError.new(
name: name,
actual_type: @outputs[name].class,
expected_type: output_configurations[name][:type]
)
end

def output_provided?(name)
name = name.to_sym

outputs.key?(name)
@outputs.key?(name)
end

def valid_output_type?(name)
Expand All @@ -84,9 +128,9 @@ def valid_output_type?(name)

output_configuration = output_configurations[name]
return true unless output_configuration[:type]
return true if !output_configuration.required? && outputs[name].nil?
return true if !output_configuration.required? && @outputs[name].nil?

outputs[name].is_a?(output_configuration[:type])
@outputs[name].is_a?(output_configuration[:type])
end
end
end
9 changes: 8 additions & 1 deletion lib/subroutine/outputs/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ def self.from(field_name, options)
end
end

DEFAULT_OPTIONS = { required: true }.freeze
DEFAULT_OPTIONS = {
required: true,
lazy: false
}.freeze

attr_reader :output_name

Expand All @@ -28,6 +31,10 @@ def required?
!!config[:required]
end

def lazy?
!!config[:lazy]
end

def inspect
"#<#{self.class}:#{object_id} name=#{output_name} config=#{config.inspect}>"
end
Expand Down
59 changes: 59 additions & 0 deletions test/subroutine/outputs_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ def perform
end
end

class LazyOutputOp < ::Subroutine::Op
outputs :foo, lazy: true
outputs :baz, lazy: true, type: String

def perform
output :foo, -> { call_me }
output :baz, -> { call_baz }
end

def call_me; end

def call_baz; end
end

class MissingOutputSetOp < ::Subroutine::Op
outputs :foo
def perform
Expand Down Expand Up @@ -99,5 +113,50 @@ def test_it_raises_an_error_if_output_is_set_to_nil_when_there_is_type_validatio
op.submit
end
end

################
# lazy outputs #
################

def test_it_does_not_call_lazy_output_values_if_not_accessed
op = LazyOutputOp.new
op.expects(:call_me).never
op.submit!
end

def test_it_calls_lazy_output_values_if_accessed
op = LazyOutputOp.new
op.expects(:call_me).once
op.submit!
op.foo
end

def test_it_validates_type_when_lazy_output_is_accessed
op = LazyOutputOp.new
op.expects(:call_baz).once.returns("a string")
op.submit!
assert_silent do
op.baz
end
end

def test_it_raises_error_on_invalid_type_when_lazy_output_is_accessed
op = LazyOutputOp.new
op.expects(:call_baz).once.returns(10)
op.submit!
error = assert_raises(Subroutine::Outputs::InvalidOutputTypeError) do
op.baz
end
assert_match(/Invalid output type for 'baz' expected String but got Integer/, error.message)
end

def test_it_returns_outputs
op = LazyOutputOp.new
op.expects(:call_me).once.returns(1)
op.expects(:call_baz).once.returns("a string")
op.submit!
assert_equal({ foo: 1, baz: "a string" }, op.outputs)
end

end
end