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 def initialize(parameter_source, result, output_map, output_callable) @parameter_source = parameter_source @result = result @output_map = output_map @output_callable = output_callable end def merge_into(object) merge_errors_into(object) merge_outputs_into(object) 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.include?(ActiveModel::Validations) && @result.instance_variable_defined?(:@errors) @result.instance_variable_get(:@errors).present? else false end end def output @parameter_source.instance_exec(@result, &@output_callable) 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) return unless mergeable_errors? @result.errors.each do |attribute, message| attribute = "#{@result.class.name.underscore}.#{attribute}" (object.errors[attribute] << message).uniq! end 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 # Manage creating results for our step ResultFactory = Struct.new(:outputs, :output_callable) do def result(step_result, parameter_source) Result.new(parameter_source, step_result, outputs, output_callable) 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.new(opts[:inputs] || {}) end def result_factory ResultFactory.new(opts[:outputs] || {}, opts[:output] || ->(result) { result }) 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 || proc { true } 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 end def call(parameter_source) parameters = @input_object.parameters_from_source(parameter_source) if @condition.call(parameter_source, parameters) @result_factory.result(@callable.call(parameter_source, parameters), parameter_source) else ConditionFailedResult.new end end def inputs @input_object.inputs end def outputs @result_factory.outputs end def to_s @callable.to_s end end end