module BusinessFlow # Core DSL for BusinessFlow. The relevant methods are all in # ClassMethods. module DSL # Contains the DSL for BusinessFlow module ClassMethods # Holds metadata about inputs to a flow class Inputs attr_reader :all, :needs, :optionals def initialize(klass) @needs = FieldList.new([], ParameterField, klass) @optionals = FieldList.new([], ParameterField, klass) @all = [] end def add_needs(fields) @all += fields @all.uniq! @needs.add_fields(fields) end def add_optional(fields) @all += fields @all.uniq! @optionals.add_fields(fields) end def add_wants(field) @all << field.name @all.uniq! @optionals.add_field(field) end end # Requires that a field be retrievable from the initialization parameters # # This will only require that the field is not nil. The field may still # be #empty? # # @param fields The fields required from the initialization parameters def needs(*fields) inputs.add_needs(fields) end # Allows a field to be retieved form the initialization paramters, # but does not require it to be non-nil def optional(*fields) inputs.add_optional(fields) end # Provides an alternate way to look up the value for a field. This can be # thought of as using a 'wants' combined with an expectation of presence, # without having to check for whether or not the wants can be executed. # # :reek:NilCheck This is one of the places where we eliminate nil. def lookup(field, by:, with:) by = Array.wrap(by) optional(*by) wants field, with, unless: -> { by.any? { |input| send(input).nil? } }, default_output: field end # Allows a field to be retrieved from the initialiaztion parameters def wants(field, default = nil, opts = {}, &blk) internal_name = "wants_#{field}".to_sym default = proc { nil } unless default || block_given? uses(internal_name, default, opts, &blk) inputs.add_wants(ParameterField.new(field, internal_name)) end def inputs @inputs ||= Inputs.new(self) end # Declares that you will expose a field to the outside world. def provides(*fields) @provides ||= FieldList.new([], PublicField, [self, const_get(:Result)]) @result_copy ||= FROM_FLOW_PREAMBLE @result_copy += fields.map { |field| "\n@#{field} = flow.#{field}" } .join @provides.add_fields(fields) end # :reek:ControlParameter It's just nicer to have this fall back to a # block than have a different DSL method when using/not using blocks def uses(field, klass = nil, opts = {}, &blk) step = Step.new(Callable.new(klass || blk), { default_output: field }.merge(opts)) retriever = proc { step.call(self).merge_into(self) } UsesField.new(field, retriever).add_to(self) end # :reek:ControlParameter It's just nicer to have this fall back to a # block than have a different DSL method when using/not using blocks def step(klass, opts = {}, &blk) step = Step.new(Callable.new(blk || klass), opts) step_queue.push(step) step.output_fields .each { |field| Field.new(field).add_to(self) } end def call(parameter_object = {}) execute(build(parameter_object)) end # :reek:UtilityFunction This is a function on us so that other modules # can change execution behavior. def execute(flow) catch(:halt_step) { flow.call } unless flow.errors? result_from(flow) end def build(parameter_object) finalize_initializer allocate.tap do |flow| catch(:halt_step) do flow.send(:_business_flow_dsl_initialize, parameter_object) end end end def call!(*args) flow = call(*args) raise FlowFailedException, flow if flow.errors? flow end def step_queue @step_queue ||= [] end def step_executor(executor_class = nil) if executor_class @executor_class = executor_class else @executor_class ||= ::BusinessFlow::DefaultStepExecutor end end def instrument(_name, _flow) yield nil end def result_from(flow) finalize_result_provider unless @finalized_result_provider # We use instance_variable_get here instead of making it part of # from_flow to ensure that we do not create the errors object unless # we need it. result = const_get(:Result).new(flow.instance_variable_get(:@errors)) result.from_flow(flow) if @result_copy result end def finalize_result_provider return if @finalized_result_provider || !@result_copy const_get(:Result).class_eval "#{@result_copy}\nend", __FILE__, __LINE__ @finalized_result_provider = true end def needs_code needs.map do |need| %(if #{need}.nil? errors[:#{need}] << 'must not be nil' throw :halt_step end ) end.join("\n") end def finalize_initializer return if @finalized_initializer class_eval %{ private def _business_flow_dsl_initialize(parameter_object) @parameter_object = parameter_object #{needs_code} initialize end }, __FILE__, __LINE__ - 6 @finalized_initializer = true end end FROM_FLOW_PREAMBLE = %( def from_flow(flow) return if errors? ).freeze RESULT_DEF = %( class Result def initialize(errors) @errors = errors end def errors @errors ||= ActiveModel::Errors.new(self) end def errors? # We're explicitly using the instance variable here so that if no # errors have been created, we don't initialize the error object. !!@errors && @errors.present? end def valid?(_context = nil) # We're explicitly using the instance variable here so that if no # errors have been created, we don't initialize the error object. @errors.blank? end def invalid?(context = nil) !valid?(context) end end ).freeze # Provides the minimum necessary methods to support the use of # ActiveModel::Errors module ErrorSupport def human_attribute_name(key, _opts = {}) key end end # :reek:ManualDispatch I have no need to actually call human_attribute_name, # I just need to know if I have to provide my own. def self.included(klass) klass.extend(ClassMethods) klass.class_eval RESULT_DEF, __FILE__, __LINE__ klass.extend(ErrorSupport) unless klass.respond_to?(:human_attribute_name) end attr_reader :parameter_object private :parameter_object def call return if invalid? klass = self.class klass.step_executor.new(klass.step_queue, self).call end # Responsible for setting the parameter object and validating inputs. # This is a method directly on the object instead of something we # handle through instance_eval/exec for performance reasons. # :reek:NilCheck This is where we ensure that our needs are non-nil. private def _business_flow_dsl_initialize(parameter_object, needs) @parameter_object = parameter_object needs.each do |need| if send(need).nil? errors[need] << 'must not be nil' throw :halt_step end end initialize end # :reek:NilCheck private def _business_flow_parameter_fetch(key) value = _business_flow_parameter_inner_fetch(key) # We only want to fall back to a default if we were # given nil. Other falsy vlues should be directly used. return yield if value.nil? && block_given? value end private def _business_flow_parameter_inner_fetch(key) if @parameter_object.is_a?(Hash) && @parameter_object.key?(key) @parameter_object[key] else @parameter_object.public_send(key) end rescue NoMethodError nil end private def _business_flow_dsl_parameters @_business_flow_dsl_parameters ||= Hash[ self.class.inputs.all.map do |input| [input, _business_flow_parameter_inner_fetch(input)] end ] end def errors @errors ||= ActiveModel::Errors.new(self) end def errors? # We're explicitly using the instance variable here so that if no # errors have been created, we don't initialize the error object. @errors && @errors.present? end def valid?(_context = nil) # We're explicitly using the instance variable here so that if no # errors have been created, we don't initialize the error object. @errors.blank? end def invalid?(context = nil) !valid?(context) end # Responsible for creating fields on one or more classes and noting the of # field class FieldList attr_reader :field_list def initialize(field_list, field_klass, klasses) @field_list = [] @field_klass = field_klass @klasses = [klasses].flatten add_fields(field_list) end def add_fields(fields) fields.each do |field| add_field(@field_klass.new(field)) end @field_list.uniq! if fields.present? @field_list end def add_field(field) @klasses.each { |klass| field.add_to(klass) } @field_list.push(field.name) end end # Helper class to manage logic around adding fields class Field def initialize(field) @field = field # For proc bindings. ivar_name = instance_variable_name @getter = ivar_name @setter = self.class.setter_factory(field, ivar_name) end def add_to(klass) Field.eval_method(klass, field, getter) Field.eval_method(klass, setter_name, setter) end def name field end def self.eval_method(klass, name, str) return if klass.method_defined?(name) || klass.private_method_defined?(name) unsafe_eval_method(klass, name, str) end def self.unsafe_eval_method(klass, name, str) body = ["private def #{name}", str, 'end'].join("\n") klass.class_eval body, __FILE__, __LINE__ end def self.setter_factory(field, ivar_name) <<-SETTER #{ivar_name} = new_value throw :halt_step unless valid?(:#{field}) new_value SETTER end private attr_reader :field, :getter, :setter def setter_name @setter_name ||= "#{field}=(new_value)" end def instance_variable_name @instance_variable_name ||= "@#{field}" end end # Create a field with a public getter class PublicField def initialize(field) @name = field @field = Field.new(field) end def add_to(klass) @field.add_to(klass) klass.send(:public, @name) end def name @field.name end end # Helper class around memoized fields class MemoizedField attr_reader :field def initialize(field, retriever, setter_factory) @field = field @retriever = retriever @setter_factory = setter_factory end def add_to(klass) setter = setter_factory.call(field, safe_ivar_name) Field.unsafe_eval_method( klass, field, memoized(safe_ivar_name, setter, retriever) ) end def name field end private attr_reader :retriever, :setter_factory def memoized(ivar_name, setter, retriever) <<-MEMOIZED return #{ivar_name} if defined?(#{ivar_name}) new_value = begin #{retriever} end #{setter} MEMOIZED end def safe_ivar_name @safe_ivar_name ||= begin "@business_flow_dsl_#{field}" .sub(/\?$/, '_query') .sub(/\!$/, '_bang') .to_sym end end end # Responsible for declaring fields which will be memoized and validated # when first set class UsesField attr_reader :name def initialize(field, retriever) @name = field @retriever = retriever @field = MemoizedField.new(field, retriever_method_name, Field.method(:setter_factory)) end def add_to(klass) klass.send(:define_method, retriever_method_name, &@retriever) klass.send(:private, retriever_method_name) @field.add_to(klass) klass.send(:public, @name) end private def retriever_method_name @retriever_method_name ||= "_business_flow_dsl_execute_step_for_#{@name}".to_sym end end # Helper class around input parameter fields class ParameterField def initialize(field, fallback = nil) retriever = "_business_flow_parameter_fetch(:#{field})" retriever += " { send(:#{fallback}) }" if fallback @field = MemoizedField.new(field, retriever, method(:setter_factory)) end def add_to(klass) @field.add_to(klass) klass.send(:public, @field.field) end def name @field.name end private def setter_factory(_field, ivar_name) "#{ivar_name} = new_value" end end end end