跳至主要内容

[Rails] Nested Model

keywords: fields_for, accepts_nested_attributes_for

Building Complex Form @ RailsGuides

許多時候我們都不是只要修改單一個 Model,例如,當我們建立 Person 時,你可能會想要讓使用者可以在同一個表單同時建立/編輯與該 Person 有關的許多 Address 的資料。

可參考專案

  • 需要關聯到對應的屬性時 filmCloud > posts_controller
  • 只需要取得對應的 ids 時 ux-website > articles_controller, article/_form

概念

透過在 Model 中使用 accepts_nested_attributes_for 可以自動幫我們建立相對應的方法可以使用,例如:

# app/models/person.rb

# 透過 accepts_nested_attributes_for :address
# 會產生相對應的 addresses_attributes= 方法讓我們可以對 address 進行 crud
class Person < ApplicationRecord
has_one :address
accepts_nested_attributes_for :address, allow_destroy: true, reject_if: :all_blank
end

一對一關係 One to One

假設 Person has_one Address:

Model

keywords: accepts_nested_attributes_for
  • accepts_nested_attributes_for
  • allow_destroy
  • reject_if

當 Person 已經有定義 has_one :address 後,可以使用 accepts_nested_attributes_for :address

# app/models/person.rb
class Person < ApplicationRecord
has_one :address
accepts_nested_attributes_for :address, allow_destroy: true, reject_if: :all_blank
end

View

keywords: <instance>.fields_for

可以使用 <instance>.fields_for

<!-- app/views/people/_form.html.erb -->

<%= form_for @person do |person_form| %>
<!-- Person 的欄位 -->
<div class="field">
<%= person_form.label :name %>
<%= person_form.text_field :name %>
</div><!-- / Person 的欄位 -->

<!-- Person.address 的欄位 -->
<div class="field">
<%= person_form.fields_for :address do |address_fields| %>
Location : <%= address_fields.text_field :location %>
Delete: <%= address_fields.check_box :_destroy %>
<% end %>
</div><!-- Person.address 的欄位 -->

<div class="actions">
<%= person_form.submit %>
</div>
<% end %>

如此將會產生相對應的 HTML:

  • _id:value 的值會對應到 DB 當中的 id,用來告訴 Rails 上面的內容是要修改 DB 中的哪筆 record
  • _destroy:當 value 設成 1 或 true 時將會刪除該筆資料

如果有使用 _destroy 的話,記得在 Model 的地方要設定 allow_destroy: true

<div class="field">
Location :
<input
type="text"
value="123"
name="person[address_attributes][location]"
id="person_address_attributes_location"
/>
Delete:
<input name="person[address_attributes][_destroy]" type="hidden" value="0" />
<input
type="checkbox"
value="1"
name="person[address_attributes][_destroy]"
id="person_address_attributes__destroy"
/>
<input
type="hidden"
value="3"
name="person[address_attributes][id]"
id="person_address_attributes_id"
/>
</div>

Controller

keywords: <instance>_attributes

Person.address 的資料到 Controller 後會自動變成 address_attributes

在 Controller 的地方要記得把允許的欄位(address_attributes: [:location, :id, :_destroy])加到 permit 中 。

如果你想要允許 address_attributes 中的所有參數,可以透過物件{ }的方式允許所有參數,使用 address_attributes: {}

class PeopleController < ApplicationController
before_action :set_person, only: [:show, :edit, :update, :destroy]

# ...

private
# Never trust parameters from the scary internet, only allow the white list through.
def person_params
# 允許所有參數:
# params.require(:person).permit(:name, address_attributes: {})
params.require(:person).permit(:name, address_attributes: [:location, :id, :_destroy])
end
end

一對多關係 One to Many

假設 Person has_many Address:

Model

keywords: accepts_nested_attributes_for
  • accepts_nested_attributes_for
  • allow_destroy
  • reject_if

當 Person 已經有定義 has_one :address 後,可以使用 accepts_nested_attributes_for :address

# app/models/person.rb
class Person < ApplicationRecord
has_many :address
accepts_nested_attributes_for :address, allow_destroy: true
end

View

keywords: <instance>.fields_for

可以使用 <instance>.fields_for

<!-- app/views/people/_form.html.erb -->

<%= form_for @person do |person_form| %>
<!-- Person 的欄位 -->
<div class="field">
<%= person_form.label :name %>
<%= person_form.text_field :name %>
</div><!-- / Person 的欄位 -->

<!-- Person.address 的欄位 -->
<div class="field">
<%= person_form.fields_for :address do |address_fields| %>
Location : <%= address_fields.text_field :location %>
Delete: <%= address_fields.check_box :_destroy %>
<% end %>
</div><!-- Person.address 的欄位 -->

<div class="actions">
<%= person_form.submit %>
</div>
<% end %>

如此將會產生相對應的 HTML:

person[address_attributes] 後因為可以接多個 address,所以多了一個 [] ,這是用來方便後端對應哪些資料是屬於同一群的

  • _id:value 的值會對應到 DB 當中的 id,用來告訴 Rails 上面的內容是要修改 DB 中的哪筆 record
  • _destroy:當 value 設成 1 或 true 時將會刪除該筆資料
  • [ ]:用來讓後端對應哪些是同一群的資料,所以不同群的不能用同一個值。通常會用 0, 1, 2 區別;但當內容是 uuid 或字串時,記得在 controller .permit() 的地方要用 { } 允許整個物件。

如果有使用 _destroy 的話,記得在 Model 的地方要設定 allow_destroy: true

<div class="field">
<!-- 第一列 -->
Location :
<input
type="text"
value="123"
name="person[address_attributes][0][location]"
id="person_address_attributes_0_location"
/>
Delete:
<input name="person[address_attributes][0][_destroy]" type="hidden" value="0" />
<input
type="checkbox"
value="1"
name="person[address_attributes][0][_destroy]"
id="person_address_attributes_0__destroy"
/>
<input
type="hidden"
value="3"
name="person[address_attributes][0][id]"
id="person_address_attributes_0_id"
/>

<!-- 第二列 -->
Location :
<input
type="text"
value="10666"
name="person[address_attributes][1][location]"
id="person_address_attributes_1_location"
/>
Delete:
<input name="person[address_attributes][1][_destroy]" type="hidden" value="0" />
<input
type="checkbox"
value="1"
name="person[address_attributes][1][_destroy]"
id="person_address_attributes_1__destroy"
/>
<input
type="hidden"
value="4"
name="person[address_attributes][1][id]"
id="person_address_attributes_1_id"
/>
</div>

Controller

keywords: <instance>_attributes

Person.address 的資料到 Controller 後會自動變成 address_attributes

在 Controller 的地方要記得把允許的欄位(address_attributes: [:location, :id, :_destroy])加到 permit 中 。

如果你想要允許 address_attributes 中的所有參數,可以透過物件{ }的方式允許所有參數,使用 address_attributes: {}

如果在 View 中用來辨認群組的 [ ] 中帶入的是 uuid,因為在後端會把 uuid 當成物件的 key,所以 .permit()中需要透過 foobar_attributes: {} 當成物件來允許所有欄位內容。

class PeopleController < ApplicationController
before_action :set_person, only: [:show, :edit, :update, :destroy]

# ...

private
# Never trust parameters from the scary internet, only allow the white list through.
def person_params
# 允許所有參數(當 View 中使用 uuid 時):
# params.require(:person).permit(:name, address_attributes: {})
params.require(:person).permit(:name, address_attributes: [:location, :id, :_destroy])
end
end

針對 create 時的情況

你會發現,如果 Person.address 中沒有資料時,在表單中不會產生任何可以填寫 address 的欄位,這時候常用的方式是在 people_controller.rb 中加上:

# app/controllers/people_controller.rb

def new
@person = Person.new
2.times { @person.address.build } # @person 後的 Model (address) 可能需要用複數
end

避免空資料存到 Model 中

使用 reject_if 可以避面空的資料存到 Model 中:

  • reject_if: :all_blank
  • reject_if: lambda {|attributes| attributes['kind'].blank?}
class Person < ApplicationRecord
has_many :addresses
accepts_nested_attributes_for :addresses, reject_if: lambda {|attributes| attributes['kind'].blank?}
end

只需要關聯到的 id 而不需要內部的 attributes 時

這種情況通常用在 JOIN TABLE,在 JOIN TABLE 中只會存放兩個不同表中 id 是如何關聯起來的,而不會有其他的 attributes,這時候的做法可以參考:

View

<%# app/views/articles/_form.html.erb %>
<%# 直接關聯到 article.users %>
<%= form_with model: article, local: true, html: { class: 'needs-validation', novalidate: true } do |form| %>
<div class="form-group">
<%= form.label :status, class: 'd-block' %>
<%= form.collection_check_boxes(:user_ids, User.all, :id, :name) %>
</div>
<% end %>

Controller

記得要設定 params permit 的部分:

def article_params
params.fetch(:article, {}).permit(:title, user_ids: [])
end

Controller 的部分,則可以透過:

article.user_ids    # 取得和此 article 關聯的所有 user ids
article.users # 取得和此 article 關聯的所有 user (object)

article.user_ids = [1, 2] # 直接使用 user id 建立關聯

u = User.first
article.users = [u] # 透過 User Object 建立關聯

⚠️ 注意:xxx_ids 這是 Rails 的慣例。

最後產生出來的 HTML 結構會像這樣:

<div class="form-group">
<label class="d-block" for="article_status">Status</label>

<input type="hidden" name="article[user_ids][]" value="" />

<input type="checkbox" value="2" name="article[user_ids][]" id="article_user_ids_2" />
<label for="article_user_ids_2">Wei</label>

<input type="checkbox" value="1" name="article[user_ids][]" id="article_user_ids_1" />
<label for="article_user_ids_1">PJCHENder</label>
</div>

Model 地方的設定類似這樣:

# app/models/article.rb
class Article < ApplicationRecord
has_and_belongs_to_many :users
end
# app/models/user.rb
class Article < ApplicationRecord
has_and_belongs_to_many :articles
end

參考