В процессе разработки интернет-магазина была поставлена задача реализовать адресную книгу для авторизованного пользователя. Таким образом, чтобы сам пользователь хранился в базе mysql, а связанные с ним адреса — в mongoDB. Отдельного внимания данная задача заслуживает в части управления пользователями и их адресными книгами из админки, основанной на SonataAdminBundle.
Есть доктриновская сущность User и доктриновский документ Address. Между ними должна быть установлена связь один-ко-многим. Всё это должно управляться с формы добавления пользователя в админке на базе сонаты. Поскольку у 1 юзера может быть много адресов, на форме добавления пользователей должна быть реализована коллекция форм добавления адресов с кнопками «добавить», «удалить» и inline редактированием полей связанных адресов. Этим мы и займёмся далее.
Это нужно, чтобы мы могли получать коллекцию связанных адресов для заданного юзера из монго, и наоборот — привязанного юзера к каждому адресу из mysql.
Пишем в composer.json:
обновляем зависимости.
Установятся все doctrine-extensions, но нам нужен только один — конкретно References, предназначенный для связи между сущностями и документами.
Подробнее о нём здесь: github.com/Atlantic18/DoctrineExtensions/blob/master/doc/references.md
Теперь нам нужно прописать в config.yml 2 сервиса, обрабатывающие обе стороны связей.
Вы можете вынести эти конфиги в отдельный файл, скажем, в doctrine_extensions.yml и потом подключить его в config.yml, если вы пользуетесь ещё какими-то доктриновскими расширениями.
Первый сервис настраивает вендорный listener. С ним работает manyToOne сторона. (getUser() метод в Address документе). А для стороны oneToMany нужен второй сервис с кастомным listenerом.
Ниже привожу класс Utils\ReferenceBundle\Listener\ReferencesListener, который следует положить в тот бандл, где находятся ваши глобальные хелперы и утилиты.
Примечание: существует удобный бандл, который делает за вас работу по прописыванию сервисов для листенеров доктрин-экстеншнов — вот этот: Stof\DoctrineExtensionsBundle ( github.com/stof/StofDoctrineExtensionsBundle ), но в нём нет реализации именно для References экстешна, поэтому приходится писать самому и его я здесь не использую.
Теперь нужно прописать соответствующие аннотации для полей вашей сущности и документа. При этом нужно предусмотреть поле в монго с user_id для внешнего ключа, поскольку самостоятельно это поле в монго не создастся.
Сеттеры\Геттеры для данных классов я пока не привожу, о них пойдёт речь дальше. Типы полей у меня смапплены в yaml конфигах, а как прописывать гедмо референсы в ямле я так и не разобрался. Буду благодарен, если укажете это в комментариях.
После вышеприведённых настроек у вас должно всё работать почти так, как-будто перед вами обычная связь one-to-many между двумя сущностями или документами, за исключением того, что подобный код работать не будет:
Вместо этого нужно явно перзистить каждый адрес доктриновским документ-менеджером. Эту проблему я пока не решил.
Внутри вашего UserAdmin класса:
Обратите внимание, что здесь мы используем обычную симфониевскую коллекцию (подробнее о ней: symfony.com/doc/current/cookbook/form/form_collections.html ) вместо sonata_type_collection, которую привязать к монго не получилось вообще.
Для использования collection типа обязательно нужен объект формы — AddressType в нашем случае. Сделаем форму. Обычную симфонивскую форму.
Обязательно следует задать дефолтную настройку data_class с полным именем класса Address со всеми неймспейсами.
В результате, на вашей форме добавления\редактирования пользователей в сонате должен появиться вот такой элемент: (при условии, что у вас уже привязана пара адресов к текущему пользователю)
Кнопка «+» — добавление блока адреса, «-» — соответственно, удаление блока с формы.
Теперь следует заняться сеттерами сущности, которую мы сабмитим, чтобы правильно работало добавление\удаление элементов из коллекции адресов в зависимости от того, что приходит из формы.
Обратите внимание, что при рендере коллекции адресов обязательно должен быть указан параметр by_reference=false, поскольку именно от него зависит будет ли вызван сеттер setAddresses() или добавление\удаление записей будет осуществляться где-то внутри с помощью строк типа getAddress()->add(), getAddress()->remove(). Нам такого не нужно, нам нужно, чтобы вызывался сеттер и мы могли переопределять его поведение.
Вот сам сеттер:
Должен быть ещё метод addAddress для добавления одного адреса к существующей коллекции с привязкой к текущему юзеру:
Теперь, если включить режим дебага, будет видно, что внутри коллекции addresses всё хорошо, но адреса в монго всё равно не пишутся. Это из-за описанного выше бага с тем, что не перзистится в монго коллекция. Чтобы записать адреса в монго вручную, а также удалить оттуда те адреса, которые не нужны, привяжемся к событию postUpdate() нашего UserAdmin класса:
Остаётся последняя проблема — в контексте класса UserAdmin неоткуда взять documentManager для doctrine_mongodb. Это решается инъекцией сервис-контейнера в UserAdmin класс с помощью вызова сеттера контейнера из сонатовского сервиса при инициализации.
В конфиге сервисов вашего Admin класса:
Затем внутри админ класса объявить новое поле container и сделать для него сеттер, который будет вызываться сервисом при инициализации класса.
На этом вроде бы всё. Адреса должны добавляться, редактироваться и удаляться также, как если бы это были две обычные сущности в mysql или два обычных документа в монго.
Исходные данные:
Есть доктриновская сущность 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 со всеми неймспейсами.
В результате, на вашей форме добавления\редактирования пользователей в сонате должен появиться вот такой элемент: (при условии, что у вас уже привязана пара адресов к текущему пользователю)
Кнопка «+» — добавление блока адреса, «-» — соответственно, удаление блока с формы.
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 или два обычных документа в монго.