Связываем Doctrine Entity и Doctrine Document на форме в Sonata Admin Bundle

от автора

В процессе разработки интернет-магазина была поставлена задача реализовать адресную книгу для авторизованного пользователя. Таким образом, чтобы сам пользователь хранился в базе mysql, а связанные с ним адреса — в mongoDB. Отдельного внимания данная задача заслуживает в части управления пользователями и их адресными книгами из админки, основанной на SonataAdminBundle.

Исходные данные:

Есть доктриновская сущность User и доктриновский документ Address. Между ними должна быть установлена связь один-ко-многим. Всё это должно управляться с формы добавления пользователя в админке на базе сонаты. Поскольку у 1 юзера может быть много адресов, на форме добавления пользователей должна быть реализована коллекция форм добавления адресов с кнопками «добавить», «удалить» и inline редактированием полей связанных адресов. Этим мы и займёмся далее.

Что нам надо:

1) Установить @Gedmo\References doctrine-extension

Это нужно, чтобы мы могли получать коллекцию связанных адресов для заданного юзера из монго, и наоборот — привязанного юзера к каждому адресу из mysql.

Пишем в composer.json:
"gedmo/doctrine-extensions": "dev-master"

обновляем зависимости.

Установятся все doctrine-extensions, но нам нужен только один — конкретно References, предназначенный для связи между сущностями и документами.
Подробнее о нём здесь: github.com/Atlantic18/DoctrineExtensions/blob/master/doc/references.md

Теперь нам нужно прописать в config.yml 2 сервиса, обрабатывающие обе стороны связей.
Вы можете вынести эти конфиги в отдельный файл, скажем, в doctrine_extensions.yml и потом подключить его в config.yml, если вы пользуетесь ещё какими-то доктриновскими расширениями.

services:     gedmo.listener.reference:         class: Gedmo\References\ReferencesListener         tags:             - { name: doctrine_mongodb.odm.event_subscriber }         calls:             - [ setAnnotationReader, [ "@annotation_reader" ] ]             - [ registerManager, [ 'entity', "@doctrine.orm.default_entity_manager" ] ]      utils.listener.reference:         class: Utils\ReferenceBundle\Listener\ReferencesListener         arguments: ["@service_container"]         tags:             - { name: doctrine.event_subscriber, connection: default }  

Первый сервис настраивает вендорный listener. С ним работает manyToOne сторона. (getUser() метод в Address документе). А для стороны oneToMany нужен второй сервис с кастомным listenerом.

Ниже привожу класс Utils\ReferenceBundle\Listener\ReferencesListener, который следует положить в тот бандл, где находятся ваши глобальные хелперы и утилиты.

<?php namespace Utils\ReferenceBundle\Listener;  use Symfony\Component\DependencyInjection\ContainerInterface;  /** * Class ReferencesListener * * @package Utils\ReferenceBundle\Listener */ class ReferencesListener extends \Gedmo\References\ReferencesListener { /** * @var \Symfony\Component\DependencyInjection\ContainerInterface */ private $container; /** * @var array */ protected $managers = [ 'document' => 'doctrine.odm.mongodb.document_manager', 'entity'   => 'doctrine.orm.default_entity_manager' ];  /** * @param ContainerInterface $container * @param array              $managers */ public function __construct(ContainerInterface $container, array $managers = array()) { $this->container = $container; parent::__construct($managers); }  /** * @param $type * * @return object */ public function getManager($type) { return $this->container->get($this->managers[$type]); } }  

Примечание: существует удобный бандл, который делает за вас работу по прописыванию сервисов для листенеров доктрин-экстеншнов — вот этот: Stof\DoctrineExtensionsBundle ( github.com/stof/StofDoctrineExtensionsBundle ), но в нём нет реализации именно для References экстешна, поэтому приходится писать самому и его я здесь не использую.

Теперь нужно прописать соответствующие аннотации для полей вашей сущности и документа. При этом нужно предусмотреть поле в монго с user_id для внешнего ключа, поскольку самостоятельно это поле в монго не создастся.

/*Entity\User:*/    /**      * @var ArrayCollection     *      * @Gedmo\ReferenceMany(type="document", class="\Application\Sonata\UserBundle\Document\Address", mappedBy="user")      */     protected $addresses; 

/*Document\Address:*/

/**
* @Gedmo\ReferenceOne(type=«entity», class="\Application\Sonata\UserBundle\Entity\User", inversedBy=«addresses», identifier=«user_id», mappedBy=«user_id»)
*/
protected $user;

/**
* var int $user_id
*/
protected $user_id;

Сеттеры\Геттеры для данных классов я пока не привожу, о них пойдёт речь дальше. Типы полей у меня смапплены в yaml конфигах, а как прописывать гедмо референсы в ямле я так и не разобрался. Буду благодарен, если укажете это в комментариях.

После вышеприведённых настроек у вас должно всё работать почти так, как-будто перед вами обычная связь one-to-many между двумя сущностями или документами, за исключением того, что подобный код работать не будет:

$user = new User(); $address = new Address(); $address->setAddress(«aaa»); $address->setUser($user); $user->getAddresses()->add($address); $em->persist($user); $em->flush(); 

Вместо этого нужно явно перзистить каждый адрес доктриновским документ-менеджером. Эту проблему я пока не решил.

2. Приступим к рендеру формы для добавления пользователей с привязанной к ней коллекцией адресов.

Внутри вашего UserAdmin класса:

protected function configureFormFields(FormMapper $formMapper)     {         $formMapper             ->with('General') //	…всякие поля 	->add('addresses', 'collection', array('type' => new AddressType(), 'allow_add' => true, 'by_reference' => false, 'allow_delete' => true)) 	->end();      } 

Обратите внимание, что здесь мы используем обычную симфониевскую коллекцию (подробнее о ней: symfony.com/doc/current/cookbook/form/form_collections.html ) вместо sonata_type_collection, которую привязать к монго не получилось вообще.

Для использования collection типа обязательно нужен объект формы — AddressType в нашем случае. Сделаем форму. Обычную симфонивскую форму.

class AddressType extends AbstractType {         /**      * @param FormBuilderInterface $builder      * @param array $options      */     public function buildForm(FormBuilderInterface $builder, array $options)     {         $builder             ->add('firstname')             ->add('lastname')             ->add('address')         ;     }          /**      * @param OptionsResolverInterface $resolver      */     public function setDefaultOptions(OptionsResolverInterface $resolver)     {         $resolver->setDefaults(array(               'data_class' => 'Application\Sonata\UserBundle\Document\Address'         ));     }      /**      * @return string      */     public function getName()     {         return 'application_sonata_userbundle_address'; // и так далее.... */ 

Обязательно следует задать дефолтную настройку data_class с полным именем класса Address со всеми неймспейсами.

В результате, на вашей форме добавления\редактирования пользователей в сонате должен появиться вот такой элемент: (при условии, что у вас уже привязана пара адресов к текущему пользователю)

image

Кнопка «+» — добавление блока адреса, «-» — соответственно, удаление блока с формы.

3. Обрабатываем форму.

Теперь следует заняться сеттерами сущности, которую мы сабмитим, чтобы правильно работало добавление\удаление элементов из коллекции адресов в зависимости от того, что приходит из формы.
Обратите внимание, что при рендере коллекции адресов обязательно должен быть указан параметр by_reference=false, поскольку именно от него зависит будет ли вызван сеттер setAddresses() или добавление\удаление записей будет осуществляться где-то внутри с помощью строк типа getAddress()->add(), getAddress()->remove(). Нам такого не нужно, нам нужно, чтобы вызывался сеттер и мы могли переопределять его поведение.

Вот сам сеттер:

public function setAddresses($addresses)     {         foreach ($this->addresses as $orig_address) {             //если на форме был удалён какой-то из существующих адресов — удалить из коллекции             if (false === $addresses->contains($orig_address)) {                 // отсоединяем адрес от пользователя                 $this->addresses->removeElement($orig_address);             }         }          //если засабмичены новые адреса, которых нет в базе, то их надо добавить в коллекцию.         foreach($addresses as $passed_address)         {             if(!$this->addresses->contains($passed_address))             {                 $passed_address->setUser($this);                 $this->addresses->add($passed_address);             }         }     } 

Должен быть ещё метод addAddress для добавления одного адреса к существующей коллекции с привязкой к текущему юзеру:

public function addAddress($addresses)     {         $addresses->setUser($this);         $this->addresses[] = $addresses;                 return $this;     } 

Теперь, если включить режим дебага, будет видно, что внутри коллекции addresses всё хорошо, но адреса в монго всё равно не пишутся. Это из-за описанного выше бага с тем, что не перзистится в монго коллекция. Чтобы записать адреса в монго вручную, а также удалить оттуда те адреса, которые не нужны, привяжемся к событию postUpdate() нашего UserAdmin класса:

 public function postUpdate($user)     {         $dm = $this->container->get("doctrine_mongodb")->getManager();         $dbAddresses = $dm->getRepository('Application\Sonata\UserBundle\Document\Address')->findBy(array('user_id'=>$user->getId()));          foreach($dbAddresses as $dbAddress)         {             if(!$user->getAddresses()->contains($dbAddress))             {                 echo $dbAddress->getFirstName();                 $dm->remove($dbAddress);             }         }          foreach($user->getAddresses() as $address)         {             $address->setUser($user);             $dm->persist($address);         }         $dm->flush();     } 

Остаётся последняя проблема — в контексте класса UserAdmin неоткуда взять documentManager для doctrine_mongodb. Это решается инъекцией сервис-контейнера в UserAdmin класс с помощью вызова сеттера контейнера из сонатовского сервиса при инициализации.

В конфиге сервисов вашего Admin класса:

sonata.user.admin.user:             class: %sonata.user.admin.user.class%             tags:                 - { name: sonata.admin, manager_type: orm, group: %sonata.user.admin.groupname%, label: users, label_catalogue: SonataUserBundle, label_translator_strategy: sonata.admin.label.strategy.underscore }             arguments:                 - ~                 - %sonata.user.admin.user.entity%                 - %sonata.user.admin.user.controller%             calls:                 - [ setUserManager, [@fos_user.user_manager]]                 - [ setTranslationDomain, [%sonata.user.admin.user.translation_domain%]]                 - [ setContainer, [@service_container]]</code>  нужно добавить строку  <code>- [ setContainer, [@service_container]] 

Затем внутри админ класса объявить новое поле container и сделать для него сеттер, который будет вызываться сервисом при инициализации класса.

/** @var \Symfony\Component\DependencyInjection\ContainerInterface */     private $container;      public function setContainer (\Symfony\Component\DependencyInjection\ContainerInterface $container) {          $this->container = $container;     } 

На этом вроде бы всё. Адреса должны добавляться, редактироваться и удаляться также, как если бы это были две обычные сущности в mysql или два обычных документа в монго.

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