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

На прошлых уроках мы написали первую сущность, поработали с формой, стандартной авторизацией и авторизацией через соц сети. Однако в реальных приложениях все намного сложнее: есть многочисленные связи между сущностями, которые надо правильно обрабатывать. К счастью, Symfony в качестве инструмента с базой данных использует Doctrine, которая очень удобна как раз для решения вопросов отношений. Сегодня мы с вами познакомимся с первым типом отношений - ManyToOne/OnyToMany.

Основы ассоциаций в Doctrine

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

<?php

declare(strict_types=1);

namespace App\Entity;

use DateTimeImmutable;
use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 */
class Comment
{

    /**
     * @ORM\Id()
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

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

    /**
     * @var Post
     *
     * @ORM\ManyToOne(targetEntity=Post::class, inversedBy="comments")
     * @ORM\JoinColumn(referencedColumnName="id")
     */
    private $post;

    /**
     * @var User
     *
     * @ORM\ManyToOne(targetEntity=User::class, inversedBy="comments")
     * @ORM\JoinColumn(referencedColumnName="id")
     */
    private $user;

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

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

    private function __construct()
    {
        $this->createdAt = new DateTimeImmutable();
        $this->updatedAt = new DateTimeImmutable();
    }

    /**
     * @param string $content
     * @param User $user
     * @param Post $post
     *
     * @return Comment
     */
    public static function create(string $content, User $user, Post $post): Comment
    {
        $comment = new self();
        $comment->comment = $content;
        $comment->post = $post;
        $comment->user = $user;

        return $comment;
    }

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

    /**
     * @return User
     */
    public function getUser(): User
    {
        return $this->user;
    }

    /**
     * @return Post
     */
    public function getPost(): Post
    {
        return $this->post;
    }

    /**
     * @param Post $post
     */
    public function setPost(Post $post)
    {
        $this->post = $post;
    }

    /**
     * @param User $user
     */
    public function setUser(User $user)
    {
        $this->user = $user;
    }
}

Давайте сначала поразмышляем, как правильно определить отношение между сущностями. У нас есть комментарии и есть публикация. У публикации может быть много комментариев, но комментарий может принадлежать только одной сущности. Отсюда и возникает, что мы должны определить к таблице Post и User отношение ManyToOne, потому как только одному посту и одному пользователю могут принадлежать много комментариев, но не наоборот.

Аннотация ManyToOne принимает несколько аргументов, один из них - targetEntity - является ссылкой (можно указывать как Entity::class, так и полный неймспейс до сущности) на класс для связи, другой inversedBy, где указывается поле для связи. В нашем случае к сущностям User и Post мы позже добавим поле comments. Важно заметить, что связь с двух сторон можно не указывать, потому как доктрина умная и додумает сама, как соединить (а именно по id), но если вам понадобится получить комментарии для всех постов, то удобно достать их через публикацию, хоть это и не очень быстро.

Кстати, @JoinColumn() можно не указывать, доктрина все равно создаст нам такие же поля, как и с ней, однако если вы хотите изменить название поля или референс, то надо использовать данную аннотацию.

Теперь давайте укажем связи в сущностях Post и User, делается это следующим образом:

// Post.php

   /**
     * @var Comment[]
     *
     * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="post")
     */
    private $comments;

// User.php

   /**
     * @var Comment[]
     *
     * @ORM\OneToMany(targetEntity=Comment::class, mappedBy="user")
     */
    private $comments;

При этом в конструкторе классов Post и User надо определить поле comments как коллекцию объектов:

public function __construct()
{
   $this->comments = new ArrayCollection();
}

Вы могли заметить небольшие отличия в аннотациях ManyToOne и OneToMany, а именно на то, что у них разные атрибуты - inversedBy и mappedBy. inversedBy указывается в аннотации ManyToOne и указывает на родителя связи, mappedBy же указывают на обратную сторону двунаправленной связи.

При этом не надо указывать @ORM\Column() для полей связи, так как тип связующего поля возьмется по id, и если вы укажете, то аннотация @ManyToOne или @OneToMany просто не выполнится. Теперь можете запускать миграции:

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

Если вы посмотрите в базу, то увидите таблицу comment со следующими полями:

id | post_id | user_id | comment | created_at | updated_at

Все сработало ровно так, как нам и нужно было.

Добавление комментария

Для добавления комментария к посту мы будем работать с коллекциями. Для этого нам сначала нужно добавить следующие методы в сущность Post:

public function addComment(Comment $comment): void
{
     $comment->setPost($this);

     if (!$this->comments->contains($comment)) {
          $this->comments->add($comment);
      }
}
public function removeComment(Comment $comment): void
{
    $this->comments->removeElement($comment);
}

У ArrayCollection достаточно простой API, советую познакомиться с ним по ссылке.
В первом методе мы сеттим Post и проверяем, содержится ли уже в коллекции комментарий с таким id, если нет, добавляем, если да, ничего не делаем. Ну и удаление работает так же просто: вызываем метод коллекции removeElement, куда передаем сущность Comment. Теперь давайте создадим форму:

class CommentType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
       $builder
            ->add('comment', TextareaType::class, [
                'label' => 'Новый комментарий',
            ])
        ;
    }
    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class' => Comment::class,
        ]);
    }

Кажется, все готово, чтобы можно было под постом добавить комментарий. Для этого сначала напишем экшен контроллера PostsController, а потом отрендерим шаблон:

      /**
        * @Route("/post/{slug}", methods={"POST"}, name="comment_new")
        *
        */
       public function commentNew(Post $post, Request $request)
       {
            $comment = new Comment();
            $comment->setUser($this->getUser());
            $post->addComment($comment);

            $form = $this->createForm(CommentType::class, $comment);

            $form->handleRequest($request);

            if ($form->isSubmitted() && $form->isValid()) {
                $em = $this->getDoctrine()->getManager();
                $em->persist($comment);
                $em->flush();

               return $this->redirectToRoute('post_show', ['slug' => $post->getSlug()]);
            }

             return $this->render('post/show.html.twig', [
               'post' => $post,
               'form' => $form->createView()
            ]);
       } 

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

{% extends 'base.html.twig' %}

{% block title %}{{ post.title }}{% endblock %}

{% block body %}
<br>
<div class="container col-sm-12">
    <h4>{{ post.title }}</h4>
    <p>{{ post.body }}</p><hr>
    <span>{{ post.getCreatedAt() | date("m/d/Y") }}</span><br><hr>
    <div class="container">
        {{ form_start(form) }}
            {{ form_row(form.comment) }}
         <button type="submit" class="btn btn-primary">Отправить</button>
        {{ form_end(form) }}
    </div>
    {%  for comment in post.comments %}
        <p>{{ comment.comment }}</p><hr>
    {% endfor %}
</div>
{% endblock %}

И вот теперь нам понадобилась связь в сущности Post, так как через post.comments мы достали все комментарии, принадлежащие конкретному посту. Так же вы можете достать все комментария юзера через user.comments в шаблоне или $this->getUser()->getComments() в коде.

Итого

На этом пока все. Еще больше концепций и правил по работе с отношениями мы рассмотрим в следующих уроках.

P.S.

Если у вас будут проблемы с последней версией Symfony или с пониманием работы отношений, пишите вопросы в комментариях.

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