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

в 14:47, , рубрики: Doctrine ORM, Doctrine2, mongodb, sonataadmin, symfony, symfony2

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

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

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

Что нам надо:

1) Установить @GedmoReferences 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: GedmoReferencesReferencesListener
        tags:
            - { name: doctrine_mongodb.odm.event_subscriber }
        calls:
            - [ setAnnotationReader, [ "@annotation_reader" ] ]
            - [ registerManager, [ 'entity', "@doctrine.orm.default_entity_manager" ] ]

    utils.listener.reference:
        class: UtilsReferenceBundleListenerReferencesListener
        arguments: ["@service_container"]
        tags:
            - { name: doctrine.event_subscriber, connection: default } 

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

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

<?php
namespace UtilsReferenceBundleListener;

use SymfonyComponentDependencyInjectionContainerInterface;

/**
* Class ReferencesListener
*
* @package UtilsReferenceBundleListener
*/
class ReferencesListener extends GedmoReferencesReferencesListener
{
/**
* @var SymfonyComponentDependencyInjectionContainerInterface
*/
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]);
}
} 

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

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

/*EntityUser:*/
   /**
     * @var ArrayCollection     *
     * @GedmoReferenceMany(type="document", class="ApplicationSonataUserBundleDocumentAddress", mappedBy="user")
     */
    protected $addresses;

/*DocumentAddress:*/

/**
* @GedmoReferenceOne(type=«entity», class="ApplicationSonataUserBundleEntityUser", 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' => 'ApplicationSonataUserBundleDocumentAddress'
        ));
    }

    /**
     * @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('ApplicationSonataUserBundleDocumentAddress')->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 SymfonyComponentDependencyInjectionContainerInterface */
    private $container;

    public function setContainer (SymfonyComponentDependencyInjectionContainerInterface $container) {

        $this->container = $container;
    }

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

Автор:

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js