在构建 Laravel 应用程序时,您可能必须编写具有约束的查询,这些约束在整个应用程序中的多个位置使用。也许您正在构建一个多租户应用程序,并且必须不断向查询添加 where 约束,以便按用户的团队进行筛选。或者,也许你正在构建一个博客,并且你不得不不断向你的查询添加一个 where 约束,以按博客文章是否发布进行筛选。
在 Laravel 中,我们可以利用查询作用域来帮助我们在一个地方保持这些约束的整洁和可重用。
#什么是查询范围?
查询范围允许您以可重用的方式在 Eloquent 查询中定义约束。它们通常定义为 Laravel 模型上的方法,或实现接口的 Illuminate\Database\Eloquent\Scope 类。
它们不仅非常适合在单个位置定义可重用的逻辑,而且还可以通过将复杂的查询约束隐藏在简单的方法调用后面,使您的代码更具可读性。
查询范围有两种不同的类型:
本地查询范围 - 您必须手动将这些范围应用于您的查询。
全局查询范围 - 默认情况下,在注册查询后,这些范围将应用于模型上的所有查询。
如果你曾经使用过 Laravel 的内置 “软删除” 功能,你可能已经在不知不觉中使用了查询范围。Laravel 利用本地查询范围在模型上为您提供 withTrashed 和 onlyTrashed 等方法。它还使用全局查询范围自动将 whereNull('deleted_at') 约束添加到模型上的所有查询,以便默认情况下不会在查询中返回软删除的记录。
让我们看一下如何在 Laravel 应用程序中创建和使用本地查询范围和全局查询范围。
#本地查询范围
本地查询范围定义为 Eloquent 模型上的方法,并允许您定义可以手动应用于模型查询的约束。
假设我们正在构建一个具有管理面板的博客应用程序。在管理面板中,我们有两个页面:一个用于列出已发布的博客文章,另一个用于列出未发布的博客文章。
我们假设使用 \App\Models\Article 模型访问博客文章,并且数据库表有一个可为 null 的 published_at 列,用于存储博客文章的发布日期和时间。如果 published_at 列是过去的,则认为该博客文章已发布。如果 published_at 列为 in future 或 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 的方法将在模型上创建一个已发布的范围。该方法应接受一个 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();
在我个人看来,我发现这些查询更容易阅读和理解。这也意味着,如果我们将来需要编写任何具有相同 constraint 的查询,我们可以重用这些作用域。
全局查询范围执行与本地查询范围类似的功能。但是,它们不是逐个查询手动应用,而是自动应用于模型上的所有查询。
正如我们之前提到的,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 约束。如果您正在编写另一个查询,但忘记添加约束,则最终会在您的应用程序中出现一个 Bug,该 Bug 允许用户与不属于其团队的文章进行交互。当然,我们不希望这种情况发生!
为了防止这种情况,我们可以创建一个全局范围,我们可以将其自动应用于所有 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 模型。
#应用全局查询范围
有几种方法可以将全局范围应用于模型。第一种方法是在模型上使用
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_id为 1 的团队的成员,则会为上面的查询生成以下 SQL:
select * from articleswhereteam_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 方法采用两个参数:
范围的名称 - 如果需要在查询中忽略范围,可以使用它来稍后引用范围
范围约束 - 定义要应用于查询的约束的闭包
就像其他方法一样,这会将 where('team_id', Auth::user()->team_id) 约束应用于 \App\Models\Article 模型上的所有查询。
根据我的经验,匿名全局范围比在单独的类中定义全局范围更不常见。但是,如果您需要,知道它们可供使用是件好事。
#忽略全局查询范围
有时,您可能希望编写的查询不使用已应用于模型的全局查询范围。例如,您可能正在构建一个需要包含所有记录的 report 或 analytics 查询,而不管全局查询范围如何。
如果是这种情况,您可以使用以下两种方法之一来忽略全局范围。
第一种方法是 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 Facades\DB 立面编写数据库查询,则不会应用全局查询范围。
例如,假设你编写了这个查询,你希望它只会抓取属于已登录用户团队的文章:
use Illuminate\Support\Facades\DB;
$articles = DB::table('articles')->get();
在上面的查询中,即使 App\Models\Scopes\TeamScope 全局查询范围是在 App\Models\Article 模型上定义的,也不会应用该范围。因此,您需要确保在数据库查询中手动应用约束。
#测试本地查询范围
现在我们已经了解了如何创建和使用查询范围,我们将看看如何为它们编写测试。 有几种方法可以测试查询范围,您选择的方法可能取决于您的个人偏好或您正在编写的范围的内容。例如,您可能希望为范围编写更多单元样式测试。或者,你可能想编写更多集成风格的测试,在控制器之类的东西中使用的上下文中测试范围。
就我个人而言,我喜欢将两者混合使用,这样我就可以确信范围正在添加正确的约束,并且这些范围实际上正在查询中使用。
让我们以前面的 published 和 notPublished 范围为例,为它们编写一些测试。我们想要编写两个不同的测试(每个范围一个):
检查已发布范围的测试仅返回已发布的文章。
检查 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 ) 来检查已发布的范围,仅返回已发布的文章。还有另一个测试 () only_not_published_articles_are_returned 检查 notPublished 范围,只返回尚未发布的文章。
通过这样做,我们现在可以确信我们的查询范围正在按预期应用约束。
#在 Controller 中测试 Scope
正如我们所提到的,另一种测试查询范围的方法是在控制器中使用的上下文中测试它们。虽然对范围的独立测试可以帮助断言范围正在向查询添加正确的约束,但它实际上并不会测试范围是否在应用程序中按预期使用。例如,您可能忘记将已发布的范围添加到控制器方法中的查询中。
这些类型的错误可以通过编写测试来捕获,这些测试断言在控制器方法中使用范围时返回正确的数据。
让我们以拥有多租户博客应用程序为例,并为列出文章的控制器方法编写一个测试。我们假设我们有一个非常简单的控制器方法,如下所示:
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()
);
}
}
在上面的测试中,我们将创建两个团队。然后,我们将创建一个属于团队 1 的用户。我们正在为团队 1 创建 3 篇文章,为团队 2 创建文章。然后,我们以用户身份向列出文章的控制器方法发出请求。控制器方法应该只返回属于团队 1 的 3 篇文章,因此我们断言通过比较文章的 ID 来仅返回这些文章。 这意味着我们可以确信全局查询范围正在控制器方法中按预期使用。