Неортодоксальный Eloquent II

Источник: «Unorthodox Eloquent II»
Я работаю с Eloquent уже более пяти лет, и пришло время поделиться своим очередным багажом трюков. Вы гарантированно узнаете что-то новое!

Это вторая часть мини-серии "Неортодоксальный Eloquent". Если вы ещё не читали оригинальную статью, вы можете прочитать её здесь.

В прошлой статье мы рассмотрели широкий спектр "неортодоксальных" вариантов, которые можно использовать в сочетании с моделями Eloquent. Однако та статья была лишь верхушкой айсберга. В этой статье я хотел бы рассказать о нескольких других рекомендациях и трюках, которые могут быть немного более эзотерическими, но, тем не менее, все ещё удобными, чем те, что были представлены в первой статье. Например, вы когда-нибудь задумывались об использовании фабрик моделей вне сидеров или тестов? Нет? Тогда я уверен, что вы ещё узнаете что-то новое, так что обязательно дочитайте до конца!

Как и все существующие инструменты, Eloquent имеет свой набор компромиссов. Как ответственные разработчики, мы всегда должны знать, на что идём. Если вы хотите узнать больше об ActiveRecord и философии её разработки, я настоятельно рекомендую эту статью Shawn McCool.

Быстрая навигация

Фабрики моделей в коде приложений

Для того чтобы в полной мере оценить этот раздел, я думаю, нам следует сначала установить точное общее понимание того, что такое Factory. Проще говоря, это то, что отвечает за создание другой вещи. Однако я бы хотел пойти на шаг дальше и определить её как нечто, отвечающее за генезис другой сущности. Как правило, все модели/сущности имеют определённый жизненный цикл: они начинают существовать по какой-то определённой причине, проходят через определённый процесс и в конце концов прекращают своё существование или "умирают". Factory особенно полезна для работы с первой частью жизненного цикла сущности.

Фабрики Eloquent существуют уже довольно давно. Из документации, дословно:

При тестировании приложения или заполнении базы данных вам может понадобиться вставить несколько записей в базу данных.

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

Подготовка

Прежде всего, мы должны решить, где разместить наши фабрики, предназначенные только для тестирования, для использования в интеграционных или функциональных тестах. Я предпочитаю оставить database/factories для кода приложения, поскольку он также автоматически обнаруживается фреймворком. tests/Factories будет хорошим кандидатом для размещения специфических для тестирования фабрик. Поэтому переместите фабрики, предназначенные только для тестирования, в новое место. После перемещения файлов нам нужно указать обнаружителю фабрик, чтобы он проверял новое местоположение при выполнении тестов. Для этого мы должны определить новый класс FactoryResolver в корне папки tests:

final readonly class FactoryResolver
{
public function __invoke(string $fqcn): string
{
$model = class_basename($fqcn);

return "Tests\\Factories\\{$model}Factory";
}
}

Далее мы должны определить TestingServiceProvider в корне папки tests, в которой зарегистрирован FactoryResolver:

final class TestingServiceProvider extends ServiceProvider
{
public function boot(): void
{
Factory::guessFactoryNamesUsing(new FactoryResolver());
}
}

Теперь мы можем регистрировать этот пользовательский TestingServiceProvider каждый раз, когда создаётся тестовое приложение:

trait CreatesApplication
{
public function createApplication(): Application
{
$app = require __DIR__.'/../bootstrap/app.php';

$app->make(Kernel::class)->bootstrap();
$app->register(TestingServiceProvider::class); // 👈

return $app;
}
}

Вы, наверное, заметили, что мы не добавляем его в массив конфигурации app.providers, да и зачем? TestingServiceProvider используется только в контексте тестирования. Регистрировать его при каждом запросе реального приложения бессмысленно, и он только тратит драгоценные циклы процессора. Когда все готово, мы можем приступать к использованию фабрик в коде приложения, с одной стороны, и фабрик, предназначенных для тестирования, с другой.

Пояснение на примере: регистрация пользователя

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

Route::post('users', [UserController::class, 'store']);

Он должен иметь возможность регистрировать пользователей с различными ролями: premium, vip, trial и т.д. После регистрации пользователя, в зависимости от его роли, на его электронный адрес должно быть отправлено письмо WelcomeEmail. Один из способов сделать это — добавить всю необходимую логику в соответствующий метод в Controller. Однако мы уже знаем, что это не очень хорошая идея. Мы можем решить эту проблему чистым и неортодоксальным способом!

Сначала создадим UserFactorydatabase/factories с помощью php artisan make:factory), который будет отвечать за генерацию объектов User, а также различные методы фабрики для приведения их в единообразное состояние:

final class UserFactory extends Factory
{
protected $model = User::class;

public function definition(): array
{
return [];
}

public function isPremium(): self
{
return $this->state([
'type' => 'premium',
]);
}

public function isTrial(): self
{
return $this->state([
'trial_expires_at' => Date::now()->addWeeks(2),
'type' => 'trial',
]);
}

public function isVip(): self
{
return $this->state([
'type' => 'vip',
]);
}
}

Вы могли заметить, что метод definition совсем пустой. Это сделано специально, потому что цель этого метода — предоставить значения по умолчанию для полей базы данных, которые не были бы установлены во время выполнения функционального теста. Однако наша цель — использовать этот класс в коде приложения, а не в тестовом коде, поэтому мы должны оставить его пустым. Отсутствие какого-либо поля будет означать ошибку в нашем коде, и мы должны как можно скорее исправить её.

После этого мы должны перейти к нашей модели User и определить метод newFactory, возвращающий новый экземпляр той самой фабрики, которую мы только что определили. Это будет очень важно для наших тестов в дальнейшем:

use Database\Factories\UserFactory;

final class User extends Authenticatable
{
use HasFactory;

// опущено для краткости

protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
}

Теперь мы можем приступить к реализации логики Controller, необходимой для выполнения бизнес требований:

final readonly class UserController
{
public function store(SignUpUser $request): UserResource
{
$user = User::factory()
->when($request->wantsTrial(), static fn (UserFactory $usr) => $usr->isTrial())
->when($request->wantsPremium(), static fn (UserFactory $usr) => $usr->isPremium())
->when($request->wantsVip(), static fn (UserFactory $usr) => $usr->isVip())
->create($request->validated());

return UserResource::make($user);
}
}

Как вы можете видеть, "недостающие" defenition атрибуты предоставляются методом валидации FormRequest в Laravel. В зависимости от выбора пользователя на фронтенде мы можем вызвать различные методы фабрики, чтобы создать корректный объект User с корректным состоянием в зависимости от выбранной роли. Все хорошо и замечательно, но нам все ещё не хватает ключевого требования: доставки "приветственных" писем. На этом этапе мы можем начать изучать события и слушателей, создавать специальные действия, процессы конвейеров… Но только потому, что мы можем, не означает, что мы должны это делать. Вместо этого давайте грокнем всю мощь фабрик Eloquent:

public function isTrial(): self
{
return $this->state(...)->afterCreating(function (User $user) {
Mail::send(new WelcomeTrialUserMail($user));
});
}

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

Автоматизированное тестирование

А как же тестирование? Тестирование не менее важно, чем сам рабочий код, поскольку оно проверяет наши предположения и гарантирует, что код соответствует бизнес требованиям. Хорошие новости: ничего не изменится в том, как вы писали функциональные тесты, кроме одной маленькой вещи.

Вместо того чтобы делать так во время подготовки тестовых примеров:

User::factory()->create();

Теперь вы должны сделать так:

use Testing\Factories\UserFactory;

UserFactory::new()->create();

Это связано с тем, что в первом случае всегда будет использоваться фабрика приложений, а во втором — фабрика, специально созданная для наших тестовых сценариев. Мы можем продолжить и написать простой тест, который убедится, что наш код работает так, как задумано:

final class UsersTest extends TestCase
{
#[Test]
public function premium_users_can_sign_up(): void
{
$this->post('users', [
'email' => 'muhammed+evilalias@GMAIL.CoM',
'name' => 'mUhAmMeD',
'type' => 'premium',
]);

$this->assertDatabaseHas(User::class, [
'email' => 'muhammed@gmail.com',
'name' => 'Muhammed',
'type' => 'premium',
]);

Mail::assertQueued(WelcomePremiumUserMail::class);
}
}

Но где же UserFactory?, — справедливо спросите вы. На самом деле, использовать тестовую фабрику здесь не имеет смысла, поскольку мы тестируем поведение, цель которого — создать для нас объект User. Если вы хотите, вы можете добавить больше тестов, если это повысит ваш уровень доверия. Пожалуйста, не добавляйте тесты ради тестов. Убедитесь, что вы действительно получаете положительный результат.

Пример теста для другого случая может выглядеть следующим образом:

use Tests\Factories\UserFactory;

#[Test]
public function only_premium_users_receive_access_to_discounts(): void
{
$this
->actingAs(UserFactory::new()->create())
->get('discounts')
->assertForbidden();

$this
->actingAs(UserFactory::new()->isPremium()->create())
->get('discounts')
->assertOk()
->assertSee('Halloween discounts!');
}

Именно здесь вступает в игру FactoryResolver, который мы определили ранее. Он будет использоваться фреймворком для автоматического разрешения реляционных экземпляров фабрик при создании других отношений внутри наших тестовых фабрик. Я настоятельно не рекомендую использовать реляционные возможности фабрик за пределами тестовых фабрик, потому что это будет лишь вопросом времени, когда кто-нибудь позвонит и скажет: Подождите, как, черт возьми, он получил доступ администратора?!. Определяйте все явно внутри своих фабрик в коде приложения. Полагаться на магию фреймворка в тестовых фабриках — это совершенно нормально.

"Нативное" отношение belongsToThrough

Не знаю, как вам, а мне то и дело хочется, чтобы в Laravel было встроенное отношение belongsToThrough, потому что оно чертовски полезно в определённых сценариях. Какие собственные отношения есть в Laravel, которые можно использовать из коробки (OOB)? Одно из них определённо привлекает моё внимание: HasOneThrough. Если подумать, то это почти то, что мы хотим, только наоборот. К сожалению, это не совсем то, что нам нужно, поэтому приходится прибегать к помощи сторонних пакетов… 😔

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

Исследование

public function hasOneThrough(
$related,
$through,
$firstKey = null,
$secondKey = null,
$localKey = null,
$secondLocalKey = null
) {}

Это определение метода для отношения HasOneThrough. Мы видим, что оно позволяет нам настроить все в отношении запроса, который должен быть выполнен для удовлетворения потребностей отношения. Если мы последуем примеру Laravel о механике-автомобиле-владельце и выполним это отношение, то будет сгенерирован следующий SQL (или что-то очень близкое, в зависимости от вашего драйвера):

public function carOwner(): HasOneThrough
{
return $this->hasOneThrough(Owner::class, Car::class);
}

Mechanic::find(504)->carOwner;
SELECT
*
FROM
`owners`
INNER JOIN `cars` ON `cars`.`id` = `owners`.`car_id`
WHERE
`cars`.`mechanic_id` = 504

Вы что-нибудь заметили? Нет? Это шаблон запроса, который используют все пакеты belongsToThrough! Единственное, что они делают, — это инвертируют внешний и локальный ключи и на этом заканчивают! Разве не мы только что утверждали, что отношение hasOneThrough позволяет настроить буквально все, что связано с запросом? Давайте попробуем!

Решение

Это то, что мы сейчас имеем в нашей модели Mechanic:

public function carOwner(): HasOneThrough
{
return $this->hasOneThrough(Owner::class, Car::class);
}

Давайте перейдём к модели Owner и определим отношения для нашей модели Mechanic:

public function carMechanic(): HasOneThrough
{
return $this->hasOneThrough(Mechanic::class, Car::class);
}

Даже если бы мы попытались выполнить это, SQL завершился бы ошибкой, потому что запрос был бы бессмысленным. Давайте инвертируем все ключи вручную и посмотрим, что произойдёт:

public function carMechanic(): HasOneThrough
{
return $this->hasOneThrough(
Mechanic::class,
Car::class,
'id',
'id',
'car_id',
'mechanic_id',
);
}

Owner::find(2156)->carMechanic;

Потрясающе! Если мы посмотрим на сгенерированный SQL, то увидим, что ключи были инвертированы в соответствии с нашим определением:

SELECT
*
FROM
`mechanics`
INNER JOIN `cars` ON `cars`.`mechanic_id` = `mechanics`.`id`
WHERE
`cars`.`id` = 2156

Мы также можем внести небольшое улучшение в определение отношений, чтобы будущие разработчики могли быстро понять, что происходит:

use Illuminate\Database\Eloquent\Relations\HasOneThrough as BelongsToThrough;

public function carMechanic(): BelongsToThrough
{
return $this->hasOneThrough(...);
}

Несколько мудрых слов

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

Полностью грокнутый Eloquent

Должен признать, что это будет крайне субъективный раздел, но, пожалуйста, выслушайте меня до конца, прежде чем доставать вилы и факелы. Спасибо. 🙏

Eloquent ORM поставляется с различными инструментами OOB: диспетчеризация событий, управление транзакциями, наблюдатели, глобальные диапазоны, автоматическое управление метками времени и т. д. Хотя у такого подхода есть свои преимущества, он, безусловно, имеет и свои недостатки. Eloquent придерживается принципа SRE: "Один Отвечает за Всё". И все же, сколько раз мы на самом деле заглядывали во внутреннюю работу этого острого как бритва инструмента?

HasEvents

Например, знаете ли вы, что все модели Eloquent имеют прямой доступ к Dispatcher событий? Так зачем мы это:

public function accept(): void
{
// опущено для краткости

event(new ApplicationWasAccepted($this));
}

Вместо этого:

public function accept(): void
{
// опущено для краткости

static::$dispatcher->dispatch(new ApplicationWasAccepted($this));
}

HasTimestamps

Почему это:

public function accept(): void
{
$this->fill([
'accepted_at' => now(),
]);
}

Вместо этого:

public function accept(): void
{
$this->fill([
'accepted_at' => $this->freshTimestamp(),
]);
}

Знаете ли вы, что это вернёт Illuminate\Support\Carbon вместо Carbon\Carbon, который является "неправильной" подсказкой типа, используемой вами все это время?

Модель

В соответствии с философией проектирования ActiveRecord, все модели должны иметь доступ к базовому соединению с базой данных. Так зачем мы делаем это:

public static function directory(User $downloader, Directory $directory): self
{
// опущено для краткости

DB::transaction(static fn () => $model->save() && $model->directories()->attach($directory));

return $model;
}

Вместо этого:

public static function directory(User $downloader, Directory $directory): self
{
// опущено для краткости

$model
->getConnection()
->transaction(static fn () => $model->save() && $model->directories()->attach($directory));

return $model;
}

Моё мнение

Но зачем?

Я просто пытаюсь спровоцировать наши мысли и поставить под сомнение то, что мы делали все это время. Представленные мною альтернативы — это такое же "гроканье фреймворка", как и использование других вариантов. Так почему бы нам не рассмотреть инструменты, которые мы используем?

Последние варианты говорят, что я опытный разработчик, который знает, как работают его инструменты за кулисами. Почему мы проходим через весь цикл разрешения службы IoC-контейнера, чтобы получить объект, который всегда был там изначально? Удобство? Я бы утверждал, что ни один из вариантов не удобнее другого, потому что правильные IDE всегда будут автоматически заполнять "длинные" варианты…

Переключаемые диапазоны

Это будет самый короткий раздел из всех, потому что я не собираюсь объяснять это дальше. Я хотел бы поблагодарить Marco Deleu, который уже проделал отличную работу по углублённому изучению Переключаемых диапазонов.

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

Pushing the boundaries of Eloquent

Резюме

По правде говоря, у меня в рукаве ещё много других приёмов. Однако они ещё более эзотеричны, чем те, о которых рассказывается в этой статье, так что на сегодня с меня хватит. Единственное, что я действительно хочу донести до читателей, — это то, что любопытство является ключом к открытию новых методологий, перспектив, подходов и привычек. Даже если вы уже давно используете одно и то же, не стесняйтесь экспериментировать и смотреть, можете ли вы улучшить свои текущие привычки. Совершенства не существует, но совершенствование — это то, что всегда, всегда возможно.

Присоединяйтесь к обсуждению на сайте X (бывший Twitter)! Мне будет интересно узнать, что вы думаете об этой статье.

Спасибо, что читаете!

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

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

Основы TypeScript: Типизация функций и сигнатур

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

Основы TypeScript: Any, Void, Never, Null, Строгие проверки Null