module BusinessFlow # Core DSL for BusinessFlow. The relevant methods are all in # ClassMethods. module DSL # Contains the DSL for BusinessFlow module ClassMethods # 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) @needs ||= FieldList.new([], ParameterField, self) @needs.add_fields(fields) end # Allows a field to be retieved form the initialization paramters, # but does not require it to be non-nil def optional(*fields) @optionals ||= FieldList.new([], ParameterField, self) @optionals.add_fields(fields) end # Allows a field to be retrieved from the initialiaztion parameters def wants(field, default = proc { nil }, opts = {}) internal_name = "wants_#{field}".to_sym uses(internal_name, default, opts) ParameterField.new(field, internal_name).add_to(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 ||= 'def from_flow(flow)' @result_copy += fields.map { |field| "\n@#{field} = flow.#{field}" } .join @provides.add_fields(fields) end def uses(field, klass, opts = {}) step = Step.new(Callable.new(klass), opts) retriever = proc { step.call(self).output } UsesField.new(field, retriever).add_to(self) end def step(klass, opts = {}) step = Step.new(Callable.new(klass), opts) step_queue.push(step) step.outputs .values .select { |field| field.is_a?(Symbol) } .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, ParameterObject.new(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 # 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 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.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 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.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.concat(fields) end private def add_field(field) @klasses.each { |klass| field.add_to(klass) } 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 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 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 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 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) 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 = "@parameter_object.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 private def setter_factory(_field, ivar_name) "#{ivar_name} = new_value" end end # Manage logic around input parameters class ParameterObject def initialize(parameters) @parameters = parameters end # :reek:NilCheck def fetch(key) value = 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 def to_s @parameters.to_s end private def inner_fetch(key) if @parameters.is_a?(Hash) && @parameters.key?(key) @parameters[key] else @parameters.public_send(key) end rescue NoMethodError nil end end end end