[ Laravel 从入门到精通 ] 数据库和 Eloquent 进阶 —— Eloquent 模型关联关系(下)

在前面两篇教程中,学院君陆续给大家介绍了 Eloquent 模型类支持的七种关联关系,通过底层提供的关联方法,我们可以快速实现模型间的关联,并且进行关联查询。今天我们将在定义好模型关联的基础上进行关联查询、插入和更新操作,看看如何借助模型关联提高代码的可读性并提高编码效率。

关联查询

关于关联查询,我们在前面介绍关联关系定义的时候已经穿插着介绍过,这里简单回顾下。在 Eloquent 模型上进行关联查询主要分为两种方式,一种是懒惰式加载(动态属性),一种是渴求式加载(通过with方法)。从性能上来说,渴求式加载更优,因为它会提前从数据库一次性查询所有关联数据,而懒惰式加载在每次查询动态属性的时候才会去执行查询,会多次连接数据库,性能上差一些(数据库操作主要开销在数据库连接上,所以在开发过程中如果想优化性能,尽量减少频繁连接数据库)。

懒惰式加载

下面这种方式就是懒惰式加载:

$post = Post::findOrFail(1);
$author = $post->author;

每次访问 author 属性都会执行一次数据库查询,如果返回的文章结果是列表的话,需要遍历获取作者信息,假设要循环 N 次的话,加上文章模型本身的获取,总共需要进行 N + 1 次查询,而 PHP 对数据库的连接是短连接,每次都要重新连接数据库,所以从性能角度考虑不建议使用这种方式。

另外,如果访问的是模型实例上的 author() 方法时,返回的不是用户实例了,而是一个关联关系实例,该实例注入了查询构建器,所以你可以在其基础上通过方法链的方式构建查询构建器进行更加复杂的查询,我们以一个一对多的查询为例:

$user = User::findOrFail(1);
$posts = $user->posts()->where('views', '>', 0)->get();

这样,我们就可以过滤出该用户发布的文章中浏览数大于 1 的结果。

基于关联查询过滤模型实例

有结果过滤

有的时候,可能需要根据关联查询的结果来过滤查询结果,比如我们想要获取所有发布过文章的用户,可以这么做:

$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();

底层执行的 SQL 查询语句如下:

select
  *
from
  `users`
where
  (
    select
      count(*)
    from
      `posts`
    where
      `users`.`id` = `posts`.`user_id`
      and `posts`.`deleted_at` is null
  ) > 1
  and `email_verified_at` is not null

你甚至还可以通过嵌套关联查询的方式过滤发布的文章有评论的用户:

$users = User::has('posts.comments')->get();

其实也就是一个嵌套的 EXISTS 查询:

此外,还有一个 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 查询语句如下:

如果你想进一步过滤出文章标题和评论都包含「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();

如果你想过滤文章标题或评论都包含「Laravel学院」的用户,将 whereExists 换成 orWhereExists 方法即可:

$users = User::whereHas('posts', function ($query) {
    $query->where('title', 'like', 'Laravel学院%')
       ->orWhereExists(function ($query)  {
          $query->from('comments')
              ->whereRaw('`posts`.`id` = `comments`.`commentable_id`')
              ->where('content', 'like', 'Laravel学院%')
              ->where('commentable_type', Post::class)
              ->whereNull('deleted_at');
      });
})->get();

如果不想自己构造查询构建器,还可以通过方法链的方式实现上述同样的功能:

// and 
$users = User::whereHas('posts', function ($query) {
    $query->where('title', 'like', 'Laravel学院%');
})->whereHas('posts.comments', function ($query) {
    $query->where('content', 'like', 'Laravel学院%');
})->get();

// or    
$users = User::whereHas('posts', function ($query) {
    $query->where('title', 'like', 'Laravel学院%');
})->orWhereHas('posts.comments', function ($query) {
    $query->where('content', 'like', 'Laravel学院%');
})->get();

无结果过滤

has/orHas 方法相对的,还有一对 doesntHave/orDoesntHave 方法。很显然,它们用于过滤不包含对应关联结果的模型实例。比如我们想要那些没有发布过文章的用户,可以通过 doesntHave 方法实现:

$users = User::doesntHave('posts')->get();

获取的结果也是模型实例集合:

底层执行的 SQL 语句一个 NOT EXISTS 查询:

select
  *
from
  `users`
where
  not exists (
    select
      *
    from
      `posts`
    where
      `users`.`id` = `posts`.`user_id`
      and `posts`.`deleted_at` is null
  )
  and `email_verified_at` is not null

如果想要获取没有评论或没有标签的文章,可以结合 doesntHaveorDoesntHave 方法实现:

$posts = Post::doesntHave('comments')->doesntHave('tags')->get();

对应的 SQL 语句是:

whereHas 方法和 orWhereHas 方法相对的,也有 whereDoesntHaveorWhereDoesntHave 方法,使用方法一样,这里就不再赘述了。

统计关联模型

我们还可以通过 Eloquent 提供的 withCount 方法在不加载关联模型的情况下统计关联结果的数量。比如我们想要统计某篇文章的评论数,可以这么做:

$post = Post::withCount('comments')->findOrFail(32);

我们查看下返回的 $post 模型实例的数据结构:

其中包含了 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);

甚至还可以为统计字段设置别名,以便可以从不同维度统计某个字段:

$post = Post::withCount([
    'comments',
    'comments as pending_comments' => function($query) {
        $query->where('status', Comment::PENDING);
    }
])->findOrFail(32);

对应的返回结果如下:

这个功能用于不考虑性能的场景进行快速查询还是很方便的,但如果对性能有较高要求,则不推荐使用,毕竟是要执行多次查询才能逐个统计出来。

渴求式加载

我们在前面已经介绍过,渴求式加载通过 with 方法实现:

$post = Post::with('author')->findOrFail(1);
$author = $post->author;

渴求式加载会在查询到模型实例结果后,通过 IN 查询获取关联结果,并将其附着到对应的模型实例上,在后面访问的时候不会再对数据库进行查询。所以不管模型实例有多少个,关联结果只会查询一次,加上模型本身查询总共是两次查询,在列表查询时,大大减少了对数据库的连接查询次数,因而有更好的性能表现,推荐使用。

渴求式加载支持一次加载多个关联模型(参数名对应相应的关联方法名):

$posts = Post::with('author', 'comments', 'tags')->findOrFail(1);

返回的数据格式如下:

此外,渴求式加载还支持嵌套查询,比如我们想要访问文章作者的扩展表信息,可以这么做:

$post = Post::with('author.profile')->findOrFail(1);

这样就可以嵌套获取到 profile 表记录的信息:

这里会涉及到三个 SQL 查询:

select * from `posts` where `posts`.`id` = ? and `posts`.`deleted_at` is null limit 1;
select * from `users` where `users`.`id` in (?) and `email_verified_at` is not null;
select * from `user_profiles` where `user_profiles`.`user_id` in (?);

你还可以通过 with 方法指定要加载的字段:

$post = Post::with('author:id,name')->findOrFail(1);

注:使用此特性 id 字段必须列出。

在渴求式加载中,也可以通过闭包传入额外的约束条件,只不过这个约束条件是对关联模型自身的过滤,不影响目标模型的查询:

$post = Post::with(['comments' => function ($query) {
    $query->where('content', 'like', 'Laravel学院%')
        ->orderBy('created_at', 'desc');
}])->where('id', '<', 5)->get();

底层执行的 SQL 语句如下:

select
  *
from
  `posts`
where
  `id` < 5
  and `posts`.`deleted_at` is null

select
  *
from
  `comments`
where
  `comments`.`commentable_id` in (1, 2, 3, 4)
  and `comments`.`commentable_type` = "App\Post"
  and `content` like "Laravel学院%"
  and `comments`.`deleted_at` is null
order by
  `created_at` desc

懒惰渴求式加载

有时候,你可能觉得一次性加载所有关联数据有点浪费,对于特定条件下才使用的数据我们可以通过动态条件判断进行渴求式加载或者延迟加载。我们将这种加载叫做懒惰渴求式加载,这种加载可以通过 load 方法实现:

$users = User::all();
$condition = true;

if ($condition) {
    $users->load('posts');
}

懒惰渴求式加载也是渴求式加载,只不过是在需要的时候才去加载,所以加上了「懒惰」这个修饰词,底层执行的 SQL 查询语句和渴求式加载是一样的:

select
  *
from
  `posts`
where
  `posts`.`user_id` in (?, ?, ?, ?, ?)
  and `posts`.`deleted_at` is null

和渴求式加载一样,它也支持通过闭包传递额外的约束条件:

$posts = Post::where('id', '<=', 3)->get();
$posts->load(['comments' => function ($query) {
    $query->where('content', 'like', 'Laravel学院%')
        ->orderBy('created_at', 'desc');
}]);

关联插入与更新

一对多关联记录插入

新增关联模型的时候,可以在父模型上调用相应方法直接插入记录到数据库,这样做的好处是不需要指定关联模型与父模型的外键关联字段值,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_idcommentable_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,否则会抛出异常。

空对象模型

如果外键字段 user_id 允许为空的话,当我们访问 Post 模型上的 author 属性时,默认返回为 null。Eloquent 允许我们为这种空对象定义一个默认的类型,这个对象的类型可以在定义关联关系的时候指定:

public function author()
{
    return $this->belongsTo(User::class, 'user_id', 'id', 'author')
        ->withDefault();
}

这样,再次访问对应 Post 模型实例的时候返回的就是一个空的 App\User 实例,你还可以为这个对象指定默认属性值:

public function author()
{
    return $this->belongsTo(User::class, 'user_id', 'id', 'author')
        ->withDefault([
            'id' => 0,
            'name' => '游客用户',
        ]);
}

再次访问对应 Post 模型上的 author 属性时,就会返回如下默认的空对象了:

该特性其实应用了设计模式中的空对象模式,好处是在代码里可以为不同情况编写一致性代码。这样,我们就不需要在每个地方去判断如果文章作者信息为空该如何处理了,因为这种情况下返回的也是一个正常的 User 模型实例。

多对多关联的绑定与解除

在插入多对多关联记录的时候,可以通过上面一对多关联记录插入的方式。以文章与标签为例,完全可以这样通过文章模型新增标签模型,同时更新中间表记录:

// 插入单条记录
$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();

上面这两种方法很方便,但还有更方便的,当我们在更新某篇文章的标签时,往往同时涉及关联标签的绑定和解除。按照上面的逻辑,我们需要先把所有标签记录查询出来,再判断哪些需要绑定关联、哪些需要解除关联、哪些需要插入新的标签记录,然后再通过 attachdetach 方法最终完成与对应文章的绑定和解除关联。

对于那些已存在的标签记录,我们可以通过更高效的方法与文章进行关联关系的绑定和解除,这个方法就是 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'
];

属性值是对应关联方法的名称,支持配置多个关联关系。下面我们简单演示下,以 id=31 的评论记录为例,对应的模型数据及所属文章模型数据如下:

现在,我们更新下对应的 Comment 模型数据并保存:

$comment = Comment::findOrFail(31);
$comment->content = 'Laravel学院致力于提供优质Laravel中文学习资源';
$comment->save();

再次查看评论模型及对应文章模型数据,可以看到文章模型的更新事件和评论模型的更新时间已经一致了:

结语

好了,关于关联关系我们就介绍到这里,我们分了三篇的篇幅来介绍 Eloquent 模型的管理关系,回顾一下,主要包含以下内容:

  • 七种关联关系的定义:一对一、一对多、多对多、远层一对多、一对一的多态关联、一对多的多态关联、多对多的多态关联;
  • 以上关联关系的查询,主要包含两种方式:懒惰式加载和渴求式加载;
  • 基于关联查询构架复杂查询对查询结果进行过滤;
  • 关联模型的更新、插入和删除操作。

希望你看完学院君的这一系列教程可以了解并完全掌握 Eloquent 模型的定义和使用,有什么问题,欢迎随时与我交流。

学院君 has written 1076 articles

Laravel学院院长,终身学习者

积分:134042 等级:P12 职业:手艺人 城市:杭州

0 条回复

登录后才能进行评论,立即登录?