使用Rails嵌套模型同时*创建*外部对象和*编辑*现有的嵌套对象?

21

使用Rails 2.3.8版本

目标是同时创建一个Blogger和更新嵌套的User模型(以防信息已更改等等),或者如果该用户尚不存在,则创建一个全新的用户。

模型:

class Blogger < ActiveRecord::Base
  belongs_to :user
  accepts_nested_attributes_for :user
end

博客控制器:
def new
  @blogger = Blogger.new
  if user = self.get_user_from_session
    @blogger.user = user
  else
    @blogger.build_user
  end
  # get_user_from_session returns existing user 
  # saved in session (if there is one)
end

def create
  @blogger = Blogger.new(params[:blogger])
  # ...
end

表单:

<% form_for(@blogger) do |blogger_form| %>
  <% blogger_form.fields_for :user do |user_form| %>
    <%= user_form.label :first_name %>
    <%= user_form.text_field :first_name %>
    # ... other fields for user
  <% end %>
  # ... other fields for blogger
<% end %>

当我通过嵌套模型创建新用户时,一切都正常,但如果嵌套的用户已经存在并且有一个ID(在这种情况下,我希望它仅更新该用户),则会失败。

错误:

Couldn't find User with ID=7 for Blogger with ID=

这个SO问题涉及到类似的问题,唯一的答案表明Rails不能以这种方式工作。该答案建议仅传递现有项目的ID而不显示其表单--这很好用,但如果有任何用户属性要编辑,我想允许编辑。有什么建议吗?这似乎并不是特别罕见的情况,似乎必须有解决方案。

Deeply nested Rails forms using belong_to not working?
3个回答

53

我正在使用Rails 3.2.8,遇到了完全相同的问题。

看起来你试图做的事情(将一个已存在的已保存记录分配/更新到一个新的未保存父模型(Blogger)的一个belongs_to关联(user))在Rails 3.2.8(或Rails 2.3.8)中似乎是不可能的,不过我希望你现在已经升级到了3.x版本...除非采用一些变通方法。

我发现了两种变通方法,它们似乎在Rails 3.2.8中起作用。要理解为什么它们有效,您应该先了解代码在哪里引发错误。

了解为什么ActiveRecord会出错...

在我的activerecord版本(3.2.8)中,处理分配给belongs_to关联的嵌套属性的代码可以在lib/active_record/nested_attributes.rb:332中找到,它看起来像这样:

def assign_nested_attributes_for_one_to_one_association(association_name, attributes, assignment_opts = {})
  options = self.nested_attributes_options[association_name]
  attributes = attributes.with_indifferent_access

  if (options[:update_only] || !attributes['id'].blank?) && (record = send(association_name)) &&
      (options[:update_only] || record.id.to_s == attributes['id'].to_s)
    assign_to_or_mark_for_destruction(record, attributes, options[:allow_destroy], assignment_opts) unless call_reject_if(association_name, attributes)

  elsif attributes['id'].present? && !assignment_opts[:without_protection]
    raise_nested_attributes_record_not_found(association_name, attributes['id'])

  elsif !reject_new_record?(association_name, attributes)
    method = "build_#{association_name}"
    if respond_to?(method)
      send(method, attributes.except(*unassignable_keys(assignment_opts)), assignment_opts)
    else
      raise ArgumentError, "Cannot build association #{association_name}. Are you trying to build a polymorphic one-to-one association?"
    end
  end
end
if语句中,如果看到您传递了一个用户ID(!attributes['id'].blank?),它会尝试从博主的user关联(record = send(association_name),其中association_name是:user)获取现有的user记录。
但由于这是一个新构建的Blogger对象,blogger.user最初将为nil,因此它不会进入该分支中处理更新现有recordassign_to_or_mark_for_destruction调用。这就是我们需要解决的问题(请参见下一节)。
因此,它转向第一个else if分支,再次检查是否存在用户ID (attributes['id'].present?)。它存在,因此检查下一个条件,即!assignment_opts[:without_protection]
由于您正在使用Blogger.new(params[:blogger])初始化新的Blogger对象(即,没有传递as: :rolewithout_protection: true),它使用默认的assignment_opts {}!{}[:without_protection]为true,因此它继续执行raise_nested_attributes_record_not_found,这就是您看到的错误。
最后,如果没有采取其他2个if分支中的任何一个,则检查是否应拒绝新记录,如果不是,则继续构建一个新记录。这就是它在您提到的“如果尚不存在则创建全新用户”的情况下所遵循的路径。

解决方法1(不推荐):without_protection: true

我想到的第一个解决办法 - 但不建议 - 是使用without_protection: true (Rails 3.2.8)将属性赋值给Blogger对象。

Blogger.new(params[:blogger], without_protection: true)

这种方式跳过了第一个elsif,并转到最后一个elsif,该分支通过从参数中构建具有所有属性的新用户(包括:id)来创建新用户。实际上,我不知道是否会导致更新现有用户记录,就像你想要的那样(可能不会 - 还没有真正测试过那个选项),但至少可以避免错误... :)

解决方法2(推荐):在user_attributes=中设置self.user

但是,我比那更推荐的解决方法是从:id参数中初始化/设置user关联,使得第一个if分支用于在内存中更新现有记录,就像你想要的那样...

  accepts_nested_attributes_for :user
  def user_attributes=(attributes)
    if attributes['id'].present?
      self.user = User.find(attributes['id'])
    end
    super
  end

要想像那样重写嵌套属性访问器并调用super,您需要使用 Edge Rails 或包含我在https://github.com/rails/rails/pull/2945上发布的猴子补丁。或者,您可以直接从user_attributes = setter 中调用assign_nested_attributes_for_one_to_one_association(:user,attributes)而不是调用super.


如果要始终创建新用户记录而更新现有用户...

在我的情况下,最终我决定希望人们能够从此表单中更新现有用户记录,因此我最终使用了上述解决方法的一个略微变体:

  accepts_nested_attributes_for :user
  def user_attributes=(attributes)
    if user.nil? && attributes['id'].present?
      attributes.delete('id')
    end
    super
  end

这种方法也能防止错误的发生,但实现方式略有不同。

如果params中传入了一个id,我并不使用它来初始化 user 关联,而是直接删除该传入的id,这样就会从提交的用户参数中构建一个新的用户。


1
你的“解决方法2”很好,因为它让Rails不再干扰我们进入“create”块代码,我们可以处理嵌套模型。我发现OP在进入创建块之前就出现了错误,这令人恼火。在我的情况下,如果它已经存在,则更新父模型的belongs_to id字段并使用'.except(:nested_model)'删除嵌套参数 - 或者作为新实例创建,在这种情况下,保存与嵌套参数一起是很好的。 - JosephK
这个解决方案很棒,它帮助我解决了我的问题。不过,我想知道如何实现类似的方法来处理多对多的关系...如果不重写代码,我总是会遇到一个问题:当我创建父对象(第一次创建)时,无法在某些子对象已经有先前id的情况下创建嵌套关系(由于是多对多关系,它们可以有“多个父对象”),导致关联表出现404未找到错误。如果我用适当的数组查找方法覆盖该方法,就会出现数据库错误(在我这里是PG数据库),因为在关联表中parent_id为空。 - Stefano Mondino
这个功能已经添加到了最新版本的Rails中吗? - varagrawal
@StefanoMondino 我认为你可以做类似于这个的东西 https://github.com/rails/rails/issues/7256#issuecomment-249735086 - brayancastrop
结果发现我在父对象上使用了has_one关系,而不是在子对象上使用belongs_to关系。区别在于保存关系ID的表。 - Stefano Mondino
1
对于晚到的访问者,解决方法#1不再是一个选项,并且已经在提交2d7ae1b08ee2a10b12cbfeef3a6cc6da55b57df6中被删除(Rails 4.0.0)。 - 3limin4t0r

2

我在Rails 3.2中遇到了同样的错误。当使用嵌套表单为现有对象创建具有属于关系的新对象时,会出现错误。Tyler Rick的方法对我不起作用。我发现有效的方法是在初始化对象之后设置关系,然后设置对象属性。以下是示例...

@report = Report.new()
@report.user = current_user
@report.attributes = params[:report] 

假设params看起来像这样... {:report => { :name => "名称", :user_attributes => {:id => 1, { :things_attributes => { "1" => {:name => "物品名称" }}}}}}
(注意:本文中的HTML标签已保留)

0
尝试在嵌套表单中添加一个用于用户ID的隐藏字段:
<%=user_form.hidden_field :id%>

嵌套的 save 函数将使用此参数来确定用户是创建还是更新。

1
Rails实际上已经做到了这一点。如果用户对象已经存在,它会添加一个隐藏的blogger[user_attributes][id]表单字段(如果是新用户则不存在)。正如我原始笔记中的错误消息所示,Rails知道嵌套模型的ID。 - Jase
1
是的,传递嵌套对象的ID绝对不是问题。在我贴出的原始错误中,它知道嵌套对象(用户)的ID为7,而外部对象(博主)尚未具有ID(因为它是全新的)。它只是在这种情况下显然会出现问题。 - Jase

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接