Description
I recently tried to set up a one-to-many relationship using the collection type to allow adding and removing elements. I had tremendous trouble getting it working, during the process of which I came across several issues, some of which are already reported, the last of which I don't believe is. I've include some information on all of it, for anybody attempting the same task.
The very first problem I had was that the addXxx and removeXxx functions never seemed to be called, I eventually tracked this down to a bug/ version incompatibility that had already been reported (Doctrines PersistentCollection didn't have __clone, meaning when the dataSnapshot is taken in MergeCollectionListener, the inner collection isn't cloned, thus changes are made to the dataSnapshot), I fixed this by updating composer.json to use doctrine/orm v2.2.1.
The second issue I had was that whilst addXxx was now being called, it was passing an array rather than an instance of my entity, I tracked this down to another bug report [https://github.com//issues/3354] , I used the suggest work around of adding the data_class to the options in the parent type.
At this point I am using symfony/symfony v2.1.* with doctrine/orm v2.2.1
After these steps the addXxx function seemed to work, but removeXxx was never getting triggered. After looking through the code I identified where I think the issue is as being MergeCollectionListener::onBindNormData. The logic for populating $itemsToDelete doesn't seem to make much sense to me. I have managed to get my test code up and running by changing the code....
// Item not found, remember for deletion
foreach ($originalData as $key => $item) {
if ($item === $originalItem) {
$itemsToDelete[$key] = $item;
continue 2;
}
}
...to...
foreach($originalData as $key => $item) {
if ($item === $originalItem) {
continue 2;
}
}
$itemsToDelete[] = $originalItem;
Here are the relevant Entities and Types, the controller / twig templates in use are the ones generated by the crud generator (with some JS added to add / remove fields).
<?php
namespace Acme\DemoBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
/**
* @ORM\Table(name="band")
* @ORM\Entity
*/
class Band
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*/
private $id;
/**
* @var string $number
*
* @ORM\Column(name="name", type="string", length=128)
*/
private $name;
/**
* @var Doctrine\Common\Collections\ArrayCollection
*
* @ORM\OneToMany(targetEntity="BandDetail", mappedBy="band", cascade={"all"})
*/
private $details;
public function __construct()
{
$this->details = new \Doctrine\Common\Collections\ArrayCollection();
}
/**
* Get id
*
* @return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set name
*
* @param string $name
* @return Band
*/
public function setName($name)
{
$this->name = $name;
return $this;
}
/**
* Get name
*
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* Add details
*
* @param Acme\DemoBundle\Entity\BandDetail $details
*/
public function addDetail(\Acme\DemoBundle\Entity\BandDetail $detail)
{
$detail->setBand($this);
$this->details->add($detail);
}
public function removeDetail(\Acme\DemoBundle\Entity\BandDetail $detail)
{
$detail->setBand(null);
$this->details->removeElement($detail);
}
public function setDetails($details)
{
foreach($details as $detail) {
$detail->setBand($this);
}
$this->details = $details;
}
/**
* Get details
*
* @return Doctrine\Common\Collections\Collection
*/
public function getDetails()
{
return $this->details;
}
}
<?php
namespace Acme\DemoBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
/**
* @ORM\Table(name="band_details")
* @ORM\Entity
*/
class BandDetail
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue
*/
private $id;
/**
* @ORM\ManyToOne(targetEntity="Band", inversedBy="details")
* @ORM\JoinColumn(name="band_id", referencedColumnName="id")
*/
private $band;
/**
* @var string $number
*
* @ORM\Column(name="text", type="string", length=128)
*/
private $text;
/**
* Get id
*
* @return integer
*/
public function getId()
{
return $this->id;
}
/**
* Set text
*
* @param string $text
* @return BandDetail
*/
public function setText($text)
{
$this->text = $text;
return $this;
}
/**
* Get text
*
* @return string
*/
public function getText()
{
return $this->text;
}
/**
* Set band
*
* @param Acme\DemoBundle\Entity\Band $band
* @return BandDetail
*/
public function setBand(\Acme\DemoBundle\Entity\Band $band = null)
{
$this->band = $band;
return $this;
}
/**
* Get band
*
* @return Acme\DemoBundle\Entity\Band
*/
public function getBand()
{
return $this->band;
}
}
<?php
namespace Acme\DemoBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
class BandDetailType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder
->add('text')
;
}
public function getDefaultOptions(array $options)
{
return array(
'data_class' => 'Acme\DemoBundle\Entity\BandDetail',
);
}
public function getName()
{
return 'banddetail';
}
}
<?php
namespace Acme\DemoBundle\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;
class BandType extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder
->add('name')
->add('details', 'collection', array(
'type' => new BandDetailType(),
'allow_add' => true,
'allow_delete' => true,
'prototype' => true,
'by_reference' => true,
'options' => array('data_class' => 'Acme\DemoBundle\Entity\BandDetail'),
))
;
}
public function getDefaultOptions(array $options)
{
return array(
'data_class' => 'Acme\DemoBundle\Entity\Band',
);
}
public function getName()
{
return 'acme_demobundle_bandtype';
}
}
Apologies for the length of the post, I was just trying to be thorough. I am a newcomer to both Symfony and Doctrine, but I'm relatively confident there is a bug here. Can anybody shed some light on to whether this is a valid fix to the issue, or whether I'm just doing something wrong?
Cheers.