Реализация системы тегов в админке с бандлом 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 %} {% trans from 'admin' %}Unpublish{% endtrans %} {% else %} {% trans from 'admin' %}Publish{% endtrans %} {% 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
