<p><img src="/static/imghwm/default1.png" data-src="https://img.php.cn/upload/article/000/000/000/174119929536701.jpg" class="lazy" alt="Learn to master Query Scopes in Laravel"></p>
<p>在构建Laravel应用程序时,您可能需要编写具有约束条件的查询,这些约束条件在整个应用程序中的多个地方使用。也许您正在构建一个多租户应用程序,并且您必须不断向查询中添加<code>where</code>约束以按用户的团队进行筛选。或者,也许您正在构建一个博客,并且您必须不断向查询中添加<code>where</code>约束以筛选博客文章是否已发布。</p>
<p>在Laravel中,我们可以使用查询范围来帮助我们将这些约束条件整洁地保存在一个地方并重复使用。</p>
<p>在本文中,我们将研究局部查询范围和全局查询范围。我们将学习两者之间的区别,如何创建您自己的查询范围,以及如何编写它们的测试。</p>
<p>在阅读完本文后,您应该能够自信地在Laravel应用程序中使用查询范围。</p>
<h1>什么是查询范围?</h1>
<hr>
<p>查询范围允许您以可重用的方式在Eloquent查询中定义约束条件。它们通常定义为Laravel模型上的方法,或者作为实现<code>IlluminateDatabaseEloquentScope</code>接口的类。</p>
<p>它们不仅非常适合在一个地方定义可重用的逻辑,而且还可以通过将复杂的查询约束隐藏在简单的函数调用之后来使您的代码更具可读性。</p>
<p>查询范围分为两种类型:</p>
<ul>
<li>局部查询范围 - 您必须手动将这些范围应用于您的查询。</li>
<li>全局查询范围 - 默认情况下,这些范围会应用于模型上的所有查询,前提是已注册该查询。</li>
</ul>
<p>如果您曾经使用过Laravel内置的“软删除”功能,您可能已经在不知不觉中使用了查询范围。Laravel使用局部查询范围为您提供模型上的<code>withTrashed</code>和<code>onlyTrashed</code>等方法。它还使用全局查询范围自动向模型上的所有查询添加<code>whereNull('deleted_at')</code>约束,以便默认情况下查询中不会返回软删除的记录。</p>
<p>让我们来看看如何在Laravel应用程序中创建和使用局部查询范围和全局查询范围。</p>
<h1>局部查询范围</h1>
<hr>
<p>局部查询范围定义为Eloquent模型上的方法,允许您定义可以手动应用于模型查询的约束条件。</p>
<p>假设我们正在构建一个具有管理面板的博客应用程序。在管理面板中,我们有两个页面:一个用于列出已发布的博客文章,另一个用于列出未发布的博客文章。</p>
<p>我们假设博客文章是使用<code>AppModelsArticle</code>模型访问的,并且数据库表具有一个可为空的<code>published_at</code>列,用于存储博客文章的发布时间。如果<code>published_at</code>列在过去,则该博客文章被认为已发布。如果<code>published_at</code>列在未来或为<code>null</code>,则该博客文章被认为未发布。</p>
<p>要获取已发布的博客文章,我们可以编写如下查询:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>要获取未发布的博客文章,我们可以编写如下查询:</p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>上面的查询并不特别复杂。但是,假设我们在整个应用程序中的多个地方使用它们。随着出现次数的增加,我们犯错或忘记在一个地方更新查询的可能性越来越大。例如,开发人员可能会意外地使用<code>>=</code>而不是<code><</code>来查询已发布的博客文章。或者,确定博客文章是否已发布的逻辑可能会更改,我们需要更新所有查询。</p>
<p>这就是查询范围非常有用的地方。因此,让我们通过在<code>AppModelsArticle</code>模型上创建局部查询范围来整理我们的查询。</p>
<p>局部查询范围是通过创建一个以<code>scope</code>开头并以范围的预期名称结尾的方法来定义的。例如,名为<code>scopePublished</code>的方法将在模型上创建一个<code>published</code>范围。该方法应该接受一个<code>IlluminateContractsDatabaseEloquentBuilder</code>实例并返回一个<code>IlluminateContractsDatabaseEloquentBuilder</code>实例。</p>
<p>我们将这两个范围都添加到<code>AppModelsArticle</code>模型中:</p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code>
</p>
<p>正如我们在上面的示例中看到的,我们将<code>where</code>约束从之前的查询移动到了两个单独的方法中:<code>scopePublished</code>和<code>scopeNotPublished</code>。我们现在可以在我们的查询中像这样使用这些范围:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p>在我个人看来,我发现这些查询更容易阅读和理解。这也意味着如果我们将来需要使用相同约束条件编写任何查询,我们可以重复使用这些范围。</p>
<h1>全局查询范围</h1>
<hr>
<p>全局查询范围执行与局部查询范围类似的功能。但是,它不是在逐个查询的基础上手动应用,而是自动应用于模型上的所有查询。</p>
<p>正如我们前面提到的,Laravel内置的“软删除”功能使用了<code>IlluminateDatabaseEloquentSoftDeletingScope</code>全局查询范围。此范围会自动向模型上的所有查询添加<code>whereNull('deleted_at')</code>约束。如果您有兴趣了解其底层工作原理,可以在这里查看GitHub上的源代码。</p>
<p>例如,假设您正在构建一个具有管理面板的多租户博客应用程序。您可能只想允许用户查看属于其团队的文章。因此,您可能会编写如下查询:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>此查询很好,但很容易忘记添加<code>where</code>约束。如果您正在编写另一个查询并忘记添加约束,则最终会在您的应用程序中出现一个错误,该错误将允许用户与不属于其团队的文章进行交互。当然,我们不希望发生这种情况!</p>
<p>为了防止这种情况,我们可以创建一个全局范围,我们可以将其自动应用于我们所有<code>AppModelArticle</code>模型查询。</p>
<h3>#如何创建全局查询范围</h3>
<p>让我们创建一个全局查询范围,该范围按<code>team_id</code>列过滤所有查询。</p>
<p>请注意,为了本文的目的,我们保持示例简单。在实际应用程序中,您可能希望使用更强大的方法来处理用户未经身份验证或用户属于多个团队等情况。但现在,让我们保持简单,以便我们可以专注于全局查询范围的概念。</p>
<p>我们将首先在终端中运行以下Artisan命令:</p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>这应该已经创建了一个新的<code>app/Models/Scopes/TeamScope.php</code>文件。我们将对此文件进行一些更新,然后查看完成的代码:</p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<p>在上面的代码示例中,我们可以看到我们有一个新的类,它实现了<code>IlluminateDatabaseEloquentScope</code>接口并具有一个名为<code>apply</code>的单个方法。这就是我们定义要应用于模型查询的约束条件的方法。</p>
<p>我们的全局范围现在可以使用了。我们可以将其添加到任何我们想要将查询范围缩小到用户团队的模型中。</p>
<p>让我们将其应用于<code>AppModelsArticle</code>模型。</p>
<h3>#应用全局查询范围</h3>
<p>有多种方法可以将全局范围应用于模型。第一种方法是在模型上使用<code>IlluminateDatabaseEloquentAttributesScopedBy</code>属性:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p>另一种方法是在模型的<code>booted</code>方法中使用<code>addGlobalScope</code>方法:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$articles = Article::query()
->where('team_id', Auth::user()->team_id)
->get();</code></pre>
<p>这两种方法都将<code>where('team_id', Auth::user()->team_id)</code>约束应用于<code>AppModelsArticle</code>模型上的所有查询。</p>
<p>这意味着您现在可以编写查询,而无需担心按<code>team_id</code>列进行过滤:</p>
<pre class="brush:php;toolbar:false"><code>php artisan make:scope TeamScope</code></pre>
<p>如果我们假设用户属于<code>team_id</code>为<code>1</code>的团队,则将为上面的查询生成以下SQL:</p>
<pre class="brush:php;toolbar:false"><code>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);
}
}</code></pre>
<p>这很酷,对吧?!</p>
<h3>#匿名全局查询范围</h3>
<p>定义和应用全局查询范围的另一种方法是使用匿名全局范围。</p>
<p>让我们更新我们的<code>AppModelsArticle</code>模型以使用匿名全局范围:</p>
<pre class="brush:php;toolbar:false"><code>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
{
// ...
}</code></pre>
<p>在上面的代码示例中,我们使用了<code>addGlobalScope</code>方法在模型的<code>booted</code>方法中定义匿名全局范围。<code>addGlobalScope</code>方法接受两个参数:</p>
<ul>
<li>范围的名称 - 如果您需要在查询中忽略它,则可以使用此名称来引用范围</li>
<li>范围约束 - 定义要应用于查询的约束的闭包</li>
</ul>
<p>与其他方法一样,这会将<code>where('team_id', Auth::user()->team_id)</code>约束应用于<code>AppModelsArticle</code>模型上的所有查询。</p>
<p>根据我的经验,匿名全局范围不如在单独的类中定义全局范围常见。但了解它们可用是很有好处的,以备不时之需。</p>
<h3>#忽略全局查询范围</h3>
<p>有时您可能希望编写一个不使用已应用于模型的全局查询范围的查询。例如,您可能正在构建一个需要包含所有记录的报表或分析查询,而不管全局查询范围如何。</p>
<p>如果是这种情况,您可以使用两种方法之一来忽略全局范围。</p>
<p>第一种方法是<code>withoutGlobalScopes</code>。如果未向其传递任何参数,此方法允许您忽略模型上的所有全局范围:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>或者,如果您只想忽略给定的一组全局范围,您可以将范围名称传递给<code>withoutGlobalScopes</code>方法:</p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>在上面的示例中,我们忽略了<code>AppModelsScopesTeamScope</code>和另一个名为<code>another_scope</code>的虚构匿名全局范围。</p>
<p>或者,如果您只想忽略单个全局范围,可以使用<code>withoutGlobalScope</code>方法:</p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<h3>#全局查询范围注意事项</h3>
<p>务必记住,全局查询范围仅应用于通过模型进行的查询。如果您使用<code>IlluminateSupportFacadesDB</code>外观编写数据库查询,则不会应用全局查询范围。</p>
<p>例如,假设您编写了此查询,您希望它只抓取属于登录用户的团队的文章:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->published()
->get();
$unpublishedPosts = Article::query()
->notPublished()
->get();</code></pre>
<p>在上面的查询中,即使在<code>AppModelsArticle</code>模型上定义了<code>AppModelsScopesTeamScope</code>全局查询范围,也不会应用该范围。因此,您需要确保在数据库查询中手动应用约束条件。</p>
<h1>测试局部查询范围</h1>
<hr>
<p>既然我们已经学习了如何创建和使用查询范围,那么我们将研究如何为它们编写测试。</p>
<p>有多种方法可以测试查询范围,您选择的方法可能取决于您的个人喜好或您正在编写的范围的内容。例如,您可能希望为范围编写更多单元样式的测试。或者,您可能希望编写更多集成样式的测试,这些测试会在诸如控制器之类的上下文中测试范围。</p>
<p>就我个人而言,我喜欢混合使用两者,这样我就可以确信范围正在添加正确的约束,并且范围实际上正在查询中使用。</p>
<p>让我们从前面的示例<code>published</code>和<code>notPublished</code>范围开始,并为它们编写一些测试。我们将需要编写两个不同的测试(每个范围一个):</p>
<ul>
<li>一个测试检查<code>published</code>范围只返回已发布的文章。</li>
<li>一个测试检查<code>notPublished</code>范围只返回未发布的文章。</li>
</ul>
<p>让我们看看这些测试,然后讨论正在做的事情:</p>
<pre class="brush:php;toolbar:false"><code>use App\Models\Article;
$publishedPosts = Article::query()
->where('published_at', '<', now())
->get();</code></pre>
<p>我们可以在上面的测试文件中看到,我们首先在<code>setUp</code>方法中创建一些数据。我们创建了两篇已发布的文章、一篇未安排的文章和一篇已安排的文章。</p>
<p>然后是一个测试(<code>only_published_articles_are_returned</code>),它检查<code>published</code>范围只返回已发布的文章。还有一个测试(<code>only_not_published_articles_are_returned</code>),它检查<code>notPublished</code>范围只返回未发布的文章。</p>
<p>通过这样做,我们现在可以确信我们的查询范围正在按预期应用约束条件。</p>
<h1>在控制器中测试范围</h1>
<hr>
<p>正如我们提到的,测试查询范围的另一种方法是在控制器中使用的上下文中测试它们。虽然范围的隔离测试可以帮助断言范围正在向查询添加正确的约束,但它实际上并没有测试范围是否按预期在应用程序中使用。例如,您可能忘记向控制器方法中的查询添加<code>published</code>范围。</p>
<p>通过编写断言在控制器方法中使用范围时返回正确数据的测试,可以捕获这些类型的错误。</p>
<p>让我们以具有多租户博客应用程序的示例为例,并为列出文章的控制器方法编写一个测试。我们假设我们有一个非常简单的控制器方法,如下所示:</p>
<pre class="brush:php;toolbar:false"><code>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();</code></pre>
<p>我们假设<code>AppModelsArticle</code>模型已应用我们的<code>AppModelsScopesTeamScope</code>。</p>
<p>我们将要断言只返回属于用户团队的文章。测试用例可能如下所示:</p>
<pre class="brush:php;toolbar:false"><code>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());
});
}
// ...
}</code></pre>
<p>在上面的测试中,我们正在创建两个团队。然后,我们创建一个属于团队一的用户。我们为团队一创建 3 篇文章,为团队二创建 2 篇文章。然后,我们充当用户并向列出文章的控制器方法发出请求。控制器方法应该只返回属于团队一的 3 篇文章,因此我们通过比较文章的 ID 来断言只返回这些文章。</p>
<p>这意味着我们可以确信全局查询范围正在控制器方法中按预期使用。</p>
<h1>结论</h1>
<hr>
<p>在本文中,我们学习了局部查询范围和全局查询范围。我们学习了它们之间的区别,如何创建和使用它们,以及如何为它们编写测试。</p>
<p>希望您现在应该能够自信地在Laravel应用程序中使用查询范围。</p>
以上是学会在Laravel中掌握查询范围的详细内容。更多信息请关注PHP中文网其他相关文章!