Реализация системы тегов в админке с бандлом SonataAdminBundle

Многие пользуются бандлом SonataAdminBundle при разработке на Symfony2. Этот бандл позволяет в кратчайшие сроки создать CRUD-админку для сущностей Doctrine и Mongo. В частности, позволяет быстро и легко сделать странички для добавления сущностей, в том числе включающими связи Один-ко-Многим и Многие-ко-Многим. Вот с последним пунктом у меня и возникли проблемы. В статье я покажу решение, как можно организовать установку тегов для нескольких сущностей, задействуя всего одну промежуточную таблицу, с помощью бандла FPNTagBundle, и что пришлось сделать, чтобы этот бандл заработал в SonataAdmin. А вначале рассмотрим, как реализовать редактирование сущностей (в том числе с тегами) на простой SonataAdmin

Простая реализация тегов


В текущем проекте есть несколько сущностей (условно назовём их Article и News, хотя всего их в этом проекте семь), которым нужно дать возможность проставлять теги, причём одной сущности можно установить несколько тегов, то есть реализуется связь Многие-ко-Многим.
Вначале рассмотрим, как сделать редактирвоание тегов в админке без бандла FPNTagBundle. Я сделал родительскую сущность, от которой наследуются все остальные:
Базовая сущность Entity
namespace App\AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

// нет тега ORM\Entity - доктрина не будет считать этот класс отдельной сущностью и не создаст таблицу
class Entity
{
    /**
     * @var integer
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var boolean
     * @ORM\Column(type="boolean", options={"default":false})
     */
    protected $published = false;

    /**
     * @var string
     * @ORM\Column(type="string", length=255)
     */
    protected $title;
    
    /**
     * @var string
     * @ORM\Column(type="text")
     */
    protected $content;

    // остальные поля
    
     /**
     * Get id
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * Set published
     * @param boolean $published
     * @return Entity
     */
    public function setPublished($published)
    {
        $this->published = $published;
        return $this;
    }

    /**
     * Toggle published
     * @return Entity
     */
    public function togglePublished()
    {
        $this->published = !$this->published;
        return $this;
    }

    /**
     * Get published
     * @return boolean
     */
    public function getPublished()
    {
        return $this->published;
    }
    
    /**
     * Set title
     * @param string $title
     * @return Entity
     */
    public function setTitle($title)
    {
        $this->title = $title;
        return $this;
    }

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

    /**
     * Set content
     * @param string $content
     * @return Entity
     */
    public function setContent($content)
    {
        $this->content = $content;
        return $this;
    }

    /**
     * Get content
     * @return string
     */
    public function getContent()
    {
        return $this->content;
    }
}


Две редактируемые сущности:
Сущность Article
namespace App\AppBundle\Entity;

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

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class Article extends Entity
{
    /**
     * @var ArrayCollection
     * @ORM\ManyToMany(targetEntity="Tag", inversedBy="articles")
     * @ORM\JoinTable(name="article_tags")
     */
    protected $tags;

    /**
     * @return ArrayCollection
     */
    public function getTags()
    {
        return $this->tags ?: $this->tags = new ArrayCollection();
    }

    public function addTag(Tag $tag)
    {
        $tag->addArticle($this);
        $this->tags[] = $tag;
    }

    public function removeTag(Tag $tag)
    {
        return $this->tags->removeElement($tag);
    }
}


Сущность News
namespace App\AppBundle\Entity;

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

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class News extends Entity
{
    /**
     * @var \DateTime
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $publishedAt;

    /**
     * @var ArrayCollection
     * @ORM\ManyToMany(targetEntity="Tag", inversedBy="news")
     * @ORM\JoinTable(name="news_tags")
     */
    protected $tags;
    
    /**
     * Set publishedAt
     * @param \DateTime $publishedAt
     * @return News
     */
    public function setPublishedAt($publishedAt)
    {
        $this->publishedAt = $publishedAt;
        return $this;
    }

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

    /**
     * @return ArrayCollection
     */
    public function getTags()
    {
        return $this->tags ?: $this->tags = new ArrayCollection();
    }

    public function addTag(Tag $tag)
    {
        $tag->addArticle($this);
        $this->tags[] = $tag;
    }

    public function removeTag(Tag $tag)
    {
        return $this->tags->removeElement($tag);
    }
}


И сущность тегов:
Сущность Tag
namespace App\AppBundle\Entity;

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

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class Tag
{
    public function __construct() {
        $this->articles = new ArrayCollection();
        $this->news = new ArrayCollection();
    }

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

    /**
     * @var string
     * @ORM\Column(type="string", length=100)
     */
    protected $name;

    /**
     * @ORM\ManyToMany(targetEntity="Article", mappedBy="tags")
     */
    private $articles;

    /**
     * @ORM\ManyToMany(targetEntity="News", mappedBy="tags")
     */
    private $news;

    public function addArticle(Article $article)
    {
        $this->articles[] = $article;
    }

    public function addNews(News $news)
    {
        $this->news[] = $news;
    }
    
    public function getArticles()
    {
        $this->articles;
    }

    public function getNews()
    {
        $this->news;
    }
    
    /**
     * @return integer
     */
    public function getId()
    {
        return $this->id;
    }

    /**
     * @param string $name
     * @return Tag
     */
    public function setName($name)
    {
        $this->name = $name;

        return $this;
    }

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

}


Можно увидеть, что две сущности Article и News отличаются только названием таблицы в связи Many-to-Many. И наличием дополнительного поля в News, что в данный момент не существенно.

В Доктрине связь Многие-ко-Многим устанавливается очень легко, на уровне пары строчек в аннотации. Те, кто работал с Doctrine, уже видели эту простоту. При этом автоматически создаётся промежуточная таблица. Установив такую связь для каждой сущности, легко настроить добавление тегов для сущностей в Sonata-админке:
Базовая админка для сущностей
namespace App\AppBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;

// Имя класса не заканчивается на Admin, поэтому Sonata не будет считать её отдельной админкой
class EntityAdminBase extends Admin
{
    protected function configureFormFields(FormMapper $formMapper)
    {
        $formMapper
            ->add('title', 'text')
            ->add('content', 'ckeditor')
            ->add('tags', 'entity', array(
                'class'=>'AppBundle:Tag', 
                'multiple' => true, 
                'attr'=>array('style'=>'width: 100%;'))
            )
            // стиль width: 100% нужен для исправления бага у Select2-поля, 
            // когда ширина поля маленькая, и выбрать теги очень сложно
        ;
    }
    
    protected function configureDatagridFilters(DatagridMapper $datagridMapper)
    {
        $datagridMapper
            ->add('title')
            ->add('tags', null, array(), null, array('multiple' => true))
        ;
    }

    protected function configureListFields(ListMapper $listMapper)
    {
        $listMapper
            ->addIdentifier('title')
            ->add('published')                
        ;
    }
}


Админка Article
namespace App\AppBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;

class ArticleAdmin extends EntityAdminBase
{
    
}


Админка News
namespace App\AppBundle\Admin;

use Sonata\AdminBundle\Admin\Admin;
use Sonata\AdminBundle\Datagrid\ListMapper;
use Sonata\AdminBundle\Datagrid\DatagridMapper;
use Sonata\AdminBundle\Form\FormMapper;

class NewsAdmin extends EntityAdminBase
{
    protected function configureFormFields(FormMapper $formMapper)
    {
        parent::configureFormFields($formMapper);
        $formMapper
            ->add('publishedAt', 'datetime')
    }
    
    protected function configureListFields(ListMapper $listMapper)
    {
        parent::configureListFields($listMapper);
        $listMapper
            ->add('publishedAt')                
        ;
    }
}


Как видно, общие для сущностей поля вынесены в родительский класс, а специфичные для конкретной сущности добавлены в каждой админке. Осталось только зарегистрировать сервисы для админок:
Настройка сервисов админки
# /src/App/AppBundle/Resources/config/admin.yml
services:
    sonata.admin.article:
        class: App\AppBundle\Admin\ArticleAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: "Content", label: "Articles" }
        arguments:
            - ~
            - App\AppBundle\Entity\Article
            - ~
        calls:
            - [ setTranslationDomain, [admin]]
    
    sonata.admin.news:
        class: App\AppBundle\Admin\NewsAdmin
        tags:
            - { name: sonata.admin, manager_type: orm, group: "Content", label: "News" }
        arguments:
            - ~
            - App\AppBundle\Entity\News
            - ~
        calls:
            - [ setTranslationDomain, [admin]]
            
# и добавим загрузку сервисов админки в глобальном конфиге
# /app/config/config.yml
imports:
    - { resource: parameters.yml }
    - { resource: security.yml }
    - { resource: @AppBundle/Resources/config/admin.yml }


На этом всё, соната автоматически создаст всё необходимое для редактирования списков статей и новостей.

Хранение связей тегов и сущностей в одной таблице


И всё работало отлично до тех пор, пока я не обратил внимание на то, что для каждой сущности создаётся отдельная таблица для организации связи Многие-ко-Многим с тегами. (Если бы у меня было всего пару таких сущностей, я бы, возможно, и не парился с этим, но в данном случае мне не хотелось создавать семь разных таблиц, а потом ещё и организовывать поиск по этим таблицам.) Для решения нашёл бандл FPNTagBundle, который разбивает связь Многие-ко-Многим на две связи Многие-к-Одному и Один-ко-Многим введением промежуточной сущности Tagging. В общем-то, такое разделение реализуется в DoctrineExtentions, а бандл добавляет их интеграцию в Symfony и реализует класс TagManager. Отличный бандл, который делает достаточно очевидную вещь — сделать одну таблицу с дополнительным полем ResourceType — типом записи, на которую привязывается тег. Проблема в том, что Sonata не поддерживает подобные связи, и реализовать админку так же просто не получится.

Но давайте рассмотрим, какие изменения внесены в сущности:
Базовая сущность Entity
namespace App\AppBundle\Entity;

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

class Entity
{
    // старые поля
    // старые геттеры и сеттеры
    
    // обратите внимание - без аннотаций доктрины!
    protected $tags;
    
    public function getTags()
    {
        return $this->tags ?: $this->tags = new ArrayCollection();
    }

    public function getTaggableType()
    {
        // в качестве типа ресурса используем класс сущности (исключив неймспейс)
        return substr(strrchr(get_class($this), "\\"), 1);
    }

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


Сущность Article
namespace App\AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class Article extends Entity
{

}


Сущность News
namespace App\AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class News extends Entity
{
    /**
     * @var \DateTime
     * @ORM\Column(type="datetime", nullable=true)
     */
    protected $publishedAt;

    
    /**
     * Set publishedAt
     * @param \DateTime $publishedAt
     * @return News
     */
    public function setPublishedAt($publishedAt)
    {
        $this->publishedAt = $publishedAt;
        return $this;
    }

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


Изменённая сущность Tag
namespace App\AppBundle\Entity;

use \Doctrine\ORM\Mapping as ORM;
use \FPN\TagBundle\Entity\Tag as BaseTag;

/**
 * @ORM\Table()
 * @ORM\Entity()
 */
class Tag extends BaseTag
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;
    
    /**
     * @ORM\OneToMany(targetEntity="Tagging", mappedBy="tag", fetch="EAGER")
     **/
    protected $tagging;
    
    /**
     * @return integer 
     */
    public function getId()
    {
        return $this->id;
    }
}


Сущность Tagging
namespace App\AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\UniqueConstraint;
use \FPN\TagBundle\Entity\Tagging as BaseTagging;

/**
 * @ORM\Table(uniqueConstraints={@UniqueConstraint(name="tagging_idx", columns={"tag_id", "resource_type", "resource_id"})})
 * @ORM\Entity
 */
class Tagging extends BaseTagging
{
    /**
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @ORM\ManyToOne(targetEntity="Tag", inversedBy="tagging")
     * @ORM\JoinColumn(name="tag_id", referencedColumnName="id")
     **/
    protected $tag;   
}


Теги вынесены в базовую сущность, классы самих сущностей не содержат ничего лишнего.

Начал копать код SonataAdminBundle в поисках решения, как научить её работать с такими тегами, набрёл сначала на хуки сохранения (Saving hooks), отмёл их и стал искать, как реализовать собственный тип поля, в который можно было бы внедрить запуск TagManager-а. Но не осилил, там достаточно запутанный код. И тут я обратил внимание, что при старой настройке тегов в адмнке на странице редактирвоания записи список тегов продолжает выводиться, и при сохранении теги попадают в свойство $tags сущности. Правда, соната не сохраняет их в базу данных (у этого свойства нет аннотаций доктрины, да и не сможет, даже если и были бы), но нахождение тегов в коллекции тегов сущности — именно то, что надо для работы TagManager! Осталось запускать менеджер тегов при изменении сущности, и тут пригодились именно Saving hooks.

В классе админки я не стал менять описание поля тегов, и соната заносит теги в свойство-коллекцию при сохранении. С помощью хуков postPersist и postUpdate вызывается сохранение связи тегов в базу:
    /**
     * @return FPN\TagBundle\Entity\TagManager
     */
    protected function getTagManager() {
        return $this->getConfigurationPool()->getContainer()
            ->get('fpn_tag.tag_manager');
    }
    
    public function postPersist($object) {
        $this->getTagManager()->saveTagging($object);
    }
    
    public function postUpdate($object) {
        $this->getTagManager()->saveTagging($object);
    }

    public function preRemove($object) {
        $this->getTagManager()->deleteTagging($object);
        $this->getDoctrine()->getManager()->flush();
    }

Тут есть ещё одна засада — баг в Сонате, который приводит к тому, что в пакетном удалении (в списке) не вызываются хуки preRemove и postRemove. Решение в расширении стандартного CRUD-контроллера сонаты:
Кастомный CRUD-контроллер
namespace App\AppBundle\Controller;

use Sonata\AdminBundle\Controller\CRUDController as Controller;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Sonata\AdminBundle\Datagrid\ProxyQueryInterface;

class CRUDController extends Controller
{

    public function publishAction()
    {
        $id = $this->get('request')->get($this->admin->getIdParameter());
        $object = $this->admin->getObject($id);
        
        if (!$object) {
            throw new NotFoundHttpException(sprintf('unable to find the object with id : %s', $id));
        }
        
        $object->togglePublished();
        $this->admin->getModelManager()->update($object);
        
        $message = $object->getPublished() ? 'Publish successfully' : 'Unpublish successfully';
        $trans = $this->get('translator.default');
        $this->addFlash('sonata_flash_success', $trans->trans($message, array(), 'admin'));
        return new RedirectResponse($this->admin->generateUrl('list'));
    }
    
    public function batchActionDelete(ProxyQueryInterface $query)
    {
        if (method_exists($this->admin, 'preRemove')) {
            foreach ($query->getQuery()->iterate() as $object) {                
                $this->admin->preRemove($object[0]);
            }
        }
        
        $response = parent::batchActionDelete($query);
        
        if (method_exists($this->admin, 'postRemove')) {
            foreach ($query->getQuery()->iterate() as $object) {                
                $this->admin->postRemove($object[0]);
            }
        }
        
        return $response;
    }

}


В этот же контроллер добавлен метод для кнопки публикации в списке сущностей. Для этой кнопки нужен ещё twig-шаблон и добавление настройки configureListFields в классе админки:
Шаблон кастомного действия в списке
{# src/App/AppBundle/Resources/views/CRUD/list__action_publish.html.twig #}

{% if object.published %}
    <a class="btn btn-sm btn-danger" href="{{ admin.generateObjectUrl('publish', object) }}">
        {% trans from 'admin' %}Unpublish{% endtrans %}
    </a>
{% else %}
    <a class="btn btn-sm btn-success" href="{{ admin.generateObjectUrl('publish', object) }}">
        {% trans from 'admin' %}Publish{% endtrans %}
    </a>
{% endif %}


Настройка кастомного действия в списке
protected function configureListFields(ListMapper $listMapper)
{
    $listMapper
        // прочие поля    
        ->add('_action', 'actions', array(
            'actions' => array(
                'Publish' => array(
                    'template' => 'AppBundle:CRUD:list__action_publish.html.twig'
                )
            )
        ))
    ;
}


Для включения расширенного контроллера нужно передать его название (AppBundle:CRUD) третьим аргументом в настройке сервиса.

Следующая задача — вывод уже назначенных тегов при редактировании сущности. Решается достаточно просто — нужно передать список тегов в поле tags типа entity. Это именно та часть, которую не получилось вынести в расширение админки (AdminExtension), иначе я сделал бы именно так.
Вывод назначенных тегов
protected function configureFormFields(FormMapper $formMapper)
{
    $tags = $this->hasSubject()
        ? $this->getTagManager()->loadTagging($this->getSubject())
        : array();
    
    $formMapper
        // прочие поля
        ->add('tags', 'entity', array(
            'class'=>'AppBundle:Tag', 
            'choices' => $tags, 
            'multiple' => true, 
            'attr'=>array('style'=>'width: 100%;'))
        )
    ;
}



Заключение


Таким образом, получилось внедрить хороший удобный бандл FPNTagBundle в админку SonataAdminBundle, добиться сохранения всех связей в одну общую таблицу, а также получше изучить внутренности Сонаты.

Бонус — запросы для работы с тегами


Некоторое время назад я в комментариях обещал выложить статью с набором SQL-запросов для работы с тегами. Отдельную статью я не стал делать, приведу их здесь.

Дано:
  • приведённые выше таблицы Article, News, Tag, Tagging
  • несколько тегов (список id), по которым нужно найти релевантные сущности. Будем считать, что тегов у нас 3, но можно и больше.

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

Первый запрос выводит id найденных записей (и тип записи)
SELECT resource_id, resource_type, count(*) as weight FROM Tagging 
WHERE tag_id IN (1,2,3) GROUP BY resource_id ORDER BY weight DESC

Второй запрос выводит список найденных статей:
SELECT Article.id, Article.title FROM Tagging, Article 
WHERE Tagging.resource_id=Article.id AND Tagging.tag_id IN (1,2,3) 
GROUP BY Tagging.resource_id ORDER BY count(*) DESC

Хабрапользователь Nashev предложил вариант запроса с исключением тегов, то есть, вывести все записи, содержащие теги (1, 2, 3) и не содержащие (4, 5, 6):
SELECT resource_id, resource_type FROM Tagging WHERE tag_id IN (1,2,3) 
AND resource_id NOT IN (SELECT resource_id FROM Tagging WHERE tag_id IN (4,5,6))
GROUP BY resource_id ORDER BY count(*) DESC
Поделиться публикацией

Комментарии 1

    0
    Спасибо за статью. Как раз столкнулся с этой же проблемой и не пришлось изобретать велосипед.

    Дополню только замечанием, что нужно реализовать в админке добавление и редактирование самих тегов. Ведь при редактировании и создании объекта можно только выбрать существующие теги.
    Также разумно поменять ограничение на ключ tag_id сущности tagging c ON DELETE RESTRICT на ON DELETE CASCADE, чтобы при удалении тега не нужно было шариться по всем объектам и удалять его.

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

    Самое читаемое