citrusbyte/ripple

View on GitHub
lib/ripple/nested_attributes.rb

Summary

Maintainability
A
1 hr
Test Coverage
require 'active_support/concern'
if ActiveSupport::VERSION::STRING < '3.2'
  require 'active_support/core_ext/class/inheritable_attributes'
else
  require 'active_support/core_ext/class/attribute'
end

module Ripple
  module NestedAttributes #:nodoc:
    extend ActiveSupport::Concern

    UNASSIGNABLE_KEYS = %w{ _destroy }
    TRUE_VALUES = [ true, "true", 1, "1", "yes", "ok", "y" ]

    included do
      if respond_to?(:class_attribute) # ActiveSupport 3.1
        class_attribute :nested_attributes_options, :instance_writer => false
      else
        class_inheritable_accessor :nested_attributes_options, :instance_writer => false
      end
      self.nested_attributes_options = {}
    end

    # = Nested Attributes
    #
    # This is similar to the `accepts_nested_attributes` functionality
    # as found in AR.  This allows the use update attributes and create
    # new child records through the parent.  It also allows the use of
    # the `fields_for` form view helper, using a presenter pattern.
    #
    # To enable in the model, call the class method, using the same
    # relationship as defined in the `one` or `many`.
    #
    #   class Shipment
    #     include Ripple::Document
    #     one :box
    #     many :addresses
    #     accepts_nested_attributes_for :box, :addresses
    #   end
    #
    # == One
    #
    # Given this model:
    #
    #   class Shipment
    #     include Ripple::Document
    #     one :box
    #     accepts_nested_attributes_for :box
    #   end
    #
    # This allows creating a box child during creation:
    #
    #   shipment = Shipment.create(:box_attributes => { :shape => 'square' })
    #   shipment.box.shape # => 'square'
    #
    # This also allows updating box attributes:
    #
    #   shipment.update_attributes(:box_attributes => { :key => 'xxx', :shape => 'triangle' })
    #   shipment.box.shape # => 'triangle'
    #
    # == Many
    #
    # Given this model
    #
    #   class Manifest
    #     include Ripple::Document
    #     many :shipments
    #     accepts_nested_attributes_for :shipments
    #   end
    #
    # This allows creating several shipments during manifest creation:
    #
    #   manifest = Manifest.create(:shipments_attributes => [ { :reference => "foo1" }, { :reference => "foo2" } ])
    #   manifest.shipments.size # => 2
    #   manifest.shipments.first.reference # => foo1
    #   manifest.shipments.second.reference # => foo2
    #
    # And updating shipment attributes:
    #
    #   manifest.update_attributes(:shipment_attributes => [ { :key => 'xxx', :reference => 'updated foo1' },
    #                                                        { :key => 'yyy', :reference => 'updated foo2' } ])
    #   manifest.shipments.first.reference # => updated foo1
    #   manifest.shipments.second.reference # => updated foo2
    #
    # NOTE: On many embedded, then entire collection of embedded documents is replaced, as there
    # is no key to specifically update.
    #
    # Given
    #
    #   class Manifest
    #     include Ripple::Documnet
    #     many :signatures
    #     accepts_nested_attributes_for :signatures
    #   end
    #
    #   class Signature
    #     include Ripple::EmbeddedDocument
    #     property :esignature, String
    #   end
    #
    # The assigning of attributes replaces existing:
    #
    #   manifest = Manifest.create(:signature_attributes => [ { :esig => 'a00001' }, { :esig => 'b00001' } ]
    #   manifest.signatures # => [<Signature esig="a00001">, <Signature esig="b00001">]
    #
    #   manifest.signature_attributes = [ { :esig => 'c00001' } ]
    #   manifest.signatures # => [<Signature esig="c00001">]
    #
    module ClassMethods

      def accepts_nested_attributes_for(*attr_names)
        options = { :allow_destroy => false }
        options.update(attr_names.extract_options!)

        attr_names.each do |association_name|
          if association = self.associations[association_name]
            nested_attributes_options[association_name.to_sym] = options

            define_method "#{association_name}_attributes=" do |attributes|
              send("assign_nested_attributes_for_#{association.type}_association",
                association_name, attributes
              )
            end

            before_save :"autosave_nested_attributes_for_#{association_name}"
            before_save :destroy_marked_for_destruction

            define_method "autosave_nested_attributes_for_#{association_name}" do
              if self.autosave[association_name]
                send("save_nested_attributes_for_#{association.type}_association",
                  association_name
                )
              end
            end
            private :"autosave_nested_attributes_for_#{association_name}"

          else
            raise ArgumentError, "Association #{association_name} not found!"
          end
        end
      end
    end


    protected
    
    def autosave
      @autosave_nested_attributes_for ||= {}
    end
    
    def marked_for_destruction
      @marked_for_destruction ||= {}
    end
    
    private
    
    def save_nested_attributes_for_one_association(association_name)
      send(association_name).save
    end
    
    def save_nested_attributes_for_many_association(association_name)
      send(association_name).map(&:save)
    end
    
    def destroy_marked_for_destruction
      self.marked_for_destruction.each_pair do |association_name, resources|
        resources.map(&:destroy)
        send(association_name).reload
      end
    end
    
    def destroy_nested_many_association(association_name)
      send(association_name).map(&:destroy)
    end
    
    def assign_nested_attributes_for_one_association(association_name, attributes)
      association = self.class.associations[association_name]
      if association.embedded?
        assign_nested_attributes_for_one_embedded_association(association_name, attributes)
      else
        self.autosave[association_name] = true
        assign_nested_attributes_for_one_linked_association(association_name, attributes)
      end
    end
    
    def assign_nested_attributes_for_one_embedded_association(association_name, attributes)
      send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
    end
    
    def assign_nested_attributes_for_one_linked_association(association_name, attributes)
      attributes = attributes.stringify_keys
      options = nested_attributes_options[association_name]
    
      if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
        send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
      else
        if ((existing_record = send(association_name)).key.to_s == attributes[key_attr.to_s].to_s)
          assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
        else
          raise ArgumentError, "Attempting to update a child that isn't already associated to the parent."
        end
      end
    end
    
    def assign_nested_attributes_for_many_association(association_name, attributes_collection)
      unless attributes_collection.is_a?(Hash) || attributes_collection.is_a?(Array)
        raise ArgumentError, "Hash or Array expected, got #{attributes_collection.class.name} (#{attributes_collection.inspect})"
      end
    
      if attributes_collection.is_a? Hash
        attributes_collection = attributes_collection.sort_by { |index, _| index.to_i }.map { |_, attributes| attributes }
      end
    
      association = self.class.associations[association_name]
      if association.embedded?
        assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
      else
        self.autosave[association_name] = true
        assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
      end
    end
    
    def assign_nested_attributes_for_many_embedded_association(association_name, attributes_collection)
      options = nested_attributes_options[association_name]
      send(:"#{association_name}=", []) # Clobber existing
      attributes_collection.each do |attributes|
        attributes = attributes.stringify_keys
        if !reject_new_record?(association_name, attributes)
          send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
        end
      end
    end
    
    def assign_nested_attributes_for_many_linked_association(association_name, attributes_collection)
      options = nested_attributes_options[association_name]
      attributes_collection.each do |attributes|
        attributes = attributes.stringify_keys
    
        if attributes[key_attr.to_s].blank? && !reject_new_record?(association_name, attributes)
          send(association_name).build(attributes.except(*UNASSIGNABLE_KEYS))
        elsif existing_record = send(association_name).detect { |record| record.key.to_s == attributes[key_attr.to_s].to_s }
          assign_to_or_mark_for_destruction(existing_record, attributes, association_name, options[:allow_destroy])
        end
      end
    end

    def assign_to_or_mark_for_destruction(record, attributes, association_name, allow_destroy)
      if has_destroy_flag?(attributes) && allow_destroy
        (self.marked_for_destruction[association_name] ||= []) << record
      else
        record.attributes = attributes.except(*UNASSIGNABLE_KEYS)
      end
    end

    def has_destroy_flag?(hash)
      TRUE_VALUES.include?(hash.stringify_keys['_destroy'])
    end

    def reject_new_record?(association_name, attributes)
      has_destroy_flag?(attributes) || call_reject_if(association_name, attributes)
    end

    def call_reject_if(association_name, attributes)
      attributes = attributes.stringify_keys
      case callback = nested_attributes_options[association_name][:reject_if]
      when Symbol
        method(callback).arity == 0 ? send(callback) : send(callback, attributes)
      when Proc
        callback.call(attributes)
      end
    end

  end

end