Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Symfony2: Removing entity from middle of collection

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.

like image 931
noShowP Avatar asked Nov 28 '14 11:11

noShowP


1 Answers

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.

like image 101
origaminal Avatar answered Oct 06 '22 01:10

origaminal