Используем трейты для полиморфных связей в Laravel

Введение

Поговорим о возможном применении трейтов вместе с полиморфными отношениями в Laravel.

Содержание статьи:

  1. Описание предметной области
  2. Создание приложения
  3. Возможные структуры БД
  4. Создание сущностей
  5. Использование трейта
  6. Написание тестов

Описание предметной области

Мы будем разрабатывать систему, в которой некие сотрудники и некие команды могут быть прикреплены к проекту. Сущностями предметной области будут сотрудники, команды и проекты: команда состоит из сотрудников, на проект могут быть прикреплены сотрудники и команды. Между командой и сотрудником отношение many-to-many (допустим, что сотрудник может участвовать в разных командах), many-to-many между проектами и сотрудниками, many-to-many между командами и проектами. Для дальнейшего рассмотрения опустим реализацию связи между командой и сотрудниками, сосредоточимся на отношении команд и сотрудников к проекту.

Создание приложения

Приложения на Laravel очень просто создавать, используя пакет-создатель приложений. После его установки создание нового приложения умещается в одну команду:

laravel new system

Возможные структуры БД

Если идти нормализованным путем, то нам понадобится три таблицы для сущностей и ещё три таблицы для связей: сотрудники-команды, сотрудники-проекты, команды-проекты.
Если снизить уровень нормализации, то можно объединить таблицы для связей сотрудник-проект и команда-проект в одну, разделяя тип связи по дополнительному полю с типом (допустим, 1 — сотрудник, 2 — команда).
Идея морф-связей похожа на менее нормализованный вариант, только вместо дополнительного поля с типом используется два — один для имени класса модели, второй для её идентификатора.

Создание сущностей

Нам понадобятся модели, миграции и фабрики для сотрудников, команд, проектов и прикреплений. Команды для создания всего этого:

php artisan make:model Employee -f // модель и фабрика сотрудника
php artisan make:model Team -f // модель и фабрика команды
php artisan make:model Project -f // модель и фабрика проекта
php artisan make:migration CreateEntitiesTables // общая миграция для всех сущностей
php artisan make:model Attach -m // модель и миграция прикрепления

После выполнения команды мы получим файлы моделей в App/, файлы миграций в папке database/migrations/ и фабрики в database/factories/.

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

Миграция для сущностей

<?php

use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;

class CreateEntitesTables extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('employees', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });

        Schema::create('teams', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });

        Schema::create('projects', function (Blueprint $table) {
            $table->id();
            $table->string('name');
            $table->timestamps();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('employees');
        Schema::dropIfExists('teams');
        Schema::dropIfExists('projects');
    }
}

Для полиморфного отношения

<?php

use IlluminateDatabaseMigrationsMigration;
use IlluminateDatabaseSchemaBlueprint;
use IlluminateSupportFacadesSchema;

class CreateAttachesTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('attachments', function (Blueprint $table) {
            $table->id();
            $table->morphs('attachable');
            $table->unsignedInteger('project_id');
            $table->timestamps();

            $table->foreign('project_id')->references('id')->on('projects')
                ->onDelete('cascade');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('attachments');
    }
}

Обратите внимание, что для создания полей полиморфного отношения нужно указать функцию morphs().

Теперь к моделям

Модель команды идентична модели сотрудника:

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Employee extends Model
{
    protected $fillable = ['name'];
}

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Team extends Model
{
    protected $fillable = ['name'];
}

Модель проекта

<?php

namespace App;

use IlluminateDatabaseEloquentModel;
use IlluminateDatabaseEloquentRelationsHasMany;
use IlluminateDatabaseEloquentRelationsMorphToMany;

class Project extends Model
{
    protected $fillable = ['name'];

    /**
     * Relation for project attachments
     * @return HasMany
     */
    public function attachments()
    {
        return $this->hasMany(Attach::class);
    }

    /**
     * Relation for project employees
     * @return MorphToMany
     */
    public function employees()
    {
        return $this->morphedByMany(Employee::class, 'attachable', 'attachments');
    }

    /**
     * Relation for project teams
     * @return MorphToMany
     */
    public function teams()
    {
        return $this->morphedByMany(Team::class, 'attachable', 'attachments');
    }    
}

Прикрепление

<?php

namespace App;

use IlluminateDatabaseEloquentModel;

class Attach extends Model
{
    protected $table = 'attachments';
    protected $fillable = ['attachable_id', 'attachable_type', 'project_id'];
}

Фабрики идентичны для всех сущностей

<?php

/** @var IlluminateDatabaseEloquentFactory $factory */

use FakerGenerator as Faker;

$factory->define(/* (сотрудник/команда/проект) */, function (Faker $faker) {
    return [
        'name' => $faker->colorName
    ];
});

Сущности готовы, переходим к трейту.

Использование трейта

Полиморфные отношения в Laravel подразумевают разные типы отношений для главных и прикрепляемых моделей — в проекте указывается тип связи morphedByMany(), а в сущностях — morphToMany(). Для всех прикрепляемых моделей метод для описания связи будет одинаков, поэтому логично вынести этот метод в трейт и использовать его в модели сотрудника и команды.
Создадим новую директорию app/Traits и трейт с названием полиморфного отношения: Attachable.php

<?php

namespace AppTraits;

use AppProject;
use IlluminateDatabaseEloquentRelationsMorphToMany;

trait Attachable
{
    /**
     * Relation for entity attachments
     * @return MorphToMany
     */
    public function attachments()
    {
        return $this->morphToMany(Project::class, 'attachable', 'attachments');
    }
}

Осталось добавить этот трейт в модели сотрудника и команды через use.

...
    use Attachable;
...

Переходим к проверке работоспособности с помощью тестов.

Написание тестов

По стандарту, Laravel использует PHPUnit для тестирования. Создать тесты для связи:

php artisan make:test AttachableTest

Файл теста можно найти в tests/Feature/. Для обновления состояния БД перед запуском тестов будем использовать трейт RefreshDatabase.

Проверим работу морфа со стороны проекта и трейта со стороны команды и сотрудников

<?php

namespace TestsFeature;

use AppTeam;
use AppEmployee;
use AppProject;
use IlluminateFoundationTestingRefreshDatabase;
use TestsTestCase;

class OrderTest extends TestCase
{
    use RefreshDatabase;

    /** @test */
    public function polymorphic_relations_scheme(): void
    {
        // Given project
        $project = factory(Project::class)->create();
        // Given team
        $team = factory(Team::class)->create();
        // Given employee
        $employee = factory(Employee::class)->create();

        // When we add team and employee to project
        $project->teams()->save($team);
        $project->employees()->save($employee);

        // Then project should have two attachments
        $this->assertCount(2, $project->attachments);
        $this->assertCount(1, $project->teams);
        $this->assertCount(1, $project->employees);
        $this->assertEquals($team->id, $project->teams->first()->id);
        $this->assertEquals($employee->id, $project->employees->first()->id);
        // Team and employee should have attachment to project
        $this->assertCount(1, $team->attachments);
        $this->assertCount(1, $employee->attachments);
        $this->assertEquals($project->id, $team->attachments->first()->id);
        $this->assertEquals($project->id, $employee->attachments->first()->id);
    }
}

Тест прошел!

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

Буду рад слышать ваши кейсы применения трейтов в Laravel и PHP.

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр