Symfony Blog Tutorial: The Comments Model, Doctrine Repositories and Migrations

Tiếp tục chuỗi bài viết giới thiệu về Symfony 2 framework thông qua việc xây dựng một blog website. Bài viết này sẽ trình bày về thêm bình luận, Doctrine Repositories và migrations. Các bạn có thể theo dõi các bài viết trước về Validators, Forms and EmailingDoctrine Model và Data Fixtures.

Tổng quan Bài viết này sẽ tiếp tục xây dựng blog model từ các bài viết trước và tạo thêm comment blog - xử lý bình luận cho bài viết. Bài viết này sẽ giới thiệu về mối quan hệ giữa các models, ví dụ 1 bài viết có nhiều bình luận. Chúng ta sẽ sử dụng Doctrine 2 QueryBuilderDoctrine 2 Repository để lấy về các entities từ database. Bên cạnh đó, bài viết sẽ giới thiệu về Doctrine 2 Migration - một cách quản lý thay đổi database. Cuối bài viết, chúng ta có thể thực hiện được việc bình luận cho mỗi bài viết.

Querying the model

Để hiển thị các bài viết, chúng ta cần lấy từ trong database. Doctrine 2 cung cấp Doctrine Query Language (DQL)QueryBuilder để thực hiện việc này. Chúng ta sẽ sử dụng QueryBuilder để sinh ra DQL giúp chúng ta query database. Hãy cập nhật trong action index của PageController như sau để lấy ra tất cả các bài viết trong database.

// src/Blogger/BlogBundle/Controller/PageController.php
class PageController extends Controller
{
    public function indexAction()
    {
        $em = $this->getDoctrine()
                   ->getEntityManager();

        $blogs = $em->createQueryBuilder()
                    ->select('b')
                    ->from('BloggerBlogBundle:Blog',  'b')
                    ->addOrderBy('b.created', 'DESC')
                    ->getQuery()
                    ->getResult();

        return $this->render('BloggerBlogBundle:Page:index.html.twig', array(
            'blogs' => $blogs
        ));
    }

    // ..
}

The View

Bây giờ sẽ hiển thị các bài viết. Thay thế nội dung hiển thị của trang chủ như sau:

{# src/Blogger/BlogBundle/Resources/views/Page/index.html.twig #}
{% extends 'BloggerBlogBundle::layout.html.twig' %}

{% block body %}
    {% for blog in blogs %}
        <article class="blog">
            <div class="date"><time datetime="{{ blog.created|date('c') }}">{{ blog.created|date('l, F j, Y') }}</time></div>
            <header>
                <h2><a href="{{ path('BloggerBlogBundle_blog_show', { 'id': blog.id }) }}">{{ blog.title }}</a></h2>
            </header>

            <img src="{{ asset(['images/', blog.image]|join) }}" />
            <div class="snippet">
                <p>{{ blog.blog(500) }}</p>
                <p class="continue"><a href="{{ path('BloggerBlogBundle_blog_show', { 'id': blog.id }) }}">Continue reading...</a></p>
            </div>

            <footer class="meta">
                <p>Comments: -</p>
                <p>Posted by <span class="highlight">{{blog.author}}</span> at {{ blog.created|date('h:iA') }}</p>
                <p>Tags: <span class="highlight">{{ blog.tags }}</span></p>
            </footer>
        </article>
    {% else %}
        <p>There are no blog entries for symblog</p>
    {% endfor %}
{% endblock %}

Screenshot from 2015-06-27 14:58:49.png

Trong khi query entities trong controller, điều này không được tốt cho lắm. Việc query có thể tốt hơn nếu như đặt bên ngoài controller vì một vài lý do sau:

  • Chúng ta không thể sử dụng query này cho bất kỳ chỗ nào nữa.
  • Nếu chúng ta lặp code QueryBuilder, chúng ta sẽ phải sửa nhiều chỗ nếu như có sự thay đổi trong query.
  • Tách biệt query với controller sẽ giúp chúng ta có thể kiểm tra quey một cách độc lập với controller. Doctrine 2 cung cấp lớp Repository giúp chúng ta làm được điều này.

Doctrine 2 Repositories

Ở bài trước cũng đã giới thiệu về Doctrine 2 Repository khi tạo trang hiển thị bài viết. Chúng ta sử dụng cài đặt mặc định của Doctrine\ORM\EntityRepository để nhận về 1 bài viết thông qua phương thức find(). Nhưng chúng ta muốn tạo một query mới thì chúng ta cần tạo 1 repository khác kế thừa từ EntityRepository. Cập nhật lại Blog entity để tạo ra BlogRepository

// src/Blogger/BlogBundle/Entity/Blog.php
/**
 * @ORM\Entity(repositoryClass="Blogger\BlogBundle\Entity\Repository\BlogRepository")
 * @ORM\Table(name="blog")
 * @ORM\HasLifecycleCallbacks()
 */
class Blog
{
    // ..
}

Sau khi đăng ký xong chúng ta cần chạy lại task sau:

php app/console doctrine:generate:entities Blogger\BlogBundle

Doctrine 2 sẽ tạo ra class BlogRepository như sau:

<?php
// src/Blogger/BlogBundle/Entity/Repository/BlogRepository.php

namespace Blogger\BlogBundle\Entity\Repository;

use Doctrine\ORM\EntityRepository;

/**
 * BlogRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class BlogRepository extends EntityRepository
{

}

Bây giờ hãy chuyển QueryBuilder từ PageController vào trong BlogRepository

<?php
// src/Blogger/BlogBundle/Entity/Repository/BlogRepository.php

namespace Blogger\BlogBundle\Entity\Repository;

use Doctrine\ORM\EntityRepository;

/**
 * BlogRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class BlogRepository extends EntityRepository
{
    public function getLatestBlogs($limit = null)
    {
        $qb = $this->createQueryBuilder('b')
                   ->select('b')
                   ->addOrderBy('b.created', 'DESC');

        if (false === is_null($limit))
            $qb->setMaxResults($limit);

        return $qb->getQuery()
                  ->getResult();
    }
}

Lúc này chúng ta gọi hàm getLastestBlogs() sẽ trả về các bài viết mới nhất. Việc này tương tự với việc sử dụng QueryBuilder trong controller. Cuối cùng, cập nhật lại PageController lại như sau để sử dụng repository.

// src/Blogger/BlogBundle/Controller/PageController.php
class PageController extends Controller
{
    public function indexAction()
    {
        $em = $this->getDoctrine()
                   ->getEntityManager();

        $blogs = $em->getRepository('BloggerBlogBundle:Blog')
                    ->getLatestBlogs();

        return $this->render('BloggerBlogBundle:Page:index.html.twig', array(
            'blogs' => $blogs
        ));
    }

    // ..
}

Creating Comment Entity

Chúng ta bắt đầu định nghĩa cơ bản cho class Comment entity và tạo quan hệ với Blog entity.

<?php
// src/Blogger/BlogBundle/Entity/Comment.php

namespace Blogger\BlogBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Mapping\ClassMetadata;
use Symfony\Component\Validator\Constraints\NotBlank;

/**
 * @ORM\Entity(repositoryClass="Blogger\BlogBundle\Entity\Repository\CommentRepository")
 * @ORM\Table(name="comment")
 * @ORM\HasLifecycleCallbacks
 */
class Comment
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    public $user;

    /**
     * @ORM\Column(type="text")
     */
    public $comment;

    /**
     * @ORM\Column(type="boolean")
     */
    protected $approved;

    /**
     * @ORM\ManyToOne(targetEntity="Blog", inversedBy="comments")
     * @ORM\JoinColumn(name="blog_id", referencedColumnName="id")
     */
    protected $blog;

    /**
     * @ORM\Column(type="datetime")
     */
    protected $created;

    /**
     * @ORM\Column(type="datetime")
     */
    protected $updated;

    public function __construct()
    {
        $this->setCreated(new \DateTime());
        $this->setUpdated(new \DateTime());

        $this->setApproved(true);
    }

    public function setCreated($created)
    {
        $this->created = $created;
    }

    public function setUpdated($updated)
    {
        $this->updated = $updated;
    }

    public function setApproved($approved)
    {
        $this->approved = $approved;
    }

    public function setBlog($blog)
    {
        $this->blog = $blog;
    }

    public function setUser($user)
    {
        $this->user = $user;
    }

    public function setComment($comment)
    {
        $this->comment = $comment;
    }

    public function blog()
    {
        return $this->blog;
    }

    public function getBlog()
    {
        return $this->blog;
    }

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

    public function getCreated()
    {
        return $this->created;
    }

    /**
     * @ORM\PreUpdate
     */
    public function setUpdatedValue()
    {
        $this->setUpdated(new \DateTime());
    }

    public static function loadValidatorMetadata(ClassMetadata $metadata)
    {
        $metadata->addPropertyConstraint('user', new NotBlank(array(
            'message' => 'You must enter your name'
        )));
        $metadata->addPropertyConstraint('comment', new NotBlank(array(
            'message' => 'You must enter a comment'
        )));
    }
}

Sau đó cập nhật lại Blog entity để khai báo quan hệ giữa 2 entity.

<?php
// src/Blogger/BlogBundle/Entity/Blog.php

namespace Blogger\BlogBundle\Entity;

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

/**
 * @ORM\Entity(repositoryClass="Blogger\BlogBundle\Entity\Repository\BlogRepository")
 * @ORM\Table(name="blog")
 * @ORM\HasLifecycleCallbacks
 */
class Blog
{
    // ..

    /**
     * @ORM\OneToMany(targetEntity="Comment", mappedBy="blog")
     */
    protected $comments;

    // ..

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

        $this->setCreated(new \DateTime());
        $this->setUpdated(new \DateTime());
    }

    // ..
}

Sau khi đã thay đổi 2 entity. Chạy lại Doctrine 2 task sau để Doctrine 2 sinh ra quan hệ.

php app/console doctrine:generate:entities Blogger\BlogBundle

Doctrine 2 Migrations

Doctrine 2 Migrations là 1 extension và cài đặt tương tự với Data Fixtures. Mở file composer.json và cập nhật lại như sau:

"require": {
    // ...
    "doctrine/doctrine-migrations-bundle": "dev-master",
    "doctrine/migrations": "dev-master"
}

Tiếp theo, cập nhật lại các thư viện.

php composer.phar update

Sau khi các thư viện cập nhật xong thì hãy đăng ký bundle vào trong app/AppKernel.php

// app/AppKernel.php
public function registerBundles()
{
    $bundles = array(
        // ...
        new Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle(),
        // ...
    );
    // ...
}

Bây giờ để thực sự cập nhật lại thay đổi cho database. Chúng ta cần 2 bước. Đầu tiên, doctrine:migrations:diff để kiểm tra sự khác nhau giữa các thực thể và cơ sở dữ liệu hiện tại. Thứ 2 là doctrine:migrations:migrate để thực thi việc thay đổi dự trên sự khác nhau đó.

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

Display Comments

Chúng ta hiển thị comments liên quan đến mỗi bài viết. Đầu tiên cập nhật lại CommentRepository để lấy ra các comments của bài viết.

<?php
// src/Blogger/BlogBundle/Entity/Repository/CommentRepository.php

namespace Blogger\BlogBundle\Entity\Repository;

use Doctrine\ORM\EntityRepository;

/**
 * CommentRepository
 *
 * This class was generated by the Doctrine ORM. Add your own custom
 * repository methods below.
 */
class CommentRepository extends EntityRepository
{
    public function getCommentsForBlog($blogId, $approved = true)
    {
        $qb = $this->createQueryBuilder('c')
                   ->select('c')
                   ->where('c.blog = :blog_id')
                   ->addOrderBy('c.created')
                   ->setParameter('blog_id', $blogId);

        if (false === is_null($approved))
            $qb->andWhere('c.approved = :approved')
               ->setParameter('approved', $approved);

        return $qb->getQuery()
                  ->getResult();
    }
}

Blog Controller

Tiếp theo, cập nhật lại BlogController để lấy lại comment của bài viết sử dụng hàm trong CommentRepository vừa viết.

// src/Blogger/BlogBundle/Controller/BlogController.php

public function showAction($id)
{
    // ..

    if (!$blog) {
        throw $this->createNotFoundException('Unable to find Blog post.');
    }

    $comments = $em->getRepository('BloggerBlogBundle:Comment')
                   ->getCommentsForBlog($blog->getId());

    return $this->render('BloggerBlogBundle:Blog:show.html.twig', array(
        'blog'      => $blog,
        'comments'  => $comments
    ));
}

Blog show template

Bây giờ chúng ta hiển thị danh sách các comments trong template show của bài viết. Chúng ta có thể hiển thị đơn giản bằng cách render trực tiếp trong template show. Nhưng comments là của entity khác vì vậy tốt hơn là tách riêng ra template khác và include template đó vào.

{# src/Blogger/BlogBundle/Resources/views/Blog/show.html.twig #}

{# .. #}

{% block body %}
    {# .. #}

    <section class="comments" id="comments">
        <section class="previous-comments">
            <h3>Comments</h3>
            {% include 'BloggerBlogBundle:Comment:index.html.twig' with { 'comments': comments } %}
        </section>
    </section>
{% endblock %}

Comment show template

Chúng ta đang include file mà chưa tồn tại. Vì vậy, chúng ta cần tạo nó với nội dung như sau:

{# src/Blogger/BlogBundle/Resources/views/Comment/index.html.twig #}

{% for comment in comments %}
    <article class="comment {{ cycle(['odd', 'even'], loop.index0) }}" id="comment-{{ comment.id }}">
        <header>
            <p><span class="highlight">{{ comment.user }}</span> commented <time datetime="{{ comment.created|date('c') }}">{{ comment.created|date('l, F j, Y') }}</time></p>
        </header>
        <p>{{ comment.comment }}</p>
    </article>
{% else %}
    <p>There are no comments for this post. Be the first to comment...</p>
{% endfor %}

Screenshot from 2015-06-27 14:57:51.png

Adding Comments

Chúng ta sẽ thực hiện việc tạo comments ở trong trang show bài viết. Chúng ta đã được giới thiệu về form trong Symfony2 ở trong bài viết về contact form. Tương tự như vậy, chạy task sau để sinh ra CommentType.

php app/console generate:doctrine:form BloggerBlogBundle:Comment

Display Comment Form

Routing

Định nghĩa routing cho việc hiển thị và thêm comment.

BloggerBlogBundle_comment_create:
    pattern:  /comment/{blog_id}
    defaults: { _controller: BloggerBlogBundle:Comment:create }
    requirements:
        _method:  POST
        blog_id: \d+
BloggerBlogBundle_comment_new:
    pattern:  /comment/{blog_id}
    defaults: { _controller: BloggerBlogBundle:Comment:new }
    requirements:
        _method:  GET
        blog_id: \d+

Controller

Tiếp theo tạo file Commentcontroller

<?php
// src/Blogger/BlogBundle/Controller/CommentController.php

namespace Blogger\BlogBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Blogger\BlogBundle\Entity\Comment;
use Blogger\BlogBundle\Form\CommentType;

/**
 * Comment controller.
 */
class CommentController extends Controller
{
    public function newAction($blog_id)
    {
        $blog = $this->getBlog($blog_id);

        $comment = new Comment();
        $comment->setBlog($blog);
        $form   = $this->createForm(new CommentType(), $comment);

        return $this->render('BloggerBlogBundle:Comment:form.html.twig', array(
            'comment' => $comment,
            'form'   => $form->createView()
        ));
    }

    public function createAction($blog_id)
    {
        $blog = $this->getBlog($blog_id);

        $comment  = new Comment();
        $comment->setBlog($blog);
        $request = $this->getRequest();
        $form    = $this->createForm(new CommentType(), $comment);
        $form->bindRequest($request);

        if ($form->isValid()) {
            // TODO: Persist the comment entity

            return $this->redirect($this->generateUrl('BloggerBlogBundle_blog_show', array(
                'id' => $comment->getBlog()->getId())) .
                '#comment-' . $comment->getId()
            );
        }

        return $this->render('BloggerBlogBundle:Comment:create.html.twig', array(
            'comment' => $comment,
            'form'    => $form->createView()
        ));
    }

    protected function getBlog($blog_id)
    {
        $em = $this->getDoctrine()
                    ->getEntityManager();

        $blog = $em->getRepository('BloggerBlogBundle:Blog')->find($blog_id);

        if (!$blog) {
            throw $this->createNotFoundException('Unable to find Blog post.');
        }

        return $blog;
    }

}

Chúng ta tạo 2 action trong controller, 1 cho new và 1 cho create. Action new thực hiện hiển thị form comment, create thực hiện việc xử lý thêm comment mới. Tiếp theo tạo 2 template tương ứng cho 2 action trên.

{# src/Blogger/BlogBundle/Resources/views/Comment/form.html.twig #}

<form action="{{ path('BloggerBlogBundle_comment_create', { 'blog_id' : comment.blog.id } ) }}" method="post" {{ form_enctype(form) }} class="blogger">
    {{ form_widget(form) }}
    <p>
        <input type="submit" value="Submit">
    </p>
</form>

Mục đích của template này rất đơn giản chi render ra comment form. Thêm view này vào create view.

{% extends 'BloggerBlogBundle::layout.html.twig' %}

{% block title %}Add Comment{% endblock%}

{% block body %}
    <h1>Add comment for blog post "{{ comment.blog.title }}"</h1>
    {% include 'BloggerBlogBundle:Comment:form.html.twig' with { 'form': form } %}
{% endblock %}

Tiếp theo, cập nhật lại template show để render ra template add comment.

{# src/Blogger/BlogBundle/Resources/views/Blog/show.html.twig #}

{# .. #}

{% block body %}

    {# .. #}

    <section class="comments" id="comments">
        {# .. #}

        <h3>Add Comment</h3>
        {{ render(controller('BloggerBlogBundle:Comment:new', { 'blog_id': blog.id })) }}
    </section>
{% endblock %}

Screenshot from 2015-06-27 14:58:40.png

Tổng kết. Kết thúc bài viết này blog của chúng ta có thể cho user post comments and đọc comment của những user khác. Ngoài ra cũng nắm được cách sử dụng Doctrine 2 Migration để quản lý thay đổi database.

Download source code