require 'active_support/core_ext/hash/except'
require 'active_support/core_ext/object/try'
require 'active_support/core_ext/object/blank'
module ActiveFedora
module NestedAttributes #:nodoc:
class TooManyRecords < RuntimeError
end
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
REJECT_ALL_BLANK_PROC = proc { |attributes| attributes.all? { |key, value| key == '_destroy' || value.blank? } }
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.autosave = true
define_autosave_association_callbacks(reflection)
## TODO this ought to work, but doesn't seem to do the class inheritance right
nested_attributes_options = self.nested_attributes_options.dup
nested_attributes_options[association_name.to_sym] = options
self.nested_attributes_options = nested_attributes_options
type = (reflection.collection? ? :collection : :one_to_one)
generate_association_writer(association_name, type)
class_eval <<-eoruby, __FILE__, __LINE__ + 1
remove_possible_method(:#{association_name}_attributes=)
def #{association_name}_attributes=(attributes)
assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
end
eoruby
elsif reflect_on_property(association_name)
resource_class.accepts_nested_attributes_for(association_name, options)
generate_property_writer(association_name, type)
# Delegate the setter to the resource.
class_eval <<-eoruby, __FILE__, __LINE__ + 1
remove_possible_method(:#{association_name}_attributes=)
def #{association_name}_attributes=(attributes)
attribute_will_change!(:#{association_name})
resource.#{association_name}_attributes=(attributes)
end
eoruby
else
raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
end
end
end
private
# Generates a writer method for this association. Serves as a point for
# accessing the objects in the association. For example, this method
# could generate the following:
#
# def pirate_attributes=(attributes)
# assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
# end
#
# This redirects the attempts to write objects in an association through
# the helper methods defined below. Makes it seem like the nested
# associations are just regular associations.
def generate_association_writer(association_name, type)
generated_association_methods.module_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)
end
eoruby
end
# Generates a writer method for this association. Serves as a point for
# accessing the objects in the association. For example, this method
# could generate the following:
#
# def pirate_attributes=(attributes)
# assign_nested_attributes_for_one_to_one_association(:pirate, attributes)
# end
#
# This redirects the attempts to write objects in an association through
# the helper methods defined below. Makes it seem like the nested
# associations are just regular associations.
def generate_property_writer(association_name, _type)
generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
if method_defined?(:#{association_name}_attributes=)
remove_method(:#{association_name}_attributes=)
end
def #{association_name}_attributes=(attributes)
attribute_will_change!(:#{association_name})
resource.#{association_name}_attributes=(attributes)
end
eoruby
end
end
# Returns ActiveFedora::Base#marked_for_destruction? It's
# used in conjunction with fields_for to build a form element for the
# destruction of this association.
#
# See ActionView::Helpers::FormHelper::fields_for for more info.
def _destroy
marked_for_destruction?
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).freeze
# Assigns the given attributes to the association.
#
# If an associated record does not yet exist, one will be instantiated. If
# an associated record already exists, the method's behavior depends on
# the value of the update_only option. If update_only is +false+ and the
# given attributes include an :id that matches the existing record's
# id, then the existing record will be modified. If no :id is provided
# it will be replaced with a new record. If update_only is +true+ the existing
# record will be modified regardless of whether an :id is provided.
#
# If the given attributes include a matching :id attribute, or
# update_only is true, and a :_destroy key set to a truthy value,
# then the existing record will be marked for destruction.
def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
options = nested_attributes_options[association_name]
attributes = attributes.to_h if attributes.respond_to?(:permitted?)
attributes = attributes.with_indifferent_access
existing_record = send(association_name)
if (options[:update_only] || !attributes['id'].blank?) && existing_record &&
(options[:update_only] || existing_record.id.to_s == attributes['id'].to_s)
assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy]) unless call_reject_if(association_name, attributes)
elsif attributes['id'].present?
raise_nested_attributes_record_not_found!(association_name, attributes['id'])
elsif !reject_new_record?(association_name, attributes)
assignable_attributes = attributes.except(*UNASSIGNABLE_KEYS)
if existing_record && existing_record.new_record?
existing_record.assign_attributes(assignable_attributes)
association(association_name).initialize_attributes(existing_record)
else
method = "build_#{association_name}"
if respond_to?(method)
send(method, assignable_attributes)
else
raise ArgumentError, "Cannot build association `#{association_name}'. Are you trying to build a polymorphic one-to-one association?"
end
end
end
end
def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
options = nested_attributes_options[association_name]
if attributes_collection.respond_to?(:permitted?)
attributes_collection = attributes_collection.to_h
end
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
check_record_limit!(options[:limit], attributes_collection)
if attributes_collection.is_a? Hash
keys = attributes_collection.keys
attributes_collection = if keys.include?('id') || keys.include?(:id)
Array(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.target
else
attribute_ids = attributes_collection.map { |a| a['id'] || a[:id] }.compact
attribute_ids.present? ? association.to_a.select { |x| attribute_ids.include?(x.id) } : []
end
attributes_collection.each do |attributes|
attributes = attributes.to_h if attributes.respond_to?(:permitted)
attributes = attributes.with_indifferent_access
if attributes['id'].blank?
unless reject_new_record?(association_name, attributes)
association.build(attributes.except(*UNASSIGNABLE_KEYS))
end
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)
unless call_reject_if(association_name, attributes)
assign_to_or_mark_for_destruction(existing_record, attributes, options[:allow_destroy])
end
else
raise_nested_attributes_record_not_found!(association_name, attributes['id'])
end
end
end
# Takes in a limit and checks if the attributes_collection has too many
# records. It accepts limit in the form of symbol, proc, or
# number-like object (anything that can be compared with an integer).
#
# Raises TooManyRecords error if the attributes_collection is
# larger than the limit.
def check_record_limit!(limit, attributes_collection)
return unless limit
limit = case limit
when Symbol
send(limit)
when Proc
limit.call
else
limit
end
raise TooManyRecords, "Maximum #{limit} records are allowed. Got #{attributes_collection.size} records instead." if limit && attributes_collection.size > limit
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)
Type::Boolean.new.cast(hash['_destroy'])
end
# Determines if a new record should be rejected by checking
# has_destroy_flag? or if a :reject_if proc exists for this
# association and evaluates to +true+.
def reject_new_record?(association_name, attributes)
will_be_destroyed?(association_name, attributes) || call_reject_if(association_name, attributes)
end
# Determines if a record with the particular +attributes+ should be
# rejected by calling the reject_if Symbol or Proc (if defined).
# The reject_if option is defined by +accepts_nested_attributes_for+.
#
# Returns false if there is a +destroy_flag+ on the attributes.
def call_reject_if(association_name, attributes)
return false if will_be_destroyed?(association_name, attributes)
opts = nested_attributes_options[association_name]
case callback = opts[:reject_if]
when Symbol
method(callback).arity.zero? ? send(callback) : send(callback, attributes)
when Proc
callback.call(attributes)
end
end
# Only take into account the destroy flag if :allow_destroy is true
def will_be_destroyed?(association_name, attributes)
allow_destroy?(association_name) && has_destroy_flag?(attributes)
end
def allow_destroy?(association_name)
nested_attributes_options[association_name][:allow_destroy]
end
def raise_nested_attributes_record_not_found!(association_name, record_id)
reflection = self.class._reflect_on_association(association_name).klass.name
raise ObjectNotFoundError, "Couldn't find #{reflection.klass.name} with ID=#{record_id} for #{self.class.name} with ID=#{id}"
end
end
end