# frozen_string_literal: true module BusinessFlow # Step is a conditional callable which can marshal its own inputs, and # returns a value which can marshal errors and outputs into a given object. class Step # Represents inputs needed to execute a step. class Inputs attr_reader :inputs def initialize(inputs) @inputs = inputs end def parameters_from_source(source) return source if inputs.blank? Hash[inputs.map do |input_name, input_value| [ input_name, Inputs.process_input(source, input_value) ] end] end def self.process_input(source, input_value) case input_value when Symbol source.send(input_value) when Proc source.instance_exec(&input_value) else input_value end end end # Represents the result of a step, and allows setting response values on # an object, and merging error data into the same object. class Result attr_reader :output def initialize(result, output_map, output) @result = result @output = output @output_map = output_map end def merge_into(object) merge_errors_into(object) if mergeable_errors? merge_outputs_into(object) if @output_map output end def executed? true end # :reek:ManualDispatch Checking respond_to? is signficantly faster than # eating the NoMethodError when grabbing our error object. def errors? if @result.respond_to?(:errors?) @result.errors? # This is here to support ActiveRecord. We don't want to call valid? # because that will run validations and a step may return a partially # constructed model. By instead pulling out the errors instance variable # we'll only merge errors if validations have already been run. elsif @result.class.ancestors.include?(ActiveModel::Validations) && @result.instance_variable_defined?(:@errors) @result.instance_variable_get(:@errors).present? elsif @result.respond_to?(:errors) @result.errors.present? else false end end def self.process_output(object, output, output_setter) case output_setter when Symbol object.send("#{output_setter}=", output) when Proc object.instance_exec(output, &output_setter) end end private # :reek:ManualDispatch Checking respond_to? is signficantly faster than # eating the NoMethodError when grabbing our error object. def mergeable_errors? @result.respond_to?(:errors) && errors? end def merge_errors_into(object) object.errors.merge!(@result.errors) throw :halt_step end def merge_outputs_into(object) @output_map.each do |(output_name, output_setter)| output = @result.public_send(output_name) Result.process_output(object, output, output_setter) end end end # Returned if our conditional check failed. Does nothing. class ConditionFailedResult def executed? false end def errors? false end def merge_into(_object); end end CONDITION_FAILED = ConditionFailedResult.new.freeze # Manage creating results for our step ResultFactory = Struct.new(:outputs, :output_callable, :default_output) do def result(step_result, parameter_source) callable = callable_for(step_result) output = if callable parameter_source.instance_exec(step_result, &callable) else step_result end Result.new(step_result, outputs, output) end private # :reek:ManualDispatch This is faster. def callable_for(step_result) if output_callable output_callable elsif default_output && step_result.respond_to?(default_output) default_output end end end # Handle some logic around conditions class ConditionList def initialize(if_stmts, unless_stmts) @conditions = Array.wrap(if_stmts).map(&Callable.method(:new)) + Array.wrap(unless_stmts).map(&method(:to_unless)) end def call(instance, inputs) @conditions.all? { |cond| cond.call(instance, inputs) } end private def to_unless(cond) if_stmt = Callable.new(cond) unless_stmt = proc do |instance, input| !if_stmt.call(instance, input) end Callable.new(unless_stmt) end end # Responsible for creating objects based on our input options Options = Struct.new(:opts) do def input_object inputs = opts[:inputs] inputs.present? ? Inputs.new(inputs) : nil end def result_factory ResultFactory.new(opts[:outputs], opts[:output], opts[:default_output]) end def condition opts.fetch(:condition) do if_stmts = opts[:if] unless_stmts = opts[:unless] if if_stmts.present? || unless_stmts.present? ConditionList.new(if_stmts, unless_stmts) end end end end def initialize(callable, opts) @callable = callable opts = Options.new(opts) @input_object = opts.input_object @result_factory = opts.result_factory @condition = opts.condition update_call_method end # This will show up as not covered in coverage reports, because it is # dynamically redefined by #update_call_method below. def call(parameter_source) parameters = @input_object.parameters_from_source(parameter_source) if !@condition || @condition.call(parameter_source, parameters) @result_factory.result(@callable.call(parameter_source, parameters), parameter_source) else CONDITION_FAILED end end def inputs @input_object.inputs end def outputs @result_factory.outputs end def output_fields return [] unless outputs outputs.values.select { |field| field.is_a?(Symbol) } end def to_s @callable.to_s end private PARAMETERS_NO_INPUT = 'parameter_source'.freeze PARAMETERS_WITH_INPUT = '@input_object.parameters_from_source(parameter_source)'.freeze WITHOUT_CONDITION = %( @result_factory.result(@callable.call(parameter_source, parameters), parameter_source) ).freeze WITH_CONDITION = %( if @condition.call(parameter_source, parameters) #{WITHOUT_CONDITION} else CONDITION_FAILED end ).freeze def update_call_method params = @input_object ? PARAMETERS_WITH_INPUT : PARAMETERS_NO_INPUT code = %( def call(parameter_source) parameters = #{params} #{@condition ? WITH_CONDITION : WITHOUT_CONDITION} end ) instance_eval code end end end