2009年1月26日,星期一

嵌套模型表单

由 michael 发布

在我们新的反馈网站上最受欢迎的要求是能够在一个表单中轻松管理多个模型。谢天谢地,Eloy Duran 提供了一个实现此功能的补丁。但在将其合并到 rails 2.3 之前,我们希望从大家那里获得更多反馈。Eloy 为此补丁写了一篇简短的介绍,所以请看一下,并将您的任何反馈添加到lighthouse 票证中。

在 Rails 中,为一个模型及其关联创建单个表单一直有些棘手。去年夏天,:accessible 选项被提交,这是处理嵌套模型统一解决方案的初步尝试。然而,这个功能在 2.2 版本发布前被撤销了,因为它只支持对象创建期间的嵌套模型。

由此产生的核心邮件列表讨论以及我们对批量赋值的需求,促成了针对票证 #1202 的此补丁,该补丁现已提交供审查。

由于这是一个相当大的补丁,我们希望邀请您尝试一下。请报告任何问题或就该补丁提供一般性反馈。我们特别想了解提供的解决方案如何适用于允许通过表单删除嵌套记录的应用程序。

下面我将简要介绍该补丁的功能以及如何使用它,这样您就没有理由不尝试一下了。

获取补丁

首先创建一个新应用程序

$ mkdir -p nested-models/vendor
$ cd nested-models/vendor

将您的 Rails 分支供应商化,并打上嵌套模型补丁

$ git clone git://github.com/alloy/rails.git
$ cd rails
$ git checkout origin/normalized_nested_attributes
$ cd ../..
$ ruby vendor/rails/railties/bin/rails .

嵌套赋值的示例

假设您有一个具有相关任务的 Project 模型

class Project < ActiveRecord::Base
  has_many :tasks

  validates_presence_of :name
end
class Task < ActiveRecord::Base
  belongs_to :project

  validates_presence_of :name
end

现在考虑以下表单,它允许您同时创建(或编辑)一个项目及其任务

<form>
  <div>
    <label for="project_name">Project:</label>
    <input type="text" name="project_name" />
  </div>

  <p>
    <label for="task_name_1">Task:</label>
    <input type="text" name="task_name_1" />
    <label for="task_delete_1">Remove:</label>
    <input type="checkbox" name="task_delete_1" />
  </p>

  <p>
    <label for="task_name_2">Task:</label>
    <input type="text" name="task_name_2" />
    <label for="task_delete_2">Remove:</label>
    <input type="checkbox" name="task_delete_2" />
  </p>
</form>

打补丁之前

在此补丁之前,您必须编写类似这样的模板

<% form_for @project do |project_form| %>
  <div>
    <%= project_form.label :name, 'Project name:' %>
    <%= project_form.text_field :name %>
  </div>

  <% @project.tasks.each do |task| %>
    <% new_or_existing = task.new_record? ? 'new' : 'existing' %> 
    <% prefix = "project[#{new_or_existing}_task_attributes][]" %> 
    <% fields_for prefix, task do |task_form| %>
      <p>
        <div>
          <%= task_form.label :name, 'Task:' %>
          <%= task_form.text_field :name %>
        </div>

        <% unless task.new_record? %>
          <div>
            <%= task_form.label :_delete, 'Remove:' %>
            <%= task_form.check_box :_delete %>
          </div>
        <% end %>
      </p>
    <% end %>
  <% end %>

  <%= project_form.submit %>
<% end %>

控制器基本上是您平常的 RESTful 控制器。然而,Project 模型需要知道如何处理嵌套属性

class Project < ActiveRecord::Base
  after_update :save_tasks

  def new_task_attributes=(task_attributes)
    task_attributes.each do |attributes|
      tasks.build(attributes)
    end 
  end

  def existing_task_attributes=(task_attributes)
    tasks.reject(&:new_record?).each do |task|
      attributes = task_attributes[task.id.to_s]
      if attributes['_delete'] == '1'
        tasks.delete(task)
      else
        task.attributes = attributes
      end
    end
  end

  private

  def save_tasks
    tasks.each do |task|
      task.save(false)
    end
  end

  validates_associated :tasks
end

上面的代码基于 Ryan Bates 的complex-form-examples 应用程序Advanced Rails Recipes 书籍。

打补丁之后

首先,您告诉 Project 模型接受其任务的嵌套属性

class Project < ActiveRecord::Base
  has_many :tasks

  accept_nested_attributes_for :tasks, :allow_destroy => true
end

然后您可以编写以下模板

<% form_for @project do |project_form| %>
  <div>
    <%= project_form.label :name, 'Project name:' %>
    <%= project_form.text_field :name %>
  </div>

  <!-- Here we call fields_for on the project_form builder instance.
       The block is called for each member of the tasks collection. -->
  <% project_form.fields_for :tasks do |task_form| %>
      <p>
        <div>
          <%= task_form.label :name, 'Task:' %>
          <%= task_form.text_field :name %>
        </div>

        <% unless task_form.object.new_record? %>
          <div>
            <%= task_form.label :_delete, 'Remove:' %>
            <%= task_form.check_box :_delete %>
          </div>
        <% end %>
      </p>
    <% end %>
  <% end %>

  <%= project_form.submit %>
<% end %>

如您所见,这更简洁,也更容易阅读。

诚然,此示例的模板仅稍短一些,但很容易想象嵌套模型更多时差异有多大。或者,如果 Task 模型本身也有嵌套模型。

验证

验证就像您期望的那样工作;#valid?也会验证嵌套模型,#save(false)将保存而不进行验证,依此类推。

唯一需要注意的是,来自嵌套模型的所有错误消息都会复制到父错误对象中,用于error_messages_for。正如在票证中讨论的那样,这将来可能会改变,但这超出了此补丁的范围。

让我们看一个 Task 验证其:name属性的示例

>> project = Project.first
=> #<Project id: 1, name: "Nested models patches", created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15", author_id: 1>

>> project.tasks
=> [#<Task id: 1, project_id: 1, name: "Write 'em", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 2, project_id: 1, name: "Test 'em", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 3, project_id: 1, name: "Create demo app", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 4, project_id: 1, name: "Scrutinize", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">]

>> project.tasks.second.name = "" 
=> "" 

>> project.valid?
=> false

>> project.errors
=> #<ActiveRecord::Errors:0x23e4b10 @errors={"tasks_name"=>["can't be blank"]}, @base=#<Project id: 1, name: "Nested models patches", …, author_id: 1>>

事务

到目前为止,您可能正在考虑当验证通过但保存失败时数据的**一致性**。考虑一下这个 Author 模型,我特意让它在保存后抛出异常

class Author < ActiveRecord::Base
  has_many :projects

  after_save :raise_exception

  def raise_exception
    raise 'Oh noes!'
  end
end

这是更新前的 Project 数据

>> project = Project.first
=> #<Project id: 1, name: "Nested models patches", created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15", author_id: 1>

>> project.tasks
=> [#<Task id: 1, project_id: 1, name: "Write 'em", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 2, project_id: 1, name: "Test 'em", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 3, project_id: 1, name: "Create demo app", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">,
#<Task id: 4, project_id: 1, name: "Scrutinize", due_at: nil, created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">]

>> project.author
=> #<Author id: 1, name: "Eloy", created_at: "2009-01-22 11:17:15", updated_at: "2009-01-22 11:17:15">

现在让我们删除第一个 Task 并更改第二个 Task 的名称

>> project.tasks_attributes = { "1" => { "_delete" => "1" }, "2" => { "name" => "Who needs tests anyway?!" } }
=> {"1"=>{}, "2"=>{"name"=>"Who needs tests anyway?!"}}

>> project.tasks.first.marked_for_destruction?
=> true

>> project.tasks.forty_two
=> nil # Oops, I meant #second of course… ;)

>> project.tasks.second.name
=> "Who needs tests anyway?!" 

最后,让我们尝试保存 Project 实例

>> project.save
RuntimeError: Oh noes!
  from /Users/eloy/code/complex-form-examples/app/models/author.rb:9:in `raise_exception_if_needed'

在保存其中一个嵌套模型时抛出了异常。现在让我们看看数据发生了什么

SQL (0.1ms)   BEGIN
Task Destroy (0.3ms)   DELETE FROM `tasks` WHERE `id` = 1
Task Update (0.2ms)   UPDATE `tasks` SET `updated_at` = '2009-01-22 11:22:23', `name` = 'Who needs tests anyway?!' WHERE `id` = 2
SQL (17.0ms)   ROLLBACK

如您所见,所有更改都已回滚。更新以及标记为销毁的记录的移除都发生在同一个事务中。

与任何事务一样,您尝试保存的实例的属性**不会**被重置。这意味着在此事务失败后,第一个任务仍被标记为销毁,第二个任务仍然具有新的名称值。

结论

此补丁允许您创建任意深度的嵌套模型表单。创建、保存和删除都应该能够透明地在单个事务中完成。

请在您的应用程序上测试此补丁,或者查看我分叉的 Ryan 的 complex-form-examples,它使用了此补丁

您的反馈将不胜感激。但是,请记住,我们不可能一次满足所有人的需求。请将其他功能或主要更改建议为单独的票证,以便在补丁应用**之后**再提交。目标是首先修复此补丁中的错误,以便我们拥有一个干净的基础来构建。

最后,我想感谢David Dollar 进行最初的:accessible实现,感谢Fingertips(我工作的地方)资助了此补丁花费的时间,感谢Manfred Stienstra 在文档方面的广泛帮助,以及按任意顺序的Lance IvyMichael KoziarskiRyan BatesPratik NaikJosh Susser 的一般性讨论。