## **简介**
Eloquent 模型支持的关联关系包括以下七种:
* 一对一
* 一对多
* 多对多
* 远层一对多
* 多态关联(一对一)
* 多态关联(一对多)
* 多态关联(多对多)
下面我们将以设计一个简单的博客系统数据库为例一一介绍上述关联关系。
## **一对一**
一对一是最简单的关联关系,一般可用于某张数据表的扩展表与主表之间的关联关系,比如`user`表——>`user_profiles`表。针对这样的场景,我们就可以在两张表对应模型之间建立一对一关联。
我们先通过[数据库迁移]('')创建一张`user_profiles`数据表,并创建对应模型`UserProfile`,这可以通过以下 Artisan 命令一次完成:
~~~
php artisan make:model UserProfile -m
~~~
在生成的`create_user_profiles`迁移文件中编写迁移类的`up`方法如下:
~~~
public function up()
{
Schema::create('user_profiles', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned()->default(0)->unique();
$table->string('bio')->nullable()->comment('个性签名');
$table->string('city')->nullable()->comment('所在城市');
$table->json('hobby')->nullable()->comment('个人爱好');
$table->timestamps();
});
}
~~~
注意,我们在`user_profiles`表中添加了一个`user_id`字段用于指向所属用户,从而建立于`users`表的关联。运行`php artisan migrate`在数据库创建这张数据表。
准备好数据表之后,接下来,我们来通过模型类建立`users`表和`user_profiles`表之间的关联,Eloquent 模型类底层提供了相应的 API 方法帮助我们建立模型之间的关联。首先,我们在`User`模型类中通过`hasOne`方法定义其与`UserProfile`的一对一关联:
~~~
public function profile()
{
return $this->hasOne(UserProfile::class);
}
~~~
我们通过[数据库填充技术]('')在`user_profiles`插入一些数据,这样就可以在`User`模型实例上通过关联方法名作为动态属性访问与其对应的`UserProfile`模型实例了:
~~~
$user = User::findOrFail(1);
$profile = $user->profile;
~~~
打印`$profile`结果如下:
![](https://img.kancloud.cn/0a/91/0a915d66d19bfbfe34337565f029b510_1412x596.jpg)
### **Eloquent底层约定**
在关联关系的建立过程中,Eloquent 也遵循了「约定大于配置」的原则。你可能注意到了我们在定义关联关系时,仅仅指定了模型类名,并没有指定通过哪些数据表字段建立关联,这并不是说 Laravel 神通广大,而是因为 Eloquent 对此做了默认的约定。`hasOne`方法的完整签名是:
~~~
public function hasOne($related, $foreignKey = null, $localKey = null)
~~~
其中,第一个参数是关联模型的类名,第二个参数是关联模型类所属表的外键,这里对应的是`user_profiles`表的`user_id`字段,第三个参数是关联表的外键关联到当前模型所属表的哪个字段,这里对应的是`users`表的`id`字段。如果没有指定`$foreignKey`,Eloquent 底层会通过如下方法去拼接:
~~~
public function getForeignKey()
{
return Str::snake(class_basename($this)).'_'.$this->getKeyName();
}
~~~
在本例中,拼接的结果正好是`user_id`。如果没有指定`$localKey`的话,Eloquent 底层会返回主键 ID(本例中就是`id`):
~~~
public function getKeyName()
{
return $this->primaryKey;
}
~~~
遵循这种默认的约定,可以帮我们少写很多代码,减少很多额外的配置,所以如果不是迫不得已(比如从其他系统迁移过来),建议你在使用 Eloquent 的话,尽量遵循这些默认约定。如果数据表没有遵循这种约定的话,只能手动传参了。
### **建立相对的关联关系**
通常我们都是通过`User`模型获取`UserProfile`模型,但是有时候我们可能需要反过来通过`UserProfile`反查所属的`User`模型,Eloquent 底层也为我们提供了相应的`belongsTo`方法来建立相对的一对一关联关系,我们在`UserProfile`模型类定义其与`User`模型的关联如下:
~~~
public function user()
{
return $this->belongsTo(User::class);
}
~~~
同样,采用关联关系方法名作为动态属性即可访问该模型所属`User`模型实例:
~~~
$profile = UserProfile::findOrFail(2);
$user = $profile->user;
~~~
和`hasOne`方法一样,`belongsTo`方法也是遵循了默认的约定规则,其完整方法签名如下:
~~~
public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)
~~~
其中第一个参数是关联模型的类名;第二个参数是当前模型类所属表的外键,在本例中是`user_profiles`表的`user_id`字段,拼接规则和`hasOne`那里类似,只不过这里是基于第四个参数关联关系名称`$relation`;第三个参数是关联模型类所属表的主键;第四个参数`$relation`默认约定是对应关联关系方法名,也是关联关系动态属性名,这里就是`user`。
## **一对多**
一对多关联是我们日常开发中经常碰到的一种关联关系,比如`user`表——>`post`用户文章表。同理,我们也可以在`User`模型类中通过 Eloquent 底层提供的`hasMany`方法来实现:
~~~
public function posts()
{
return $this->hasMany(Post::class);
}
~~~
由于我们之间已经创建过`users`表和`posts`表,并且初始化过数据,所以我们可以直接通过动态属性的方式来调用用户模型上的文章,返回模型类集合(不同与`hasOne`返回的是单个模型实例):
~~~
$user = User::findOrFail(1);
$posts = $user->posts;
~~~
### **Eloquent底层约定**
和`hasOne`方法一样,`hasMany`方法底层也对如何建立关联关系做了约定,而且`hasMany`方法和`hasOne`方法的签名一样:
~~~
public function hasMany($related, $foreignKey = null, $localKey = null)
~~~
`$foreignKey`和`$localKey`默认获取逻辑也和`hasOne`完全一样,这里不再赘述。其实你完全可以把一对一关联看作一对多关联的简化版本,只不过一对一退化为只返回一条记录,所以实现逻辑一样也不难理解了。
### **建立相对的关联关系**
同理地,我们依然可以通过 Eloquent 提供的`belongsTo`方法在文章模型中建立于用户模型之间的相对关联关系,比如在文章详细页或列表页显示文章作者信息:
~~~
//为了提升代码可读性,关联关系调方法名修改为`author`
public function author()
{
return $this->belongsTo(User::class, 'user_id', 'id', 'author');
}
~~~
这样我们就可以在文章模型实例上通过动态属性`user`来访问对应的用户信息:
~~~
$post = Post::findOrFail(29);
$author = $post->user;
~~~
### **渴求式加载**
前面我们提到的关联关系查询都是通过动态属性的方式,这种加载方式叫做「懒惰式加载」,因为都是用到的时候才回去查询,这就意味着多条记录要多次对数据库的进行查询才能返回需要的结果,比如文章列表页获取作者信息(每篇文章的作者都要通过动态属性获取),那能不能一次就返回所有的关联查询结果呢?
为此,Eloquent 为我们提供了`with`方法,我们将需要查询的关联关系动态属性(关联方法名)传入该方法,并将其链接到 Eloquent 模型原有的查询中,就可以一次完成关联查询,加上模型自身查询,总共查询两次。我们将这种加载方式叫做「渴求式加载」,即根据所需预先查询所有数据。
以文章列表为例,我们可以通过这种方式获取文章及对应作者信息:
~~~
$posts = Post::with('author')
->where('views', '>', 0)
->offset(1)->limit(10)
->get();
//对应的底层SQL执行语句是:
select * from `posts` where `views` > 0 and `posts`.`deleted_at` is null limit 10 offset 0;
select * from `users` where `users`.`id` in (?, ?, ?, ?, ?, ?) and `email_verified_at` is not null;
~~~
这样就可以在返回的列表中看到关联的作者信息了,在遍历的时候可以通过`$post->author`获取,而无需每次加载,从而提高数据库查询性能。
## **多对多**
多对多关联比一对一和一对多关联复杂一些,需要借助一张中间表才能建立关联关系。以文章标签为例,文章表已经存在了,还需要创建一张`tags`表和中间表`post_tags`。
~~~
//创建Tags模型类及其对应数据表tags迁移文件
php artisan make:model Tag -m
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->unique()->comment('标签名');
$table->timestamps();
});
}
//创建post_tags数据表迁移文件
public function up()
{
Schema::create('post_tags', function (Blueprint $table) {
$table->increments('id');
$table->integer('post_id')->unsigned()->default(0);
$table->integer('tag_id')->unsigned()->default(0);
$table->unique(['post_id', 'tag_id']);
$table->timestamps();
});
}
~~~
接下来,我们在`Post`模型类中定义其与`Tags`模型类的关联关系,通过 Eloquent 提供的`belongsToMany`方法来实现:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags');
}
~~~
通过数据库填充器填充一些数据到`tags`表和`post_tags`表,这样我们就可以通过关联查询查询指定`Post`模型上的标签信息了:
~~~
$post = Post::findOrFail(1);
$tags = $post->tags;
~~~
### **Eloquent底层约定**
我们在定义多对多关联的时候,也没有指定通过哪些字段进行关联,这同样是遵循 Eloquent 底层默认约定的功劳,`belongsToMany`方法签名如下:
~~~
public function belongsToMany($related, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $relation = null)
~~~
除了第一个参数之外,其它参数都可以为空。第一个参数是关联模型的类名,这里是`Tag`;第二个参数`$table`是建立多对多关联的中间表名,该表名默认拼接规则如下:
~~~
$segments = [
$instance ? $instance->joiningTableSegment()
: Str::snake(class_basename($related)),
$this->joiningTableSegment(),
];
sort($segments);
return strtolower(implode('_', $segments));
~~~
其中`$this->joiningTableSegment()`将当前模型类名转化为小写字母+下划线格式(注意不是复数格式,所以并不是对应默认表名),`$instance`对应关联模型类实例,如果为空的话返回`Str::snake(class_basename($related))`,也会将关联类名转化为小写字母+下划线格式(也不是表名),然后对转化后的字符片段按字母表排序。所以本例中如果不指定中间表名,按照默认约定该值是`post_tag`。但是为了遵循 Laravel 数据表名都是复数,这里就自定义了一回。
第三个参数是`$foreignPivotKey`指的是中间表中当前模型类的外键,默认拼接规则和前面一对一、一对多一样,所以在本例中是`posts`表的`post_id`字段。
第四个参数`$relatedPivotKey`是中间表中当前关联模型类的外键,拼接规则和`$foreignPivotKey`一样,只不过作用于关联模型类,所以在本例中是`tags`表的`tag_id`字段。
第五个参数`$parentKey`表示对应当前模型的哪个字段(即`$foreignPivotKey`映射到当前模型所属表的哪个字段),默认是主键 ID,即`posts`表的`id`字段。
第六个参数`$relatedKey`表示对应关联模型的哪个字段(即`$relatedPivotKey`映射到关联模型所属表的哪个字段),默认是关联模型的主键 ID,即`tags`表的`id`字段。
最后一个参数`$relation`表示关联关系名称,用于设置查询结果中的关联属性,默认是关联方法名。
如果你没有遵循上述约定,需要手动指定自己的参数字段,不过还是建议遵循这些默认的约定,不然写着写着容易把自己绕晕。
### **建立相对的关联关系**
与之前的关联关系一样,多对多关联也支持建立相对的关联关系,而且由于多对多的双方是平等的,不存在谁归属谁的问题,所以建立相对关联的方法都是一样的,我们可以在`Tag`模型中通过`belongsToMany`方法建立其与`Post`模型的关联关系:
~~~
public function posts()
{
return $this->belongsToMany(Post::class, 'post_tags');
}
~~~
通过指定标签查看归属该标签下的所有文章,就可以用到类似的关联查询,相应的实现代码如下:
~~~
$tag = Tag::with('posts')->where('name', 'ab')->first();
$posts = $tag->posts;
~~~
### **获取中间表字段**
Eloquent 还提供了方法允许你获取中间表的字段,你仔细看查询结果字段,会发现`relations`字段中有一个`pivot`属性,中间表字段就存放在这个属性对象上:
![](https://img.kancloud.cn/8b/c5/8bc59abd4acedffd58c35c2ebd6c1597_892x1100.jpg)
我们在遍历返回结果的时候可以在循环中通过`$post->pivot->tag_id`获取中间表字段值。不过中间表默认只返回关联模型的主键字段,如果要返回额外字段,需要在定义关联关系的时候手动指定,比如如果想要返回时间戳信息,可以这么定义:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withTimestamps();
}
~~~
这样就可以返回文章标签创建时间和更新时间了:
![](https://img.kancloud.cn/f9/cf/f9cf7a562a9a6df9d234819a8c1baeff_1330x1098.jpg)
如果除此之外,你还在中间表中定义了额外的字段信息,比如`user_id`,可以通过`with`方法传入字段然后将其返回:
~~~
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withPivot('user_id')->withTimestamps();
}
~~~
## **远层一对多关联**
远层一对多在一对多关联的基础上加上了一个修饰词「远层」,意味着这个一对多关系不是直接关联,而是「远层」关联,远层怎么关联呢?借助中间表。
举个例子,如果我们的博客系统是针对全球市场的话,可能针对不同的国家推出不同的用户系统和功能,然后中国用户过来就只展示中国用户发布的文章,日本用户过来就只展示日本用户发布的文章。这里面涉及到三张表,存储国家的`countries`表,存储用户的`users`表,以及存储文章的`posts`表。用户与文章是一对多的关联关系,国家与用户之间是一对多的关联(一个用户只能有一个国籍),那么通过用户这张中间表,国家和文章之间就建立起来「远层」的一对多的关联。
### **建立远层的一对多关联关系**
了解这个关联的概念之后,我们要查询某个国家下的文章,要怎么做呢?
我们先创建`Country`模型类及其对应数据库迁移:
~~~
php artisan make:model Country -m
~~~
编写新生成的数据库迁移文件对应迁移类的`up`方法如下:
~~~
public function up()
{
Schema::create('countries', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->unique();
$table->string('slug', 100)->unique();
$table->timestamps();
});
}
~~~
然后,编写迁移文件为`users`表新增一个`country_id`字段:
~~~
php artisan make:migration alter_users_add_country_id --table=users
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->integer('country_id')->unsigned()->default(0);
$table->index('country_id');
});
}
~~~
准备好数据库、模型类并填充测试数据后,接下来,我们在`Country`模型类中通过 Eloquent 提供的`hasManyThrough`方法定义其与`Post`模型类之间的远层一对多关联:
~~~
public function posts()
{
return $this->hasManyThrough(Post::class, User::class);
}
~~~
其中,第一个参数是关联的模型类,第二个参数是中间借助的模型类。
这样,我们就可以在代码中通过`Country`模型实例获取归属于该国家的所有文章了,查询方式和前面其它关联查询一样,可以懒惰式加载,也可以渴求式加载:
~~~
$country = Country::findOrFail(1);
$posts = $country->posts;
~~~
### **Eloquent 底层约定**
同样,我们在通过`hasManyThrough`方法定义远层一对多关联关系的时候,并没有指定关联字段,因为我们在定义数据库字段、模型类的时候都遵循了 Eloquent 底层的约定。
我们来看一下`hasManyThrough`方法的完整签名:
~~~
public function hasManyThrough($related, $through, $firstKey = null, $secondKey = null, $localKey = null, $secondLocalKey = null)
~~~
其中,第一个参数和第二个参数分别是关联模型类和中间模型类。
第三个参数`$firstKey`表示中间模型类与当前模型类的关联外键,按照默认约定,在本例中拼接出来的字段是`country_id`,正好和我们在中间表`users`中新增的`country_id`吻合,所以不需要额外指定。
第四个参数`$secondKey`指的是中间模型类与关联模型类的关联外键,按照默认约定,在本例中拼接出来的字段是`user_id`,正好和我们在关联表`posts`中定义的`user_id`吻合,所以也不需要额外指定。
第五个参数`$localKey`默认是当前模型类的主键 ID,第六个参数是中间模型类的主键 ID。
如果你的字段定义与 Eloquent 底层默认约定拼接出来的字段不一致,需要手动指定对应参数。
## **一对一的多态关联**
多态关联允许目标模型通过单个关联归属于多种类型的模型,根据模型之间的关联关系类型,又可以将多态关联细分为一对一、一对多和多对多三种关联。首先我们来看最简单的一对一多态关联。
举个例子,在我们的博客系统中用户可以设置头像,文章也可以设置缩略图,我们知道每个用户只能有一个头像,一篇文章也只能有一个缩略图,所以此时用户和图片之间是一对一关联,文章和图片之间也是一对一关联,通过多态关联,我们可以让用户和文章共享与图片的一对一关联,我们只需要在图片模型类通过一次定义,就可以动态建立与用户和文章的关联。
要建立这种多态管理,需要图片表结构支持与对应用户和文章的关联,这里我们需要两个字段建立这种关联:1. 类型字段,表示归属于用户还是文章;2. ID字段,指向对应的用户/文章ID;So,结合这两个字段我们就能唯一确定该图片归属于哪个用户/哪篇文章了。
首先,我们创建图片模型类`Image`及其对应数据库迁移文件:
~~~
php artisan make:model Image -m
public function up()
{
Schema::create('images', function (Blueprint $table) {
$table->increments('id');
$table->string('url')->comment('图片URL');
$table->morphs('imageable');
$table->timestamps();
});
}
~~~
其中`$table->morphs('imageable')`用于创建`imageable_id`和`imageable_type`两个字段,其中`imageable_type`用于存放`User`模型类或`Post`模型类,而`imageable_id`用于存放对应的模型实例 ID,从而方便遵循默认约定建立多态关联。
然后,我们在模型类中建立一对一的多态关联:在`Image`模型类中通过`morphTo`建立其于`User/Post`模型类之间的关联:
~~~
public function imageable()
{
return $this->morphTo();
}
~~~
我们不需要指定任何字段,因为我们在创建数据表和定义关联方法的时候都遵循了 Eloquent 底层的约定,还是来看下`morphTo`方法的完整签名:
~~~
public function morphTo($name = null, $type = null, $id = null, $ownerKey =null )
~~~
第一个参数`$name`是关联关系名称,默认就是关联方法名,在本例中是`imageable`。
第二个参数`$type`、第三个参数`$id`结合第一个参数`$name`用于构建关联字段,在本例中就是`imageable_type`和`imageable_id`。由于我们的数据库字段和关联方法名都遵循了默认约定,所以不需要额外指定。如果你的数据库字段名是自定义的,比如`item_id`和`item_type`,那么就需要指定第一个参数值为`item`。
最后一个参数是当前模型类的主键 ID。
这样,我们就可以在`images`表中填充一些测试数据进行测试了,你可以借助填充器来填充,或者手动插入,需要注意的是在`imageable_type`字段中需要插入完整的类名作为类型,比如`App\User`或者`App\Post`,以便 Eloquent 在插询的时候结合`imageable_id`字段利用反射构造对应的模型实例:
![](https://img.kancloud.cn/3c/e7/3ce7cac1bd16aa62ca726a724503ce1e_2268x224.jpg)
这样,我们就可以在`Image`实例上获取其归属的模型实例了:
~~~
$image = Image::findOrFail(1);
$item = $image->imageable;
~~~
### **定义相对的关联关系**
我们在日常开发中,更常见的是获取某个用户的头像或者某篇文章的缩略图,这样我们就需要在`User`模型中定义其与`Image`模型的关联:
~~~
public function image()
{
return $this->morphOne(Image::class, 'imageable');
}
//Post模型中完全雷同
~~~
同样,因为我们遵循了 Eloquent 底册的约定,只需要传入最少的参数即可建立关联。`morphOne`方法的完整签名如下:
~~~
public function morphOne($related, $name, $type = null, $id = null, $localKey = null)
~~~
第一个参数表示关联的模型类。
第二个参数`$name`、第三个参数`$type`、第四个参数`$id`和前面的`morphTo`方法的前三个参数一样,用于在关联表中拼接关联外键,在本例中就是`imageable_type`和`imageable_id`,所以第三个和第四个参数不需要额外指定,当然如果你是用的是`item_id`和`item_type`字段需要将第二个参数设置为`item`,如果结尾不是以`type`和`id`作为后缀,也需要通过`$type`和`$id`参数传入。
最后一个参数`$localKey`表示当前模型类的主键 ID。
在模型类中定义完关联方法后,就可以在代码中通过相应方法获取关联模型了:
~~~
$post = Post::findOrFail(1);
$image = $post->image;
//对应的查询SQL语句为:
select * from `images`
where `images`.`imageable_id` = 1 and `images`.`imageable_id` is not null and `images`.`imageable_type` = "App\Post"
limit 1
~~~
## **一对多的多态关联**
理解了一对一的多态关联之后,一对多的多态关联理解起来就简单多了,其实就是模型类与关联类之间的关联变成一对多了,只不过这个一对多是多态的。
其实所谓多态,就是在关联表引入了类型的概念,关联表中的数据不再是与某一张表有关联,而是与多张表有关联,具体是哪张表通过关联类型来确定,具体与哪条记录关联,通过关联ID来确定。
举个例子,以Laravel学院为例,它支持两种类型的内容发布,一种是普通的文章,一种是独立的页面,分别存在两张表里。博客系统中免不了评论系统,用户可以评论普通文章,也可以评论页面,留言内容都放在评论表(结构完全一样)。如果单独看文章和评论,它们是一对多的关系,现在我们的评论表还要支持页面评论的存储,因此,需要引入一个类型字段做区分,这样文章/页面与评论之间的关联关系就变成一对多的多态关联了。
首先,我们还是创建评论模型类`Comment`及其数据库迁移文件:
~~~
php artisan make:model Comment -m
public function up()
{
Schema::create('comments', function (Blueprint $table) {
$table->increments('id');
$table->text('content')->comment('评论内容');
$table->integer('user_id')->unsigned()->default(0);
$table->morphs('commentable');
$table->index('user_id');
$table->softDeletes();
$table->timestamps();
});
}
~~~
然后在`Comment`模型类中通过 Eloquent 提供的`morphTo`方法定义其与`Post`模型和`Page`之间的一对多多态关联:
~~~
public function commentable()
{
return $this->morphTo();
}
~~~
因为一个评论只会对应一篇文章/页面,所以,通过和一对一的多态关联同样的`morphTo`方法定义其与文章和页面的关联关系即可。同时,因为我们的数据表字段和关联方法名都遵循了 Eloquent 底层的默认约定,所以不需要指定任何额外参数,即可完成关联关系的构建。这些默认约定我们在上面一对一多态关联中已经详细列出,这里就不再赘述了。
这样,我们就可以通过`Comment`实例查询其归属的文章或页面了:
~~~
$comment = Comment::findOrFail(1);
$item = $comment->commentable;
~~~
### **定义相对的关联关系**
同样我们在日常开发中,更多的是通过文章或页面实例获取对应的评论信息,比如在文章页或页面页获取该文章或页面的所有评论。为此,我们需要在`Post`模型类和`Page`模型类中定义其与`Comment`模型的关联关系,这需要通过`morphMany`方法来实现:
~~~
public function comments()
{
return $this->morphMany(Comment::class, 'commentable');
}
~~~
和`morphOne`方法一样,因为我们遵循了 Eloquent 底层的默认约定,所以只需要传递很少的必要参数就可以定义关联关系了,`morphMany`方法的完整签名如下:
~~~
public function morphMany($related, $name, $type = null, $id = null, $localKey = null)
~~~
这些参数的含义和`morphOne`方法完全一样,这里就不再赘述了。如果想要在`Post`模型下获取对应的所有评论,可以这么做:
~~~
$post = Post::with('comments')->findOrFail(23);
$comments = $post->comments;
//对应的查询SQL语句为:
select * from `comments`
where `comments`.`commentable_id` in (23) and `comments`.`commentable_type` = "App\Post" and `comments`.`deleted_at` is null
~~~
## **多对多的多态关联**
多对多的多态关联比前面的一对一和一对多更加复杂,但是有了前面讲解的基础,理解起来也很简单。你可以类比下常规的多对多关联,现在加入了「多态」的概念,常规的多对多需要借助中间表,多态的也是,只不过此时不仅仅是两张表之间的关联,而是也要引入类型字段。
举个例子,以文章和标签的关联为例,在常规的多对多关联中,中间表只需要一个标签 ID 和文章 ID 即可建立它们之间的关联,但当我们添加新的内容类型,比如页面、视频、音频,它们也有标签,而且完全可以共享一张标签表,此时仅仅一个文章 ID 已经满足不了定义内容与标签之间的关联了,所以此时引入多对多的多态关联,和前面两种多态关联思路一样,只是在多对多关联中,我们需要在中间表中引入类型字段来标识内容类型,将原来的文章ID调整为内容ID,这样就可以从数据库层面满足不同内容类型与标签之间的关联了。
首先我们要废弃原来的`post_tags`数据表,创建一个新的`taggables`数据表来构建不同内容类型与标签之间的关联:
~~~
php artisan make:migration create_taggables_table --create=taggables
Schema::create('taggables', function (Blueprint $table) {
$table->increments('id');
$table->integer('tag_id');
$table->morphs('taggable');
$table->index('tag_id');
$table->unique(['tag_id', 'taggable_id', 'taggable_type']);
$table->timestamps();
});
~~~
然后,在`Tag`模型类中通过Eloquent提供的`morphedByMany`方法定义其与其他模型类的多对多多态关联:
~~~
public function posts()
{
return $this->morphedByMany(Post::class, 'taggable');
}
public function pages()
{
return $this->morphedByMany(Page::class, 'taggable');
}
~~~
和之前一样,因为我们遵循了 Eloquent 底层的默认约定,所以我们只需传递必需参数,无需额外配置即可定义关联关系,我们来看下`morphedByMany`方法的完整签名:
~~~
public function morphedByMany($related, $name, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null)
~~~
其中第一个参数`$related`表示关联的模型类。
第二个参数`$name`表示关联的名称,和定义中间表数据库迁移的时候`morphs`方法中指定的值一致,也就是`taggable`。
第三个参数`$table`表示中间表名称,默认是第二个参数`$name`的复数格式,这里就是`taggables`了,因为我们在创建数据表的时候遵循了这一约定,所以不需要额外指定。
第四个参数`$foreignPivotKey`表示当前模型类在中间表中的外键,默认拼接结果是`tag_id`,和我们在数据表中定义的一样,所以这里不需要额外指定。
第五个参数`$relatedPivotKey`表示默认是通过`$name`和`_id`组合而来,表示中间表中的关联ID字段,这里组合结果是`taggable_id`,和我们定义的一致,也不需要额外指定。
第六个参数`$parentKey`默认表示当前模型类的主键 ID,即与中间表中`tag_id`关联的字段。
第七个参数`$relatedKey`表示关联模型类的主键 ID,这个因`$related`指定的模型而定。
如果你不是按照默认约定的规则定义的数据库字段,需要明确每一个参数的含义,然后传入对应的参数值,和之前一样,对新手来说,还是按照默认约定来比较好,免得出错。
定义好上述关联关系后,就可以查询指定标签模型上关联的文章/页面了:
~~~
$tag = Tag::with('posts', 'pages')->findOrFail(53);
$posts = $tag->posts;
$pages = $tag->pages;
~~~
### **定义相对的关联关系**
我们还可以在`Post`模型类或`Page`模型类中通过 Eloquent 提供的`morphToMany`方法定义该模型与`Tag`模型的关联关系(两个模型类中定义的方法完全一样):
~~~
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
~~~
因为我们遵循和 Eloquent 底层默认的约定,所以指定很少的参数就可以定义多对多的多态关联,`morphToMany`方法的完整签名如下:
~~~
public function morphToMany($related, $name, $table = null, $foreignPivotKey = null, $relatedPivotKey = null, $parentKey = null, $relatedKey = null, $inverse = false)
~~~
其中前七个参数和`morphedByMany`方法含义一致,只不过针对的关联模型对调过来,最后一个参数`$inverse`表示定义的是否是相对的关联关系,默认是`false`。如果你是不按套路出牌自定义的字段,需要搞清楚以上参数的含义并传入自定义的参数值。
定义好上述关联关系后,就可以通过`Post`模型或`Page`模型获取对应的标签信息了:
~~~
$post = Post::with('tags')->findOrFail(6);
$tags = $post->tags;
//对应的查询SQL语句为:
select `tags`.*, `taggables`.`taggable_id` as `pivot_taggable_id`,
`taggables`.`tag_id` as `pivot_tag_id`,
`taggables`.`taggable_type` as `pivot_taggable_type`
from `tags`
inner join `taggables` on `tags`.`id` = `taggables`.`tag_id`
where `taggables`.`taggable_id` in (6) and `taggables`.`taggable_type` = "App\Post"
~~~
## **基于关联查询过滤模型实例**
有的时候,可能需要根据关联查询的结果来过滤查询结果,比如我们想要获取所有发布过文章的用户,可以这么做:
~~~
$users = User::has('posts')->get();
//对应的EXISTS查询为:
select * from `users`
where exists (
select * from `posts`
where `users`.`id` = `posts`.`user_id` and `posts`.`deleted_at` is null
) and `email_verified_at` is not null
~~~
如果你想要进一步过滤发布文章数量大于 1 的用户,可以带上查询条件:
~~~
$users = User::has('posts', '>', 1)->get();
~~~
你甚至还可以通过嵌套关联查询的方式过滤发布的文章有评论的用户:
~~~
$users = User::has('posts.comments')->get();
~~~
此外,还有一个`orHas`方法,顾名思义,它会执行一个`OR`查询,比如我们想要过滤包含评论或标签的文章:
~~~
$posts = Post::has('comments')->orHas('tags')->get();
~~~
如果你想要通过更复杂的关联查询过滤模型实例,还可以通过`whereHas`/`orWhereHas`方法基于闭包函数定义查询条件,比如我们想要过滤发布文章标题中包含「Laravel学院」的所有用户:
~~~
$users = User::whereHas('posts', function ($query) {
$query->where('title', 'like', 'Laravel学院%');
})->get();
//对应的SQL语句为:
select * from `users`
where exists (
select * from `posts`
where `users`.`id` = `posts`.`user_id` and `posts`.`deleted_at` is null
and `title` like "Laravel学院%"
) and `email_verified_at` is not null
~~~
如果你想进一步过滤出文章标题和评论都包含「Laravel学院」的用户,可以在上述闭包函数中通过查询构建器进一步指定:
~~~
$users = User::whereHas('posts', function ($query) {
$query->where('title', 'like', 'Laravel学院%')
->whereExists(function ($query) {
$query->from('comments')
->whereRaw('`posts`.`id` = `comments`.`commentable_id`')
->where('content', 'like', 'Laravel学院%')
->where('commentable_type', Post::class)
->whereNull('deleted_at');
});
})->get();
~~~
与`has`/`orHas/whereHas/orWhereHas`方法相对的,还有一对`doesntHave`/`orDoesntHave/whereDoesntHave/orWhereDoesntHave`方法。很显然,它们用于过滤不包含对应关联结果的模型实例,使用方法一样,不再赘述。
### **统计关联模型**
我们还可以通过 Eloquent 提供的`withCount`方法在不加载关联模型的情况下统计关联结果的数量。比如我们想要统计某篇文章的评论数,可以这么做:
~~~
$post = Post::withCount('comments')->findOrFail(32);
~~~
![](https://img.kancloud.cn/87/de/87de4bcc8a89d8d4cafb5be5a6a47074_1028x468.jpg)
返回结果中包含了`comments_count`字段,通过这个字段就可以访问该文章的评论数。如果要统计其它关联模型结果数量字段,可以依次类推,对应字段都是`{relation}_count`结构。
> 注:实际开发中为了提高查询性能,我们往往是在`posts`表中冗余提供一个`comments_count`字段,每新增一条评论,该字段值加 1,查询的时候直接取该字段即可,从而提高查询的性能。
此外,你还可以通过数组传递多个关联关系一次统计多个字段,还可以通过闭包函数指定对应统计的过滤条件:
~~~
$post = Post::withCount(['tags', 'comments' => function ($query) {
$query->where('content', 'like', 'Laravel学院')
->orderBy('created_at', 'desc');
}])->findOrFail(32);
~~~
### **渴求式加载**
我们在前面已经介绍过,渴求式加载通过`with`方法实现:
~~~
$post = Post::with('author')->findOrFail(1);
$author = $post->author;
~~~
渴求式加载会在查询到模型实例结果后,通过`IN`查询获取关联结果,并将其附着到对应的模型实例上,在后面访问的时候不会再对数据库进行查询。所以不管模型实例有多少个,关联结果只会查询一次,加上模型本身查询总共是两次查询,在列表查询时,大大减少了对数据库的连接查询次数,因而有更好的性能表现,推荐使用。
渴求式加载支持一次加载多个关联模型(参数名对应相应的关联方法名):
~~~
$posts = Post::with('author', 'comments', 'tags')->findOrFail(1);
~~~
返回的数据格式如下:
![](https://img.kancloud.cn/ab/45/ab4598282749b478f038cee253b1afba_1400x1404.jpg)
此外,渴求式加载还支持嵌套查询,比如我们想要访问文章作者的扩展表信息,可以这么做:
~~~
$post = Post::with('author.profile')->findOrFail(1);
~~~
有时候,你可能觉得一次性加载所有关联数据有点浪费,对于特定条件下才使用的数据我们可以通过动态条件判断进行渴求式加载或者延迟加载。我们将这种加载叫做懒惰渴求式加载,这种加载可以通过`load`方法实现:
~~~
$users = User::all();
$condition = true;
if ($condition) {
$users->load('posts');
}
~~~
懒惰渴求式加载也是渴求式加载,只不过是在需要的时候才去加载,所以加上了「懒惰」这个修饰词,底层执行的 SQL 查询语句和渴求式加载是一样的。
## **关联插入与更新**
### **一对多关联记录插入**
新增关联模型的时候,可以在父模型上调用相应方法直接插入记录到数据库,这样做的好处是不需要指定关联模型与父模型的外键关联字段值,Eloquent 底层会自动判断并设置。比如,如果我们要在某篇文章上新增一条评论可以这么做:
~~~
$post = Post::findOrFail(1);
$faker = \Faker\Factory::create();
$comment = new Comment();
$comment->content = $faker->paragraph;
$comment->user_id = mt_rand(1, 15);
$post->comments()->save($comment);
~~~
Eloquent 底层会自动帮我们维护`commentable_id`和`commentable_type`字段。
还可以通过`saveMany`方法一次插入多条关联记录,前提是为关联模型配置了[批量赋值](''),比如我们为`Comment`模型类配置白名单`$fillable`属性如下(你也可以不配置批量赋值,但是需要多次实例化并逐个设置评论模型属性值,很麻烦):
~~~
protected $fillable = [
'content', 'user_id'
];
~~~
这样我们就可以批量插入文章评论数据了:
~~~
$post = Post::findOrFail(1);
$faker = \Faker\Factory::create();
$post->comments()->saveMany([
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]),
new Comment(['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)])
]);
~~~
此外,我们还可以通过`create`/`createMany`方法来插入关联数据,与`save`/`saveMany`方法不同的是,这两个方法接收的是数组参数:
~~~
// 插入一条记录
$post->comments()->create([
'content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)
]);
// 插入多条记录
$post->comments()->createMany([
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)],
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)],
['content' => $faker->paragraph, 'user_id' => mt_rand(1, 15)]
]);
~~~
### **更新一对多所属模型外键字段**
如果是要更新新创建的模型实例所属模型(父模型)的外键字段,比如以`posts`表为例,新增的记录想要更新`user_id`字段,可以这么实现:
~~~
$user = User::findOrFail(1);
$post->author()->associate($user);
$post->save();
~~~
相对的,如果想要解除当前模型与所属模型之间的关联,可以通过`dissociate`方法来实现:
~~~
$post->author()->dissociate();
$post->save();
~~~
这样,就会将`posts.user_id`置为`null`。前提是`user_id`允许为`null`,否则会抛出异常。
### **多对多关联的绑定与解除**
在插入多对多关联记录的时候,可以通过上面一对多关联记录插入的方式。以文章与标签为例,完全可以这样通过文章模型新增标签模型,同时更新中间表记录:
~~~
// 插入单条记录
$post->tags()->save(
new Tag(['name' => $faker->word])
);
// 如果中间表接收额外参数可以通过第二个参数传入
$post->tags()->save(
new Tag(['name' => $faker->word]),
['user_id' => 1]
);
// 插入多条记录
$post->tags()->saveMany([
new Tag(['name' => $faker->unique()->word]),
new Tag(['name' => $faker->unique()->word]),
new Tag(['name' => $faker->unique()->word])
]);
// 如果插入多条记录需要传递中间表额外字段值(通过键值关联对应记录与额外字段)
$post->tags()->saveMany([
1 => new Tag(['name' => $faker->unique()->word]),
2 => new Tag(['name' => $faker->unique()->word]),
3 => new Tag(['name' => $faker->unique()->word])
], [
1 => ['user_id' => 1],
2 => ['user_id' => 2],
3 => ['user_id' => 3],
]);
~~~
此外,Eloquent 底层还提供了为已有模型之间进行多对多关联的绑定和解除操作。还是以文章和标签为例,要将两个本来没有关联关系的记录绑定起来,可以通过`attach`方法实现:
~~~
$post = Post::findOrFail(1);
$tag = Tag::findOrFail(1);
$post->tags()->attach($tag->id);
// 如果中间表还有其它额外字段,可以通过第二个数组参数传入
// $post->tags()->attach($tag->id, ['user_id' => $userId]);
// 还可以一次绑定多个标签
// $post->tags()->attach([1, 2]);
// 如果绑定多个标签,要传递额外字段值,可以这么做:
/*$post->tags()->attach([
1 => ['user_id' => 1],
2 => ['user_id' => 2]
]);*/
~~~
如果要解除这个关联关系可以通过`detach`方法实现:
~~~
$post->tags()->detach(1);
// 如果想要一次解除多个关联,可以这么做:
// $post->tags()->detach([1, 2]);
// 如果想要一次解除所有关联,可以这么做:
// $post->tags()->detach();
~~~
上面这两种方法很方便,但还有更方便的,当我们在更新某篇文章的标签时,往往同时涉及关联标签的绑定和解除。按照上面的逻辑,我们需要先把所有标签记录查询出来,再判断哪些需要绑定关联、哪些需要解除关联、哪些需要插入新的标签记录,然后再通过`attach`和`detach`方法最终完成与对应文章的绑定和解除关联。
对于那些已存在的标签记录,我们可以通过更高效的方法与文章进行关联关系的绑定和解除,这个方法就是`sync`,调用该方法时只需传入刚创建/更新后文章的标签对应 ID 值,至于哪些之前不存在的关联需要绑定,哪些存在的关联需要解除,哪些需要维护现状,交由 Eloquent 底层去判断:
~~~
$post->tags()->sync([1, 2, 3]);
~~~
如果对应新增数据需要传递额外参数,参考`attach`即可,两者是一样的。
有时候,你可能仅仅是想要更新中间表字段值,这个时候,可以通过`updateExistingPivot`方法在第二个参数中将需要更新的字段值以关联数组的方式传递过去:
~~~
$post->tags()->updateExistingPivot($tagId, $attributes);
~~~
### **触发父模型时间戳更新**
当一个模型归属于另外一个模型时,例如`Comment`模型归属于`Post`模型,当子模型更新时,父模型的更新时间也同步更新往往很有用,比如在有新评论时触发文章页缓存更新,或者通知搜索引擎页面有更新等等。Eloquent 提供了这种同步机制帮助我们更新子模型时触发父模型的更新时间`updated_at`字段值更新,要让该机制生效,需要在子模型中配置`$touches`属性:
~~~
// 要触发更新的父级关联关系
protected $touches = [
'commentable'
];
~~~
属性值是对应关联方法的名称,支持配置多个关联关系。