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

от автора

Многие пользуются бандлом SonataAdminBundle при разработке на Symfony2. Этот бандл позволяет в кратчайшие сроки создать CRUD-админку для сущностей Doctrine и Mongo. В частности, позволяет быстро и легко сделать странички для добавления сущностей, в том числе включающими связи Один-ко-Многим и Многие-ко-Многим. Вот с последним пунктом у меня и возникли проблемы. В статье я покажу решение, как можно организовать установку тегов для нескольких сущностей, задействуя всего одну промежуточную таблицу, с помощью бандла FPNTagBundle, и что пришлось сделать, чтобы этот бандл заработал в 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';         $this->addFlash('sonata_flash_success', $this->get('translator.default')->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:

Вывод назначенных тегов

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 

ссылка на оригинал статьи http://habrahabr.ru/post/233695/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *