Thinkphp 第八章:模型关联

模型的关联操作是模型的最为强大,也是最为复杂的部分,通过模型关联操作把数据表的关联关系对象化,解决了大部分常用的关联场景,封装的关联操作比起常规的数据库联表操作更加智能和高效,并且直观,所以关联也可以说是模型的一个杀手锏,一旦使用了就会越来越喜欢,本章学习的内容包括:

要掌握关联,最关键是要掌握如何定义关联(包括明确模型之间的关联关系)以及如何进行关联查询,其它的关联写入操作基本了解即可,因为你可以选择采用其它的替代方案完成区别并不大(对于多对多关联,关联写入的优势才能体现出来),也充分说明了关联的优势主要在查询_

定义关联

定义关联最主要是要搞清楚模型之间的关联关系是什么,然后才能“对症下药”调用相关的关联方法。

我们先举个简单的例子来了解下关联关系的概念,例如有一个多用户博客系统,这个系统可能包括下面的一些数据表(当然实际上可能远远不止这些表,只是用来说明一些典型问题和仅供参考):城市表(city)、用户表(user)、博客表(blog ,只记录博客基础信息)、内容表(content ,记录博客的具体内容和扩展信息)、分类表(cate)、评论表(comment)、角色表(role)和用户-角色表(auth)。

关联关系通常有一个参照模型,这个参照模型我们一般称为主模型(或者当前模型),关联关系对应的模型就是关联模型,关联关系是指定义在主模型中的关联,有些关联关系还会设计到一个中间表的概念,但中间表不一定需要存在具体的模型。

主模型和关联模型之间通常是通过某个外键进行关联,而这个外键的命名系统会有一个约定规则,通常是主模型名称+_id,尽量遵循这个约定会给关联定义带来很大简化。

假设我们已经给这些数据表创建了各自的模型,这些模型之间存在一定的关联关系,我们来分析下(注意关联关系是相对某个参照模型的):

  • 博客和内容是一对一的,属于hasOne关联(以博客模型为参照),一般content表会有一个blog_id字段;
  • 反过来内容和博客之间就属于belongsTo关联(以内容模型为参照);
  • 博客一定属于某个分类(这里设计为单个分类),就是belongsTo关联(以博客模型为参照),一般blog表会有一个cate_id字段;
  • 而每个分类下面有多个博客,因此属于hasMany关联(以分类模型为参照);
  • 每个用户会发布多个博客,所以用户和博客之间属于hasMany关联(以用户模型为参照),一般blog表会有一个user_id字段;
  • 每个博客会有多个评论,所以博客和评论之间属于hasMany关联(以博客模型为参照);
  • 每个用户可以有多个角色,而每个角色也会有多个用户,因此用户和角色属于belongsToMany关联(多对多关联无论以哪个模型为参照关联不变),用户和角色之间的中间表就是用户权限表,这个中间表通常会设计user_idrole_id字段;
  • 每个城市有多个用户,而每个用户有多个博客,城市和博客之间并无直接关系,而是通过中间模型产生关联,城市和博客之间就属于hasManyThrough关联(远程一对多,以城市模型为参照),中间模型就是用户;
  • 如果针对某个用户和某个博客都能发表评论,那么用户、博客和评论之间就形成了一种多态一对多的关联关系,也就是说用户会有多个评论(morphMany关联,以用户模型为参照),博客会有多个评论(morphMany关联,以博客模型为参照),但评论表只有一个,评论表对于博客和用户来说,不需要定义两个关联关系,而只需要定义一个morphTo关联(以评论模型为参照)即可,评论表的设计就会被改造以满足多态的设计,普遍的设计是会增加一个多态类型的字段来标识属于某个类型(这里就是用户或者博客类型);

大概了解了关联关系的概念后,我们来看下关联的表现方式是怎样的。从面向对象的角度来看关联的话,模型的关联其实应该是模型的某个属性,比如用户的档案关联,就应该是下面的情况:

// 用户的档案
$user->profile;
// 用户的档案属性中的手机资料
$user->profile->mobile;

$user本身是一个User模型的对象实例,而$user->profile则是一个Profile模型的对象实例,所以具备模型的所有特性而不是一个数组,包括进行Profile模型的CURD操作和业务逻辑执行,$user->profile->mobile则表示获取Profile模型对象实例的mobile数据,包括下面的操作也是有效的。

// 对查询出来的关联模型进行数据更新
$user->profile->email = 'thinkphp@qq.com'
$user->profile->save();

这种关联关系使用Db类是无法完成的,所以这个使命是由模型来完成的,模型的关联用法很好的解决了关联的对象化,支持大部分的关联场景和需求。

为了更方便和灵活的定义模型的关联关系,框架选择了方法定义而不是属性定义的方式,每个关联属性其实是对应了一个模型的关联方法,这个关联属性和模型的数据一样是动态的,并非模型类的实际属性,下面我们会来解释下原理。

例如上面的关联属性就是在User模型类中定义了一个profile方法:

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    public function profile()
    {
        return $this->hasOne('Profile');
    }
}

当我们访问User模型对象实例的profile属性的时候,其实就是调用了profile方法来完成关联查询。我们知道当获取一个模型的属性的时候会触发模型的获取器,而当获取器在没有检测到模型有对应属性的时候就会检查是否存在关联方法定义(对于关联方法的判断很简单,关联方法返回的是一个think\model\Relation对象),如果存在则调用对应关联类的getRelation方法。

我们知道模型的方法名都是驼峰命名的,所以系统做了一个兼容处理,当我们定义了一个userProfile的关联方法的时候,在获取关联属性的时候,下面两种方式都是有效的:

$user->userProfile;
$user->user_profile;

我们推荐关联属性统一使用后者,和数据表的字段命名规范一致,因此在很多时候系统自动获取关联属性的时候采用的也是后者。

有兴趣的可以去了解下Model类中getAttr方法的源码,看看关联属性获取的具体代码实现。

看起来很普通的一个方法赋予了模型神奇的关联特性,一个小小的hasOne方法背后是强大而复杂的关联实现逻辑(后面会慢慢给你描述),ThinkPHP所说的让开发更简单就是因为有众多这些简单而又神奇的特性。

关联方法的定义最关键是要搞清楚具体应该使用何种关联关系,其次是掌握不同的关联关系的定义方法和参数。

可以简单的理解为关联定义就是在模型类中添加一个方法(该方法注意不要和模型的对象属性以及其它业务逻辑方法冲突),一般情况下无需任何参数,并在方法中指定一种关联关系,比如上面的hasOne关联关系(关联的玄妙和复杂就在这个关联方法的定义),5.0版本支持的关联关系包括下面七种,后面会给大家陆续介绍:

模型方法 关联类型
hasOne 一对一HAS ONE
belongsTo 一对一BELONGS TO
hasMany 一对多 HAS MANY
hasManyThrough 远程一对多 HAS MANY THROUTH
belongsToMany 多对多 BELONGS TO MANY
morphMany 多态一对多 MORPH MANY
morphTo 多态 MORPH TO

关联方法的第一个参数就是要关联的模型名称,也就是说当前模型的关联模型必须也是已经定义的一个模型。

一般不需要使用命名空间,会自动使用当前模型的命名空间,如果不同请使用完整命名空间定义,例如:

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    public function profile()
    {
        // Profile模型和当前模型的命名空间不一致
        return $this->hasOne('app\model\Profile');
    }
}

两个模型之间因为参照模型的不同就会产生相对的但不一定相同的关联关系,并且相对的关联关系只有在需要调用的时候才需要定义,下面是每个关联类型的相对关联关系对照:

类型 关联关系 相对的关联关系
一对一 hasOne belongsTo
一对多 hasMany belongsTo
多对多 belongsToMany belongsToMany
远程一对多 hasManyThrough 不支持
多态一对多 morphMany morphTo

除此之外,关联定义的几个要点必须了解:

  • 关联方法必须使用驼峰法命名;
  • 关联方法一般无需定义任何参数;
  • 关联调用的时候驼峰法和小写+下划线都支持;
  • 关联字段设计尽可能按照规范可以简化关联定义;
  • 关联方法定义可以添加额外查询条件;

关联方法定义参数说明:

下面先对七种关联关系的定义方法及参数给出一个大致的说明。

hasOne关联

用法:hasOne('关联模型','外键','主键');

除了关联模型外,其它参数都是可选。

  • 关联模型(必须):模型名或者模型类名
  • 外键:默认的外键规则是当前模型名(不含命名空间,下同)+_id ,例如user_id
  • 主键:当前模型主键,一般会自动获取也可以指定传入

belongsTo关联

用法:belongsTo('关联模型','外键','关联表主键');

除了关联模型外,其它参数都是可选。

  • 关联模型(必须):模型名或者模型类名
  • 外键:当前模型外键,默认的外键名规则是关联模型名+_id
  • 关联主键:关联模型主键,一般会自动获取也可以指定传入

hasMany关联

用法:hasMany('关联模型','外键','主键');

除了关联模型外,其它参数都是可选。

  • 关联模型(必须):模型名或者模型类名
  • 外键:关联模型外键,默认的外键名规则是当前模型名+_id
  • 主键:当前模型主键,一般会自动获取也可以指定传入

hasManyThrough

用法:hasManyThrough('关联模型','中间模型','外键','中间表关联键','主键');

  • 关联模型(必须):模型名或者模型类名
  • 中间模型(必须):模型名或者模型类名
  • 外键:默认的外键名规则是当前模型名+_id
  • 中间表关联键:默认的中间表关联键名的规则是中间模型名+_id
  • 主键:当前模型主键,一般会自动获取也可以指定传入

belongsToMany关联

用法:belongsToMany('关联模型','中间表','外键','关联键');

  • 关联模型(必须):模型名或者模型类名
  • 中间表:默认规则是当前模型名+_+关联模型名 (注意,在V5.0.8版本之前需要添加表前缀)
  • 外键:中间表的当前模型外键,默认的外键名规则是关联模型名+_id
  • 关联键:中间表的当前模型关联键名,默认规则是当前模型名+_id

morphMany关联

用法:morphMany('关联模型','多态字段','多态类型');

  • 关联模型(必须):模型名或者模型类名
  • 多态字段:多态字段信息定义包含两种方式,字符串的话表示多态字段的前缀,数组则表示实际的多态字段
  • 多态类型:默认是当前模型名

数据表的多态字段一般包含两个字段:多态类型和多态主键。

如果多态字段使用字符串例如morph,那么多态类型和多态主键字段分别对应morph_typemorph_id,如果用数组方式定义的话,就改为['morph_type','morph_id']即可。

morphTo关联

用法:morphTo('多态字段','多态类型别名(数组)');

  • 多态字段:定义和morphMany一致
  • 多态类型别名:用于设置特殊的多态类型(比如用数字标识的多态类型)

基础方法

关联操作经常会涉及到几个重要的方法,也是关联操作的基础,掌握了这几个方法对于掌握关联(尤其是关联查询)有很大的帮助,包括:

方法名 作用
relation 关联查询
with 关联预载入
withCount 关联统计(V5.0.5+
load 关联延迟预载入(V5.0.5+
together 关联自动写入(V5.0.5+

我们对这些方法先有个基本的了解,暂时不用深究,首先要明白的是如何使用这些方法。load方法是数据集对象的方法,together方法是模型类提供的方法,其它几个都是Query类提供的链式方法,在查询方法之前调用。

relationwith方法的主要区别在于relation是单纯的关联查询,比如你查询一个用户列表,然后需要关联查询用户的档案数据,使用relation方法的话就是,我先查询用户列表数据,然后每个每个用户再单纯查询档案数据。如果用户列表数据有10个,那么就会产生11次查询。如果使用with方法的话,虽然最终查询出来的关联数据是一样的,但由于with查询使用的是预载入查询,因此实际只会产生2次查询。而load方法则更先进,先查询出用户列表,然后在需要关联数据的时候使用load方法获取关联数据,尤其适合动态关联的情况,最终也是两次查询,因此称为延迟预载入。

由于模型关联的对象化封装机制的优势,其实relation方法基本上很少被用到,而是使用关联惰性查询及关联方法的自定义查询来替代了(会在下一节给你讲解)。最常用的莫过于with方法,因为最常用因此被内置到模型类的getall方法的第二个参数了,我们后面对with方法的用法说明也均适用于getall方法的第二个参数。withCount用于在不获取关联数据的情况下提供关联数据的统计,在查询一对多或者多对多关联的时候才需要使用。load方法则适用于在数据集的延迟预载入关联查询(对于默认的数据集查询类型系统提供了一个load_relation助手函数,作用是等效的)。together方法用于一对一的关联自动写入操作(包括新增、更新和删除),提供了更简单的关联写入机制。

虽然作用不尽相同,但这几个方法的使用方法都是类似的,这四个方法都只有一个参数,参数类型包括字符串和数组,并且数组方式还支持索引数组以方便完成关联的自定义查询。

下面以relation方法为例,来说明下上述关联方法的基本用法(我们演示的是查询用法,至于代码示例中的具体关联是怎么定义的你暂时不必关注或者自行按照前面讲解的关联定义进行测试定义),其它的几个方法用法完全一样,就不再一一重复,后面具体涉及到的某个方法的时候可能只会采用其中一种或者个别进行讲解,请悉知。

最简单的用法是:

// 查询用户的Profile关联数据
$users = $user->relation('profile')->select();
// 查询用户的Book关联数据
$users = $user->relation('books')->select();

关联查询的方法返回的依然是包含User对象实例的数据集,relation方法设定的关联查询结果只是数据集中的User模型对象实例的某个关联属性。

relation方法传入的字符串就是关联定义的方法名而不是关联模型的名称,由于模型方法名使用的都是驼峰法规范,假设定义了一个名为userBooks的关联方法的话,relation方法可以使用两种方式的关联查询:

// 驼峰法的关联方法定义
$users = $user->relation('userBooks')->select();
// 或者使用下面的方式等效
$users = $user->relation('user_books')->select();

第一种传入的是实际的驼峰法关联方法名userBooks,第二种是传入小写和下划线的转化名称user_books,两种关联查询用法都会实际定位到关联方法名称userBooks,所以关联方法定义必须使用驼峰法

对于上面的关联查询用法,在获取关联查询数据的时候,同样可以支持两种方式:

foreach ($users as $user) {
    dump($user->userBooks);
}

或者

foreach ($users as $user) {
    dump($user->user_books);
}

默认情况下,关联方法获取的是满足关联条件的所有数据,如果需要自定义关联查询条件的话,可以使用

// 使用自定义关联查询
$user->relation(['books' => function ($query) {
    $query->where('title', 'like', '%thinkphp%');
}])->select();

表示查询该用户写的标题中包含thinkphp的书籍,闭包中不仅仅可以使用查询条件,还可以支持其它的链式方法,比如对关联数据进行排序和指定字段:

// 使用自定义关联查询
$user->relation(['books' => function ($query) {
    $query
        ->field('id,name,title,pub_time,user_id')
        ->order('pub_time desc')
        ->whereTime('pub_time', 'year');
}])->select();

如果使用field方法指定查询字段,务必包含你的当前模型的主键以及关联模型的关键键,否则会导致关联查询失败。

关联方法可以同时指定多个关联,即使是不同的关联类型,使用:

// 查询用户的Profile和Book关联数据
$users = $user->relation('profile,books')->select();

下面的数组方式是等效的

// 查询用户的Profile和Book关联数据
$users = $user->relation(['profile','books'])->select();

一般使用数组的话,主要需要使用闭包进行自定义关联查询的情况,否则用逗号分割的字符串就可以了。

together方法不支持闭包,但可以支持数组方式定义多个关联方法

关联查询

在熟悉了如何定义关联方法和关联方法的基础用法之后,我们来具体了解如何进行实际的关联查询以及细节。

通常有两种方式进行关联的数据获取:关联预查询和关联延迟查询。

关联预查询方式就是使用上节提到的relation方法,使用

// 指定User模型的profile关联
$user = User::relation('profile')->find(1);
// profile关联属性也是一个模型对象实例
dump($user->profile);

relation方法中传入关联(方法)名称即可(多个可以使用逗号分割的字符串或者数组)。这种方式,无论你是否最终获取profile属性,都会事先进行关联查询,因此称为关联预查询。

如果关联数据不存在,一对一关联返回的是null,一对多关联的话返回的是空数组或空数据集对象。

出于性能考虑,通常我们选择关联延迟查询的方式。

// 不需要指定关联
$user = User::get(1);
// 获取profile属性的时候自动进行关联查询
dump($user->profile);

这种方式下的关联查询是惰性的,只有在获取关联属性的时候才会实际进行关联查询,因此称之为关联延迟查询。

关联属性的名称一般就是关联(定义)方法的名称,但同时也支持驼峰关联方法的小写+下划线转化名称。

关联自定义查询

模型的关联方法除了会自动在关联获取的时候自动调用外,仍然可以作为查询构造器的链式操作来对待,以完成额外的附加条件或者其它自定义查询(一对多的关联关系时候比较多见类似场景),例如User模型定义了一个articleshasMany关联:

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    public function articles()
    {
        return $this->hasMany('Article');
    }
}

普通的关联查询获取的是全部的关联数据,例如:

$user = User::get(1);
$articles = $user->articles;

articles返回的类型根据Article模型的数据集返回类型设定,如果Article模型返回的数据集类型是Collection,那么关联数据集返回的也是Collection对象。

如果需要对关联数据进行筛选,例如需要查询用户发表的标题里面包含think的文章,并且按照create_time倒序排序,则可以使用下面的方式:

$user     = User::get(1);
$articles = $user->articles()
    ->where('title', 'like', '%think%')
    ->order('create_time desc')
    ->select();

调用articles()关联方法的动作有下面几个:

  • 相当于切换当前模型到关联模型对象(Article);
  • 并且会自动传入关联条件(user_id = 1);

如果是一对多或者多对多关联,并且希望自主条件查询关联数据的话请参考该方式

如果你希望改变默认的关联查询条件而不是在外部查询的时候指定,可以直接在定义关联的时候添加额外条件,例如上面的查询条件可以写成:

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    public function articles()
    {
        return $this->hasMany('Article')
          ->where('title', 'like', '%think%')
          ->order('create_time desc');
    }
}

关联方法里面的查询条件会自动作为关联查询的条件带入,下面的关联查询出来的数据就是包含额外条件的:

$user = User::get(1);
$articles = $user->articles;

如果需要你仍然可以在外部调用的时候追加额外条件,例如下面的关联查询就包含了关联方法里面定义的和额外追加的条件:

$user     = User::get(1);
$articles = $user->articles()
    ->where('name', 'thinkphp')
    ->field('id,name,title')
    ->select();

如果你担心基础的关联条件定义影响你的其它查询,你可以像下面一样单独定义多个关联关系,各自独立使用互不影响。

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    public function articles()
    {
        return $this->hasMany('Article');
    }

    public function articlesLike($title)
    {
        return $this->hasMany('Article')
                    ->where('title', 'like', '%' . $title . '%')
                    ->field('id,name,title')
                    ->order('create_time desc');
    }    
}

articlesLike方法就作为自定义关联查询专用,并且需要传入title参数,用法如下:

$user = User::get(1);
$articles = $user->articlesLike('think')
    ->select();

下面的用法则是错误的:

$user = User::get(1);
$articles = $user->articlesLike;

带有参数的关联定义方法不能直接用于关联属性获取,只能用于链式关联自定义查询。

关联约束

对于hasMany关联关系,系统提供了根据关联数据条件来查询当前模型数据的关联约束方法,包括hashasWhere两个方法。

has方法主要用于查询关联数据的记录数来作为当前模型的查询依据,默认是存在一条数据即可。

// 查询有评论数据的文章
$list = Article::has('comments')->select();

可以指定关联数据的数量进行查询,例如:

// 查询评论超过3个的文章
$list = Article::has('comments', '>', 3)->select();

has方法的第二个参数支持>>=<<= 以及 =,第三个参数是一个整数。

如果需要复杂的关联查询约束条件的话,可以使用hasWhere方法,例如:

// 查询评论状态正常的文章
$list = Article::hasWhere('comments', ['status' => 1])->select();

或者直接使用闭包查询,然后在闭包里面使用链式方法查询:

// 查询最近一周包含think字符的评论的文章
$list = Article::hasWhere('comments', function ($query) {
    $query
        ->whereTime('create_time', 'week')
        ->where('content', 'like', '%think%');
})->select();

使用闭包方式查询的时候,需要注意一点,如果查询的关联模型字段可能同时存在当前模型和关联模型的话,需要加上关联模型的名称作为别名。

// 查询最近一周包含think字符的评论的文章
$list = Article::hasWhere('comments', function ($query) {
    $query
        ->whereTime('Comment.create_time', 'week')
        ->where('content', 'like', '%think%');
})->select();

V5.0.5+版本开始,has也支持hasWhere的所有用法。

关联预载入

关联查询只是为了方便,但在实际的应用过程中,查询多个数据的情况下如果数据较多,关联查询产生的性能开销会较大(虽然这个很正常),比如查询用户的Profile关联数据的话,如果有100个用户数据,就会产生100+1次查询,这就是N+1查询问题,关联预载入功能提供了更好的性能,但完成了一样的关联查询效果。

关联查询的预查询载入功能,主要解决了N+1次查询的问题,例如下面的查询如果有3个记录,会执行4次查询:

$list = User::all([1, 2, 3]);
foreach ($list as $user) {
    // 获取用户关联的profile模型数据
    dump($user->profile);
}

如果使用关联预查询功能,对于一对一关联来说,默认只有一次查询,对于一对多关联的话,就变成2次查询,有效提高性能,关联预载入使用with方法指定需要预载入的关联(方法),用法和relation方法类似。

$list = User::with('profile')->select([1, 2, 3]);
foreach ($list as $user) {
    // 获取用户关联的profile模型数据
    dump($user->profile);
}

关联的预载入查询不是惰性的,是连同数据查询一起完成的,但由于封装的合并查询,性能方面远远优于普通的关联惰性查询,所以整体的查询性能是非常乐观的。

鉴于预载入查询的重要性,模型的getall方法的第二个参数可以直接传入预载入参数,例如下面的预载入查询和前面是等效的:

$list = User::all([1, 2, 3], 'profile');
foreach ($list as $user) {
    // 获取用户关联的profile模型数据
    dump($user->profile);
}

嵌套预载入

嵌套预载入指的是如果关联模型本身还需要进行关联预载入的话,可以在当前模型预载入查询的时候直接指定,理论上嵌套是可以任意级别的(但实际上估计不会有这么复杂的关联设计),假设Profile模型还关联了一个名片模型(cards关联方法),可以这样进行嵌套预载入查询。

$list = User::all([1, 2, 3], 'profile.cards');
foreach ($list as $user) {
    // 获取用户关联数据
    dump($user->profile->cards);
}

一对一关联的JOIN方式不支持嵌套预载入

预载入条件限制

可以在预载入的时候通过闭包指定额外的条件限制,但记住了,不要在闭包里面执行任何的查询,例如:

$list = User::with(['articles' => function ($query) {
    $query->where('title', 'like', '%think%')
        ->field('id,name,title')
        ->order('create_time desc');
}])->select([1, 2, 3]);

foreach ($list as $user) {
    // 获取用户关联的profile模型数据
    dump($user->profile);
}

如果是一对一预载入查询的条件限制,注意field方法要改为withField方法,否则会产生字段混淆。

延迟预载入

有些情况下,需要根据查询出来的数据来决定是否需要使用关联预载入,当然关联查询本身就能解决这个问题,因为关联查询是惰性的,不过用预载入的理由也很明显,性能具有优势。

延迟预载入仅针对多个数据的查询,因为单个数据的查询用延迟预载入和关联惰性查询没有任何区别,所以不需要使用延迟预载入。

如果你的数据集查询返回的是数据集对象,可以使用调用数据集对象的load实现延迟预载入:

// 查询数据集
$list = User::all([1, 2, 3]);
// 延迟预载入
$list->load('cards');
foreach ($list as $user) {
    // 获取用户关联的card模型数据
    dump($user->cards);
}

如果你的数据集查询返回的是数组,系统提供了一个load_relation助手函数可以完成同样的功能。

// 查询数据集
$list = User::all([1, 2, 3]);
// 延迟预载入
$list = load_relation($list, 'cards');
foreach ($list as $user) {
    // 获取用户关联的card模型数据
    dump($user->cards);
}

关联统计

有些时候,并不需要获取关联数据,而只是希望获取关联数据的统计(关联统计仅针对一对多或者多对多的关联关系),这个时候可以使用withCount方法进行制定关联的统计。

$list = User::withCount('cards')->select([1, 2, 3]);
foreach ($list as $user) {
    // 获取用户关联的card关联统计
    echo $user->cards_count;
}

关联统计功能会在模型的对象属性中自动添加一个以“关联方法名+_count”为名称的动态属性来保存相关的关联统计数据。

如果需要对关联统计进行条件过滤,可以使用

$list = User::withCount(['cards' => function ($query) {
    $query->where('status', 1);
}])->select([1, 2, 3]);
foreach ($list as $user) {
    // 获取用户关联的card关联统计
    echo $user->cards_count;
}

一对一关联关系使用关联统计是无效的,一般可以用exists查询来判断是否存在关联数据。

关联输出

关联属性的输出和模型的输出转换一样,使用模型的toArray方法可以同时输出关联属性(对象),例如:

$user = User::get(1,'profile');
$data = $user->toArray();
dump($data);
$data = $user->toJson();
dump($data);

对于使用了关联预载入查询和手动获取了关联属性(延迟关联查询)的情况,toArraytoJson方法都会包含关联数据。

可以调用visiblehidden方法对当前模型以及关联模型的属性进行输出控制,下面来看一个例子:

$user = User::get(1, 'profile');
$data = $user->hidden(['name', 'profile.email'])->toArray();

上面的代码返回的data数据中不会包含用户模型的name属性以及关联profile模型的email属性。

如果要隐藏多个关联属性的话,可以使用下面的方式:

$user = User::get(1, 'profile');
$data = $user->hidden(['name', 'profile' => ['email', 'address']])->toArray();

模型的visible方法(用于设置需要输出的属性)的用户和hidden一致,在此不再多说,有一点必须强调下,同时调用visiblehidden方法的话,visible是优先的,所以下面的profile关联属性输出会包含emailsex

$user = User::get(1, 'profile');
$data = $user->visible(['profile' => ['email', 'sex']])->hidden(['name', 'profile' => ['email', 'address']])->toArray();

在需要的时候,即使之前没有进行任何的关联查询,你也可以在输出的时候追加关联属性,例如:

$user = User::get(1);
$user->append(['profile'])->toArray();

该例子在调用toArray方法的时候才会进行profile关联数据获取并转换输出。

对于数据集查询,如果返回类型是数据集对象仍然支持调用visiblehiddenappend方法,如果不是数据集对象的话可以先用collection助手函数转换为数据集对象。

$users = User::all();
$data  = $users->hidden(['name', 'profile' => ['email', 'address']])
    ->toArray();

关联实例

在学习完了关联查询、自定义条件查询、关联(及嵌套)预载入、延迟预载入、关联约束和关联统计后,我们已经基本上掌握了关联的所有查询操作,现在我们来通过一些实例来复习下关联查询操作, 以及了解下不同的关联类型的新增、更新和删除等操作,及其注意事项。

其实只要理解模型和对象的概念,关联的新增、更新和删除,甚至其它的业务逻辑操作的调用都是很容易掌握的。

本节涉及的关联实例,各个模型对应的数据表结构如下(本示例仅仅演示关联的用法,不打算重复强调模型本身的功能,因此对数据表结构做了必要的简化以达到说明的效果):

city
    id - integer
    name - string

user
    id - integer
    name - integer
    email - string
    city_id - integer

role
    id - integer
    name - string

auth
    user_id - integer
    role_id - integer
    add_time - dateTime

blog
    id - integer
    name - string
    title - string
    cate_id - integer
    user_id - integer

content
    id - integer
    blog_id - integer
    data - text

cate
    id - integer
    name - string
    title - string

comment
    id - integer
    content - text
    commentable_id - integer
    commentable_type - string

模型类分别如下:

City模型

<?php

namespace app\index\model;

use think\Model;

class City extends Model
{
    /**
     * 获取城市的用户
     */    
    public function users()
    {
        return $this->hasMany('User');
    }    

    /**
     * 获取城市的所有博客
     */    
    public function blog()
    {
        return $this->hasManyThrough('Blog', 'User');
    }    
}

User模型

<?php

namespace app\index\model;

use think\Model;

class User extends Model
{
    /**
     * 获取用户所属的角色信息
     */
    public function roles()
    {
        return $this->belongsToMany('Role', 'auth');
    }

    /**
     * 获取用户发表的博客信息
     */    
    public function blogs()
    {
        return $this->hasMany('Blog');
    }    

    /**
     * 获取所有针对用户的评论
     */
    public function comments()
    {
        return $this->morphMany('Comment', 'commentable');
    }    
}

Role模型

<?php

namespace app\index\model;

use think\Model;

class Role extends Model
{
    /**
     * 获取角色下面的用户信息
     */
    public function users()
    {
        return $this->belongsToMany('User', 'auth');
    }
}

Blog模型

<?php

namespace app\index\model;

use think\Model;

class Blog extends Model
{
    /**
     * 获取博客所属的用户
     */
    public function user()
    {
        return $this->belongsTo('User');
    }

    /**
     * 获取博客的内容
     */    
    public function content()
    {
        return $this->hasOne('Content');
    }    

    /**
     * 获取所有博客所属的分类
     */    
    public function cate()
    {
        return $this->belongsTo('Cate');
    }    

    /**
     * 获取所有针对文章的评论
     */
    public function comments()
    {
        return $this->morphMany('Comment', 'commentable');
    }    
}

Content模型

<?php

namespace app\index\model;

use think\Model;

class Content extends Model
{
    /**
     * 获取内容所属的博客信息
     */
    public function blog()
    {
        return $this->belongsTo('Blog');
    }
}

Cate模型

<?php

namespace app\index\model;

use think\Model;

class Cate extends Model
{
    /**
     * 获取分类下的所有博客信息
     */
    public function blogs()
    {
        return $this->hasMany('Blog');
    }
}

Comment模型

<?php

namespace app\index\model;

use think\Model;

class Comment extends Model
{
    /**
     * 获取评论对应的多态模型
     */
    public function commentable()
    {
        return $this->morphTo();
    }
}

关于不同关联方法的参数说明请参考关联定义部分,这里不再重复叙述。

auth数据表不需要创建模型,对于多对多关联来说,中间表是不需要关注的。

一对一关联

一对一关联包含hasOnebelongsTo两种关联关系定义,系统对一对一关联尤其是hasOne做了强化支持,这里用博客模型和内容模型之间的关联为例说明。

先来说下普通情况的关联操作。

[ 新增 ]

$blog        = new Blog;
$blog->name  = 'thinkphp';
$blog->title = 'ThinkPHP5关联实例';
if ($blog->save()) {
    $content       = new Content;
    $content->data = '实例内容';
    $blog->content()->save($content);
}

当然,支持使用数组方式新增数据,例如:

$data = [
    'name'  => 'thinkphp',
    'title' => 'ThinkPHP5关联实例',
];
$blog    = Blog::create($data);
$content = [
    'data' => '实例内容',
];
$blog->content()->save($content);

[ 查询 ]

普通关联查询

$blog = Blog::get(1);
echo $blog->content->data;

预载入关联查询

$blog = Blog::get(1,'content');
echo $blog->content->data;

数据集查询

$blogs = Blog::with('content')->select();
foreach ($blogs as $blog) {
    dump($blog->content->data);
}

默认一对一关联查询也是使用2次查询,如果希望获取更好的性能,可以修改关联定义为:

    /**
     * 获取博客的内容
     */    
    public function content()
    {
        // 修改关联查询方式为JOIN查询方式
        return $this->hasOne('Content')->setEagerlyType(0);
    }   

修改后,关联查询从原来默认的IN查询改为JOIN查询,可以减少一次查询,但有一个地方必须注意,指定的关联表字段field方法必须改为withField方法。

[ 更新 ]

// 查询
$blog = Blog::get(1);
// 更新当前模型
$blog->title = '更改标题';
$blog->save();
// 更新关联模型
$blog->content->data = '更新内容';
$blog->content->save();

[ 删除 ]

// 查询
$blog = Blog::get(1);
// 删除当前模型
$blog->delete();
// 删除关联模型
$blog->content->delete();

为了更简单的使用一对一关联的写入操作,系统提供了关联自动写入功能(V5.0.5+版本开始支持),比较下面的代码就会发现写入操作和之前的写法更简洁了。

[ 新增 ]

$blog          = new Blog;
$blog->name    = 'thinkphp';
$blog->title   = 'ThinkPHP5关联实例';
$blog->content = ['data' => '实例内容'];
$blog->together('content')->save();

当然,还可以更加对象化一些,例如:

$blog          = new Blog;
$blog->name    = 'thinkphp';
$blog->title   = 'ThinkPHP5关联实例';
$content       = new Content;
$content->data = '实例内容';
$blog->content = $content;
$blog->together('content')->save();

甚至可以把关联属性合并到主模型进行赋值后写入,只需要改成:

$blog        = new Blog;
$blog->name  = 'thinkphp';
$blog->title = 'ThinkPHP5关联实例';
$blog->data  = '实例内容';
$blog->together(['content' => ['data']])->save();

如果不想这么麻烦每次调用together方法,也可以直接在模型类中定义relationWrite属性,但必须是数组方式。不过考虑到模型的独立操作的可能性,并不建议。

[ 查询 ]

关联查询支持把关联模型的属性直接附加到当前模型

$blog = Blog::get(1);
$blog->appendRelationAttr('content', 'data');
echo $blog->data;

如果不想每次都附加操作的话,可以修改Blog模型的关联定义如下:

    /**
     * 获取博客的内容
     */    
    public function content()
    {
        return $this->hasOne('Content')->bind('data');
    }   

现在就可以直接使用

$blog = Blog::get(1, 'content');
echo $blog->data;

数据集的用法基本上类似。

[ 更新 ]

采用关联自动更新的写法如下:

// 查询
$blog          = Blog::get(1);
$blog->title   = '更改标题';
$blog->content = ['data' => '更新内容'];
// 更新当前模型及关联模型
$blog->together('content')->save();

更加对象化的写法是:

// 查询
$blog                = Blog::get(1);
$blog->title         = '更改标题';
$blog->content->data = '更新内容';
// 更新当前模型及关联模型
$blog->together('content')->save();

一样可以支持关联属性合并到主模型操作

// 查询
$blog        = Blog::get(1);
$blog->title = '更改标题';
$blog->data  = '更新内容';
// 更新当前模型及关联模型
$blog->together(['content' => 'data'])->save();

在关联方法中使用bind方法把关联属性绑定到当前模型并不会影响关联写入,必须使用数组方式来明确告知当前模型哪些属性是关联的绑定属性。

[ 删除 ]

关联自动删除的操作很简单

// 查询
$blog = Blog::get(1);
// 删除当前及关联模型
$blog->together('content')->delete();

一对多关联

一对多关联包括hasManybelongsTo两种关联关系,我们以用户和博客模型为例来说明,其实一对多关联主要是查询为主,关联写入比起单独模型的操作并没有任何优势,所以建议一对多的关联写入仍然由各个独立模型完成,请不要纠结。

可以查询某个用户的博客

$user = User::get(1);
// 获取用户的所有博客
dump($user->blogs);
// 也可以进行条件搜索
dump($user->blogs()->where('cate_id', 1)->select());

如果需要对关联数据进行额外的条件查询、更新和删除操作就可以使用blogs方法。

反过来,如果需要查询博客所属的用户信息,可以使用

$blog = Blog::get(1);
dump($blog->user->name);

远程一对多

远程一对多的作用是跨过一个中间模型操作查询另外一个远程模型的关联数据,而这个远程模型通常和当前模型是没有任何关联的,用前面的例子来说的话就是:

  • 一个用户发表了多个博客;
  • 一个城市有多个用户;
  • 假设城市和博客之间没有直接关联;

如果需要获取某个城市下面的所有博客,利用已经掌握的关联概念是可以实现的,只是需要通过两次关联操作来获取,代码看起来类似下面:

$city  = City::getByName('shanghai');
$blogs = [];
foreach ($city->users as $user) {
    $blogs[$user->id] = $user->blogs()->order('id desc')->limit(100)->select();
}
// 然后对博客数据进行额外组装处理
// ...

虽然思路还是比较清晰,但略显麻烦,另外还要对数据进行组装,而且不便于统一排序和限制,例如希望一共取出100个博客数据就不好办。

为了简化这种操作,我们引入了远程一对多的关联关系来更好的解决,在City模型中已经定义了blogs关联,实现方案修改如下:

$city  = City::getByName('shanghai');
$blogs = $city->blogs()
    ->order('id desc')
    ->limit(100)
    ->select();

看起来是不是直观很多,而且对博客数据的自定义查询也相当方便,无论是性能还是功能都更佳,因为我们不需要对用户模型进行查询操作。当然,很多朋友会说,直接在博客模型中添加城市id岂不是更简单,这是架构设计的问题了,不属于本次讨论的范畴,本实例的假设前提是城市和博客模型之间没有任何直接关联。

但有一个结论是显而易见的:架构的优化对于代码的优化来说有时候更有效

多对多关联

多对多关联较前面两种关联来说复杂很多,但越是复杂越能体现出模型关联的优势,下面我们以用户和角色模型来看下如何操作多对多关联。

多对多关联关系必然会有一个中间表,最少必须包含两个字段,例如auth表就包含了user_idrole_id(建议对这两个字段设置联合唯一索引),但中间表仍然可以包含额外的数据。

中间表不需要创建任何模型(auth表没有对应模型),多对多关联关系会创建一个虚拟的中间表模型(也称之为枢纽模型)Pivot,对中间表的所有操作只需要对该模型进行操作即可,事实上,一般情况下你根本无需关注中间表的存在就可以轻松完成多对多关联操作。

多对多的关联写入操作一般有下列几种方式:

  • 用户和角色数据独立写入,然后通过关联完成中间表的写入;
  • 用户数据独立写入,然后通过关联完成角色数据和中间表数据写入;
  • 角色数据独立写入,然后通过关联完成用户数据和中间表数据写入(多对多关联相互之间操作是等同的,因此本质上和上面是同一种方式);
  • 通过关联单独完成中间表数据更新及删除;

多对多的关联写入操作主要需要掌握下面两个方法,我们后面会详细讲解,除非模型独立操作,一般不需要使用save方法。

方法 描述
attach 附加关联的一个中间表数据
detach 解除关联的一个或者多个中间表数据

首先完成第一种方式,仅仅操作中间表数据。

// 查询用户
$user = User::get(1);
// 查询角色
$role = Role::getByName('admin');
// 增加用户-角色数据
$user->roles()->attach($role->id);

如果中间表有额外数据需要写入,可以使用:

// 查询用户
$user = User::get(1);
// 查询角色
$role = Role::getByName('admin');
// 传入中间表的额外属性
$user->roles()->attach($role->id, ['add_time' => '2017-1-18']);

事实上,attach方法是一个很智能的方法,第一个参数能够识别包括数字、字符串、数组和模型实例并做出不同的处理。

参数类型 作用描述
数字或字符串 要附加中间表的关联模型主键
索引数组 首先写入关联模型,然后附加中间表
普通数组 附加多个关联数据的主键
模型实例 附加关联模型

如果要添加的角色尚未创建,则可以使用下面的方式添加用户-角色数据:

// 查询用户
$user = User::get(1);
// 增加用户-角色数据 并同时创建新的角色
$user->roles()->attach([
    // 添加一个编辑角色
    'name' => 'editor',
]);

如果需要获取新增的角色表自增主键ID,最新版本的attach方法返回的是一个Pivot模型对象。

// 查询用户
$user = User::get(1);
// 增加用户-角色数据 并同时创建新的角色
$pivot = $user->roles()->attach([
    // 添加一个编辑角色
    'name' => 'editor',
], ['add_time' => '2017-1-31']);
// 获取中间表的数据
echo $pivot->role_id;
echo $pivot->user_id;
echo $pivot->add_time;

下面则表示给用户添加多个角色授权:

// 查询用户
$user = User::get(1);
// 给用户授权多个角色(根据角色主键)
$user->roles()->attach([1, 2, 3], ['add_time' => '2017-1-31']);

要解除一个用户的角色,可以使用:

// 查询用户
$user = User::get(1);
// 查询角色
$role = Role::getByName('admin');
// 删除中间表数据
$user->roles()->detach($role->id);

可以同时解除用户的多个角色权限

// 查询用户
$user = User::get(1);
// 删除中间表数据
$user->roles()->detach([1, 2, 3]);

解除用户的所有角色可以用

// 查询用户
$user = User::get(1);
// 删除中间表数据
$user->roles()->detach();

如果需要解除用户的权限同时删除这个角色,可以使用:

// 查询用户
$user = User::get(1);
// 查询角色
$role = Role::getByName('test');
// 删除中间表数据以及关联表数据
$user->roles()->detach($role->id,true);

多对多关联的查询和其它关联类似(一样支持关联自定义查询),区别在于每个关联模型数据还有一个额外的枢纽模型数据,例如:

// 查询用户
$user = User::get(1);
// 获取用户的角色
$roles = $user->roles;
foreach ($roles as $role) {
    // 输出用户的角色名
    echo $role->name;
    // 获取中间表模型
    dump($role->pivot);
}

多态一对多

多态关联允许一个模型在单个关联定义方法中从属一个以上其它模型,例如用户可以评论书和文章,但评论表通常都是同一个数据表的设计。多态一对多关联关系,就是为了满足类似的使用场景而设计。

多态一对多关联主要涉及的是关联查询,关联写入本身不建议通过关联操作完成,请确保用各自的模型独立完成数据写入。

多态一对多的多态表设计很重要,例如本例子中的评论表因为需要保存多个模型的评论数据,就可以设计成多态关联。

要获取博客的评论数据可以使用:

$blog = Blog::get(1);

foreach ($blog->comments as $comment) {
    dump($comment);
}

当然,一样可以进行评论筛选过滤

$blog     = Blog::get(1);
$comments = $blog->comments()
    ->where('content', 'like', '%think%')
    ->order('id desc')
    ->limit(20)
    ->select();
foreach ($comments as $comment) {
    echo $comment->content;
}

对于评论模型来说,则可以这样操作

$comment = Comment::get(1);
$commentable = $comment->commentable;

Comment 模型的 commentable 关联会返回 BlogUser 模型的对象实例,这取决于评论所属模型的类型。

如果你的多态类型字段保存的数据并非是模型名称之类的,而是采用数字保存(提高存储和查询性能),比如1表示博客,2表示用户。

关联定义方法需要对应修改为:
Blog模型

    /**
     * 获取所有针对文章的评论
     */
    public function comments()
    {
        return $this->morphMany('Comment', 'commentable', 1);
    }   

User模型

    /**
     * 获取所有针对用户的评论
     */
    public function comments()
    {
        return $this->morphMany('Comment', 'commentable', 2);
    }   

Comment模型

    /**
     * 获取评论对应的多态模型
     */
    public function commentable()
    {
        return $this->morphTo(null, [
            '1' => 'Blog',
            '2' => 'User',
        ]);
    }

如果你的模型使用不同的命名空间,可以使用完整的命名空间方式定义:

    /**
     * 获取评论对应的多态模型
     */
    public function commentable()
    {
        return $this->morphTo(null, [
            '1' => 'app\model\Blog',
            '2' => 'app\model\User',
        ]);
    }

总结

本章我们了解了模型关联的概念,并着重学习了关联的查询,并针对不同的关联类型给出了实际的关联操作指引,下一章我们会来说下数据库和模型操作的性能和安全方面的话题。

上一篇:第七章:模型高级用法
下一篇:第九章:性能和安全

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • Eloquent: 关联模型 简介 数据库中的表经常性的关联其它的表。比如,一个博客文章可以有很多的评论,或者一个...
    Dearmadman阅读 17,252评论 6 16
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,065评论 1 32
  • 国家电网公司企业标准(Q/GDW)- 面向对象的用电信息数据交换协议 - 报批稿:20170802 前言: 排版 ...
    庭说阅读 10,800评论 6 13
  • 1.设计模式是什么? 你知道哪些设计模式,并简要叙述?设计模式是一种编码经验,就是用比较成熟的逻辑去处理某一种类型...
    龍飝阅读 2,113评论 0 12
  • 太阳落下了时候可真美呀! 可他却把江山河水照的更美了,江山就像一件漂亮的衣裳,把河水照的就像一个 活娃娃水中划...
    高诗涵阅读 191评论 0 1