Простая реализация тегов
В текущем проекте есть несколько сущностей (условно назовём их Article и News, хотя всего их в этом проекте семь), которым нужно дать возможность проставлять теги, причём одной сущности можно установить несколько тегов, то есть реализуется связь Многие-ко-Многим.
Вначале рассмотрим, как сделать редактирвоание тегов в админке без бандла FPNTagBundle. Я сделал родительскую сущность, от которой наследуются все остальные:
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; } }
Две редактируемые сущности:
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); } }
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); } }
И сущность тегов:
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') ; } }
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 { }
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 не поддерживает подобные связи, и реализовать админку так же просто не получится.
Но давайте рассмотрим, какие изменения внесены в сущности:
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(); } }
namespace App\AppBundle\Entity; use Doctrine\ORM\Mapping as ORM; /** * @ORM\Table() * @ORM\Entity() */ class Article extends Entity { }
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; } }
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; } }
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-контроллера сонаты:
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/
Добавить комментарий