4.3 模型中的关联关系(Relations)
概要:
本课时讲解 Rails 中 Model 和 Model 间的关联关系。
知识点:
- belongs_to
- has_one
- has_many
- has_and_belongs_to_many
- self join
正文
导读
如果你对一对一关系,一对多关系,多对多关系并不十分了解的话,或者你对关系型数据库并不十分了解的话,建议你在阅读下面的内容前,先熟悉一下相关内容。因为我并不想照本宣科的讲解手册。我想讲的,是对它的理解,并且把我们的精力,放到设计我们的商城中。
本章涉及的知识,可以查看 Active Record Associations,或者 ActiveRecord::ClassMethods。
接下来的内容,希望能帮助你理解模型间的关联关系。
4.3.1 模型间的关系
在前面的章节里,我们为商城设计了界面,并且使用了3个 model:
- User,网站用户,使用 devise 提供了用户注册,登录功能。
- Product,商品
- Variant,商品类型
我们在前面讲解的过程中,已经提到了 Product 和 Variant 的关系。一个 Product 有多个 Variant。现在我们需要增加几个模型,模型是根据功能来的,我们的网店要增加哪些功能呢?
- 当用户购买实物商品的时候,我们是要输入它的收货地址(Address)。
- 当用户选择商品的时候,选择不同的颜色和大小,会有不同的价格(Variant)。
- 我们点击购买,会创建一个购物订单(Order),上面有我们选择的商品,应支付的金额,和订单的状态。
- 查看用户购买的商品类型
在我们的网店里,一个 User 有一个地址,每次购物的时候,会读取这个地址作为送货地址。
一个 Product 有多个 Variant,每个 Variant 保存它的颜色,大小等属性。
一个用户会有多个订单 Order,每个订单会显示购买的商品 Product,以及多条购买记录,每条记录显示购买的 Variant 的每个数量和应付的价格,这里我们使用 LineItem 表示订单的订单项。
4.3.2 外键
两个 model 之间,通过外键进行关联,Rails 中默认的外键名称是所属 model 的 名称_id
,比如,User 有一条 Address 记录,那么 addresses 表上,需要增加一个数字类型的字段 user_id
。而 User 的主键通常为 id 字段。有一些遗留的数据库,使用的外键可能不是按照 Rails 默认的格式,所以在声明外键关联时,需要指定 foreign_key
。
在我们创建 Model 的时候,可以在 generate 命令上增加外键关联,我们现在创建 Address 这个 Model
rails g model address user:references state city address address2 zipcode receiver phone
在创建的 migration 文件中:
create_table :addresses do |t|
t.references :user, index: true, foreign_key: true
自动增加了外键关联,并且将 user_id 加入索引。如果是更改其他数据库,需要在 migration 文件内单独设置索引:
add_index "addresses", ["user_id"], name: "index_addresses_on_user_id"
模型间的关系,都是通过外键实现的,下面我们详细介绍模型间的关系,并且实现我们商城的 Model。
4.3.3 一对一关系
一对一关系的设定,再一次体现了 Rails 在开发中的便捷:
class User < ActiveRecord::Base
has_one :address
end
class Address < ActiveRecord::Base
belongs_to :user
end
在一对一关系中,belongs_to :user
中,:user
是单数,has_one :address
中,:address
也是单数。
我们进入到 console 里来测试一下:
user = User.first
user.address
=> nil
4.3.3.1 新建子资源
如何为 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( ... )
这种方法会产生两句 SQL,先是 insert 一个 address 到数据库,然后更新它的 user_id 为刚才的 user。我们可以换一个方法:
user.address = Address.new( ... )
它只产生一条 insert SQL,并且会带上 user_id 的值。
在创建关联关系时,还有这样的方法:
user.create_address( ... )
user.build_address( ... )
build_xxx 相当于 Address.new。create_xxx也会产生两条 SQL,每条 SQL 都包含在一个 transaction 中。
所以我们得出结论:
把一个未保存的实例,赋值给一对一关系时,它会自动保存,并且只有一条 sql 产生。
先 create 一个实例,再把赋值给一对一关系时,是先保存,再更新,产生两条 sql。
4.3.3.2 保存子资源
当我们编写表单的时候,一个表单针对的是一个资源。当这个资源拥有(has_one 或 has_many)子资源时,我们可以在提交表单的时候,将它拥有的资源也保存到数据库中。
这时,我们需要在 User中,做一个声明:
class User < ActiveRecord::Base
has_one :address
accepts_nested_attributes_for :address
end
accepts_nested_attributes_for
会为 User 增加一个新的方法 address_attributes=(attributes)
,这样,在创建 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 的参数,就可以把关联的 address 一并保存到数据库中了。
更新记录的时候,也可以使用同样的方法:
user_hash = { email: "changed@123.com", address_attributes: { receiver: "Other One" } }
user.update(user_hash)
但是,这里要注意,上面的方法会把之前旧记录的 user_id 设为 nil,然后插入一条新的记录。这并不能真正起到更新的作用,除非所有属性都重新复制,不然,新的 address 记录只有 receiver 这个值。
我们在 accepts_nested_attributes_for 后增加一个参数:
accepts_nested_attributes_for :address, update_only: true
这样,update 时候会更新已有的记录。
如果我们不能增加 update_only
属性,为了避免创建无用的记录,需要在 hash 里指定子资源的 id:
user_hash = { email: "changed@123.com", address_attributes: { id: 1, receiver: "Other One" } }
user.update(user_hash)
4.3.3.3 使用表单保存子资源
accepts_nested_attributes_for
方法,在 Form 中有其对应的方法:
<%= 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
的子表单,对应了子资源的属性。
我想,这段代码这并不难理解,不过我们用了 Devise 这个 gem,还需要做一点额外的处理。
打开 application_controller.rb,我们需要让 devise 支持传进来新增的参数:
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
在我们注册账号的时候,并没有创建 address ,但是在编辑的时候,因为它是 nil,所以不会显示这个子表单,所以我们需要在编辑的时候创建一个空的 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? %>
...
当然,我们也可以在注册的时候提供地址表单,大家不妨一试。
4.3.3.4 删除关联的子资源
在上一节里,我们介绍了 delete 和 destroy 方法,我们可以使用这两个方法把关联的 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
两者的区别在上一节介绍过,我们注意到,delete 直接发送数据库删除命令,而 destroy 会将删除命令放置到一个 sql 的事物中,因为它会触发模型中的回调,如果回调抛出异常,删除动作会失败。
4.3.3.5 删除自身同时删除关联的子资源
在删除某个资源的时候,我们想把它拥有的资源一并删除,这时,我们需要给 has_one 方法,增加一个参数:
has_one :address, dependent: :destroy
dependent 可以接收五个参数:
参数 | 含义 |
---|---|
:destroy | 删除拥有的资源 |
:delete | 直接发送删除命令,不会执行回调 |
:nullify | 将拥有的资源外键设为 null |
:restrict_with_exception | 如果拥有资源,会抛出异常,也就是说,当它 has_one 为 nil 的时候,才能正常删除它自己 |
:restrict_with_error | 如有拥有资源,会增加一个 errors 信息。 |
在 belongs_to 上,也可以设置 dependent,但它只有两个参数:
参数 | 含义 |
---|---|
:destroy | 删除它所属的资源 |
:delete | 删除它所属的资源,直接发送删除命令,不会执行回调 |
两种设定,出发角度是不同的,不过,删除本身的同时删除上层资源是比较危险的,需谨慎。
4.3.3.6 失去关联关系的子资源
如果在 has_one 中设置了 dependent: :destroy
或 dependent: :delete
,当子资源失去该关联关系时,它也会被删除。
user.address = nil
如果不设置,一个子资源失去关系时,外键设置为 null。
4.3.3.7 子资源维护
当一个子资源失去关联关系,和它在关联关系中被删除,是一样的。我们在设计时,应尽量避免产生孤立的记录,这些记录外键为 null,或者所属的资源已经被删除,他们是无意义的存在。
4.3.4 一对多关系
在电商系统里,一个用户是有多个订单(Order)的,User 中使用的是 has_many 方法:
class User < ActiveRecord::Base
has_many :orders
end
除了名称变为复数形式,返回的结果是数组,其他情形和“一对一”是一样的。
我们使用 generate 创建 Order:
rails g model order user:references number payment_state shipment_state
number 是订单的唯一编号,payment_state 是付款状态,shipment_state 是发货状态。
payment_state 的状态顺序是:pending(等待支付),paid(已支付)。
shipment_state 的状态顺序是:pending(等待发货),shipped(已发货)。
这两种状态,我们只做简单的设计,实际中要复杂得多。
开源电商程序 spree 是一套很好的在线交易程序,因为其开源,其中的概念和定义对开发电商程序有很好的启发。它的源代码在 这里,目前是最新版本是 3.0.2.beta。
4.3.4.1 添加子资源
一对多关系返回的,是 CollectionProxy 实例。
当添加一对多关系时,可以很“形象”的使用:
product.variants << Variant.new
product.variants << [Variant.new, Variant.new]
执行 <<
的时候,variant 的 product_id 会自动保存为 product.id。
如果 variant 是一个未保存到数据库的实例,<< 执行的时候会自动将它保存,并且赋予它 product_id 值。这是一步完成的,只有一条 SQL。
但是,如果是下面的情形:
product.variants << Variant.create
会把 variant 先保存到数据库,然后再更新它的 product_id 字段,这会产生两条 SQL。
这里也可以使用 build 方法,和上面“一对一关系”不同的是,它需要在 collection 上执行:
variant = product.variants.build( ... )
variant.save
build 返回的是一个未保存的实例。查看 product.variants
,会看到它包含了一个未保存的 variant(ID 为 nil)。
另一种情形:
product.variants.build( ... )
product.save
当这个 product.save 的时候,这个 variant 也会保存到数据库中。
4.3.4.2 删除子资源
删除资源的时候,可以使用几个方法:
product.variants.delete(...)
product.variants.destroy(...)
product.variants.clear
delete 不会真正删除掉资源,而是把它的外键(product_id)设为 nil,而 destroy 会真正的删除掉它并出发回调。
他们都可以传递进一个实例,或者实例的集合,而并不管这个实例是否真的属于它。
product.variants.delete(Variant.find(1))
product.variants.delete(Variant.find(1,2,3))
这样是不是太霸道了?所以,建议用第三个方法更稳妥些。clear 方法会把外键置为 nil。
如果再 has_many 上声明了 dependent: :destroy
,会用 destroy 方式把它们删除(有回调)。如果声明的是 dependent: :delete_all
,会用 delete 方法(跳过回调)。这和一对一中描述是一致的。
注意:
has_many 和 has_one 上的 dependent 选项,适用以下两种情形:
- 删除自身时,如何处理子资源
- 当子资源失去该关联关系时,如何处理该子资源
我们来看下一节。
4.3.4.3 更改子资源
当改动关系的时候,可以直接使用 =
,假设我们有 ID 为 1,2,3,4 的 Variant:
product.variants = Variant.find(1,2)
这时会自动把 ID:1,ID:2 的 product_id 外键设为 null。
再次选择 ID:3,ID:4 的 variant:
product.variants = Variant.find(3,4)
会自动把 ID:3,ID:4 的 product_id 外键设置为 product.id。
如果在 has_many 设置了 dependent: :destroy
,当 UD:1 和 ID:2 失去关联的时候,会把它们从数据库中删除掉。这与 has_one 中的 dependent 选项是一样的。详见本章前面 4.3.3.4 删除自身同时删除关联的子资源
。
4.3.4.4 counter_cache
“一对多”关系中,belongs_to
方法可以增加 counter_cache 属性:
class Order < ActiveRecord::Base
belongs_to :user, counter_cache: true
end
这时,我们需要给 users 表增加一个字段:orders_count,当我们把一个 order 保存到一对多的关系中时,orders_count 会自动 +1,当把一个资源从关系中删除,该字段会 -1。如此我们不必去增加计算一个 user 有多少个 orders,只需要读该字段就可以了。
向 Users 表添加 orders_count 字段:
rails g migration add_orders_count_to_users orders_count:integer
4.3.4.5 多态
当一个资源可能属于多种资源时,可以用到多态。举个栗子:
商品可以评论,文章可以评论,而评论 model 对任何一个资源都是一样的功能,所以,评论在 belongs_to 的后面,增加:
class Comment < ActiveRecord::Base
belongs_to :commentable, polymorphic: true
end
Comment 的迁移文件,也相应的增加设定:
t.references :commentable, polymorphic: true, index: true
如果是手动添加字段,需要这样来写:
t.string :commentable_type
t.integer :commentable_id
说明,查找一个多态资源时,是根据拥有者的类型(type,一般是它的类名称)和 ID 进行匹配的。
拥有评论的 model,也需要改动下:
class Product < ActiveRecord::Base
has_many :commentable, as: :commentable
end
class Topic < ActiveRecord::Base
has_many :commentable, as: :commentable
end
多态并不局限于一对多关系,一对一也同样适用。
4.3.5 中间模型和中间表
has_one 和 has_many,是两个 model 间的操作。我们可以增加一个中间模型,描述之前两个 model间的关系。
4.3.5.1 中间模型
我们先创建订单项(LineItem)这个 model,它属于一个订单,也属于一个商品类型(Variant)。
rails g model line_item order:references variant:references quantity:integer
对于一个订单,我们有多个订单项,对于一个订单项,会关联购买的具体商品类型,那么,一个订单拥有的商品类型,就可以通过 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 增加关联:
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 字段,因为这不符合我们的业务逻辑,所以无法查找 line_item.user。如果需要查找,可以给 line_item 上增加 user_id 字段。
class LineItem < ActiveRecord::Base
belongs_to :order
belongs_to :variant
belongs_to :user
end
4.3.5.2 中间表
中间模型的作用,除了连接两端模型外,更重要的是,它保存了业务中属于中间模型的数据,比如,订单项中的 quantity 字段。如果模型不必或者没有这种字段,可以不用增加 model,而直接使用中间表。
我们有一个功能:保存用户购买的商品类型。这时可以使用中间表,保存购买关系。
中间表具有两端模型的外键。两端模型使用 has_and_belongs_to_many
方法(简写:HABTM)。
在创建中间表的时候,也可以使用 migration,如果在表名中包含 JoinTable
字样,会自动创建中间表:
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
调整一下 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]]
利用中间表,实现了多对多关系。
4.3.5.3 多对多关系
查看一个用户购买了哪些商品类型,和查看一个商品类型被哪些用户购买,这就是多对多关系。
保存和删除多对多关系,和一对多关系的操作是一样的。因为我们在创建 migration 时,增加了索引唯一校验,在操作时要做好异常处理,或者保存前进行判断。
user.variants << variant
user.variants << variant
=> SQLite3::ConstraintException: columns variant_id, user_id are not unique: ...
4.3.5.4 inner join
ActiveRecord 在查询关联关系时,使用的是 inner join 查询,我们可以单独使用 join
方法,实现该查询。
比如,一个简单的 join 查询:
% Order.joins(:line_items)
=> SELECT "orders".* FROM "orders" INNER JOIN "line_items" ON "line_items"."order_id" = "orders"."id"
也可以查询多个关联的:
% 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"
或者嵌套关联:
% 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"
但是,在一些更复杂的查询中,我们需要改变 inner join
查询为 left join
或 right join
:
User.select("users.*, orders.*").joins("LEFT JOIN `orders` ON orders.user_id = users.id")
这时返回的是全部用户,即便它没有订单。这在生成一些报表时是有用的。
4.3.6 自连接
在设计模型的时候,一个模型即可以是 Catalog(类别),也可以是 Subcatalog(子类别),我们为网店添加 类别
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
这样,我们可以实现分类,也可以吧商品加入到某个分类中。
4.3.7 双向关联
我们查找关联关系的时候,是可以在两边同时查找,比如:
class User < ActiveRecord::Base
has_one :address
end
class Address < ActiveRecord::Base
belongs_to :user
end
我们可以 user.address
,也可以 address.user
,这叫做 Bi-directional,双向关联。(和它相反,Uni-directional,单向关联)
但是,这在我们的内存查找中,会引起问题:
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
两个类并不是在内存中指向同一个地址,他们是不同的两个类。
为了避免这个问题,我们需要使用 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 的关联关系上,已经有 polymorphic,through,as 时,可以不用加 inverse_of,它自然会指向同一个 object,大家可以使用 user 和 order 之间的关联验证。对于 user 和 address 之间,还是应该加上 inverse_of 选项。
4.3.8 Rspec测试
关联关系的测试,可以使用 shoulda-matchers 这个 gem。它为 Rails 的模型间关联提供了方便的测试方法。
比如:
RSpec.describe User, type: :model do
it { should have_many(:orders) }
end
RSpec.describe Order, type: :model do
it { should belong_to(:user) }
end
更多模型间关联关系测试的方法,可以查看 ActiveRecord matchers