accepts_nested_attributes_for与xxx_attributes=方法

类方法accepts_nested_attributes_for主要做的就是为指定的accept的关联对象,定义"#{association_name}_attributes="方法

# activerecord-5.2.1/lib/active_record/nested_attributes.rb
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_validation_callbacks(reflection)

      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)
    else
      raise ArgumentError, "No association found for name `#{association_name}'. Has it been defined yet?"
    end
  end
end

private

def generate_association_writer(association_name, type)
  generated_association_methods.module_eval <<-eoruby, __FILE__, __LINE__ + 1
    silence_redefinition_of_method :#{association_name}_attributes=
    def #{association_name}_attributes=(attributes)
      assign_nested_attributes_for_#{type}_association(:#{association_name}, attributes)
    end
  eoruby
end

定义得的"#{association_name}_attributes=",会调用"assign_nested_attributes_for_#{type}_association",使嵌套提交的"#{association_name}_attributes"能够build到关联对象上,从而最后save关联对象时能够一并save掉

# activerecord-5.2.1/lib/active_record/nested_attributes.rb
def assign_nested_attributes_for_one_to_one_association(association_name, attributes)
  # ...
  if (options[:update_only] || !attributes["id"].blank?) && existing_record &&
    # ...
  elsif attributes["id"].present?
    # ...
  elsif !reject_new_record?(association_name, attributes)
    # ...
  end
end

def assign_nested_attributes_for_collection_association(association_name, attributes_collection)
  # ...

  attributes_collection.each do |attributes|
    # ...
    if attributes["id"].blank?
      # ...
    elsif existing_record = existing_records.detect { |record| record.id.to_s == attributes["id"].to_s }
      # ...
    else
      # ...
    end
  end
end

使用嵌套表单fields_for顺便提交对关联对象的修改,一般会配套使用accepts_nested_attributes_for。

如果没有accepts_nested_attributes_for所定义的"#{association_name}_attributes="方法,则fields_for产生的表单项的name只会是survey[questions][content],而非survey[questions_attributes][0][content](假设Survey.has_many :questions)。这样的params只能由后端另外写代码来自行处理。

# actionview-5.2.1/lib/action_view/helpers/form_helper.rb
def fields_for(record_name, record_object = nil, fields_options = {}, &block)
  fields_options, record_object = record_object, nil if record_object.is_a?(Hash) && record_object.extractable_options?
  fields_options[:builder] ||= options[:builder]
  fields_options[:namespace] = options[:namespace]
  fields_options[:parent_builder] = self

  case record_name
  when String, Symbol
    if nested_attributes_association?(record_name)
      return fields_for_with_nested_attributes(record_name, record_object, fields_options, block)
    end
  else
    record_object = record_name.is_a?(Array) ? record_name.last : record_name
    record_name   = model_name_from_record_or_class(record_object).param_key
  end

  # ...
end

def nested_attributes_association?(association_name)
  @object.respond_to?("#{association_name}_attributes=")
end

在controller过滤表单参数时,也是使用"#{association_name}_attributes" => [...](注意有_attributes结尾)

fields_for的hidden id

一般嵌套表单都会给每条关联记录生成一个值为该记录id的hidden input tag,使顺便提交对关联对时能更新到对应关联记录或新增关联记录

如果调用field_for时指定了include_id: false,导致生成的嵌套表单没有这个hidden input,或页面上(不知何故真的)没有了这个hidden,或controller过滤参数时没有放过:id,则提交时总会对应不到原记录,而当成新纪录来插入(见上文的attributes["id"].blank?和existing_records.detect)

# actionview-5.2.1/lib/action_view/helpers/form_helper.rb
def fields_for_with_nested_attributes(association_name, association, options, block)
  # ...

  if association.respond_to?(:to_ary)
    explicit_child_index = options[:child_index]
    output = ActiveSupport::SafeBuffer.new
    association.each do |child|
      # ...
      output << fields_for_nested_model("#{name}[#{options[:child_index]}]", child, options, block)
    end
    output
  elsif association
    fields_for_nested_model(name, association, options, block)
  end
end

def fields_for_nested_model(name, object, fields_options, block)
  # ...

  emit_hidden_id = object.persisted? && fields_options.fetch(:include_id) {
    options.fetch(:include_id, true)
  }

  @template.fields_for(name, object, fields_options) do |f|
    output = @template.capture(f, &block)
    output.concat f.hidden_field(:id) if output && emit_hidden_id && !f.emitted_hidden_id?
    output
  end
end