进阶篇(七):Eloquent 模型关联关系(上)
我们所熟知的 MySQL、SQL Server、Oracle 都是关系型数据库,何谓关系型数据库?简单来说就是数据表之间存在关联关系。到目前为止,我们介绍的所有 Eloquent 模型操作都是针对单表的,接下来我们将花三篇左右的篇幅来给大家介绍如何在 Eloquent 模型类中建立模型之间的各种关联关系,以及如何实现关联查询和更新。
Eloquent 模型支持的关联关系包括以下七种:
- 一对一
- 一对多
- 多对多
- 远层一对多
- 多态关联(一对一)
- 多态关联(一对多)
- 多态关联(多对多)
下面我们将以设计一个简单的博客系统数据库为例一一介绍上述关联关系。
一对一
建立关联关系
一对一是最简单的关联关系,一般可用于某张数据表的扩展表与主表之间的关联关系。比如在大型系统中,我们的用户表通常用于最基本信息的存储,如邮箱、用户名、密码等,然后像用户爱好、标签、个性签名、所在地等信息都存到另一张扩展表中,需要的时候才会去扩展表取数据,从而提高查询性能。针对这样的场景,我们就可以在两张表对应模型之间建立一对一关联。
在开始之前,我们先通过数据库迁移创建一张 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
结果如下:
Eloquent 底层约定
需要指出的是,在关联关系的建立过程中,Eloquent 也遵循了「约定大于配置」的原则。你可能注意到了我们在定义关联关系时,仅仅指定了模型类名,并没有指定通过哪些数据表字段建立关联,这并不是说 Laravel 神通广大,能知过去未来之事,而是因为 Eloquent 对此做了默认的约定。hasOne
方法的完整签名是:
public function hasOne($related, $foreignKey = null, $localKey = null)
其中,第一个参数是关联模型的类名,第二个参数是关联模型类所属表的外键,这里对应的是 user_profiles
表的 user_id
字段,第三个参数是关联表的外键关联到当前模型所属表的哪个字段,这里对应的是 users
表的 id
字段。为什么我们不需要指定 Laravel 就能完成这种关联呢,这是因为如果没有指定 $foreignKey
,Eloquent 底层会通过如下方法去拼接:
public function getForeignKey()
{
return Str::snake(class_basename($this)).'_'.$this->getKeyName();
}
你可以看到,在本例中,拼接的结果正好是 user_id
。
同样,没有指定 $localKey
的话,Eloquent 底层会返回主键 ID:
public function getKeyName()
{
return $this->primaryKey;
}
在本例中,就是 id
了。
遵循这种默认的约定,可以帮我们少写很多代码,减少很多额外的配置,所以如果不是迫不得已(比如从其他系统迁移过来),建议你在使用 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;
打印 $user
用户信息如下:
同样,和 hasOne
方法一样,belongsTo
方法也是遵循了默认的约定规则,其完整方法签名如下:
public function belongsTo($related, $foreignKey = null, $ownerKey = null, $relation = null)
其中第一个参数是关联模型的类名。
第二个参数是当前模型类所属表的外键,在本例中是 user_profiles
表的 user_id
字段,拼接规则和 hasOne
那里类似,只不过这里是基于第四个参数关联关系名称 $relation
:
if (is_null($relation)) {
$relation = $this->guessBelongsToRelation();
}
...
if (is_null($foreignKey)) {
$foreignKey = Str::snake($relation).'_'.$instance->getKeyName();
}
$relation
默认约定是对应关联关系方法名,这里的是 user
。如果你这里定义的方法名不是 user
,则需要手动指定外键参数。
第三个参数是关联模型类所属表的主键:
$instance = $this->newRelatedInstance($related);
...
$ownerKey = $ownerKey ?: $instance->getKeyName();
第四个参数前面已经说过,默认约定就是关联关系方法名,也是关联关系动态属性名。
还是和之前一样,建议遵循这些默认约定,可以少写很多代码,避免配置出错导致程序bug。如果实在满足不了这些默认的约定,只能指定按照上述约定指定传入自己的参数了。
一对一关联很简单,但是我们还是花了很长的篇幅来讨论,因为后面其它关联的实现思路、访问方式、底层约定都是类似的,掌握了一对一关联,就能更好的理解和掌握其它关联关系的创建和使用。
一对多
建立关联关系
一对多关联是我们日常开发中经常碰到的一种关联关系。以博客系统为例,一个用户可以发布多篇文章,反过来,一篇只能归属于一个用户,那么用户和文章之间就是一对多的关系,同样,用户可以发布多条评论,一条评论只能归属于一个用户,用户与评论之间也是一对多关系。
要定义用户文章之间的一对多关联,可以在 User
模型类中通过 Eloquent 底层提供的 hasMany
方法来实现:
public function posts()
{
return $this->hasMany(Post::class);
}
由于我们之间已经创建过 users
表和 posts
表,并且初始化过数据,所以我们可以直接通过动态属性的方式来调用用户模型上的文章:
$user = User::findOrFail(1);
$posts = $user->posts;
与 hasOne
返回的是单个模型实例不一样,hasMany
返回的是模型类集合:
Eloquent 底层约定
和 hasOne
方法一样,hasMany
方法底层也对如何建立关联关系做了约定,而且 hasMany
方法和 hasOne
方法的签名一样:
public function hasMany($related, $foreignKey = null, $localKey = null)
$foreignKey
和 $localKey
默认获取逻辑也和 hasOne
完全一样,这里不再赘述。其实你完全可以把一对一关联看作一对多关联的简化版本,只不过一对一退化为只返回一条记录,所以实现逻辑一样也不难理解了。
如果你的数据表结构不符合这种默认约定,可以自定义传入对应字段参数值。
建立相对的关联关系
与一对一一样,我们可以在文章模型中建立于用户模型之间的相对关联关系,而且这种使用场景很普遍,比如在文章详细页或列表页显示文章作者信息。还是通过 Eloquent 提供的 belongsTo
方法来实现:
public function user()
{
return $this->belongsTo(User::class);
}
这样,我们就可以在文章模型实例上通过动态属性 user
来访问对应的用户信息:
$post = Post::findOrFail(29);
$author = $post->user;
belongsTo
方法的底层约定我们在前面一对一关联中已经讨论过。这里,如果你想要让代码可读性更好,可以将 Post
模型中的关联关系调方法名修改为 author
,这样,我们就需要手动指定更多的 belongsTo
方法传入参数了:
public function author()
{
return $this->belongsTo(User::class, 'user_id', 'id', 'author');
}
相应的访问方式也需要做调整:
$author = $post->author;
返回结果完全一样。
渴求式加载
前面我们演示的关联关系查询都是通过动态属性的方式,这种加载方式叫做「懒惰式加载」,因为都是用到的时候才回去查询,这就意味着要多次对数据库的进行查询才能返回需要的结果。如果是单条记录获取关联关系,就需要两次查询;如果是多条记录获取关联关系,比如文章列表页获取作者信息,因为每篇文章的作者通过动态属性获取都有一次查询,所以对N条记录来说,需要「N+1」次查询才能返回需要的结果,从数据库查询优化的角度来说,显然是不合理的。能不能一次就返回所有的关联查询结果呢?
可以,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
编写 create_tags_table
迁移文件对应类的 up
方法如下:
public function up()
{
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
$table->string('name', 100)->unique()->comment('标签名');
$table->timestamps();
});
}
然后创建 post_tags
数据表迁移文件:
php artisan make:migration create_post_tags_table --create=post_tags
编写其对应迁移类的 up
方法如下:
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();
});
}
运行 php artisan migrate
让迁移生效。
接下来,我们在 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;
返回的是个模型集合:
当然,你也可以通过前面提到的渴求式加载方式获取标签数据:
$post = Post::with('tags')->find(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
属性,中间表字段就存放在这个属性对象上:
我们在遍历返回结果的时候可以在循环中通过 $post->pivot->tag_id
获取中间表字段值。不过中间表默认只返回关联模型的主键字段,如果要返回额外字段,需要在定义关联关系的时候手动指定,比如如果想要返回时间戳信息,可以这么定义:
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withTimestamps();
}
这样就可以返回文章标签创建时间和更新时间了:
如果除此之外,你还在中间表中定义了额外的字段信息,比如 user_id
,可以通过 with
方法传入字段然后将其返回:
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->withPivot('user_id')->withTimestamps();
}
自定义中间表模型类
你还可以通过自定义中间表对应模型类实现更多自定义操作,中间表模型类继承自 Illuminate\Database\Eloquent\Relations\Pivot
,Pivot
也是 Eloquent Model 类的子类,只不过为中间表操作定义了很多方法和属性,比如我们创建一个自定义的中间表模型类 PostTag
:
namespace App;
use Illuminate\Database\Eloquent\Relations\Pivot;
class PostTag extends Pivot
{
protected $table = 'post_tags';
}
这样,我们在定义多对多关联关系的时候指定自定义的模型类了:
public function tags()
{
return $this->belongsToMany(Tag::class, 'post_tags')->using(PostTag::class);
}
更多中间表操作
此外,如果你觉得 pivot
可读性不好,你还可以自定义中间表实例属性名称:
$this->belongsToMany(Tag::class, 'post_tags')->as('taggable')->withTimestamps();
这样,就可以通过 $tag->taggable->created_at
访问中间表字段值了。
还可以通过中间表字段值过滤关联数据(支持 where
和 in
查询):
return $this->belongsToMany(Tag::class, 'post_tags')->wherePivot('user_id', 1);
return $this->belongsToMany(Tag::class, 'post_tags')->wherePivotIn('user_id', [1, 2]);
好了,今天先到这,明天我们继续探讨更高级的关联关系。
17 Comments
补充 belongsToMany 最后一个参数也是同样的情况.
嗯 确实是只能通过关联方法名获取对应信息
$relationName
这个参数目前还没有看到有什么具体的查询用途 除了设置关联关系的对应属性外$table->json('hobby')怎么用$faker填充数据
$faker->words(3, false)
打错字了!“还是很之前一样”应该是“还是和之前一样”
感谢反馈 已修正 🙏
问一个问题,这些关联方法不是成员函数吗,为什么调用的时候不加括号呀