Доктрина, сохраняющая сущности "многие-к-одному"

Я использую Zend Framework 3 с Doctrine и пытаюсь сохранить объект "Cidade", связанный с другим объектом "Estado", который уже хранится в базе данных. Однако Doctrine пытается сохранить Entity «Estado», и единственный атрибут, который у меня есть от Estado, - это первичный ключ в комбинации HTML.

Мои формы представления построены в формах и наборах полей Zend, что означает, что данные POST автоматически преобразуются в целевые сущности с помощью гидратора ClassMethods.

Проблема в том, что если я установил атрибут $estado с cascade={"persist"} в Cidade Entity, Doctrine попытается сохранить в Estado Entity все необходимые атрибуты, кроме идентификатора первичного ключа, который поступает из запроса POST (комбинация HTML). Я также рассматривал возможность использования cascade={"detach"} ir для того, чтобы Doctrine игнорировала объект Estado в EntityManager. Но я получаю такую ​​ошибку:

Была обнаружена новая сущность через отношение Application \ Entity \ Cidade # estado, которая не была настроена для каскадного сохранения операций для объекта: Application \ Entity \ Estado @ 000000007598ee720000000027904e61.

Я нашел аналогичное сомнение здесь и единственный способ найти по этому поводу сначала извлекал Estado Entity и устанавливал его на Cidade Entity перед сохранением. Если это единственный способ, могу ли я сказать, что моя структура формы не будет работать, если я не извлечу все отношения перед сохранением зависимых сущностей? Другими словами, как лучше всего сделать это в Doctrine (например):

<?php
    /*I'm simulating the creation of Estado Entity representing an
    existing Estado in database, so "3" is the ID rendered in HTML combo*/
    $estado = new Entity\Estado();
    $estado->setId(3);

    $cidade = new Entity\Cidade();
    $cidade->setNome("City Test");

    $cidade->setEstado($estado); //relationship here

    $entityManager->persist($cidade);
    $entityManager->flush();

Как это сделать, не возвращая Estado все время, когда мне нужно спасти Cidade? Не повлияет на производительность?

Моя сущность Cidade:

<?php

     namespace Application\Entity;

     use Zend\InputFilter\Factory;
     use Zend\InputFilter\InputFilterInterface;
     use Doctrine\ORM\Mapping as ORM;

     /**
      * Class Cidade
      * @package Application\Entity
      * @ORM\Entity
      */
     class Cidade extends AbstractEntity
     {
         /**
          * @var string
          * @ORM\Column(length=50)
          */
         private $nome;

         /**
          * @var Estado
          * @ORM\ManyToOne(targetEntity="Estado", cascade={"detach"})
          * @ORM\JoinColumn(name="id_estado", referencedColumnName="id")
          */
         private $estado;

         /**
          * Retrieve input filter
          *
          * @return InputFilterInterface
          */
         public function getInputFilter()
         {
             if (!$this->inputFilter) {
                 $factory = new Factory();
                 $this->inputFilter = $factory->createInputFilter([
                     "nome" => ["required" => true]
                 ]);
             }
             return $this->inputFilter;
         }

         /**
          * @return string
          */
         public function getNome()
         {
             return $this->nome;
         }

         /**
          * @param string $nome
          */
         public function setNome($nome)
         {
             $this->nome = $nome;
         }

         /**
          * @return Estado
          */
         public function getEstado()
         {
             return $this->estado;
         }

         /**
          * @param Estado $estado
          */
         public function setEstado($estado)
         {
             $this->estado = $estado;
         }
     }

Моя компания Estado:

<?php

    namespace Application\Entity;

    use Doctrine\ORM\Mapping as ORM;
    use Zend\InputFilter\Factory;
    use Zend\InputFilter\InputFilterInterface;

    /**
     * Class Estado
     * @package Application\Entity
     * @ORM\Entity
     */
    class Estado extends AbstractEntity
    {
        /**
         * @var string
         * @ORM\Column(length=50)
         */
        private $nome;

        /**
         * @var string
         * @ORM\Column(length=3)
         */
        private $sigla;

        /**
         * @return string
         */
        public function getNome()
        {
            return $this->nome;
        }

        /**
         * @param string $nome
         */
        public function setNome($nome)
        {
            $this->nome = $nome;
        }

        /**
         * @return string
         */
        public function getSigla()
        {
            return $this->sigla;
        }

        /**
         * @param string $sigla
         */
        public function setSigla($sigla)
        {
            $this->sigla = $sigla;
        }

        /**
         * Retrieve input filter
         *
         * @return InputFilterInterface
         */
        public function getInputFilter()
        {
            if (!$this->inputFilter) {
                $factory = new Factory();
                $this->inputFilter = $factory->createInputFilter([
                    "nome" => ["required" => true],
                    "sigla" => ["required" => true]
                ]);
            }
            return $this->inputFilter;
        }
    }

Обе сущности расширяют мой суперкласс AbstractEntity:

<?php

    namespace Application\Entity;

    use Doctrine\ORM\Mapping\MappedSuperclass;
    use Doctrine\ORM\Mapping as ORM;
    use Zend\InputFilter\InputFilterAwareInterface;
    use Zend\InputFilter\InputFilterInterface;

    /**
     * Class AbstractEntity
     * @package Application\Entity
     * @MappedSuperClass
     */
    abstract class AbstractEntity implements InputFilterAwareInterface
    {
        /**
         * @var int
         * @ORM\Id
         * @ORM\GeneratedValue
         * @ORM\Column(type="integer")
         */
        protected $id;

        /**
         * @var InputFilterAwareInterface
         */
        protected $inputFilter;

        /**
         * @return int
         */
        public function getId()
        {
            return $this->id;
        }

        /**
         * @param int $id
         */
        public function setId($id)
        {
            $this->id = $id;
        }

        /**
         * @param InputFilterInterface $inputFilter
         * @return InputFilterAwareInterface
         * @throws \Exception
         */
        public function setInputFilter(InputFilterInterface $inputFilter)
        {
            throw new \Exception("Método não utilizado");
        }
    }

Мои входные данные HTML отображаются следующим образом:

<input name="cidade[nome]" class="form-control" value="" type="text">
<select name="cidade[estado][id]" class="form-control">
    <option value="3">Bahia</option>
    <option value="2">Espírito Santo</option>
    <option value="1">Minas Gerais</option>
    <option value="9">Pará</option>
</select>

Каждый option выше - это объект Estado, полученный из базы данных. Мои данные POST представлены в следующем примере:

[
    "cidade" => [
        "nome" => "Test",
        "estado" => [
            "id" => 3
        ]
    ]
]

В методе isValid() Zend Form эти данные POST автоматически преобразуются в целевые сущности, что приводит к сбою в этой проблеме с Doctrine. Как мне двигаться дальше?


person Siipe    schedule 02.09.2018    source источник


Ответы (1)


Вы должны привязать объект к своей форме и использовать Doctrine Hydrator. В форме имена полей должны точно соответствовать названию Entity. Итак, Entity#name это Form#name.

С разделением проблем я категорически против размещения InputFilter для сущности внутри самой сущности. Таким образом, я приведу вам пример, в котором все разделено, если вы решите снова собрать все вместе, это зависит от вас.

AbstractEntity для ID

/**
 * @ORM\MappedSuperclass
 */
abstract class AbstractEntity
{
    /**
     * @var int
     * @ORM\Id
     * @ORM\Column(name="id", type="integer")
     * @ORM\GeneratedValue(strategy="IDENTITY")
     */
    protected $id;
    // getter/setter
}

Cicade Entity

/**
 * @ORM\Entity
 */
class Cidade extends AbstractEntity
{
    /**
     * @var string
     * @ORM\Column(length=50)
     */
    protected $nome; // Changed to 'protected' so can be used in child classes - if any

    /**
     * @var Estado
     * @ORM\ManyToOne(targetEntity="Estado", cascade={"persist", "detach"}) // persist added
     * @ORM\JoinColumn(name="id_estado", referencedColumnName="id")
     */
    protected $estado;

    // getters/setters
}

Estado Entity

/**
 * @ORM\Entity
 */
class Estado extends AbstractEntity
{
    /**
     * @var string
     * @ORM\Column(length=50)
     */
    protected $nome;

    //getters/setters
}

Итак, выше представлена ​​настройка Entity для Многие к одному - однонаправленное отношение.

Вы хотите легко справиться с этим с помощью форм. Итак, нам нужно создать InputFilters для обоих.

Наличие InputFilters отдельно от Entity позволяет нам вкладывать их. Это, в свою очередь, позволяет нам создавать структурированные и вложенные формы.

Например, вы можете создать новый Estado на лету. Если бы это было двунаправленное отношение, вы могли бы создавать несколько объектов Cicade Entity на лету из / во время создания Estado.

Во-первых: InputFilters. В духе абстракции, которую вы начали с Entities, давайте сделаем это и здесь:


АннотацияДоктринаВходФильтр

источник AbstractDoctrineInputFilter и источник AbstractDoctrineFormInputFilter

Это дает хорошую чистую настройку и требование, которое необходимо выполнить. Я замалчиваю более сложные элементы, добавленные в исходные файлы, но не стесняйтесь их искать.

Оба объекта (Estado и Cicade) требуют ObjectManager (в конце концов, это объекты Doctrine), поэтому я предполагаю, что у вас может быть больше. Приведенное ниже должно пригодиться.

<?php
namespace Application\InputFilter;

use Doctrine\Common\Persistence\ObjectManager;
use Zend\InputFilter\InputFilter;

abstract class AbstractInputFilter extends InputFilter
{
    /**
     * @var ObjectManager
     */
    protected $objectManager;

    /**
     * AbstractFormInputFilter constructor.
     *
     * @param array $options
     */
    public function __construct(array $options)
    {
        // Check if ObjectManager|EntityManager for FormInputFilter is set
        if (isset($options['object_manager']) && $options['object_manager'] instanceof ObjectManager) {
            $this->setObjectManager($options['object_manager']);
        }
    }

    /**
     * Init function
     */
    public function init()
    {
        $this->add(
            [
                'name' => 'id',
                'required' => false, // Not required when adding - should also be in route when editing and bound in controller, so just additional
                'filters' => [
                    ['name' => ToInt::class],
                ],
                'validators' => [
                    ['name' => IsInt::class],
                ],
            ]
       );

        // If CSRF validation has not been added, add it here
        if ( ! $this->has('csrf')) {
            $this->add(
                [
                    'name'       => 'csrf',
                    'required'   => true,
                    'filters'    => [],
                    'validators' => [
                        ['name' => Csrf::class],
                    ],
                ]
            );
        }
    }

    // getters/setters for ObjectManager
}

Estado InputFilter

class EstadoInputFilter extends AbstractInputFilter
{
    public function init()
    {
        parent::init();

        $this->add(
            [
                'name'        => 'nome', // <-- important, name matches entity property
                'required'    => true,
                'allow_empty' => true,
                'filters'     => [
                    ['name' => StringTrim::class],
                    ['name' => StripTags::class],
                    [
                        'name'    => ToNull::class,
                        'options' => [
                            'type' => ToNull::TYPE_STRING,
                        ],
                    ],
                ],
                'validators'  => [
                    [
                        'name'    => StringLength::class,
                        'options' => [
                            'min' => 2,
                            'max' => 255,
                        ],
                    ],
                ],
            ]
        );
    }
}

Входной фильтр Cicade

class EstadoInputFilter extends AbstractInputFilter
{
    public function init()
    {
        parent::init(); // Adds the CSRF

        $this->add(
            [
                'name'        => 'nome', // <-- important, name matches entity property
                'required'    => true,
                'allow_empty' => true,
                'filters'     => [
                    ['name' => StringTrim::class],
                    ['name' => StripTags::class],
                    [
                        'name'    => ToNull::class,
                        'options' => [
                            'type' => ToNull::TYPE_STRING,
                        ],
                    ],
                ],
                'validators'  => [
                    [
                        'name'    => StringLength::class,
                        'options' => [
                            'min' => 2,
                            'max' => 255,
                        ],
                    ],
                ],
            ]
        );

        $this->add(
            [
                'name'     => 'estado',
                'required' => true,
            ]
        );
    }
}

Так. Теперь у нас есть 2 входных фильтра на основе AbstractInputFilter.

EstadoInputFilter фильтрует только свойство nome. Добавьте дополнительные, если хотите;)

CicadeInputFilter фильтрует свойство nome и имеет обязательное поле estado.

Имена соответствуют определениям Entity в соответствующих классах Entity.

Для полноты, ниже - CicadeForm, возьмите то, что вам нужно для создания EstadoForm.

class CicadeForm extends Form
{

    /**
     * @var ObjectManager
     */
    protected $objectManager;

    /**
     * AbstractFieldset constructor.
     *
     * @param ObjectManager $objectManager
     * @param string        $name Lower case short class name
     * @param array         $options
     */
    public function __construct(ObjectManager $objectManager, string $name, array $options = [])
    {
        parent::__construct($name, $options);

        $this->setObjectManager($objectManager);
    }

    public function init()
    {
        $this->add(
            [
                'name'     => 'nome',
                'required' => true,
                'type'     => Text::class,
                'options'  => [
                    'label' => _('Nome',
                ],
            ]
        );

        // @link: https://github.com/doctrine/DoctrineModule/blob/master/docs/form-element.md
        $this->add(
            [
                'type'       => ObjectSelect::class,
                'required'   => true,
                'name'       => 'estado',
                'options'    => [
                    'object_manager'     => $this->getObjectManager(),
                    'target_class'       => Estado::class,
                    'property'           => 'id',
                    'display_empty_item' => true,
                    'empty_item_label'   => '---',
                    'label'              => _('Estado'),
                    'label_attributes'   => [
                        'title' => _('Estado'),
                    ],
                    'label_generator'    => function ($targetEntity) {
                        /** @var Estado $targetEntity */
                        return $targetEntity->getNome();
                    },
                ],
            ]
        );

        //Call parent initializer. Check in parent what it does.
        parent::init();
    }

    /**
     * @return ObjectManager
     */
    public function getObjectManager() : ObjectManager
    {
        return $this->objectManager;
    }

    /**
     * @param ObjectManager $objectManager
     *
     * @return AbstractDoctrineFieldset
     */
    public function setObjectManager(ObjectManager $objectManager) : AbstractDoctrineFieldset
    {
        $this->objectManager = $objectManager;
        return $this;
    }
}

Конфиг

Теперь, когда классы есть, как их использовать? Вставьте их вместе с конфигурацией модуля!

В свой module.config.php файл добавьте этот конфиг:

'form_elements'   => [
    'factories' => [
        CicadeForm::class => CicadeFormFactory::class,
        EstadoForm::class => EstadoFormFactory::class,

        // If you create separate Fieldset classes, this is where you register those
    ],
],
'input_filters'   => [
    'factories' => [
        CicadeInputFilter::class => CicadeInputFilterFactory::class,
        EstadoInputFilter::class => EstadoInputFilterFactory::class,

        // If you register Fieldsets in form_elements, their InputFilter counterparts go here
    ],
],

Из этой конфигурации мы читаем, что нам нужна фабрика как для формы, так и для входного фильтра набора.

Ниже CicadeInputFilterFactory

class CicadeInputFilterFactory implements FactoryInterface
{
    /**
     * @param ContainerInterface $container
     * @param string             $requestedName
     * @param array|null         $options
     *
     * @return CicadeInputFilter
     * @throws \Psr\Container\ContainerExceptionInterface
     * @throws \Psr\Container\NotFoundExceptionInterface
     */
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
    {
        /** @var ObjectManager|EntityManager $objectManager */
        $objectManager = $this->setObjectManager($container->get(EntityManager::class));

        return new CicadeInputFilter(
            [
                'object_manager' => objectManager,
            ]
        );
    }
}

Соответствие CicadeFormFactory

class CicadeFormFactory implements FactoryInterface
{
    /**
     * @param ContainerInterface $container
     * @param string             $requestedName
     * @param array|null         $options
     *
     * @return CicadeForm
     * @throws \Psr\Container\ContainerExceptionInterface
     * @throws \Psr\Container\NotFoundExceptionInterface
     */
    public function __invoke(ContainerInterface $container, $requestedName, array $options = null) : CicadeForm
    {
        $inputFilter = $container->get('InputFilterManager')->get(CicadeInputFilter::class);

        // Here we creazte a new Form object. We set the InputFilter we created earlier and we set the DoctrineHydrator. This hydrator can work with Doctrine Entities and relations, so long as data is properly formatted when it comes in from front-end.
        $form = $container->get(CicadeForm::class);
        $form->setInputFilter($inputFilter);
        $form->setHydrator(
            new DoctrineObject($container->get(EntityManager::class))
        );
        $form->setObject(new Cicade());

        return $form;
    }
}

Большая подготовка сделана, пора ее использовать.

Определенный EditController для редактирования существующего Cicade объекта

class EditController extends AbstractActionController // (Zend's AAC)
{
    /**
     * @var CicadeForm
     */
    protected $cicadeForm;

    /**
     * @var ObjectManager|EntityManager
     */
    protected $objectManager;

    public function __construct(
        ObjectManager $objectManager, 
        CicadeForm $cicadeForm
    ) {
        $this->setObjectManager($objectManager);
        $this->setCicadeForm($cicadeForm);
    }

    /**
     * @return array|Response
     * @throws ORMException|Exception
     */
    public function editAction()
    {
        $id = $this->params()->fromRoute('id', null);

        if (is_null($id)) {

            $this->redirect()->toRoute('home'); // Do something more useful instead of this, like notify of id received from route
        }

        /** @var Cicade $entity */
        $entity = $this->getObjectManager()->getRepository(Cicade::class)->find($id);

        if (is_null($entity)) {

            $this->redirect()->toRoute('home'); // Do something more useful instead of this, like notify of not found entity
        }

        /** @var CicadeForm $form */
        $form = $this->getCicadeForm();
        $form->bind($entity); // <-- This here is magic. Because we overwrite the object from the Factory with an existing one. This pre-populates the form with value and allows us to modify existing one. Assumes we got an entity above.

        /** @var Request $request */
        $request = $this->getRequest();
        if ($request->isPost()) {
            $form->setData($request->getPost());

            if ($form->isValid()) {
                /** @var Cicade $cicade */
                $cicade = $form->getObject();

                $this->getObjectManager()->persist($cicade);

                try {
                    $this->getObjectManager()->flush();
                } catch (Exception $e) {

                    throw new Exception('Could not save. Error was thrown, details: ', $e->getMessage());
                }

                $this->redirect()->toRoute('cicade/view', ['id' => $entity->getId()]);
            }
        }

        return [
            'form'               => $form,
            'validationMessages' => $form->getMessages() ?: '',
        ];
    }

    /**
     * @return CicadeForm
     */
    public function getCicadeForm() : CicadeForm
    {
        return $this->cicadeForm;
    }

    /**
     * @param CicadeForm $cicadeForm
     *
     * @return EditController
     */
    public function setCicadeForm(CicadeForm $cicadeForm) : EditController
    {
        $this->cicadeForm = $cicadeForm;

        return $this;
    }

    /**
     * @return ObjectManager|EntityManager
     */
    public function getObjectManager() : ObjectManager
    {
        return $this->objectManager;
    }

    /**
     * @param ObjectManager|EntityManager $objectManager
     *
     * @return EditController
     */
    public function setObjectManager(ObjectManager $objectManager) : EditController
    {
        $this->objectManager = $objectManager;
        return $this;
    }
}

Так что мне хотелось дать действительно развернутый ответ. Охватывает все на самом деле.

Если у вас есть вопросы по вышеизложенному, дайте мне знать ;-)

person rkeet    schedule 03.09.2018
comment
В качестве примечания - Создал приведенную выше форму ответа рабочих элементов проектов, объединенных вместе. Если вы скопируете / вставите его, могут возникнуть ошибки, поскольку я не создавал полную настройку для репликации проблемы, но вы сможете быстро их выяснить, если создадите / воспользуетесь этой настройкой. - person rkeet; 03.09.2018
comment
Не только дал мне краткое объяснение гидратора DoctrineObject, который является моим прямым решением, но и представил целый контекст с богатыми деталями! Спасибо! - person Siipe; 04.09.2018