diff --git a/README.md b/README.md index eba8c3a..fb352b6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -33,6 +34,7 @@ class SignupOp < ::Subroutine::Op output :user, u output :business, b + output :heavy_operation, -> { some_heavy_operation } end def create_user! @@ -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 diff --git a/lib/subroutine/outputs.rb b/lib/subroutine/outputs.rb index 0320090..f9a1d20 100644 --- a/lib/subroutine/outputs.rb +++ b/lib/subroutine/outputs.rb @@ -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 @@ -39,20 +55,42 @@ 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! @@ -60,21 +98,27 @@ def validate_outputs! 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) @@ -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 diff --git a/lib/subroutine/outputs/configuration.rb b/lib/subroutine/outputs/configuration.rb index 01626c8..30af9a8 100644 --- a/lib/subroutine/outputs/configuration.rb +++ b/lib/subroutine/outputs/configuration.rb @@ -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 @@ -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 diff --git a/test/subroutine/outputs_test.rb b/test/subroutine/outputs_test.rb index 28d7380..0502f0c 100644 --- a/test/subroutine/outputs_test.rb +++ b/test/subroutine/outputs_test.rb @@ -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 @@ -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