本課時講解 Rails 中 Model 和 Model 間的關(guān)聯(lián)關(guān)系。
如果你對一對一關(guān)系,一對多關(guān)系,多對多關(guān)系并不十分了解的話,或者你對關(guān)系型數(shù)據(jù)庫并不十分了解的話,建議你在閱讀下面的內(nèi)容前,先熟悉一下相關(guān)內(nèi)容。因為我并不想照本宣科的講解手冊。我想講的,是對它的理解,并且把我們的精力,放到設(shè)計我們的商城中。
本章涉及的知識,可以查看 Active Record Associations,或者 ActiveRecord::Associations::ClassMethods。
接下來的內(nèi)容,希望能幫助你理解模型間的關(guān)聯(lián)關(guān)系。
在前面的章節(jié)里,我們?yōu)樯坛窃O(shè)計了界面,并且使用了3個 model:
我們在前面講解的過程中,已經(jīng)提到了 Product 和 Variant 的關(guān)系。一個 Product 有多個 Variant?,F(xiàn)在我們需要增加幾個模型,模型是根據(jù)功能來的,我們的網(wǎng)店要增加哪些功能呢?
在我們的網(wǎng)店里,一個 User 有一個地址,每次購物的時候,會讀取這個地址作為送貨地址。
一個 Product 有多個 Variant,每個 Variant 保存它的顏色,大小等屬性。
一個用戶會有多個訂單 Order,每個訂單會顯示購買的商品 Product,以及多條購買記錄,每條記錄顯示購買的 Variant 的每個數(shù)量和應(yīng)付的價格,這里我們使用 LineItem 表示訂單的訂單項。
兩個 model 之間,通過外鍵進行關(guān)聯(lián),Rails 中默認的外鍵名稱是所屬 model 的 名稱_id,比如,User 有一條 Address 記錄,那么 addresses 表上,需要增加一個數(shù)字類型的字段 user_id。而 User 的主鍵通常為 id 字段。有一些遺留的數(shù)據(jù)庫,使用的外鍵可能不是按照 Rails 默認的格式,所以在聲明外鍵關(guān)聯(lián)時,需要指定 foreign_key。
在我們創(chuàng)建 Model 的時候,可以在 generate 命令上增加外鍵關(guān)聯(lián),我們現(xiàn)在創(chuàng)建 Address 這個 Model
rails g model address user:references state city address address2 zipcode receiver phone
在創(chuàng)建的 migration 文件中:
create_table :addresses do |t|
t.references :user, index: true, foreign_key: true
自動增加了外鍵關(guān)聯(lián),并且將 user_id 加入索引。如果是更改其他數(shù)據(jù)庫,需要在 migration 文件內(nèi)單獨設(shè)置索引:
add_index "addresses", ["user_id"], name: "index_addresses_on_user_id"
模型間的關(guān)系,都是通過外鍵實現(xiàn)的,下面我們詳細介紹模型間的關(guān)系,并且實現(xiàn)我們商城的 Model。
一對一關(guān)系的設(shè)定,再一次體現(xiàn)了 Rails 在開發(fā)中的便捷:
class User < ActiveRecord::Base
has_one :address
end
class Address < ActiveRecord::Base
belongs_to :user
end
在一對一關(guān)系中,belongs_to :user 中,:user 是單數(shù),has_one :address 中,:address 也是單數(shù)。
我們進入到 console 里來測試一下:
user = User.first
user.address
=> nil
如何為 user 保存 address 呢?
一種是使用 Address 的類方法 create:
Address.create(user_id: user.id, ...)
我們也可以省去 id 的寫法,直接寫上所屬的實例:
Address.create(user: user, ...)
一種是使用實例方法:
address = Address.new
address.user = user
address.save
或者:
user.address = Address.create( ... )
這種方法會產(chǎn)生兩句 SQL,先是 insert 一個 address 到數(shù)據(jù)庫,然后更新它的 user_id 為剛才的 user。我們可以換一個方法:
user.address = Address.new( ... )
它只產(chǎn)生一條 insert SQL,并且會帶上 user_id 的值。
在創(chuàng)建關(guān)聯(lián)關(guān)系時,還有這樣的方法:
user.create_address( ... )
user.build_address( ... )
build_xxx 相當于 Address.new。create_xxx也會產(chǎn)生兩條 SQL,每條 SQL 都包含在一個 transaction 中。
所以我們得出結(jié)論:
把一個未保存的實例,賦值給一對一關(guān)系時,它會自動保存,并且只有一條 sql 產(chǎn)生。
先 create 一個實例,再把賦值給一對一關(guān)系時,是先保存,再更新,產(chǎn)生兩條 sql。
當我們編寫表單的時候,一個表單針對的是一個資源。當這個資源擁有(has_one 或 has_many)子資源時,我們可以在提交表單的時候,將它擁有的資源也保存到數(shù)據(jù)庫中。
這時,我們需要在 User中,做一個聲明:
class User < ActiveRecord::Base
has_one :address
accepts_nested_attributes_for :address
end
accepts_nested_attributes_for 會為 User 增加一個新的方法 address_attributes=(attributes),這樣,在創(chuàng)建 User 的 時候:
user_hash = { email: "test@123.com", password: "123456", password_confirmation: "123456", address_attributes: { receiver: "Some One", state: "Beijing", city: "Beijing", phone: "123456"} }
u = User.create(user_hash)
u.address
只要保存 User 的時候,傳遞入 Address 的參數(shù),就可以把關(guān)聯(lián)的 address 一并保存到數(shù)據(jù)庫中了。
更新記錄的時候,也可以使用同樣的方法:
user_hash = { email: "changed@123.com", address_attributes: { receiver: "Other One" } }
user.update(user_hash)
但是,這里要注意,上面的方法會把之前舊記錄的 user_id 設(shè)為 nil,然后插入一條新的記錄。這并不能真正起到更新的作用,除非所有屬性都重新復(fù)制,不然,新的 address 記錄只有 receiver 這個值。
我們在 accepts_nested_attributes_for 后增加一個參數(shù):
accepts_nested_attributes_for :address, update_only: true
這樣,update 時候會更新已有的記錄。
如果我們不能增加 update_only 屬性,為了避免創(chuàng)建無用的記錄,需要在 hash 里指定子資源的 id:
user_hash = { email: "changed@123.com", address_attributes: { id: 1, receiver: "Other One" } }
user.update(user_hash)
accepts_nested_attributes_for 方法,在 Form 中有其對應(yīng)的方法:
<%= f.fields_for :address do |address_form| %>
<%= address_form.hidden_field :id unless resource.new_record? %>
<div class="form-group">
<%= address_form.label :state, class: "control-label" %><br />
<%= address_form.text_field :state, class: "form-control" %>
</div>
...
<% end %>
打開 代碼,在編輯一個用戶的時候,我為它增加了一個 f.fields_for 的子表單,對應(yīng)了子資源的屬性。
我想,這段代碼這并不難理解,不過我們用了 Devise 這個 gem,還需要做一點額外的處理。
打開 application_controller.rb,我們需要讓 devise 支持傳進來新增的參數(shù):
class ApplicationController < ActionController::Base
before_action :configure_permitted_parameters, if: :devise_controller?
protected
def configure_permitted_parameters
devise_parameter_sanitizer.for(:sign_up) { |u| u.permit(:email, :password, :password_confirmation, :address_attributes) }
devise_parameter_sanitizer.for(:account_update) { |u| u.permit(:email, :password, :password_confirmation, :current_password, address_attributes: [:state, :city, :address, :address2, :zipcode, :receiver, :phone] ) }
end
end
在我們注冊賬號的時候,并沒有創(chuàng)建 address ,但是在編輯的時候,因為它是 nil,所以不會顯示這個子表單,所以我們需要在編輯的時候創(chuàng)建一個空的 address:
views/devise/registrations/edit.html.erb
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| %>
<% resource.build_address if resource.address.nil? %>
...
當然,我們也可以在注冊的時候提供地址表單,大家不妨一試。
在上一節(jié)里,我們介紹了 delete 和 destroy 方法,我們可以使用這兩個方法把關(guān)聯(lián)的 address 刪除掉:
u.address.delete
SQL (10.0ms) DELETE FROM "addresses" WHERE "addresses"."id" = ? [["id", 2]]
或者:
u.address.destroy
(0.1ms) begin transaction
SQL (0.7ms) DELETE FROM "addresses" WHERE "addresses"."id" = ? [["id", 3]]
(9.2ms) commit transaction
兩者的區(qū)別在上一節(jié)介紹過,我們注意到,delete 直接發(fā)送數(shù)據(jù)庫刪除命令,而 destroy 會將刪除命令放置到一個 sql 的事物中,因為它會觸發(fā)模型中的回調(diào),如果回調(diào)拋出異常,刪除動作會失敗。
在刪除某個資源的時候,我們想把它擁有的資源一并刪除,這時,我們需要給 has_one 方法,增加一個參數(shù):
has_one :address, dependent: :destroy
dependent 可以接收五個參數(shù):
| 參數(shù) | 含義 |
|---|---|
| :destroy | 刪除擁有的資源 |
| :delete | 直接發(fā)送刪除命令,不會執(zhí)行回調(diào) |
| :nullify | 將擁有的資源外鍵設(shè)為 null |
| :restrict_with_exception | 如果擁有資源,會拋出異常,也就是說,當它 has_one 為 nil 的時候,才能正常刪除它自己 |
| :restrict_with_error | 如有擁有資源,會增加一個 errors 信息。 |
在 belongs_to 上,也可以設(shè)置 dependent,但它只有兩個參數(shù):
| 參數(shù) | 含義 |
|---|---|
| :destroy | 刪除它所屬的資源 |
| :delete | 刪除它所屬的資源,直接發(fā)送刪除命令,不會執(zhí)行回調(diào) |
兩種設(shè)定,出發(fā)角度是不同的,不過,刪除本身的同時刪除上層資源是比較危險的,需謹慎。
如果在 has_one 中設(shè)置了 dependent: :destroy 或 dependent: :delete,當子資源失去該關(guān)聯(lián)關(guān)系時,它也會被刪除。
user.address = nil
如果不設(shè)置,一個子資源失去關(guān)系時,外鍵設(shè)置為 null。
當一個子資源失去關(guān)聯(lián)關(guān)系,和它在關(guān)聯(lián)關(guān)系中被刪除,是一樣的。我們在設(shè)計時,應(yīng)盡量避免產(chǎn)生孤立的記錄,這些記錄外鍵為 null,或者所屬的資源已經(jīng)被刪除,他們是無意義的存在。
在電商系統(tǒng)里,一個用戶是有多個訂單(Order)的,User 中使用的是 has_many 方法:
class User < ActiveRecord::Base
has_many :orders
end
除了名稱變?yōu)閺?fù)數(shù)形式,返回的結(jié)果是數(shù)組,其他情形和“一對一”是一樣的。
我們使用 generate 創(chuàng)建 Order:
rails g model order user:references number payment_state shipment_state
number 是訂單的唯一編號,payment_state 是付款狀態(tài),shipment_state 是發(fā)貨狀態(tài)。
payment_state 的狀態(tài)順序是:pending(等待支付),paid(已支付)。
shipment_state 的狀態(tài)順序是:pending(等待發(fā)貨),shipped(已發(fā)貨)。
這兩種狀態(tài),我們只做簡單的設(shè)計,實際中要復(fù)雜得多。
開源電商程序 spree 是一套很好的在線交易程序,因為其開源,其中的概念和定義對開發(fā)電商程序有很好的啟發(fā)。它的源代碼在 這里,目前是最新版本是 3.0.2.beta。
一對多關(guān)系返回的,是 CollectionProxy 實例。
當添加一對多關(guān)系時,可以很“形象”的使用:
product.variants << Variant.new
product.variants << [Variant.new, Variant.new]
執(zhí)行 << 的時候,variant 的 product_id 會自動保存為 product.id。
如果 variant 是一個未保存到數(shù)據(jù)庫的實例,<< 執(zhí)行的時候會自動將它保存,并且賦予它 product_id 值。這是一步完成的,只有一條 SQL。
但是,如果是下面的情形:
product.variants << Variant.create
會把 variant 先保存到數(shù)據(jù)庫,然后再更新它的 product_id 字段,這會產(chǎn)生兩條 SQL。
這里也可以使用 build 方法,和上面“一對一關(guān)系”不同的是,它需要在 collection 上執(zhí)行:
variant = product.variants.build( ... )
variant.save
build 返回的是一個未保存的實例。查看 product.variants,會看到它包含了一個未保存的 variant(ID 為 nil)。
另一種情形:
product.variants.build( ... )
product.save
當這個 product.save 的時候,這個 variant 也會保存到數(shù)據(jù)庫中。
刪除資源的時候,可以使用幾個方法:
product.variants.delete(...)
product.variants.destroy(...)
product.variants.clear
delete 不會真正刪除掉資源,而是把它的外鍵(product_id)設(shè)為 nil,而 destroy 會真正的刪除掉它并出發(fā)回調(diào)。
他們都可以傳遞進一個實例,或者實例的集合,而并不管這個實例是否真的屬于它。
product.variants.delete(Variant.find(1))
product.variants.delete(Variant.find(1,2,3))
這樣是不是太霸道了?所以,建議用第三個方法更穩(wěn)妥些。clear 方法會把外鍵置為 nil。
如果再 has_many 上聲明了 dependent: :destroy,會用 destroy 方式把它們刪除(有回調(diào))。如果聲明的是 dependent: :delete_all,會用 delete 方法(跳過回調(diào))。這和一對一中描述是一致的。
注意:
has_many 和 has_one 上的 dependent 選項,適用以下兩種情形:
我們來看下一節(jié)。
當改動關(guān)系的時候,可以直接使用 =,假設(shè)我們有 ID 為 1,2,3,4 的 Variant:
product.variants = Variant.find(1,2)
這時會自動把 ID:1,ID:2 的 product_id 外鍵設(shè)為 null。
再次選擇 ID:3,ID:4 的 variant:
product.variants = Variant.find(3,4)
會自動把 ID:3,ID:4 的 product_id 外鍵設(shè)置為 product.id。
如果在 has_many 設(shè)置了 dependent: :destroy,當 UD:1 和 ID:2 失去關(guān)聯(lián)的時候,會把它們從數(shù)據(jù)庫中刪除掉。這與 has_one 中的 dependent 選項是一樣的。詳見本章前面 4.3.3.4 刪除自身同時刪除關(guān)聯(lián)的子資源。
“一對多”關(guān)系中,belongs_to 方法可以增加 counter_cache 屬性:
class Order < ActiveRecord::Base
belongs_to :user, counter_cache: true
end
這時,我們需要給 users 表增加一個字段:orders_count,當我們把一個 order 保存到一對多的關(guān)系中時,orders_count 會自動 +1,當把一個資源從關(guān)系中刪除,該字段會 -1。如此我們不必去增加計算一個 user 有多少個 orders,只需要讀該字段就可以了。
向 Users 表添加 orders_count 字段:
rails g migration add_orders_count_to_users orders_count:integer
當一個資源可能屬于多種資源時,可以用到多態(tài)。舉個栗子:
商品可以評論,文章可以評論,而評論 model 對任何一個資源都是一樣的功能,所以,評論在 belongs_to 的后面,增加:
class Comment < ActiveRecord::Base
belongs_to :commentable, polymorphic: true
end
Comment 的遷移文件,也相應(yīng)的增加設(shè)定:
t.references :commentable, polymorphic: true, index: true
如果是手動添加字段,需要這樣來寫:
t.string :commentable_type
t.integer :commentable_id
說明,查找一個多態(tài)資源時,是根據(jù)擁有者的類型(type,一般是它的類名稱)和 ID 進行匹配的。
擁有評論的 model,也需要改動下:
class Product < ActiveRecord::Base
has_many :commentable, as: :commentable
end
class Topic < ActiveRecord::Base
has_many :commentable, as: :commentable
end
多態(tài)并不局限于一對多關(guān)系,一對一也同樣適用。
has_one 和 has_many,是兩個 model 間的操作。我們可以增加一個中間模型,描述之前兩個 model間的關(guān)系。
我們先創(chuàng)建訂單項(LineItem)這個 model,它屬于一個訂單,也屬于一個商品類型(Variant)。
rails g model line_item order:references variant:references quantity:integer
對于一個訂單,我們有多個訂單項,對于一個訂單項,會關(guān)聯(lián)購買的具體商品類型,那么,一個訂單擁有的商品類型,就可以通過 through 查找到。
class Order < ActiveRecord::Base
belongs_to :user, counter_cache: true
has_many :line_items
has_many :variants, through: :line_items
end
class LineItem < ActiveRecord::Base
belongs_to :order
belongs_to :variant
end
我們進到終端里進行查找:
order = Order.first
order.variants
=> SELECT "variants".* FROM "variants" INNER JOIN "line_items" ON "variants"."id" = "line_items"."variant_id" WHERE "line_items"."order_id" = ? [["order_id", 1]]
=> #<ActiveRecord::Associations::CollectionProxy []>
可以看到,through 為使用了 inner join 的 sql 語法。
LineItem 是兩個模型,Order 和 Variant 的中間模型,它表示訂單中的每一項。但是,中間模型不一定要使用兩個 belongs_to 連接兩邊的模型,比如:
class User < ActiveRecord::Base
has_many :orders
has_many :line_items, through: :orders
end
進到終端,我們查看一個用戶有哪些訂單項:
user = User.first
user.line_items
=> SELECT "line_items".* FROM "line_items" INNER JOIN "orders" ON "line_items"."order_id" = "orders"."id" WHERE "orders"."user_id" = ? [["user_id", 1]]
從左邊可以查到右邊資源,那么,可以通過中間表,從右邊查找左邊資源么?
我們給 Variant 增加關(guān)聯(lián):
class Variant < ActiveRecord::Base
belongs_to :product
has_many :line_item
has_many :orders, through: :line_item
end
進入終端:
v = Variant.last
v.orders
=> SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "orders"."id" = "line_items"."order_id" WHERE "line_items"."variant_id" = ? [["variant_id", 2]]
因為中間表 LineItem 擁有兩邊的外鍵,所以可以查找 variant 的 orders。但是 orders 上沒有 line_item_id 字段,因為這不符合我們的業(yè)務(wù)邏輯,所以無法查找 line_item.user。如果需要查找,可以給 line_item 上增加 user_id 字段。
class LineItem < ActiveRecord::Base
belongs_to :order
belongs_to :variant
belongs_to :user
end
中間模型的作用,除了連接兩端模型外,更重要的是,它保存了業(yè)務(wù)中屬于中間模型的數(shù)據(jù),比如,訂單項中的 quantity 字段。如果模型不必或者沒有這種字段,可以不用增加 model,而直接使用中間表。
我們有一個功能:保存用戶購買的商品類型。這時可以使用中間表,保存購買關(guān)系。
中間表具有兩端模型的外鍵。兩端模型使用 has_and_belongs_to_many 方法(簡寫:HABTM)。
在創(chuàng)建中間表的時候,也可以使用 migration,如果在表名中包含 JoinTable 字樣,會自動創(chuàng)建中間表:
rails g migration CreateJoinTable users variants:uniq
運行 rake db:migrate,查看 schema.rb:
create_table "users_variants", id: false, force: :cascade do |t|
t.integer "user_id", null: false
t.integer "variant_id", null: false
end
add_index "users_variants", ["variant_id", "user_id"], name: "index_users_variants_on_variant_id_and_user_id", unique: true
調(diào)整一下 User 和 Variant model:
class User < ActiveRecord::Base
...
has_and_belongs_to_many :variants
end
class Variant < ActiveRecord::Base
...
has_and_belongs_to_many :users
end
在終端里測試:
user.variants
=> SELECT "variants".* FROM "variants" INNER JOIN "users_variants" ON "variants"."id" = "users_variants"."variant_id" WHERE "users_variants"."user_id" = ? [["user_id", 1]]
variant.users
=> SELECT "users".* FROM "users" INNER JOIN "users_variants" ON "users"."id" = "users_variants"."user_id" WHERE "users_variants"."variant_id" = ? [["variant_id", 2]]
利用中間表,實現(xiàn)了多對多關(guān)系。
查看一個用戶購買了哪些商品類型,和查看一個商品類型被哪些用戶購買,這就是多對多關(guān)系。
保存和刪除多對多關(guān)系,和一對多關(guān)系的操作是一樣的。因為我們在創(chuàng)建 migration 時,增加了索引唯一校驗,在操作時要做好異常處理,或者保存前進行判斷。
user.variants << variant
user.variants << variant
=> SQLite3::ConstraintException: columns variant_id, user_id are not unique: ...
ActiveRecord 在查詢關(guān)聯(lián)關(guān)系時,使用的是 inner join 查詢,我們可以單獨使用 join 方法,實現(xiàn)該查詢。
比如,一個簡單的 join 查詢:
% Order.joins(:line_items)
=> SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
也可以查詢多個關(guān)聯(lián)的:
% Order.joins(:line_items, :user)
=> SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id" INNER JOIN "users" ON "users"."id" = "orders"."user_id"
或者嵌套關(guān)聯(lián):
% Order.joins(line_items: [:variant])
=> SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id" INNER JOIN "variants" ON "variants"."id" = "line_items"."variant_id"
但是,在一些更復(fù)雜的查詢中,我們需要改變 inner join 查詢?yōu)?left join 或 right join:
User.select("users.*, orders.*").joins("LEFT JOIN `orders` ON orders.user_id = users.id")
這時返回的是全部用戶,即便它沒有訂單。這在生成一些報表時是有用的。
在設(shè)計模型的時候,一個模型即可以是 Catalog(類別),也可以是 Subcatalog(子類別),我們?yōu)榫W(wǎng)店添加 類別 Model:
rails g model catalog parent_catalog:references name parent:boolean
看一下 catalog.rb:
class Catalog < ActiveRecord::Base
has_many :subcatalogs, class_name: "Catalog", foreign_key: "parent_catalog_id"
belongs_to :parent_catalog, class_name: "Catalog"
has_many :products
end
這樣,我們可以實現(xiàn)分類,也可以吧商品加入到某個分類中。
我們查找關(guān)聯(lián)關(guān)系的時候,是可以在兩邊同時查找,比如:
class User < ActiveRecord::Base
has_one :address
end
class Address < ActiveRecord::Base
belongs_to :user
end
我們可以 user.address,也可以 address.user,這叫做 Bi-directional,雙向關(guān)聯(lián)。(和它相反,Uni-directional,單向關(guān)聯(lián))
但是,這在我們的內(nèi)存查找中,會引起問題:
u = User.first
a = u.address
u.email == a.user.email
=> true
u.email = "a@1.com"
u.email == a.user.email
=> false
原因是:
u.object_id
=> 70241969456560
a.user.object_id
=> 70241969637580
兩個類并不是在內(nèi)存中指向同一個地址,他們是不同的兩個類。
為了避免這個問題,我們需要使用 inverse_of:
class User < ActiveRecord::Base
has_one :address, inverse_of: :user
end
class Address < ActiveRecord::Base
belongs_to :user, inverse_of: :address
end
當 model 的關(guān)聯(lián)關(guān)系上,已經(jīng)有 polymorphic,through,as 時,可以不用加 inverse_of,它自然會指向同一個 object,大家可以使用 user 和 order 之間的關(guān)聯(lián)驗證。對于 user 和 address 之間,還是應(yīng)該加上 inverse_of 選項。
關(guān)聯(lián)關(guān)系的測試,可以使用 shoulda-matchers 這個 gem。它為 Rails 的模型間關(guān)聯(lián)提供了方便的測試方法。
比如:
RSpec.describe User, type: :model do
it { should have_many(:orders) }
end
RSpec.describe Order, type: :model do
it { should belong_to(:user) }
end
更多模型間關(guān)聯(lián)關(guān)系測試的方法,可以查看 ActiveRecord matchers