Работа с доктриной. Один ко многим: связываем пост с автором

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

Поскольку с последней статьи прошло много времени, за которое я успел познакомиться с Symfony и Doctrine поближе, в этой статье мы будем пересмотрим логику по работе с сущностями.

Для начала вспомним, что у нас есть вот такая неказистая сущность поста:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\DBAL\Types\DateType;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity(repositoryClass="App\Repository\PostRepository")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     * @Assert\Length(min=10, max=255)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $created_at;

    public function getId(): ?int
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @param mixed $title
     */
    public function setTitle($title): void
    {
        $this->title = $title;
    }

    /**
     * @return mixed
     */
    public function getBody()
    {
        return $this->body;
    }

    /**
     * @param mixed $body
     */
    public function setBody($body): void
    {
        $this->body = $body;
    }

    /**
     * @return mixed
     */
    public function getSlug()
    {
        return $this->slug;
    }

    /**
     * @param mixed $slug
     */
    public function setSlug($slug): void
    {
        $this->slug = $slug;
    }

    /**
     * @return \DateTimeInterface
     */
    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->created_at;
    }

    /**
     * @param \DateTimeInterface $created_at
     */
    public function setCreatedAt(\DateTimeInterface $created_at): void
    {
        $this->created_at = $created_at;
    }
}

Уберем репозиторий из аннотации @ORM\Entity(), уберем @Assert\Length над полем title (сущность должна быть валидна в любом случае, и поэтому валидность полей должна проверяться до создания сущности: в форме или дто), уберем все сеттеры. Как теперь будет выглядеть наша сущность:

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="posts")
 */
class Post
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=false)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=false)
     */
    private $slug;

    /**
     * @ORM\Column(type="datetime")
     */
    private $createdAt;

    public function getId(): int
    {
        return $this->id;
    }

    /**
     * @return mixed
     */
    public function getTitle()
    {
        return $this->title;
    }

    /**
     * @return mixed
     */
    public function getBody()
    {
        return $this->body;
    }

    /**
     * @return mixed
     */
    public function getSlug()
    {
        return $this->slug;
    }

    /**
     * @return \DateTimeInterface
     */
    public function getCreatedAt(): ?\DateTimeInterface
    {
        return $this->createdAt;
    }
}

Также мы добавили аннотацию @Table, где определили имя таблицы через атрибут name, и позже добавим индексы. Вероятно, вы думаете, как без сеттеров теперь можно заполнить сущность? Через именованные конструкторы. И предвосхищая ваш вопрос, чем именованные конструкторы лучше сеттеров, прошу представить, что у вас есть сущность с 20-тью или больше полями. Разумеется, у такой сущности могут быть необязательные поля или даже половина из них. Предположим, эта сущность хранит информацию о пользователях из разных соц. сетей: гугл, гитхаб, вконтакте, что-то еще. Кроме обязательных полей, необходимых для авторизации на вашем сайте, соц сети могут по запросу отдавать и другую информацию о пользователях: город, ссылку на репозиторий, фотографию, возраст. А может и не отдавать. Именно поэтому многие из этих полей вам необходимо будет сделать nullable. Тем не менее, тестируя авторизацию через разные соц сети, вы точно знаете, откуда и что приходит. Гугл точно отдает почту, а вконтакте может и не отдавать. Гитхаб точно отдает данные о репозиториях, а у других соц сетей такого просто нет. И чтобы не путаться в том, когда и какой сеттер вызывать, можно использовать именованный конструктор, сигнатуру которого вы точно знаете.

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

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

/**
 * @ORM\Entity()
 * @ORM\Table(name="posts")
 */
class Post
{
    public const DRAFT = 'draft';
    public const PUBLISHED = 'published';

    /**
     * @var Uuid
     * @ORM\Id()
     * @ORM\Column(type="uuid")
     */
    private $id;

    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $title;

    /**
     * @ORM\Column(type="text", nullable=true)
     */
    private $body;

    /**
     * @ORM\Column(type="string", nullable=true)
     */
    private $slug;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=false)
     */
    private $status;

    /**
     * @ORM\Column(type="datetime_immutable", nullable=false)
     */
    private $createdAt;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable", nullable=true)
     */
    private $publishedAt;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable", nullable=false)
     */
    private $updatedAt;

    private function __construct()
    {
        $this->id = Uuid::uuid4();
        $this->createdAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
    }

    /**
     * @return Uuid
     */
    public function getId(): Uuid
    {
        return $this->id;
    }

    /**
     * @return string|null
     */
    public function getTitle(): ?string
    {
        return $this->title;
    }

    /**
     * @return string|null
     */
    public function getBody(): ?string
    {
        return $this->body;
    }

    /**
     * @return string|null
     */
    public function getSlug(): ?string
    {
        return $this->slug;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getCreatedAt(): ?\DateTimeImmutable
    {
        return $this->createdAt;
    }

    /**
     * @return string
     */
    public function getStatus(): string
    {
        return $this->status;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getPublishedAt(): \DateTimeImmutable
    {
        return $this->publishedAt;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getUpdatedAt(): \DateTimeImmutable
    {
        return $this->updatedAt;
    }
}

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

composer require ramsey/uuid-doctrine

После того, как установите, в файле config/packages/doctrine.yaml найдите директиву types и напишите следующее:

types:
     uuid:  'Ramsey\Uuid\Doctrine\UuidType'

Теперь этот тип доступен вам в любых сущностях. Теперь давайте подумаем, чем uuid лучше автоинкремента. Если мы захотим кидать события между компонентами, нам надо передавать событию id созданного поста, который мы не знаем в случае автоинкремента, так как он генерируется на стороне базы, и нам надо будет дождаться флаша сущности. Генерируя же id самостоятельно, у нас есть к нему доступ в любой момент. Чтобы иметь возможность юзерам иметь черновики и при обновлении попадать на свою же статью, нам надо после сохранения (фоном или по кнопке) сделать редирект, например, со страницы /post/add на страницу /post/{uuid}/edit. В этом случае мы можем генерировать uuid на клиенте и передавать на сервер с остальными данными, где сохранить сущность. В случае с автоинкрементом мы бы так не смогли сделать.

Также мы все обязательные поля сделали необязательными (поменяли nullable=false на nullable=true), добавили поле статус, чтобы отличать черновик от опубликованного поста, а также добавили поля updatedAt и publishedAt. Конструктор сделали приватным и внутри него спрятали установку даты для поля createdAt, которое никогда не может быть nullable и есть как у черновика, так и опубликованного поста, поэтому ему самое место быть в обычном конструкторе. А также определили id, присвоив ему Uuid четвертой версии. Теперь давайте напишем именованные конструкторы для черновика и публикации:

    public static function fromDraft(?string $title, ?string $body, ?string $slug): Post
    {
        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::DRAFT;

        return $post;
    }

    public static function fromPublished(string $title, string $body, string $slug): Post
    {
        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->publishedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::PUBLISHED;

        return $post;
    }

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

   /**
     * @var User
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(referencedColumnName="id", onDelete="CASCADE")
     */
    private $user;

Для начала мы указали связь Многие к Одному от поста, что означает, что у одного юзера может быть много постов. В аннотации @ORM\JoinColumn мы указали, на какое поле делать референс, а также в атрибуте onDelete="CASCADE" мы определили, что посты удалятся автоматически, если удалится пользователь. Если же вам не хочется удалять посты, тогда сделайте следующее:

   /**
     * @var User
     * @ORM\ManyToOne(targetEntity="App\Entity\User")
     * @ORM\JoinColumn(referencedColumnName="id", onDelete="SET NULL", nullable=true)
     */
    private $user;

Во-первых, мы добавили nullable, а во-вторых, поменяли onDelete с CASCADE на SET NULL, что значит, что при удалении юзера у его постов автоматически проставится null в базе. Удобно? Удобно. Теперь осталось немного отредактировать метод fromDraft, добавив туда юзера.

public static function fromDraft(
        User $user,
        ?string $title,
        ?string $body,
        ?string $slug
    ): Post {

        $post = new self();
        $post->title = $title;
        $post->body = $body;
        $post->slug = $slug;
        $post->user = $user;
        $post->updatedAt = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
        $post->status = self::DRAFT;

        return $post;
    }

Внимание: не нужно определять юзера еще и в методе fromPublished, ведь если у вас на сайте будет премодерация и опубликовать может только админ, то при определении юзера в этом методе автором постам станет админ, а не пользователь. Достаточно юзера связать один раз.

И пока я не забыл, давайте поставим индексы. Для это в аннотации @Table надо сделать так:

@ORM\Table(name="posts", indexes={
     @ORM\Index(columns={"user_id"}),
     @ORM\Index(columns={"status"}),
     @ORM\Index(columns={"created_at"})
 })

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

Вроде закончили, теперь не забудьте выполнить миграцию:

php bin/console doctrine:migrations:diff && bin/console doctrine:migrations:migrate

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

<?php

declare(strict_types=1);

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Mapping as ORM;
use Ramsey\Uuid\Uuid;

/**
 * @ORM\Entity()
 * @ORM\Table(name="categories", indexes={
 *     @ORM\Index(columns={"name"}),
 *     @ORM\Index(columns={"date"})
 *  })
 */
class Category
{
    /**
     * @var Uuid
     * @ORM\Id()
     * @ORM\Column(type="uuid")
     */
    private $id;

    /**
     * @var string
     * @ORM\Column(type="string", nullable=false)
     */
    private $name;

    /**
     * @var \DateTimeImmutable
     * @ORM\Column(type="datetime_immutable")
     */
    private $date;

    public function __construct(string $name)
    {
        $this->id = Uuid::uuid4();
        $this->name = $name;
        $this->date = new \DateTimeImmutable('now', new \DateTimeZone('Europe/Moscow'));
    }

    /**
     * @return Uuid
     */
    public function getId(): Uuid
    {
        return $this->id;
    }

    /**
     * @return \DateTimeImmutable
     */
    public function getDate(): \DateTimeImmutable
    {
        return $this->date;
    }

    /**
     * @return string
     */
    public function getName(): string
    {
        return $this->name;
    }
}

Все, что написано тут, вам уже знакомо, поэтому пропустим объяснения.

До встречи в следующем уроке! Там мы рассмотрим связь многие ко многим с помощью доктрины!

loader
Логические задачи с собеседований