学习掌握 Laravel 中的查询范围
最后更新于 作者: Ashley Allen
在构建 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_id
为1
的团队,则将为上面的查询生成以下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
全局查询范围,它也不会被应用。因此,您需要确保在数据库查询中手动应用约束。
测试本地查询范围
现在我们已经了解了如何创建和使用查询范围,我们将看看如何为它们编写测试。
有多种方法可以测试查询范围,您选择的方法可能取决于您的个人喜好或您正在编写的范围的内容。例如,您可能希望为范围编写更多单元样式的测试。或者,您可能希望编写更多集成样式的测试,这些测试在范围内被用在控制器等内容的上下文中进行测试。
我个人喜欢将两者结合使用,这样我就可以确信范围正在添加正确的约束,并且范围实际上正在查询中使用。
让我们以我们之前示例的published
和notPublished
范围为例,并为它们编写一些测试。我们希望编写两个不同的测试(每个范围一个)
- 一个测试检查
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 应用程序中使用查询范围充满信心。