企业🤖AI智能体构建引擎,智能编排和调试,一键部署,支持私有化部署方案 广告
# 4.3 模型中的关联关系(Relations) ## 概要: 本课时讲解 Rails 中 Model 和 Model 间的关联关系。 ## 知识点: 1. belongs_to 1. has_one 1. has_many 1. has_and_belongs_to_many 1. self join ## 正文 ### 导读 如果你对一对一关系,一对多关系,多对多关系并不十分了解的话,或者你对关系型数据库并不十分了解的话,建议你在阅读下面的内容前,先熟悉一下相关内容。因为我并不想照本宣科的讲解手册。我想讲的,是对它的理解,并且把我们的精力,放到设计我们的商城中。 本章涉及的知识,可以查看 [Active Record Associations](http://guides.rubyonrails.org/association_basics.html),或者 [ActiveRecord::Associations::ClassMethods](http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html)。 接下来的内容,希望能帮助你理解模型间的关联关系。 ### 4.3.1 模型间的关系 在前面的章节里,我们为商城设计了界面,并且使用了3个 model: 1. User,网站用户,使用 devise 提供了用户注册,登录功能。 1. Product,商品 1. 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 %> ~~~ 打开 [代码](https://github.com/liwei78/rails-practice-code/blob/master/chapter_4/shop/app/views/devise/registrations/edit.html.erb#L32),在编辑一个用户的时候,我为它增加了一个 `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](https://spreecommerce.com/) 是一套很好的在线交易程序,因为其开源,其中的概念和定义对开发电商程序有很好的启发。它的源代码在 [这里](https://github.com/spree/spree),目前是最新版本是 3.0.2.beta。 #### 4.3.4.1 添加子资源 一对多关系返回的,是 [CollectionProxy](http://api.rubyonrails.org/classes/ActiveRecord/Associations/CollectionProxy.html) 实例。 当添加一对多关系时,可以很“形象”的使用: ~~~ 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](https://github.com/thoughtbot/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](https://github.com/thoughtbot/shoulda-matchers#activerecord-matchers)