Laravel: создание фабрик и seeders при связях между моделями

от автора

В ситуациях, когда одна модель обязательно должна быть связана с другой моделью (например, статья и ее автор, компания и сотрудники и т.п.), большинство программистов допускают различные ошибки при создании фабрик (Factory) и сидов (Seeders) к этим моделям.

Предлагаю исследовать эту проблему и вывести единственно правильную реализацию создания фабрик и сидов в такой ситуации.

Спойлер: фабрики не должны зависеть от сидов.

Подготовка проекта

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

Создание и настройка проекта

Подготовим этот проект (готовую реализацию уже можно посмотреть в этом репозитории). Создаем модель, миграцию, фабрику и сид для статей. Таблица с пользователями достаточна той версии, что идет в Laravel из коробки.

Создадим новую установка Laravel, используя установщик, и ставим проект с помощью консольной команды.

laravel new relation_factories

Теперь настроим файл .env, чтобы не создавать отдельную БД, для ускорения можно использовать БД sqlite.

// Параметры файла .env  DB_CONNECTION=sqlite DB_DATABASE=/path...to...project/database/database.sqlite

Создать файл с БД можно также простой консольной командой.

touch database/database.sqlite

Создание модели статьи

Пока мы находимся недалеко от консоли, создадим модель с миграцией и фабрикой, используя консольную команду.

php artisan make:model -f -m Article

На следующем шаге необходимо описать миграцию статьи. Здесь все стандартно — добавим поле для хранения заголовка и обязательную ссылку на пользователя.

<?php  use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema;  class CreateArticlesTable extends Migration {    public function up()    {        Schema::create('articles', function (Blueprint $table) {            $table->id();            $table->string('title');            $table->foreignId('author_id')                ->references('id')                ->on('users')                ->cascadeOnDelete()            ;            $table->timestamps();        });    }     public function down()    {        Schema::dropIfExists('articles');    } }

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

<?php  namespace App\Models;  use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo;  class Article extends Model {    use HasFactory;     public function author(): BelongsTo    {        return $this->belongsTo(User::class, 'author_id');    } }

В завершении первой части подготовки остается только выполнить миграцию. Используем знакомую консольную команду.

php artisan migrate

Создание фабрики и сидов

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

Начнем с фабрики. У статьи есть только одно поле с данным — это заголовок, заполним его случайным предложением.

<?php  namespace Database\Factories;  use App\Models\Article; use App\Models\User; use Illuminate\Database\Eloquent\Factories\Factory;  class ArticleFactory extends Factory {    protected $model = Article::class;     public function definition(): array    {        return [            'title' => $this->faker->sentence,        ];    } }

Опытный читатель уже заметил здесь ошибку, но мы вернемся к ней чуть позже, а пока опишем код сида. Будем писать код сразу в классе DatabaseSeeder, для упрощения без создания отдельных сидов.

Создадим, как того требует задание, одного пользователя и пять статей.

<?php  namespace Database\Seeders;  use App\Models\Article; use App\Models\User; use Illuminate\Database\Seeder;  class DatabaseSeeder extends Seeder {     public function run()    {         User::factory()->create();         Article::factory()->count(5)->create();    } }

А теперь попробуем выполнить сидер с помощью консольной команды.

php artisan db:seed

При попытке ее выполнить мы получили фатальную ошибку.

Illuminate\Database\QueryException   SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: articles.author_id ...

Как создать фабрику неправильно, вариант №1

Мы не можем создать статью, не описав обязательное поле для связи с автором. Исправим это.

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

Это можно сделать разными неправильными ? способами.

Например, просто указав магический 1 в поле.

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */     public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => 1,        ];    } }

Работать будет, но что означает эта волшебная единица? Я тоже не знаю. Так код писать нельзя, поэтому отбрасываем этот вариант.

Второй вариант неправильной реализации — это привязка к id пользователя. Выберем первого пользователя из БД и возьмем его id.

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */     public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => User::first()->id,        ];    } }

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

Следующий вариант. Выбрать случайного пользователя из БД и его id, как показано на скрине ниже

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */     public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => User::all()->random()->id,        ];    } }

Однако, и этот путь ошибочный. В БД могут быть миллионы пользователей. Зачем тянуть их всех, загружать в память php и потом силами php перемешивать? Нужно уже на уровне запроса к БД, взять одного случайного пользователя.

Исправляем и получаем такой промежуточный вариант фабрики.

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */     public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => User::inRandomOrder()->first()->id,        ];    } }

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

php artisan migrate:fresh --seed

Такой вариант реализации можно встретить очень часто. Однако, это неверное решение. Будем разбираться почему и для этого доработаем проект.

Создание страницы со списком статей и теста к ней

Создадим страницу, на которой будет выводиться список статей и имена авторов.

Для этого будем использовать главную страницу. Для упрощения реализуем обработчик маршрута в виде callback функции. 

Отредактируем файл web.php

<?php  use App\Models\Article; use Illuminate\Support\Facades\Route;  Route::get('/', function () {    $articles = Article::with('author')->get();    return view('welcome', compact('articles')); })->name('home');

Теперь отредактируем файл welcome.blade.php. Сделаем его максимально простым.

@foreach ($articles as $article)    <div>{{ $article->title }} - {{ $article->author->name }}</div> @endforeach

Запустим Laravel приложение с помощью консольной команды.

php artisan serve

Откроем запущенный сайт и увидим на нем примерно такой контент.

Modi eum aliquam beatae ab ut commodi dignissimos est. - Karley Nicolas Quia nostrum id quos et inventore tenetur. - Karley Nicolas Perferendis earum ipsam ex rerum nihil dicta. - Karley Nicolas Amet eos rem adipisci dolorem. - Karley Nicolas Enim debitis itaque et illo occaecati non. - Karley Nicolas

Создаем автотест

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

Класс теста создадим с помощью генератора, выполнив консольную команду.

php artisan make:test ArticlesTest

Теперь напишем этот тест. Для корректной работы теста его правильнее запускать на чистой БД, поэтому обязательно используем трейт RefreshDatabase.

<?php  namespace Tests\Feature;  use App\Models\Article; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\WithFaker; use Tests\TestCase;  class ArticlesTest extends TestCase {    use RefreshDatabase;     public function test_main_page_has_articles()    {        /**         * Если на сайте создана статья         */        $article = Article::factory()            ->create(['title' => 'example'])        ;         /**         * То, когда пользователь заходит на главную страницу         */        $response = $this->get(route('home'));         /**         * Страница ДОЛЖНА открыться,         * и контент страницы ДОЛЖЕН содержать название этой статьи         */        $response            ->assertStatus(200)            ->assertSee($article->title)        ;    } }

В Laravel функция выполнения теста доступна «из коробки», поэтому просто выполним консольную команду.

php ./vendor/bin/phpunit

Однако, такой простой тест не прошел. Он вывел ошибку

1) Tests\Feature\ArticlesTest::test_main_page_has_articles ErrorException: Attempt to read property "id" on null

Неподготовленному разработчику Laravel может показаться, что определить источник ошибки не так-то просто. На самом деле проблема в фабрике.

Как создать фабрику неправильно, вариант №2

Проблема в следующем. При выполнении теста на чистую БД накатываются миграции, но сидеры не выполняются. Поэтому в БД нет пользователей, а значит попытка выбрать пользователей в фабрике вернет null.

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

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

Воспользуемся фабрикой внутри фабрики. Сделать это очень просто. Однако, как уже заведено в этой статье, сначала я покажу вариант реализации с ошибкой. Кстати, поле id можно не указывать, Laravel достаточно умный, чтобы взять id модели за вас.

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */    public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => User::factory()->create(),        ];    } }

Снова запустим тест, выполнив консольную команду.

php ./vendor/bin/phpunit

Тест прошел, но на сайте возникла другая ошибка, даже две.

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

php artisan migrate:fresh --seed

На сайте видим, что теперь каждая статья привязана к своему отдельному пользователю, что не подходит нам по условию задачи.

Ad id sit distinctio perspiciatis accusantium numquam rerum quia. - Isai Rohan Fugit ipsum odio iure. - Gretchen Prosacco Qui est quae asperiores sed. - Abby Leannon Placeat nobis est sed aperiam. - Dahlia McKenzie Quod iste unde assumenda molestias eaque quia dignissimos earum. - Dr. Alysha Gutkowski Jr.

Исправить это очень просто, для этого подкорректируем код сида, передав пользователя в качестве параметра методу create.

<?php  /* заголовки файла */  class DatabaseSeeder extends Seeder {    public function run()    {         $user = User::factory()->create();         Article::factory()            ->count(5)            ->create(['author_id' => $user])        ;    } }

Перевыполним миграции и сиды. На сайте все отображается, тесты выполняются.

«Теперь все работает так, как надо» — подумали вы. Но не тут-то было, у нас появилась новая скрытая проблема.

Как создать фабрику неправильно, вариант №3

Использование метода фабрики create() внутри другой фабрики мгновенно приводит к созданию новой модели  связи. Таким образом, у нас сейчас в БД не один пользователь, как мы думаем, а шесть. Одного мы создали через сидер, еще пять созданы фабрикой статьи.

Это можно проверить с помощью тинкера, посчитав количество пользователей в БД.

php artisan tinker  Psy Shell v0.10.8 (PHP 8.0.10 -- cli) by Justin Hileman >>> App\Models\User::count() => 6

Можно подумать, что вместо метода create в фабрике нужно использовать метод make, но тогда снова перестанут работать тесты. Получается замкнутый круг.

Правильное решение

Верное решение очень простое. Внутри фабрики необходимо использовать другую фабрику без вызова метода make() или create().

<?php /* заголовки файла */  class ArticleFactory extends Factory {    /* остальная часть класса */     public function definition(): array    {        return [            'title' => $this->faker->sentence,            'author_id' => User::factory(),        ];    } }

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

Laravel настолько умен, что при создании модели заметит, что в поле указана фабрика и, отложит ее выполнение на самый последний момент. Если в методе create() или make() поле author_id не будет переопределено, только в этом случае эта фабрика выполнится и создаст нового автора для статьи.

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

С вами был руководитель QSOFT Академии по направлению “Разработка” — Волков Михаил (@mvsvolkov), всем классных фабрик, вкусных сидов и тестов без ошибок.


ссылка на оригинал статьи https://habr.com/ru/post/645055/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *