学习掌握 Laravel 中的查询范围

最后更新于 作者:

Learn to master Query Scopes in Laravel image

在构建 Laravel 应用程序时,您可能需要编写查询,这些查询具有在整个应用程序中多个位置使用的约束条件。也许您正在构建一个多租户应用程序,并且需要不断向查询中添加 `where` 约束条件,以便根据用户的团队进行过滤。或者,也许您正在构建一个博客,并且需要不断向查询中添加 `where` 约束条件,以便根据博客文章是否发布进行过滤。

在 Laravel 中,我们可以使用查询范围来帮助我们保持这些约束条件整洁并在一个地方可重用。

在本文中,我们将探讨本地查询范围和全局查询范围。我们将了解两者之间的区别、如何创建自己的范围以及如何编写它们的测试。

在阅读完本文后,您应该对在 Laravel 应用程序中使用查询范围充满信心。

什么是查询范围?

查询范围允许您以可重用的方式在 Eloquent 查询中定义约束条件。它们通常定义为 Laravel 模型上的方法,或者作为实现 `Illuminate\Database\Eloquent\Scope` 接口的类。

它们不仅非常适合在一个地方定义可重用的逻辑,而且还可以通过将复杂的查询约束隐藏在简单的函数调用后面来使您的代码更易读。

查询范围有两种类型

  • 本地查询范围 - 您必须手动将这些范围应用于您的查询。
  • 全局查询范围 - 这些范围在查询注册后默认应用于模型上的所有查询。

如果您曾经使用过 Laravel 的内置“软删除”功能,您可能已经在不知不觉中使用了查询范围。Laravel 使用本地查询范围为您提供模型上的 `withTrashed` 和 `onlyTrashed` 等方法。它还使用全局查询范围自动向模型上的所有查询添加 `whereNull('deleted_at')` 约束条件,这样默认情况下软删除的记录不会在查询中返回。

让我们看看如何在 Laravel 应用程序中创建和使用本地查询范围和全局查询范围。

本地查询范围

本地查询范围定义为 Eloquent 模型上的方法,允许您定义可以手动应用于模型查询的约束条件。

假设我们正在构建一个具有管理面板的博客应用程序。在管理面板中,我们有两个页面:一个用于列出已发布的博客文章,另一个用于列出未发布的博客文章。

我们假设博客文章是使用 `\App\Models\Article` 模型访问的,并且数据库表有一个可为空的 `published_at` 列,用于存储博客文章要发布的日期和时间。如果 `published_at` 列在过去,则认为博客文章已发布。如果 `published_at` 列在将来或为 `null`,则认为博客文章未发布。

要获取已发布的博客文章,我们可以编写以下查询

use App\Models\Article;
 
$publishedPosts = Article::query()
->where('published_at', '<=', now())
->get();

要获取未发布的博客文章,我们可以编写以下查询

use App\Models\Article;
use Illuminate\Contracts\Database\Eloquent\Builder;
 
$unpublishedPosts = Article::query()
->where(function (Builder $query): void {
$query->whereNull('published_at')
->orWhere('published_at', '>', now());
})
->get();

上面的查询并不特别复杂。但是,假设我们在整个应用程序中的多个位置使用它们。随着发生次数的增加,我们更有可能犯错或忘记在一个地方更新查询。例如,开发人员可能会在查询已发布的博客文章时意外使用 `>=` 而不是 `<=`。或者,确定博客文章是否已发布的逻辑可能会更改,我们需要更新所有查询。

这就是查询范围非常有用的地方。因此,让我们通过在 `\App\Models\Article` 模型上创建本地查询范围来整理我们的查询。

本地查询范围通过创建以 `scope` 开头并以范围的预期名称结尾的方法来定义。例如,名为 `scopePublished` 的方法将在模型上创建一个 `published` 范围。该方法应该接受一个 `Illuminate\Contracts\Database\Eloquent\Builder` 实例并返回一个 `Illuminate\Contracts\Database\Eloquent\Builder` 实例。

我们将这两个范围都添加到 `\App\Models\Article` 模型中

declare(strict_types=1);
 
namespace App\Models;
 
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
 
final class Article extends Model
{
public function scopePublished(Builder $query): Builder
{
return $query->where('published_at', '<=', now());
}
 
public function scopeNotPublished(Builder $query): Builder
{
return $query->where(function (Builder $query): Builder {
return $query->whereNull('published_at')
->orWhere('published_at', '>', now());
});
}
 
// ...
}

正如我们在上面的示例中看到的,我们将之前的查询中的 `where` 约束条件移到了两个单独的方法中:`scopePublished` 和 `scopeNotPublished`。现在,我们可以在查询中使用这些范围,如下所示

use App\Models\Article;
 
$publishedPosts = Article::query()
->published()
->get();
 
$unpublishedPosts = Article::query()
->notPublished()
->get();

在我个人看来,我发现这些查询更易读易懂。这也意味着如果我们将来需要使用相同约束条件编写任何查询,我们可以重用这些范围。

全局查询范围

全局查询范围执行与本地查询范围类似的功能。但它们不是手动应用于每个查询,而是自动应用于模型上的所有查询。

正如我们之前提到的,Laravel 的内置“软删除”功能利用了 `Illuminate\Database\Eloquent\SoftDeletingScope` 全局查询范围。此范围会自动向模型上的所有查询添加 `whereNull('deleted_at')` 约束条件。如果您有兴趣了解它在幕后是如何工作的,可以在此处查看 GitHub 上的源代码

例如,假设您正在构建一个具有管理面板的多租户博客应用程序。您只希望允许用户查看属于其团队的文章。因此,您可能会编写以下查询

use App\Models\Article;
 
$articles = Article::query()
->where('team_id', Auth::user()->team_id)
->get();

此查询很好,但很容易忘记添加 `where` 约束条件。如果您在编写另一个查询时忘记添加约束条件,您最终会在应用程序中出现一个错误,该错误将允许用户与不属于其团队的文章进行交互。当然,我们不希望这种情况发生!

为了防止这种情况,我们可以创建一个全局范围,我们可以将其自动应用于我们所有的 `App\Model\Article` 模型查询。

如何创建全局查询范围

让我们创建一个全局查询范围,该范围根据 `team_id` 列过滤所有查询。

请注意,为了本文的简单起见,我们保持示例简单。在实际应用中,您可能希望使用更健壮的方法来处理诸如用户未经身份验证或用户属于多个团队等情况。但现在,让我们保持简单,以便我们可以专注于全局查询范围的概念。

我们将从在终端中运行以下 Artisan 命令开始

php artisan make:scope TeamScope

这应该会创建一个新的 `app/Models/Scopes/TeamScope.php` 文件。我们将对该文件进行一些更新,然后查看完成后的代码

declare(strict_types=1);
 
namespace App\Models\Scopes;
 
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
use Illuminate\Support\Facades\Auth;
 
final readonly class TeamScope implements Scope
{
/**
* Apply the scope to a given Eloquent query builder.
*/
public function apply(Builder $builder, Model $model): void
{
$builder->where('team_id', Auth::user()->team_id);
}
}

在上面的代码示例中,我们可以看到我们有一个新类,它实现了 `Illuminate\Database\Eloquent\Scope` 接口并具有一个名为 `apply` 的方法。这是我们定义要应用于模型上查询的约束条件的方法。

我们的全局范围现在已准备好使用。我们可以将其添加到我们希望将查询范围缩小到用户团队的任何模型中。

让我们将其应用于 `\App\Models\Article` 模型。

应用全局查询范围

有多种方法可以将全局范围应用于模型。第一种方法是在模型上使用 `Illuminate\Database\Eloquent\Attributes\ScopedBy` 属性

declare(strict_types=1);
 
namespace App\Models;
 
use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Attributes\ScopedBy;
use Illuminate\Database\Eloquent\Model;
 
#[ScopedBy(TeamScope::class)]
final class Article extends Model
{
// ...
}

另一种方法是在模型的 `booted` 方法中使用 `addGlobalScope` 方法

declare(strict_types=1);
 
namespace App\Models;
 
use App\Models\Scopes\TeamScope;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
 
final class Article extends Model
{
use HasFactory;
 
protected static function booted(): void
{
static::addGlobalScope(new TeamScope());
}
 
// ...
}

这两种方法都将 `where('team_id', Auth::user()->team_id)` 约束条件应用于 `\App\Models\Article` 模型上的所有查询。

这意味着您现在可以编写查询,而无需担心按team_id列进行筛选。

use App\Models\Article;
 
$articles = Article::query()->get();

如果我们假设用户属于team_id1的团队,则将为上面的查询生成以下SQL:

select * from `articles` where `team_id` = 1

这很酷,对吧!?

匿名全局查询范围

另一种定义和应用全局查询范围的方法是使用匿名全局范围。

让我们更新我们的\App\Models\Article模型以使用匿名全局范围

declare(strict_types=1);
 
namespace App\Models;
 
use Illuminate\Contracts\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Auth;
 
final class Article extends Model
{
protected static function booted(): void
{
static::addGlobalScope('team_scope', static function (Builder $builder): void {
$builder->where('team_id', Auth::user()->team_id);
});
}
 
// ...
}

在上面的代码示例中,我们使用addGlobalScope方法在模型的booted方法中定义了一个匿名全局范围。addGlobalScope方法接受两个参数

  • 范围的名称 - 如果您需要在查询中忽略范围,可以使用它来引用范围
  • 范围约束 - 一个闭包,定义要应用于查询的约束

就像其他方法一样,这将对\App\Models\Article模型上的所有查询应用where('team_id', Auth::user()->team_id)约束。

根据我的经验,匿名全局范围不如在单独的类中定义全局范围常见。但了解它们可用以备不时之需总是一件好事。

忽略全局查询范围

有时您可能希望编写一个不使用应用于模型的全局查询范围的查询。例如,您可能正在构建一个需要包含所有记录的报告或分析查询,而不管全局查询范围如何。

如果是这种情况,您可以使用两种方法之一来忽略全局范围。

第一种方法是withoutGlobalScopes。如果未向该方法传递任何参数,则此方法允许您忽略模型上的所有全局范围

use App\Models\Article;
 
$articles = Article::query()->withoutGlobalScopes()->get();

或者,如果您只想忽略一组给定的全局范围,可以将范围名称传递给withoutGlobalScopes方法

use App\Models\Article;
use App\Models\Scopes\TeamScope;
 
$articles = Article::query()
->withoutGlobalScopes([
TeamScope::class,
'another_scope',
])->get();

在上面的示例中,我们忽略了App\Models\Scopes\TeamScope和另一个名为another_scope的假想匿名全局范围。

或者,如果您只想忽略单个全局范围,可以使用withoutGlobalScope方法

use App\Models\Article;
use App\Models\Scopes\TeamScope;
 
$articles = Article::query()->withoutGlobalScope(TeamScope::class)->get();

全局查询范围注意事项

重要的是要记住,全局查询范围仅应用于通过模型进行的查询。如果您使用Illuminate\Support\Facades\DB外观编写数据库查询,则不会应用全局查询范围。

例如,假设您编写了此查询,您希望它只获取属于登录用户团队的文章

use Illuminate\Support\Facades\DB;
 
$articles = DB::table('articles')->get();

在上面的查询中,即使在App\Models\Article模型上定义了App\Models\Scopes\TeamScope全局查询范围,它也不会被应用。因此,您需要确保在数据库查询中手动应用约束。

测试本地查询范围

现在我们已经了解了如何创建和使用查询范围,我们将看看如何为它们编写测试。

有多种方法可以测试查询范围,您选择的方法可能取决于您的个人喜好或您正在编写的范围的内容。例如,您可能希望为范围编写更多单元样式的测试。或者,您可能希望编写更多集成样式的测试,这些测试在范围内被用在控制器等内容的上下文中进行测试。

我个人喜欢将两者结合使用,这样我就可以确信范围正在添加正确的约束,并且范围实际上正在查询中使用。

让我们以我们之前示例的publishednotPublished范围为例,并为它们编写一些测试。我们希望编写两个不同的测试(每个范围一个)

  • 一个测试检查published范围只返回已发布的文章。
  • 一个测试检查notPublished范围只返回未发布的文章。

让我们看一下测试,然后讨论正在做什么

declare(strict_types=1);
 
namespace Tests\Feature\Models\Article;
 
use App\Models\Article;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
 
final class ScopesTest extends TestCase
{
use LazilyRefreshDatabase;
 
protected function setUp(): void
{
parent::setUp();
 
// Create two published articles.
$this->publishedArticles = Article::factory()
->count(2)
->create([
'published_at' => now()->subDay(),
]);
 
// Create an unpublished article that hasn't
// been scheduled to publish.
$this->unscheduledArticle = Article::factory()
->create([
'published_at' => null,
]);
 
// Create an unpublished article that has been
// scheduled to publish.
$this->scheduledArticle = Article::factory()
->create([
'published_at' => now()->addDay(),
]);
}
 
#[Test]
public function only_published_articles_are_returned(): void
{
$articles = Article::query()->published()->get();
 
$this->assertCount(2, $articles);
$this->assertTrue($articles->contains($this->publishedArticles->first()));
$this->assertTrue($articles->contains($this->publishedArticles->last()));
}
 
#[Test]
public function only_not_published_articles_are_returned(): void
{
$articles = Article::query()->notPublished()->get();
 
$this->assertCount(2, $articles);
$this->assertTrue($articles->contains($this->unscheduledArticle));
$this->assertTrue($articles->contains($this->scheduledArticle));
}
}

我们可以在上面的测试文件中看到,我们首先在setUp方法中创建了一些数据。我们创建了两篇文章,一篇文章未安排,另一篇文章已安排。

然后有一个测试(only_published_articles_are_returned)检查published范围只返回已发布的文章。还有一个测试(only_not_published_articles_are_returned)检查notPublished范围只返回未发布的文章。

通过这样做,我们现在可以确信我们的查询范围按预期应用了约束。

在控制器中测试范围

正如我们提到的,测试查询范围的另一种方法是在控制器中使用范围的上下文中进行测试。虽然范围的隔离测试可以帮助断言范围正在向查询添加正确的约束,但它实际上并没有测试范围是否在应用程序中按预期使用。例如,您可能忘记在控制器方法中的查询中添加published范围。

通过编写测试来断言在控制器方法中使用范围时返回正确的数据,可以发现这类错误。

让我们以具有多租户博客应用程序的示例为例,并编写一个用于列出文章的控制器方法的测试。我们假设我们有一个非常简单的控制器方法,如下所示

declare(strict_types=1);
 
namespace App\Http\Controllers;
 
use App\Models\Article;
use Illuminate\Http\Request;
 
final class ArticleController extends Controller
{
public function index()
{
return view('articles.index', [
'articles' => Article::all(),
]);
}
}

我们假设App\Models\Article模型应用了我们的App\Models\Scopes\TeamScope

我们希望断言只返回属于用户团队的文章。测试用例可能看起来像这样

declare(strict_types=1);
 
namespace Tests\Feature\Controllers\ArticleController;
 
use App\Models\Article;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\LazilyRefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Tests\TestCase;
 
final class IndexTest extends TestCase
{
use LazilyRefreshDatabase;
 
#[Test]
public function only_articles_belonging_to_the_team_are_returned(): void
{
// Create two new teams.
$teamOne = Team::factory()->create();
$teamTwo = Team::factory()->create();
 
// Create a user that belongs to team one.
$user = User::factory()->for($teamOne)->create();
 
// Create 3 articles for team one.
$articlesForTeamOne = Article::factory()
->for($teamOne)
->count(3)
->create();
 
// Create 2 articles for team two.
Article::factory()
->for($teamTwo)
->count(2)
->create();
 
// Act as the user and make a request to the controller method. We'll
// assert that only the articles belonging to team one are returned.
$this->actingAs($user)
->get('/articles')
->assertOk()
->assertViewIs('articles.index')
->assertViewHas(
key: 'articles',
value: fn (Collection $articles): bool => $articles->pluck('id')->all()
=== $articlesForTeamOne->pluck('id')->all()
);
}
}

在上面的测试中,我们创建了两个团队。然后,我们创建了一个属于团队一的用户。我们为团队一创建了 3 篇文章,为团队二创建了 2 篇文章。然后,我们以用户的身份执行操作,并向列出文章的控制器方法发出请求。控制器方法应该只返回属于团队一的 3 篇文章,因此我们通过比较文章的 ID 来断言只返回这些文章。

这意味着我们现在可以确信全局查询范围在控制器方法中按预期使用。

结论

在本文中,我们学习了本地查询范围和全局查询范围。我们学习了这两者的区别,如何创建和使用它们,以及如何为它们编写测试。

希望你现在应该对在 Laravel 应用程序中使用查询范围充满信心。

Ashley Allen photo

我是一名自由 Laravel 网站开发人员,喜欢为开源项目做贡献,构建令人兴奋的系统,并帮助他人学习 Web 开发。

Cube

Laravel 新闻

加入 40,000 多名其他开发人员,永不错过新的提示、教程等。

Laravel Forge logo

Laravel Forge

轻松创建和管理您的服务器,并在几秒钟内部署您的 Laravel 应用程序。

Laravel Forge
Tinkerwell logo

Tinkerwell

Laravel 开发人员必备的代码运行器。使用 AI、自动完成和对本地和生产环境的即时反馈进行调试。

Tinkerwell
No Compromises logo

No Compromises

Joel 和 Aaron 是 No Compromises 播客中两位经验丰富的开发人员,现在可以为您的 Laravel 项目提供服务。⬧ 7500 美元/月的固定费用。

No Compromises
Kirschbaum logo

Kirschbaum

提供创新和稳定性,确保您的 Web 应用程序成功。

Kirschbaum
Shift logo

Shift

正在运行旧版本的 Laravel 吗?立即进行自动化的 Laravel 升级和代码现代化,使您的应用程序保持新鲜。

Shift
Bacancy logo

Bacancy

只需 2500 美元/月,即可用拥有 4-6 年经验的经验丰富的 Laravel 开发人员为您的项目增值。获得 160 小时的专用专业知识和 15 天的无风险试用期。立即安排通话!

Bacancy
Lucky Media logo

Lucky Media

现在就来试试 Lucky - Laravel 开发的理想选择,拥有十多年的经验!

Lucky Media
Lunar: Laravel E-Commerce logo

Lunar: Laravel 电子商务

Laravel 的电子商务。一个开源包,将现代无头电子商务功能的强大功能带到 Laravel。

Lunar: Laravel 电子商务
LaraJobs logo

LaraJobs

官方 Laravel 招聘板

LaraJobs
SaaSykit: Laravel SaaS Starter Kit logo

SaaSykit: Laravel SaaS 启动工具包

SaaSykit 是一个 Laravel SaaS 启动工具包,它包含运行现代 SaaS 所需的所有功能。支付、精美结账、管理面板、用户仪表板、身份验证、即用型组件、统计信息、博客、文档等。

SaaSykit: Laravel SaaS 启动工具包
Rector logo

Rector

您无缝升级 Laravel、降低成本和加速创新以帮助公司成功的合作伙伴

Rector
MongoDB logo

MongoDB

通过 MongoDB 和 Laravel 的强大集成来增强您的 PHP 应用程序,使开发人员能够轻松高效地构建应用程序。支持事务、搜索、分析和移动用例,同时使用熟悉的 Eloquent API。了解 MongoDB 的灵活、现代数据库如何改变您的 Laravel 应用程序。

MongoDB
Maska is a Simple Zero-dependency Input Mask Library image

Maska 是一个简单的零依赖输入掩码库

阅读文章
Add Swagger UI to Your Laravel Application image

将 Swagger UI 添加到您的 Laravel 应用程序

阅读文章
Assert the Exact JSON Structure of a Response in Laravel 11.19 image

在 Laravel 11.19 中断言响应的精确 JSON 结构

阅读文章
Build SSH Apps with PHP and Laravel Prompts image

使用 PHP 和 Laravel 提示构建 SSH 应用程序

阅读文章
Building fast, fuzzy site search with Laravel and Typesense image

使用 Laravel 和 Typesense 构建快速、模糊的网站搜索

阅读文章
Add Comments to your Laravel Application with the Commenter Package image

使用 Commenter 包在您的 Laravel 应用程序中添加评论

阅读文章