Rails中的assign_attributes和accepts_nested_attributes_for

工作中遇到的一个问题。

背景

场景类似下面这样:

现有model project, model member, model user,可以将member看作是user与project之间多对多关系的中间表,现在需要在project新建后,将创建该project的当前用户current_user设为该项目的负责人,也就是在member中新增一条记录。同时在project 的new.html.erb表单中,只有一个name,即项目名称字段。

如何实现?

我开始用了极为粗暴简单的方式,在projects_controller.rb的create action中添加了这么一句:

def create
  @project.save
  Member.create!(role: "owner", project: @project, user: current_user)
  respond_with @project, location: ok_url_or_default(Project)
end

这样功能是实现了,但是这明显不是正确的写法,找不到一点rails的影子,完全新手小白的方式。老大提到了用assign_attributes和accepts_nested_attributes_for来实现。改写了,实现了,踩了点坑。

记录下assign_attributes的用法,主要是与accepts_nested_attributes_for结合使用,在不依靠表单的情况下,更新数据库。

基本用法

assign_attributes,既可以更新model所有的attributes,也可以用来更新model的部分attributes。光看没什么感觉,拿个例子练练手。

有一个model movie,字段name, description, name要求不能为空,同时与user之间存在一对多的关联。

class Movie < ApplicationRecord
  validates :name, presence: true
  belongs_to :user
end  

进入console试试:

m = Movie.new

### 只更新一部分
m.assign_attributes(name: "before you")
m.attributes
=> {"id"=>nil, "name"=>"before you", "description"=>nil, "created_at"=>nil, "updated_at"=>nil, "user_id"=>nil}

### 更新全部并保存
m.assign_attributes(name: "before you", description: "it is a love story", user: User.first)
m.save
=> true
m.attributes
=> {"id"=>7, "name"=>"before you", "description"=>"it is a love story", "created_at"=>Sat, 23 Jun 2018 01:25:15 UTC +00:00, "updated_at"=>Sat, 23 Jun 2018 01:25:15 UTC +00:00, "user_id"=>1}

上面的例子是create,如果要update呢?

m = Movie.first
=> #<Movie id: 4, name: "once", description: "Apart but still together", created_at: "2018-06-04 07:55:59", updated_at: "2018-06-04 07:55:59", user_id: 1>

m.assign_attributes(name: "one day")
m.attributes

=> {"id"=>4, "name"=>"one day", "description"=>"Apart but still together", "created_at"=>Mon, 04 Jun 2018 07:55:59 UTC +00:00, "updated_at"=>Mon, 04 Jun 2018 07:55:59 UTC +00:00, "user_id"=>1}

m.save
m
=> #<Movie id: 4, name: "one day", description: "Apart but still together", created_at: "2018-06-04 07:55:59", updated_at: "2018-06-23 01:30:41", user_id: 1>

可以看到成功完成了更新!

这么看,好像没什么,更新和新建而已,但是结合accepts_nested_attributes_for就很cool了。

下面的例子均以create为导向,不涉及update。

单个关联下

assign_attributes中,针对一对一的关联关系,有一个单独的private method,assign_nested_attributes_for_one_to_one_association, private method不能直接调用,用assign_attributes 和accepts_nested_attributes_for也可以实现一样的效果。

比如有一个model 别名alias , 与user之间是一对一的关系,每次创建alias时,需要关联到user。对应model中的代码如下:

Class Alias < ActiveRecord::Base
  belongs_to :user
  accepts_nested_attributes_for :user
end

这样使用assign_attributes,如果alias的user已经存在,则会更新,如果不存在,则新建一个对应的user。这里要注意,不要在user里面添加belongs_to :alias, 这样你在传递user_attribute时,会报错,显示alias必须存在,而此时alias还没有创建成功。

进入console试试, 在alias未创建前,创建一个user:

a = Alias.new

a.assign_attributes({name: "ruby", user_attributes: { email: "[email protected]", password: "123456", password_confirmation: "123456"}})

a.save
=> true
a
=> #<Alias id: 1, name: "ruby", user_id: 3, created_at: "2018-06-23 02:33:23", updated_at: "2018-06-23 02:33:23">
a.user
=> #<User id: 3, email: "[email protected]", created_at: "2018-06-23 02:33:23", updated_at: "2018-06-23 02:33:23">

可以看到user已经创建完成!

不过我觉得这种情况,多半是你需要新建一个默认的user,因为参数的值是你传递过去的,已经定义好了,而不是像页面的表单那样。这个方法的好处在于,你不需要在页面表单中添加user部分,让用户选择,直接在创建alias时就与user建立起了关联。

多关联下

如同一对一关联中,一对多关系下,也有一个private method:assign_nested_attributes_for_collection_association。 官方给到的例子是这样的:

assign_nested_attributes_for_collection_association(:people, {
  '1' => { id: '1', name: 'Peter' },
  '2' => { name: 'John' },
  '3' => { id: '2', _destroy: true }
})

People 是复数,这里传递的参数是一个hash,可以看出传了三条记录。如果用assign_attributes怎么实现呢?我这里直接用背景中那个问题来举例。【啧啧,在背景里卖关子没给到解答,悄悄把解答放在了这里。】

project.rb中,添加accepts_nested_attributes_for :members:

class Project < ApplicationRecord
  has_many :members, dependent: :destroy
  accepts_nested_attributes_for :members
end

这样,我们可以修改controller中的action create了, 在save之前进行assign_attributes:

def create
  @project.assign_attributes(members_attributes: [ { role: "owner", project: @project, user: current_user } ])
  @project.save
  respond_with @project, location: ok_url_or_default(Project)
end

踩坑的地方就在于members_attributes后面传递的是一个Array数组[ { role: "owner", project: @project, user: current_user } ],数组里面的元素才是Hash。启发来自于文档中的这个例子:

assign_nested_attributes_for_collection_association(:people, [
  { id: '1', name: 'Peter' },
  { name: 'John' },
  { id: '2', _destroy: true }
])

OK!

BTW,还有一个类似assign_attributes的method,update_attributes,官方没有给到例子,可以参考这篇文章accepts_nested_attributes_for is Creating New Records; Gotcha!,感觉跟assign_attributes还是很像的。

参考

assign_attributes

assign_nested_attributes_for_one_to_one_association

assign_nested_attributes_for_collection_association

accepts_nested_attributes_for is Creating New Records; Gotcha!

一点思考

当我写出正确解答的时候,再看,发现其实挺简单的,可是我为什么花了差不多两个小时呢?因为我的搜索习惯。

曾经我的习惯是,出问题了就Google,看stack overflow,对于文档,觉得冗长枯燥,效率低,不愿意去读,其实这样反而是走了弯路。不懂的时候,看文档,看文档,看文档!不要偷懒去看别人写的那些解答和文章,极有可能完全不是你想要的,甚至可能让你离正确的解决方法越来越远。看文档虽然略显枯燥,却最有效,如果文档中没有相关的实例,可以去搜一下别人用例。
change your mind, change your habit.