2009 年 1 月 26 日,星期一

嵌套模型表单

Posted by 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 .

嵌套分配示例

假设您有一个带有关联任务的项目模型

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 应用程序高级 Rails 配方 书籍。

此次补丁之后

首先,告诉 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 对一般性讨论做出的贡献(按无特定顺序排列)。