李玮 · 更新于 2018-11-28 11:00:42

4.3 模型中的关联关系(Relations)

概要:

本课时讲解 Rails 中 Model 和 Model 间的关联关系。

知识点:

  1. belongs_to
  2. has_one
  3. has_many
  4. has_and_belongs_to_many
  5. self join

正文

导读

如果你对一对一关系,一对多关系,多对多关系并不十分了解的话,或者你对关系型数据库并不十分了解的话,建议你在阅读下面的内容前,先熟悉一下相关内容。因为我并不想照本宣科的讲解手册。我想讲的,是对它的理解,并且把我们的精力,放到设计我们的商城中。

本章涉及的知识,可以查看 Active Record Associations,或者 ActiveRecord::Associations::ClassMethods

接下来的内容,希望能帮助你理解模型间的关联关系。

4.3.1 模型间的关系

在前面的章节里,我们为商城设计了界面,并且使用了3个 model:

  1. User,网站用户,使用 devise 提供了用户注册,登录功能。
  2. Product,商品
  3. 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: :destroydependent: :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 joinright 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

上一篇: 深入模型查询 下一篇: 模型中的校验