Освоение области запросов в Laravel

Источник: «Learn to master Query Scopes in Laravel»
В статье рассмотрим локальные и глобальные области запросов. Узнаем, в чем разница между ними, как создавать свои собственные и как писать для них тесты.

При создании приложений Laravel, скорее всего, придётся писать запросы с ограничениями, используемыми в разных местах приложения. Возможно, создавая многопользовательское приложение, придётся постоянно добавлять в запросы ограничение where для фильтрации по команде пользователя. Или создаёте блог и вынуждены постоянно добавлять в запросы ограничение where для фильтрации по тому, опубликована запись в блоге или нет.

В Laravel можно использовать области запросов, чтобы сохранить эти ограничения в одном месте.

В статье рассмотрим локальные и глобальные области запросов. Узнаем, в чем разница между ними, как создавать свои собственные и как писать для них тесты.

К концу статьи вы должны уверенно использовать области запросов в своих приложениях Laravel.

Что такое области запросов

Области запросов позволяют определять ограничения в запросах Eloquent многократно используемым способом. Обычно они определяются как методы в моделях Laravel или как класс, реализующий интерфейс Illuminate\Database\Eloquent\Scope.

Они не только отлично подходят для определения логики многократного использования в одном месте, но и делают код более читабельным, скрывая сложные ограничения запроса за простым вызовом метода.

Области запросов бывают двух типов:

Если вы когда-либо использовали встроенную в Laravel функцию "soft delete", то, возможно, уже использовали области запросов, не осознавая этого. Laravel использует локальные области запросов, чтобы предоставить такие методы, как withTrashed и onlyTrashed для ваших моделей. Он также использует глобальную область запросов для автоматического добавления ограничения whereNull('deleted_at') во все запросы к модели, чтобы по умолчанию в запросах не возвращались записи, удалённые "мягким" способом.

Давайте рассмотрим, как создавать и использовать локальные и глобальные области запросов в приложениях Laravel.

Локальные области запросов

Локальные области запросов определяются как методы в модели Eloquent и позволяют определить ограничения, которые могут быть вручную применены к запросам модели.

Представим, что разрабатываем приложение для ведения блога, имеющее панель администратора. В панели администратора есть две страницы: одна для списка опубликованных записей в блоге, другая — для списка неопубликованных записей.

Представим, что доступ к записям блога осуществляется с помощью модели \App\Models\Article, а в таблице базы данных есть столбец published_at с nullable значением, хранящий дату и время публикации записи в блоге. Если дата публикации в столбце 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 создаст область опубликованных записей в модели. Метод должен принимать экземпляр 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 функция "soft delete" использует глобальную область запросов 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
{
/**
* Применение области к заданному конструктору запросов Eloquent.
*/

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
{
// ...
}

Другой способ — использовать метод addGlobalScope в методе booted модели:

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\Scopes\TeamScope не будет применена, даже если эта область определена в модели App\Models\Article. Поэтому необходимо убедиться, что ограничение применяется вручную в запросах к базе данных.

Тестирование локальных областей запросов

Теперь, когда мы знаем, как создавать и использовать области запросов, рассмотрим, как писать для них тесты.

Существует несколько способов тестирования областей запросов, и выбранный метод может зависеть от личных предпочтений или содержимого области, которую вы пишете. Например, может потребоваться написать больше юнит-тестов для областей. Или можно написать больше интеграционных тестов, проверяющих область в контексте её использования в каком-нибудь контроллере.

Я предпочитаю использовать смесь двух способов, чтобы убедиться, что области добавляют правильные ограничения, и что области действительно используются в запросах.

Давайте возьмём наши примеры 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();

// Создание двух published статей.
$this->publishedArticles = Article::factory()
->count(2)
->create([
'published_at' => now()->subDay(),
]);

// Создание двух notPublished статей,
// которые не запланированы к публикации.
$this->unscheduledArticle = Article::factory()
->create([
'published_at' => null,
]);

// Создание notPublished статьи,
// которая запланирована к публикации.
$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
{
// CСоздание двух новых команд.
$teamOne = Team::factory()->create();
$teamTwo = Team::factory()->create();

// Создание пользователя принадлежащего первой команде.
$user = User::factory()->for($teamOne)->create();

// Создание трёх статей первой команды.
$articlesForTeamOne = Article::factory()
->for($teamOne)
->count(3)
->create();

// Создание двух статей второй команды.
Article::factory()
->for($teamTwo)
->count(2)
->create();

// Выступаем в роли пользователя и делаем запрос к методу контроллера.
// Проверяем, что возвращаются статьи, принадлежащие только первой команде.
$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()
);
}
}

В приведённом выше тесте создаются две команды. Затем создаётся пользователь, принадлежащий к первой команде. Создаётся три статьи для первой команды и две статьи для второй. Затем выступаем в роли пользователя и делаем запрос к методу контроллера, выводящему список статей. Метод контроллера должен вернуть только три статьи, принадлежащие первой команде, поэтому утверждаем, что возвращаются только эти статьи, сравнивая идентификаторы статей.

Это означает, что можно быть уверенным в том, что глобальная область запроса используется в методе контроллера по назначению.

Заключение

Мы изучили локальные и глобальные области запросов в этой статье. Узнали, чем они отличаются друг от друга, как создавать и использовать их, а также как писать для них тесты.

Надеюсь, вы будете уверенно использовать области запросов в своих приложениях Laravel.

Дополнительные материалы

Предыдущая Статья

CSS однострочники для улучшения (почти) любого проекта

Следующая Статья

CSS свойство display