Многие пользуются бандлом SonataAdminBundle при разработке на Symfony2. Этот бандл позволяет в кратчайшие сроки создать CRUD-админку для сущностей Doctrine и Mongo. В частности, позволяет быстро и легко сделать странички для добавления сущностей, в том числе включающими связи Один-ко-Многим и Многие-ко-Многим. Вот с последним пунктом у меня и возникли проблемы. В статье я покажу решение, как можно организовать установку тегов для нескольких сущностей, задействуя всего одну промежуточную таблицу, с помощью бандла FPNTagBundle, и что пришлось сделать, чтобы этот бандл заработал в SonataAdmin.
Простая реализация тегов
В текущем проекте есть несколько сущностей (условно назовём их Article и News, хотя всего их в этом проекте семь), которым нужно дать возможность проставлять теги, причём одной сущности можно установить несколько тегов, то есть реализуется связь Многие-ко-Многим.
Вначале рассмотрим, как сделать редактирвоание тегов в админке без бандла FPNTagBundle. Я сделал родительскую сущность, от которой наследуются все остальные:
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
// нет тега ORMEntity - доктрина не будет считать этот класс отдельной сущностью и не создаст таблицу
class Entity
{
/**
* @var integer
* @ORMId
* @ORMColumn(type="integer")
* @ORMGeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @var boolean
* @ORMColumn(type="boolean", options={"default":false})
*/
protected $published = false;
/**
* @var string
* @ORMColumn(type="string", length=255)
*/
protected $title;
/**
* @var string
* @ORMColumn(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;
}
}
Две редактируемые сущности:
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use DoctrineCommonCollectionsArrayCollection;
/**
* @ORMTable()
* @ORMEntity()
*/
class Article extends Entity
{
/**
* @var ArrayCollection
* @ORMManyToMany(targetEntity="Tag", inversedBy="articles")
* @ORMJoinTable(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);
}
}
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use DoctrineCommonCollectionsArrayCollection;
/**
* @ORMTable()
* @ORMEntity()
*/
class News extends Entity
{
/**
* @var DateTime
* @ORMColumn(type="datetime", nullable=true)
*/
protected $publishedAt;
/**
* @var ArrayCollection
* @ORMManyToMany(targetEntity="Tag", inversedBy="news")
* @ORMJoinTable(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);
}
}
И сущность тегов:
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use DoctrineCommonCollectionsArrayCollection;
/**
* @ORMTable()
* @ORMEntity()
*/
class Tag
{
public function __construct() {
$this->articles = new ArrayCollection();
$this->news = new ArrayCollection();
}
/**
* @var integer $id
* @ORMColumn(type="integer")
* @ORMGeneratedValue(strategy="AUTO")
* @ORMId
*/
protected $id;
/**
* @var string
* @ORMColumn(type="string", length=100)
*/
protected $name;
/**
* @ORMManyToMany(targetEntity="Article", mappedBy="tags")
*/
private $articles;
/**
* @ORMManyToMany(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 AppAppBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleFormFormMapper;
// Имя класса не заканчивается на 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')
;
}
}
namespace AppAppBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleFormFormMapper;
class ArticleAdmin extends EntityAdminBase
{
}
namespace AppAppBundleAdmin;
use SonataAdminBundleAdminAdmin;
use SonataAdminBundleDatagridListMapper;
use SonataAdminBundleDatagridDatagridMapper;
use SonataAdminBundleFormFormMapper;
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: AppAppBundleAdminArticleAdmin
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "Articles" }
arguments:
- ~
- AppAppBundleEntityArticle
- ~
calls:
- [ setTranslationDomain, [admin]]
sonata.admin.news:
class: AppAppBundleAdminNewsAdmin
tags:
- { name: sonata.admin, manager_type: orm, group: "Content", label: "News" }
arguments:
- ~
- AppAppBundleEntityNews
- ~
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 не поддерживает подобные связи, и реализовать админку так же просто не получится.
Но давайте рассмотрим, какие изменения внесены в сущности:
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use DoctrineCommonCollectionsArrayCollection;
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();
}
}
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
/**
* @ORMTable()
* @ORMEntity()
*/
class Article extends Entity
{
}
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
/**
* @ORMTable()
* @ORMEntity()
*/
class News extends Entity
{
/**
* @var DateTime
* @ORMColumn(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;
}
}
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use FPNTagBundleEntityTag as BaseTag;
/**
* @ORMTable()
* @ORMEntity()
*/
class Tag extends BaseTag
{
/**
* @ORMColumn(name="id", type="integer")
* @ORMId
* @ORMGeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @ORMOneToMany(targetEntity="Tagging", mappedBy="tag", fetch="EAGER")
**/
protected $tagging;
/**
* @return integer
*/
public function getId()
{
return $this->id;
}
}
namespace AppAppBundleEntity;
use DoctrineORMMapping as ORM;
use DoctrineORMMappingUniqueConstraint;
use FPNTagBundleEntityTagging as BaseTagging;
/**
* @ORMTable(uniqueConstraints={@UniqueConstraint(name="tagging_idx", columns={"tag_id", "resource_type", "resource_id"})})
* @ORMEntity
*/
class Tagging extends BaseTagging
{
/**
* @ORMColumn(name="id", type="integer")
* @ORMId
* @ORMGeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @ORMManyToOne(targetEntity="Tag", inversedBy="tagging")
* @ORMJoinColumn(name="tag_id", referencedColumnName="id")
**/
protected $tag;
}
Теги вынесены в базовую сущность, классы самих сущностей не содержат ничего лишнего.
Начал копать код SonataAdminBundle в поисках решения, как научить её работать с такими тегами, набрёл сначала на хуки сохранения (Saving hooks), отмёл их и стал искать, как реализовать собственный тип поля, в который можно было бы внедрить запуск TagManager-а. Но не осилил, там достаточно запутанный код. И тут я обратил внимание, что при старой настройке тегов в адмнке на странице редактирвоания записи список тегов продолжает выводиться, и при сохранении теги попадают в свойство $tags сущности. Правда, соната не сохраняет их в базу данных (у этого свойства нет аннотаций доктрины, да и не сможет, даже если и были бы), но нахождение тегов в коллекции тегов сущности — именно то, что надо для работы TagManager! Осталось запускать менеджер тегов при изменении сущности, и тут пригодились именно Saving hooks.
В классе админки я не стал менять описание поля тегов, и соната заносит теги в свойство-коллекцию при сохранении. С помощью хуков postPersist и postUpdate вызывается сохранение связи тегов в базу:
/**
* @return FPNTagBundleEntityTagManager
*/
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-контроллера сонаты:
namespace AppAppBundleController;
use SonataAdminBundleControllerCRUDController as Controller;
use SymfonyComponentHttpFoundationRedirectResponse;
use SonataAdminBundleDatagridProxyQueryInterface;
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
Автор: lexxpavlov