module ACH # Objects of this class host essential functionality required to create # associated object from within owner objects. # # Newly instantiated +HasManyAssociation+ object has no owner, and should # be used to assign it's copies to owners via +for+ method. This technique # has following application: # class Batch < ACH::Component # association = HasManyAssociation.new(:entries) # # association.delegation_methods.each do |method_name| # delegate method_name, :to => '@batches_association' # end # # after_initialize_hooks << lambda{ instance_variable_set('@batches_association', association.for(self)) } # # All these lines of code are macrosed by ACH::Component.has_many method # end # # Now, whenever new batch is created, it will have it's own @batches_association, # # and essential methods +batches+, +batch+, +build_batch+ delegated to it # # (accordingly, to +container+, +create+, and +build+ methods) class Component::HasManyAssociation # If Record should be attached to (preceded by) other Record, this # exception is raised on attempt to create attachment record without # having preceded record. For example, Addenda records should be # created after Entry records. Each new Addenda record will be attached # to the latest Entry record. class NoLinkError < ArgumentError # Initialize the error with a descriptive message. # # @param [String] link # @param [Class] klass def initialize(link, klass) super "No #{link} was found to attach a new #{klass}" end end # Exception thrown if an association object, assigned for particular # owner object, is used to assign to another owner object class DoubleAssignmentError < StandardError # Initialize the error with a descriptive message. # # @param [String] name # @param [ACH::Component] owner def initialize(name, owner) super "Association #{name} has alredy been assigned to #{owner}" end end attr_reader :name, :linked_to, :proc_defaults private :linked_to, :proc_defaults # Initialize the association with a plural name and options. # # @param [String] plural_name # @param [Hash] options # @option options [Symbol] :linked_to plural name of records to link associated ones # @option options [Proc] :proc_defaults def initialize(plural_name, options = {}) @name = plural_name.to_s @linked_to, @proc_defaults = options.values_at(:linked_to, :proc_defaults) end # Clone +self+ and assign +owner+ to clone. Also, for newly created # clone association that has owner, aliases main methods so that +owner+ # may delegate to them. # # @param [ACH::Component] owner # @raise [DoubleAssignmentError] def for(owner) raise DoubleAssignmentError.new(@name, @owner) if @owner clone.tap do |association| plural, singular = name, singular_name association.instance_variable_set('@owner', owner) association.singleton_class.class_eval do alias_method "build_#{singular}", :build alias_method singular, :create alias_method plural, :container end end end # Return an array of methods to be delegated by +owner+ of the association. # For example, for association named :items, it will include: # * +build_item+ - for instantiating Item from the string (used by parsing functionality) # * +item+ - for instantiating Item during common ACH File creation # * +items+ - that returns set of Item objects. # # @return [Array] def delegation_methods ["build_#{singular_name}", singular_name, name] end # Use klass#from_s to instantiate object from a string. Thus, +klass+ should be # descendant of ACH::Record::Base. Then pushes object to appropriate container. # # @param [String] str # @return [Array] def build(str) obj = klass.from_s(str) container_for_associated << obj end # Create an associated object using common to ACH controls pattern, and push it to # an appropriate container. For example, for :items association, this method is # aliased to +item+, so you will have: # # item(:code => 'WEB') do # other_code 'BEW' # # ... # end # # @param [*Object] args # @return [Object] instance of a class under ACH namespace def create(*args) fields = args.first || {} defaults = proc_defaults ? @owner.instance_exec(&proc_defaults) : {} klass.new(@owner.fields_for(klass).merge(defaults).merge(fields)).tap do |component| component.instance_eval(&Proc.new) if block_given? container_for_associated << component end end # Return the main container for association. For plain (without :linked_to option), it is # array. For linked associations, it is a hash, which keys are records from linking # associations, and values are arrays for association's objects. # # @return [Hash, Array] def container @container ||= linked? ? {} : [] end # Return an array onto which the associated object may be be pushed. For # plain associations, it is equivalent to +container+. For linked # associations, uses +@owner+ and linking association's name to get the # latest record from linking associations. If it does not exist, # +NoLinkError+ will be raised. # # Example: # class Batch < ACH::Component # has_many :entries # has_many :addendas, :linked_to => :entries # end # batch = Batch.new # batch.entry(:amount => 100) # batch.addenda(:text => 'Foo') # batch.entry(:amount => 200) # batch.addenda(:text => 'Bar') # batch.addenda(:text => 'Baz') # # batch.entries # => [, ] # batch.addendas # => { => [], # # => [, ]} # # @return [Array] # @raise [NoLinkError] def container_for_associated return container unless linked? last_link = @owner.send(linked_to).last raise NoLinkError.new(linked_to.to_s.singularize, klass.name) unless last_link container[last_link] ||= [] end # Return +true+ if the association is linked to another association (thus, its records must # be preceded by other association's records). Returns +false+ otherwise. # # @return [Boolean] def linked? !!linked_to end private :linked? # Return +klass+ that corresponds to the association name. Should be defined either in # ACH module, or in ACH::Record module. # # @return [Class] def klass @klass ||= ACH.to_const(@name.classify.to_sym) end private :klass # Return the singular name of the association. # # @return [String] def singular_name @singular_name ||= name.singularize end private :singular_name end end