require 'active_support/core_ext/hash/except' require 'active_support/core_ext/object/try' require 'active_support/core_ext/object/blank' require 'active_support/core_ext/hash/indifferent_access' module ActiveFedora module NestedAttributes #:nodoc: extend ActiveSupport::Concern included do class_attribute :nested_attributes_options, :instance_writer => false self.nested_attributes_options = {} end # Defines an attributes writer for the specified association(s). If you # are using attr_protected or attr_accessible, then you # will need to add the attribute writer to the allowed list. # # Supported options: # [:allow_destroy] # If true, destroys any members from the attributes hash with a # _destroy key and a value that evaluates to +true+ # (eg. 1, '1', true, or 'true'). This option is off by default. # [:reject_if] # Allows you to specify a Proc or a Symbol pointing to a method # that checks whether a record should be built for a certain attribute # hash. The hash is passed to the supplied Proc or the method # and it should return either +true+ or +false+. When no :reject_if # is specified, a record will be built for all attribute hashes that # do not have a _destroy value that evaluates to true. # Passing :all_blank instead of a Proc will create a proc # that will reject a record where all the attributes are blank. # [:limit] # Allows you to specify the maximum number of the associated records that # can be processed with the nested attributes. If the size of the # nested attributes array exceeds the specified limit, NestedAttributes::TooManyRecords # exception is raised. If omitted, any number associations can be processed. # Note that the :limit option is only applicable to one-to-many associations. # [:update_only] # Allows you to specify that an existing record may only be updated. # A new record may only be created when there is no existing record. # This option only works for one-to-one associations and is ignored for # collection associations. This option is off by default. # # Examples: # # creates avatar_attributes= # accepts_nested_attributes_for :avatar, :reject_if => proc { |attributes| attributes['name'].blank? } # # creates avatar_attributes= # accepts_nested_attributes_for :avatar, :reject_if => :all_blank # # creates avatar_attributes= and posts_attributes= # accepts_nested_attributes_for :avatar, :posts, :allow_destroy => true module ClassMethods def accepts_nested_attributes_for(*attr_names) options = { :allow_destroy => false, :update_only => false } options.update(attr_names.extract_options!) # options.assert_valid_keys(:allow_destroy, :reject_if, :limit, :update_only) options[:reject_if] = REJECT_ALL_BLANK_PROC if options[:reject_if] == :all_blank attr_names.each do |association_name| if reflection = reflect_on_association(association_name) reflection.options[:autosave] = true # add_autosave_association_callbacks(reflection) ## TODO this ought to work, but doesn't seem to do the class inheitance right nested_attributes_options[association_name.to_sym] = options type = (reflection.collection? ? :collection : :one_to_one) # def pirate_attributes=(attributes) # assign_nested_attributes_for_one_to_one_association(:pirate, attributes) # end class_eval <<-eoruby, __FILE__, __LINE__ + 1 if method_defined?(:#{association_name}_attributes=) remove_method(:#{association_name}_attributes=) end def #{association_name}_attributes=(attributes) assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes) ## in lieu of autosave_association_callbacks just save all of em. send(:#{association_name}).each {|obj| obj.save} end eoruby else raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?" end end end end private # Attribute hash keys that should not be assigned as normal attributes. # These hash keys are nested attributes implementation details. UNASSIGNABLE_KEYS = %w( id _destroy ) def assign_nested_attributes_for_collection_association(association_name, attributes_collection) options= {} #options = nested_attributes_options[association_name] if attributes_collection.is_a? Hash keys = attributes_collection.keys attributes_collection = if keys.include?('id') || keys.include?(:id) Array.wrap(attributes_collection) else attributes_collection.sort_by { |i, _| i.to_i }.map { |_, attributes| attributes } end end association = send(association_name) existing_records = if association.loaded? association.to_a else attribute_ids = attributes_collection.map {|a| a['id'] || a[:id] }.compact attribute_ids.present? ? association.select{ |x| attribute_ids.include?(x.pid)} : [] end attributes_collection.each do |attributes| attributes = attributes.with_indifferent_access if attributes['id'].blank? association.build(attributes.except(*UNASSIGNABLE_KEYS)) elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes['id'].to_s } association.send(:add_record_to_target_with_callbacks, existing_record) if !association.loaded? && !call_reject_if(association_name, attributes) assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) else raise_nested_attributes_record_not_found(association_name, attributes['id']) end end end # Updates a record with the +attributes+ or marks it for destruction if # +allow_destroy+ is +true+ and has_destroy_flag? returns +true+. def assign_to_or_mark_for_destruction(record, attributes, allow_destroy) record.attributes = attributes.except(*UNASSIGNABLE_KEYS) record.mark_for_destruction if has_destroy_flag?(attributes) && allow_destroy end # Determines if a hash contains a truthy _destroy key. def has_destroy_flag?(hash) ["1", "true"].include?(hash['_destroy'].to_s) end def raise_nested_attributes_record_not_found(association_name, record_id) reflection = self.class.reflect_on_association(association_name) raise ObjectNotFoundError, "Couldn't find #{reflection.klass.name} with ID=#{record_id} for #{self.class.name} with ID=#{id}" end end end