Symfony2: Removing entity from middle of collection - php

1. Overview
I want to be able to remove an entity from a collection using a symfony2 Form.
1.1 Problem
I can add new entities to the collection, and remove them, as long as the entity being added or removed is at the end of the collection. As soon as I remove one from the start or middle I get the following error:
When I try to do this I get this error:
Neither the property "id" nor one of the methods
"addId()"/"removeId()", "setId()", "id()", "__set()" or "__call()"
exist and have public access in class
"ApiBundle\Entity\Data\Column".
1.2 Code
Here is all the relevant code.
Data
/**
* Data
*
* #ORM\Table(name="data__data")
* #ORM\Entity(repositoryClass="ApiBundle\Repository\Data\DataRepository")
*/
class Data
{
/**
* #var integer
*
* #ORM\Column(name="id", type="string")
* #ORM\Id
* #ORM\GeneratedValue(strategy="UUID")
*/
protected $id;
/**
* #var ArrayCollection
* #ORM\OneToMany(targetEntity="Column", mappedBy="parent", cascade={"all"}, orphanRemoval=true)
*/
protected $columns;
/**
* Initialise the array collections
*/
public function __construct()
{
$this->columns = new ArrayCollection();
}
/**
* #param mixed $columns
*/
public function setColumns($columns)
{
$this->columns = $columns;
}
/**
* #param Column $column
*/
public function addColumn($column)
{
$column->setParent($this);
$this->columns->add($column);
}
/**
* #param Column $column
*/
public function removeColumn($column)
{
$this->columns->removeElement($column);
}
}
Column
/**
* Data
*
* #ORM\Table(name="data__column")
* #ORM\Entity
*/
class Column
{
/**
* #var integer
*
* #ORM\Column(name="id", type="string")
* #ORM\Id
* #ORM\GeneratedValue(strategy="UUID")
*/
protected $id;
/**
* #var Data
* #ORM\ManyToOne(targetEntity="Data", inversedBy="columns")
*/
protected $parent;
/**
* #return Data
*/
public function getParent()
{
return $this->parent;
}
/**
* #param Data $parent
*/
public function setParent($parent)
{
$this->parent = $parent;
}
}
DataFormType
class DataFormType extends AbstractType {
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('id')
->add('columns', 'collection', array(
'type' => new ColumnFormType(),
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false
))
;
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'ApiBundle\Entity\Data\Data',
'csrf_protection' => false
));
}
public function getName()
{
return 'data';
}
}
ColumnFormType
class ColumnFormType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('id');
}
public function setDefaultOptions(OptionsResolverInterface $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'ApiBundle\Entity\Data\Column',
'csrf_protection' => false
));
}
public function getName()
{
return 'data_column';
}
}
I have removed some code from these snippets for clarity
1.3 Conclusion
Like I say, I get no problems when adding or deleting from the end of the collection. But as soon as it is anywhere else it errors out.
Thanks for any help.

The error is caused by the lack of the collection key preserving.
CollectionType is high tought with ResizeListener. It fills the collection form with subforms:
public function preSetData(FormEvent $event)
{
$form = $event->getForm();
$data = $event->getData();
...
// Then add all rows again in the correct order
foreach ($data as $name => $value) {
$form->add($name, $this->type, array_replace(array(
'property_path' => '['.$name.']',
), $this->options));
}
}
So the every subform is mapped to the collection object (underlying data) and has the name that applies to collection index, e.g. '[0]', '[1]'. When you delete elements from the collection ResizeListener removes redundant subforms.
public function preSubmit(FormEvent $event)
{
$form = $event->getForm();
$data = $event->getData();
...
// Remove all empty rows
if ($this->allowDelete) {
foreach ($form as $name => $child) {
if (!isset($data[$name])) {
$form->remove($name);
}
}
}
}
Lets say there were data[columns][0][id]=1, data[columns][1][id]=2, data[columns][2][id]=3.
When you remove an element from the end - all is fine. There comes data[columns][0][id]=1, data[columns][1][id]=2 with corresponding content. Then the subform [2] will be deleted and then the element with index 2 will be deleted from the collection.
When you remove an element not at the end and you don't preserve keys - the error occurs. For example you send data[columns][0][id]=2, data[columns][1][id]=3. ResizeListener will delete the subform with index [2]. Underlying data will be overrided for the rest of subforms ([0], [1]) and their child (id). Most nested subforms are processed first.
[0] (Column)
[id]
1 => 2
[1] (Column)
[id]
2 => 3
Then PropertyPathMapper will detect that id subform's data is not equals to Column's id property value (this is underlying data of the [0]):
public function mapFormsToData($forms, &$data)
{
...
if (!is_object($data) || !$config->getByReference() || $form->getData() !== $this->propertyAccessor->getValue($data, $propertyPath)) {
$this->propertyAccessor->setValue($data, $propertyPath, $form->getData());
}
...
}
It will make PropertyAccessor to set new id value to Column object. The last one will throw exception as there is no way to set new id to Column (no setter, property is not public, etc).
Solution: To preserve key order. If you get data[columns][0][id]=1, data[columns][1][id]=2, data[columns][2][id]=3 and you delete first element you should send data[columns][1][id]=2, data[columns][2][id]=3
PS Key order preserving for Forms is good practice for all cases. It will prevent you from redundant UPDATE queries and loops.

Related

Doctrine Array type field not being updated

My question is more or less the same as this one:
How to force Doctrine to update array type fields?
but for some reason the solution didn't work for me, so there must be a detail I am missing and I would be happy if someone could point it out to me.
The context is a Symfony 5.2 app with Doctrine ^2.7 being used.
Entity-Class-Excerpt:
class MyEntity {
// some properties
/**
* #var string[]
* #Groups("read")
* #ORM\Column(type="array")
* #Assert\Valid
*/
protected array $abbreviations = [];
public function getAbbreviations(): ?array
{
return $this->abbreviations;
}
//this is pretty much the same set-function as in the question I referenced
public function setAbbreviations(array $abbreviations)
{
if (!empty($abbreviations) && $abbreviations === $this->abbreviations) {
reset($abbreviations);
$abbreviations[key($abbreviations)] = clone current($abbreviations);
}
$this->abbreviations = $abbreviations;
}
public function addAbbreviation(LocalizableStringEmbeddable $abbreviation): self
{
foreach ($this->abbreviations as $existingAbbreviation) {
if ($abbreviation->equals($abbreviation)) {
return $this;
}
}
$this->abbreviations[] = $abbreviation;
return $this;
}
public function removeAbbreviation(LocalizableStringEmbeddable $abbreviation): self
{
foreach ($this->abbreviations as $i => $existingAbbreviation) {
if ($abbreviation->equals($existingAbbreviation)) {
array_splice($this->abbreviations, $i, 1);
return $this;
}
}
return $this;
}
}
But none of these methods are ever being called (I also tried removing add-/removeAbbreviation only leaving get/set in place).
LocalizableStringEmbeddable being an Embeddable like this:
* #ORM\Embeddable
*/
class LocalizableStringEmbeddable
{
/**
* #var string|null
* #Groups("read")
* #ORM\Column(type="string", nullable=false)
*/
private ?string $text;
/**
* #var string|null
* #Groups("read")
* #ORM\Column(type="string", nullable=true)
* #Assert\Language
*/
private ?string $language;
//getters/setters/equals/#Assert\Callback
}
By using dd(...) I can furthermore say that in my controller on submit
$myEntity = $form->getData();
yields a correctly filled MyEntity with an updated array but my subsequent call to
$entityManager->persist($myEntity);
$entityManager->flush();
doesn't change the database.
What am I missing?
EDIT: I was asked to give information about the Type I use. It is a custom one that is based on this class. So technically at the base of things I am using a collection type.
abstract class AbstractLocalizableUnicodeStringArrayType extends AbstractType implements DataMapperInterface
{
abstract public function getDataClassName(): string;
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('items', CollectionType::class, [
'entry_type' => LocalizedStringEmbeddableType::class,
'entry_options' => [
'data_class' => $this->getDataClassName(),
'attr' => ['class' => 'usa-item-list--item-box'],
],
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
])
;
$builder->setDataMapper($this);
}
public function mapDataToForms($viewData, iterable $forms): void
{
$forms = iterator_to_array($forms);
if (null === $viewData) {
return;
}
if (!is_array($viewData)) {
throw new UnexpectedTypeException($viewData, "array");
}
$forms['items']->setData($viewData);
}
public function mapFormsToData(iterable $forms, &$viewData): void
{
$forms = iterator_to_array($forms);
$viewData = $forms['items']->getData();
}
}
As I found out this can be solved (in an unelegant way in my opinion but until something better comes around I want to state this) by combining two answers from another post on SO:
https://stackoverflow.com/a/13231876/6294605
https://stackoverflow.com/a/59898632/6294605
This means combining a preflush-hook in your entity class with a "fake-change" of the array in question.
Remark that doing the fake-change in a setter/adder/remover didn't work for me as those are not being called when editing an existing entity. In this case only setters of the changed objects inside the array will be called thus making Doctrine not recognize there was a change to the array itself as no deep-check seems to be made.
Another thing that was not stated in the other thread I wanna point out:
don't forget to annotate your entity class with
#ORM\HasLifecycleCallbacks
or else your preflush-hook will not be executed.

Symfony and FOSRestBundle: form not filling entity data

I'm trying to build a very simple rest api with FOS Rest bundle.
The GET and DELETE methods were easy, but I'm struggling with post.
Here's my post method of a very simple entity (only has a "name" and "active" property):
/**
* #param Request $request
* #return array|View
*/
public function postSkillsAction(Request $request)
{
$skill = new Skill();
$form = $this->createForm(SkillType::class, $skill);
$form->submit($request->request->get($form->getName()));
if ($form->isSubmitted() && $form->isValid()) {
$this->entityManager->persist($skill);
$this->entityManager->flush();
return $this->redirectToRoute('skillset', ['id' => $skill->getId()], Response::HTTP_CREATED);
}
return [
'form' => $form
];
}
And this is my form:
final class SkillType extends AbstractType
{
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options) : void
{
$builder
->add(
'name', TextType::class, [
'label' => 'fields.name'
])
->add('active', CheckboxType::class, [
'label' => 'fields.active',
'required' => false
]);
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Skill::class,
'csrf_protection' => false
]);
}
/**
* #return string
*/
public function getName() : string
{
return 'skill';
}
}
The problem is that it seems like the form is not filling the entity data, when I execute /api/skills sending by post name and active I get the following SQL error
An exception occurred while executing 'INSERT INTO skill (name, active, created_at, updated_at) VALUES (?, ?, ?, ?)' with params [null, 0, "2017-03-19 19:49:37", "2017-03-19 19:49:37"]
The form data arrives correctly, I've debug and if I do $request->request->get('name') I got the proper value.
I couldn't find any updated example, this one for instance is for symfony 2, although I tried to follow it as much as possible
https://github.com/gimler/symfony-rest-edition/blob/2.7/src/AppBundle/Controller/NoteController.php
UPDATE
If I do a var_dump of var_dump($request->request->all()); I get
array(
'name' => a,
'active' => 1
)
And here's the entity
final class Skill
{
use OptionalDateTimeTrait;
/**
* #var integer
*/
private $id;
/**
* #var string
*/
private $name;
/**
* #var integer
*/
private $active;
/**
* #return int
*/
public function getId(): int
{
return $this->id;
}
/**
* #param int $id
* #return Skill
*/
public function setId(int $id) : self
{
$this->id = $id;
return $this;
}
/**
* #return string
*/
public function getName()
{
return $this->name;
}
/**
* #param string $name
* #return self
*/
public function setName(string $name) : self
{
$this->name = $name;
return $this;
}
/**
* Set active
*
* #param boolean $active
*
* #return Skill
*/
public function setActive($active) : self
{
$this->active = $active;
return $this;
}
/**
* Get active
*
* #return boolean
*/
public function getActive()
{
return $this->active;
}
}
As you've told us, when you're calling $request->request->get('name') you get correct value. Please take a look at your code where you pass data to form:
$form->submit($request->request->get($form->getName()));
This line of code means that you're passing to form only those data, which comes as array named as your form (in your case - skill). In this case you should pass through POST such data:
skill[name]=John
skill[active]=1
If you want not to send data through POST using array wrapper, you have to submit to your form whole request:
$form->submit($request->request->all());
Both methods are technically correct, but second is in fact anti-pattern.

Symfony2 setNormalizer with defined options

I'm having a problem with Symfony options resolver where I need to specify a list of defined variables that should be normalized.
The problem is: I don't want to define all these variables again in
$resolver->setDefined();
because I have a list of defined fields in $builder and the same fields are defined in the entity SlotRequest.
Is there a different way of assigning all fields/variables from entity to resolver?
First approach:
$resolver->setDefined([
'date_form','etc..' ]);
But, it pointless because in the real world I have to normalize 10+ variables + 20 fields)
Second approach would be to parse all annotations from the entity 'SlotRequest', and then fill up an array with that object.
$resolver->setDefined($anArrayOfParsedFieldsFromEntity);
Is there a better way of doing this?
An example of using:
In controller:
$form = $this->createForm(new SlotRequestType(), new SlotRequest());
SlotRequestType:
class SlotRequestType extends AbstractType
{
/**
* #var CCriteria
*/
protected $resolved = null;
/**
* #param FormBuilderInterface $builder
* #param array $options
*/
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('date_from',null,['property_path'=>'dateFrom']);
//more fields
}
/**
* #param OptionsResolver $resolver
*/
public function configureOptions(OptionsResolver $resolver)
{
$this->resolved = new \CCriteria();
$resolver->setDefaults(array(
'data_class' => SlotRequest::class,
'allow_extra_fields' => true,
'method' => 'GET'
));
$resolver->setDefined([....]);// the list of fields
$resolver->setNormalizer('date_from', function (Options $options, $value) {
$dateFrom = new \DateTime($value);
$this->resolved->setStartDate($dateFrom->getTimestamp());
return $value;
});
//more normalizers
}
/**
* #return null
*/
public function getName()
{
return null;
}
/**
* #return CCriteria
*/
public function getResolved()
{
return $this->resolved;
}
Entity SlotRequest
<?php
namespace Test/Entity;
class SlotRequest
{
/**
* #var string
* #Assert\NotBlank(message="Parameter [date_from] is missing.")
* #Assert\Type(
* type="string",
* message="The value {{ value }} is not a valid {{ type }}."
* )
* #Assert\Date()
*/
public $dateFrom;
//more fields
}

Symfony 3: allow_delete in Forms not working

I have a problem with my form in Symfony 3. I have one-to-many-to-one join (Doctrine 2). It handles Orders (Order), Products (Product) and joining entity (OrderProduct), which holds amount of product in order.
I have a form for add and update order entries, which uses Collection of OrderProducts. It's all based on documentation (link).
In the form I have a button for add a product (from documentation, adds a <li> to DOM) and every added has a button for remove it (from documentation, removes <li> from DOM). This part is working - adding to and removing from DOM.
Adding products works (as at the new order, than when editting).
But my problem is with removing. Products which was succesfully deleted from form, are still appearing in $editForm->getData().
OrderProduct Form
namespace AppBundle\Form;
use AppBundle\Entity\ProductType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OrderProductType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('amount')
->add('product')
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(
array(
'data_class' => 'AppBundle\Entity\OrderProduct',
)
);
}
public function getName()
{
return 'app_bundle_order_product_type';
}
}
Order Form
namespace AppBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class OrderType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('customer')
->add('date', null, array('widget' => 'single_text'))
->add('payment', null, array('widget' => 'single_text'))
->add('processed', null, array('widget' => 'single_text'))
->add(
'orderProducts',
CollectionType::class,
array(
'entry_type' => OrderProductType::class,
'allow_add' => true,
'allow_delete' => true,
'by_reference' => false,
'prototype' => true,
'delete_empty' => true,
'entry_options' => array('data_class' => 'AppBundle\Entity\OrderProduct'),
)
);
}
public function configureOptions(OptionsResolver $resolver)
{
}
public function getName()
{
return 'app_bundle_order_type';
}
}
Current action in OrderController
(Adding product works, removing not)
public function editAction (Request $request, $orderId) {
$em = $this->getDoctrine()->getManager();
$order = $em->getRepository('AppBundle:Order')->find($orderId);
if (!$order) {
throw $this->createNotFoundException('No order found for id '.$orderId);
}
$editForm = $this->createForm(OrderType::class, $order);
$editForm->add('submit', SubmitType::class);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$order = $editForm->getData();
//print '<pre>';
//var_dump($order->getOrderProducts());
//die();
$orderProducts = $order->getOrderProducts();
$em->persist($order);
foreach ($orderProducts as $oneOrderProduct) {
$oneOrderProduct->setOrder($order);
$em->persist($oneOrderProduct);
}
//print '<pre>';
//var_dump($order->getOrderProducts());
//die();
$em->flush();
return $this->redirectToRoute('one_order', array('orderId' => $order->getId()));
}
return $this->render(
'order/new.html.twig', array(
'form' => $editForm->createView(),
));
}
I know that I must remove removed OrderProducts from Order in editAction, but now I can't, because from the form is sent all OrderProducts.
Order Entity
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Doctrine\Common\Collections\ArrayCollection;
use AppBundle\Entity\OrderProduct;
use Symfony\Component\Form\Extension\Core\Type\CollectionType;
/**
* #ORM\Entity
* #ORM\Table(name="order_")
*/
class Order
{
/**
* #ORM\Column(type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* #ORM\ManyToOne(targetEntity="Customer", inversedBy="orders")
* #ORM\JoinColumn(name="customer_id", referencedColumnName="id")
*/
private $customer;
/**
* #ORM\Column(type="date")
*/
private $date;
/**
* #ORM\Column(type="date")
*/
private $payment;
/**
* #ORM\Column(type="date")
*/
private $processed;
public function __toString()
{
return strval($this->getId());
}
/**
* #ORM\OneToMany(targetEntity="OrderProduct", mappedBy="order")
*/
private $orderProducts;
public function __construct()
{
$this->orderProducts = new \Doctrine\Common\Collections\ArrayCollection();
}
/**
* Get id
*
* #return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set date
*
* #param \DateTime $date
*
* #return Order
*/
public function setDate($date)
{
$this->date = $date;
return $this;
}
/**
* Get date
*
* #return \DateTime
*/
public function getDate()
{
return $this->date;
}
/**
* Set payment
*
* #param \DateTime $payment
*
* #return Order
*/
public function setPayment($payment)
{
$this->payment = $payment;
return $this;
}
/**
* Get payment
*
* #return \DateTime
*/
public function getPayment()
{
return $this->payment;
}
/**
* Set processed
*
* #param \DateTime $processed
*
* #return Order
*/
public function setProcessed($processed)
{
$this->processed = $processed;
return $this;
}
/**
* Get processed
*
* #return \DateTime
*/
public function getProcessed()
{
return $this->processed;
}
/**
* Set customer
*
* #param \AppBundle\Entity\Customer $customer
*
* #return Order
*/
public function setCustomer(\AppBundle\Entity\Customer $customer = null)
{
$this->customer = $customer;
return $this;
}
/**
* Get customer
*
* #return \AppBundle\Entity\Customer
*/
public function getCustomer()
{
return $this->customer;
}
/**
* Add orderProduct
*
* #param \AppBundle\Entity\OrderProduct $orderProduct
*
* #return Order
*/
public function addOrderProduct(\AppBundle\Entity\OrderProduct $orderProduct)
{
$this->orderProducts[] = $orderProduct;
return $this;
}
/**
* Remove orderProduct
*
* #param \AppBundle\Entity\OrderProduct $orderProduct
*/
public function removeOrderProduct(\AppBundle\Entity\OrderProduct $orderProduct)
{
$this->orderProducts->removeElement($orderProduct);
}
/**
* Get orderProducts
*
* #return \Doctrine\Common\Collections\Collection
*/
public function getOrderProducts()
{
return $this->orderProducts;
}
}
But in POST it's OK as you can see here, here I removed 2 of 4 products. Problem appears in form handling.
If by_reference is set to false the underlying Order entity MUST have a method in your case called [removeOrderProduct]. What also might be the problem is that you are not specifying data_class option inside the configureOptions method. In your case if Order entity is in 'AppBundle\Entity\Order', then the configureOptions method should contain:
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Order',
));
}
And I would suggest you do the same in OrderProductType class also.
My guess would be that since you haven't specified the 'data_class' option in your OrderType class, the by_reference option in the 'orderProducts' field might not be able to figure out where to look for the [removeOrderProduct] method. So set that option and make sure you have that method inside your Order entity class.
IF this is not the problem then you should provide more information about your order entity and where exactly are you calling the getData method.
Update:
Looking at your code I can't identify the problem, that might be causing the entities to not get removed. But I have spotted a few oddities in your code:
In the controller, when handling form submittions it is not necessary to call the getData method: after you call handleRequest the $order object gets updated and holds the new information (since objects are passed by reference, the form can't apply the changes without changing the original $order as well). So there is no need to $order = $form->getData(), since you have already defined the $order variable before and it holds the reference to the same object to which the form mapped the posted values.
If that does not help, I suggest you add die; statements all over the place, just to make sure that at each step the right methods are called. For instance add die; to the removeOrderProduct method, to check if its hit. If it was hit, the problem is not going to be evident from the data you have provided to us, so it will require further debugging.
Also it might not be a problem now, but if you want to remove the products not present in the list after submittion, you have to call $order->getOrderProducts and add each of the items to a new collection that holds the previous orderProducts (before submittion) and compare that to the values after submittion to figure out which ones need removing.
I solved it last night :)
This is working controller action:
/**
* #Route("/{orderId}/edit", name="edit_order", requirements={"orderId": "\d+"})
* #param Request $request
* #param $orderId
*
* #return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function editAction(Request $request, $orderId)
{
$em = $this->getDoctrine()->getManager();
$order = $em->getRepository('AppBundle:Order')->find($orderId);
if (!$order) {
return $this->redirectToRoute('new_order');
}
$originalOrderProducts = new ArrayCollection();
foreach ($order->getOrderProducts() as $orderProduct) {
$originalOrderProducts->add($orderProduct);
}
$editForm = $this->createForm(OrderType::class, $order);
$editForm->add('submit', SubmitType::class);
$editForm->handleRequest($request);
if ($editForm->isSubmitted() && $editForm->isValid()) {
$orderProducts = $order->getOrderProducts();
foreach ($originalOrderProducts as $oneOriginalOrderProduct) {
if (false === $order->getOrderProducts()->contains($oneOriginalOrderProduct)) {
$order->removeOrderProduct($oneOriginalOrderProduct);
$em->remove($oneOriginalOrderProduct);
$em->flush();
}
}
foreach ($orderProducts as $oneOrderProduct) {
if ($oneOrderProduct->getAmount() == 0) {
$order->removeOrderProduct($oneOrderProduct);
$em->remove($oneOrderProduct);
} else {
if (!$originalOrderProducts->contains($oneOrderProduct)) {
$oneOrderProduct->setOrder($order);
}
$em->persist($oneOrderProduct);
}
$em->persist($order);
$em->flush();
}
return $this->redirectToRoute('order');
}
return $this->render('order/new.html.twig', array('form' => $editForm->createView()));
}

Symfony2 Form: AUTOMATION on persisting with related objects

I have a form for inserting an entity Category. This entity has two other entities that are related to it.
One related entity is an other separate Entity Group. The other entity is itself self-referenced Category that is an array collection that represents preconditions. So far so good, i can persist the main entity with the relations with the correct ORM annotations.
Rough scheme of Category
id : int
title: string
group : Group obj
preconditions : [Category obj, Category obj, ...]
I made an type class for creating the form as described as best-practice in the documentation.
$form = $this->createForm(new CategoryType($em));
Situation
Before i persist the entity, i must initialize it and set the posted datas to it. The posted related objects can’t simply setted to the persisting entity, because they have the wrong datatype. (E.g. the self-referencing collection is posted only as array with id’s, and not an array collection of the choosed items.)
So i catch this raw datas and get separatelly the related entities from the entity manager.
Goal
The inserting entity should be filled automatically with the related entities, whitout get those separately through the entity manager
Question
Is this the meaning of the form component that those related objects are not posted and made available fully? Or what im missing in my implementation?
Is there a way to do this more automated?
On the form class for the ‘preconditions’ property i had to do mapped => false otherwise i recieve an exception that a wrong type was passed. But at the end i want that the form matches all automatically through the mapping, whitout skipping a mapping, and whitout getting the related entities separately from the entity manager.
class CategoryType extends AbstractType
{
public function __construct($em)
{
$this->em = $em;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$qb = $this->em->createQueryBuilder();
$categories = $qb->select('e.id, e.title')
->from('MyvendorCoreBundle:Category', 'e')
->indexBy('e', 'e.id')
->orderBy('e.title')
->getQuery()
->getResult();
$categories_choice = array_map(function ($value) {
return $value['title'];
}, $categories);
$builder->add('title')
->add('group_Id', new GroupType($this->em))
->add('preconditions', 'choice', array(
'choices' => $categories_choice,
'multiple' => true,
'mapped' => false
))
->add('save', 'submit');
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Myvendor\CoreBundle\Entity\Category'
));
}
public function getName()
{
return 'category';
}
}
Controller method
public function newAction(Request $request)
{
$em = $this->getDoctrine()->getManager();
$form = $this->createForm(new CategoryType($em));
// Repopulating the form after submission
$form->handleRequest($request);
// Prepare a new empty Category
$category = new Category();
if ($form->isValid()) {
/* Catch some raw datas posted from the form */
// Posted precondition category ids to get its entities more later
$precondition_category_ids = $form->get('preconditions')->getData();
// Posted group entity that have only filled the group id in the object
$group_raw = $form->get('group_Id')->getData();
// Get the explicit filled group entity throuth the posted id.
$group = $em->find('MyvendorCoreBundle:Group', $group_raw->getGroupid());
// Fill the prepaired group with the posted datas
$category->setTitle($form->get('title')->getData());
$category->setGroupId($group);
// Adding preconditions
try {
for ($i = 0; count($precondition_category_ids) > $i; $i ++) {
$precondition_category_id = $precondition_category_ids[$i];
if (0 >= $precondition_category_id) { // Retrieving id must be greater than 0
throw new \Exception('Error retrieving precondition id');
}
$precondition_category = $em->find('MyvendorCoreBundle:Category', $precondition_category_id);
if ($precondition_category instanceof Category) {
$category->addPrecondition($precondition_category);
} else {
throw new \Exception('Error retrieving precondition as Myvendor\CoreBundle\Entity\Category');
}
}
$em->persist($category); // Insert the group item with its relations
$em->flush();
echo '<h1 style="color:green">persisted</h1>';
} catch (\Exception $e) {
echo '<h1 style="color:red">' . $e->getMessage() . '</h1>';
}
}
return $this->render('MyvendorCoreBundle:fbm:new.html.twig', array(
'form' => $form->createView()
));
}
GroupType
class GroupType extends AbstractType
{
public function __construct($em){
$this->em = $em;
}
public function buildForm(FormBuilderInterface $builder, array $options)
{
$groups = $this->em->createQuery("
SELECT o.groupid, o.descr
FROM MyvendorCoreBundle:Group o
INDEX BY o.groupid
ORDER BY o.descr
")->getResult();
$groups_dropdown = array();
$groups_dropdown = array_map(function($value) { return $value['descr']; }, $groups);
$builder->add('groupid', 'choice', array(
'label' => false,
'choices' => $groups_dropdown,
'attr' => array('style' => 'width: 300px')
));
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'Myvendor\CoreBundle\Entity\Group',
));
}
public function getName()
{
return 'group';
}
}
/**
* #ORM\Entity
* #ORM\Table(name="category")
*/
class Category
{
public function __construct()
{
$this->preconditions = new ArrayCollection();
}
/**
* #ORM\Column(type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* #var \Myvendor\CoreBundle\Entity\Group
*
* #Assert\Type(type="Myvendor\CoreBundle\Entity\Group")
* #Assert\Valid()
* #ORM\ManyToOne(targetEntity="Myvendor\CoreBundle\Entity\Group", inversedBy="Category")
* #ORM\JoinColumn(name="group_id", nullable=false, referencedColumnName="groupid")
*/
private $group_Id;
/**
* #var string
* #Assert\NotBlank()
* #ORM\Column(type="string", length=255, nullable=false)
*/
private $title;
/**
* Preconditions are Categorys referencing to an Category.
* For a single Category its empty (which have no subelements).
* A join table holds the references of a main Category to its sub-Categorys (preconditions)
*
* #ORM\ManyToMany(targetEntity="Category")
* #ORM\JoinTable(name="category_precondition",
* joinColumns={#JoinColumn(name="category_id", referencedColumnName="id")},
* inverseJoinColumns={#JoinColumn(name="category_precondition_id", referencedColumnName="id")}
* )
*/
private $preconditions;
/**
* Get id
*
* #return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set title
*
* #param string $title
*
* #return Category
*/
public function setTitle($title)
{
$this->title = $title;
return $this;
}
/**
* Get title
*
* #return string
*/
public function getTitle()
{
return $this->title;
}
/**
* Set groupId
*
* #param \Myvendor\CoreBundle\Entity\Group $groupId
*
* #return Category
*/
public function setGroupId(\Myvendor\CoreBundle\Entity\Group $groupId)
{
$this->group_Id = $groupId;
return $this;
}
/**
* Get groupId
*
* #return \Myvendor\CoreBundle\Entity\Group
*/
public function getGroupId()
{
return $this->group_Id;
}
/**
* Add precondition
*
* #param \Myvendor\CoreBundle\Entity\Category $precondition
*
* #return $this
*/
public function addPrecondition(\Myvendor\CoreBundle\Entity\Category $precondition)
{
$this->preconditions[] = $precondition;
return $this;
}
/**
* Get preconditions
*
* #return \Doctrine\Common\Collections\Collection
*/
public function getPreconditions()
{
return $this->preconditions;
}
/**
* Group
*
* #ORM\Table(name="group", indexes={#ORM\Index(name="homepage", columns={"homepage"}), #ORM\Index(name="theme", columns={"theme"})})
* #ORM\Entity
*/
class Group
{
/**
* #var string
*
* #ORM\Column(name="descr", type="string", length=60, nullable=true)
*/
private $descr;
/**
* #var integer
*
* #Assert\NotBlank()
* #ORM\Column(name="groupid", type="integer")
* #ORM\Id
* #ORM\GeneratedValue(strategy="IDENTITY")
*/
public $groupid;
/**
* Set descr
*
* #param string $descr
* #return Group
*/
public function setDescr($descr)
{
$this->descr = $descr;
return $this;
}
/**
* Get descr
*
* #return string
*/
public function getDescr()
{
return $this->descr;
}
/**
* Get groupid
*
* #return integer
*/
public function getGroupid()
{
return $this->groupid;
}
}
The solution was that the type of the selecting choice entities, must be not a choicelist, but really an collection type.
So use something like this
->add('preconditions', 'collection', array(
'entry_type' => 'entity',
'entry_options' => array(
'class' => 'MyVendorCoreBundle:EduStructItem',
'choice_label' => 'title'
),
'allow_add' => true,
'allow_delete' => true
))
instead of
->add('preconditions', 'choice', array(
'choices' => $categories_choice,
'multiple' => true,
'mapped' => false
))

Categories