Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Symfony2 updating items in "subform"

Short version of my question:

How can I edit entities of subforms in Symfony2?

=-=-=-=-=-=-= Long and detailed version =-=-=-=-=-=-=-=

I have an entity Order

<?php

class Order
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Customer")
     * @ORM\JoinColumn(name="customer_id", referencedColumnName="id", nullable=false)
     **/
    private $customer;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="date", type="date")
     */
    private $date;

    /**
     * @ORM\ManyToOne(targetEntity="\AppBundle\Entity\OrderStatus")
     * @ORM\JoinColumn(name="order_status_id", referencedColumnName="id", nullable=false)
     **/
    private $orderStatus;

    /**
     * @var string
     *
     * @ORM\Column(name="reference", type="string", length=64)
     */
    private $reference;

    /**
     * @var string
     *
     * @ORM\Column(name="comments", type="text")
     */
    private $comments;

    /**
     * @var array
     *
     * @ORM\OneToMany(targetEntity="OrderRow", mappedBy="Order", cascade={"persist"})
     */
    private $orderRows;

    ...
}

MySQL

_____________________________________________________________
|id                           | order id                    |
|customer_id                  | fk customer.id NOT NULL     |
|date                         | order date                  |
|order_status_id              | fk order_status.id NOT NULL |
|reference                    | varchar order reference     |
|comments                     | text comments               |
|___________________________________________________________|

And an entity OrderRow (an order can have one or more rows)

<?php

class OrderRow
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @ORM\ManyToOne(targetEntity="Order", inversedBy="orderRows", cascade={"persist"})
     * @ORM\JoinColumn(name="order_id, referencedColumnName="id", nullable=false)
     **/
    private $order;

    /**
     * @ORM\ManyToOne(targetEntity="[MyShop\Bundle\ProductBundle\Entity\Product")
     * @ORM\JoinColumn(name="product_id", referencedColumnName="id", nullable=true)
     **/
    private $product;

    /**
     * @var string
     *
     * @ORM\Column(name="description", type="string", length=255)
     */
    private $description;

    /**
     * @var integer
     *
     * @ORM\Column(name="count", type="integer")
     */
    private $count = 1;

    /**
     * @var \DateTime
     *
     * @ORM\Column(name="date", type="date")
     */
    private $date;

    /**
     * @var decimal
     *
     * @ORM\Column(name="amount", type="decimal", precision=5, scale=2)
     */
    private $amount;

    /**
     * @var string
     *
     * @ORM\Column(name="tax_amount", type="decimal", precision=5, scale=2)
     */
    private $taxAmount;

    /**
     * @var string
     *
     * @ORM\Column(name="discount_amount", type="decimal", precision=5, scale=2)
     */
    private $discountAmount;

    ...
}

MySQL

_____________________________________________________________
|id                           | order id                    |
|order_id                     | fk order.id NOT NULL        |
|product_id                   | fk product.id               |
|description                  | varchar product description |
|count                        | int count                   |
|date                         | date                        |
|amount                       | amount                      |
|taxAmount                    | tax amount                  |
|discountAmount               | discount amount             |
|___________________________________________________________|

I'd like to create one form which allows editing one order and it's rows.

OrderType.php

class OrderType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('customer', 'entity', array(
                'class' => 'Customer',
                'multiple' => false
            ))
            ->add('orderStatus', 'entity', array(
                'class' => 'AppBundle\Entity\OrderStatus',
                'multiple' => false
            ))
            ->add('date')
            ->add('reference')
            ->add('comments')
            ->add('orderRows', 'collection', [
                'type' => new OrderRowType(),
                'allow_add' => true,
                'by_reference' => false,
            ])
        ;
    }

    ...
}

OrderRowType.php

class OrderRowType extends AbstractType
{
    /**
     * @param FormBuilderInterface $builder
     * @param array $options
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('order', 'entity', array(
                'class' => 'MyShop\Bundle\OrderBundle\Entity\Order',
                'multiple' => false
            ))
            ->add('product', 'product_selector') // service
            ->add('orderRowStatus', 'entity', array(
                'class' => 'AppBundle\Entity\OrderRowStatus',
                'multiple' => false
            ))
            ->add('description')
            ->add('count')
            ->add('startDate')
            ->add('endDate')
            ->add('amount')
            ->add('taxAmount')
            ->add('discountAmount')
        ;
    }

    ...
}

Updating an order is done by sending a request to my API:

  • Request Url: https://api.example.net/admin/orders/update/37
  • Request Method: POST
  • Status Code: 200

    Params: {
    
    "order[customer]": "3",
    "order[orderStatus]": "1",
    "order[date][month]:": "5",
    "order[date][day]": "18",
    "order[date][year]": "2015",
    "order[reference]": "Testing",
    "order[comments]": "I have nothing to say!",
    "order[orderRows][0][order]": "32",
    "order[orderRows][0][product]": "16721",
    "order[orderRows][0][orderRowStatus]:1": "1",
    "order[orderRows][0][description]": "8 GB memory",
    "order[orderRows][0][count]": "12",
    "order[orderRows][0][startDate][month]": "5",
    "order[orderRows][0][startDate][day]": "18",
    "order[orderRows][0][startDate][year]": "2015",
    "order[orderRows][0][endDate][month]": "5",
    "order[orderRows][0][endDate][day]": "18",
    "order[orderRows][0][endDate][year]": "2015",
    "order[orderRows][0][amount]": "122.03",
    "order[orderRows][0][taxAmount]": "25.63",
    "order[orderRows][0][discountAmount]": "0",
    "order[orderRows][1][order]": "32",
    "order[orderRows][1][product]": "10352",
    "order[orderRows][1][orderRowStatus]": "2",
    "order[orderRows][1][description]": "12 GB MEMORY",
    "order[orderRows][1][count]": "1",
    "order[orderRows][1][startDate][month]": "5",
    "order[orderRows][1][startDate][day]": "18",
    "order[orderRows][1][startDate][year]": "2015",
    "order[orderRows][1][endDate][month]": "5",
    "order[orderRows][1][endDate][day]": "18",
    "order[orderRows][1][endDate][year]": "2015",
    "order[orderRows][1][amount]": "30.8",
    "order[orderRows][1][taxAmount]": "6.47",
    "order[orderRows][1][discountAmount]": "0",
    "order[orderRows][2][order]": "32",
    "order[orderRows][2][product]": "2128",
    "order[orderRows][2][orderRowStatus]": "3",
    "order[orderRows][2][description]": "4GB MEMORY",
    "order[orderRows][2][count]": "5",
    "order[orderRows][2][startDate][month]": "5",
    "order[orderRows][2][startDate][day]": "18",
    "order[orderRows][2][startDate][year]": "2015",
    "order[orderRows][2][endDate][month]": "5",
    "order[orderRows][2][endDate][day]": "18",
    "order[orderRows][2][endDate][year]": "2015",
    "order[orderRows][2][amount]": "35.5",
    "order[orderRows][2][taxAmount]": "7.46",
    "order[orderRows][2][discountAmount]": "0"
    }
    

The request above edits the order details and creates new order_rows, because no order_row_id has been provided. Nowere in Symfony2 I found that I should just $builder->add('id') to my OrderRowType, nor do my entities have setters for the column ID.

After a lot of information, I have a very short question. How should I update the order_rows records within this form?

like image 269
ivodvb Avatar asked May 18 '15 19:05

ivodvb


1 Answers

Dealing with collection and Doctrine can sometime be complex if you don't know the internals. I will first give you some information about the internals so you have clearer picture of what is done under-the-hood.

It is a it hard to estimate the actual problem from the details you gave, but I give you some advice that can help you debug the issue. I give an extensive answer, so it can possibly help others.

TL;DR version

Here are my guess: you are modifying the entity by reference, even if you set by_reference to false. This is probably because you haven't defined addOrderRow and removeOrderRow methods (both of them) or because you are not using doctrine collection object

Some Internals

Form

When you create a Form object in your controller, you bind it with an Entity that you retrieved from the Database (i.e. with an ID), or that you just created: this means that the Form DO NOT require the id of the main entities, nor it require the ids of the collection object. You may add it to forms for your convenience, but if you do make sure that they are immutable (e.g. hidden type with disabled => true option).

When a Collection form is created, Symfony automatically create one subform for each entity already present in the entity collection; this is why in a entity/<id>/edit action you (should) always see the editable form for the element of the collection already present.

The allow_add and allow_delete options, controls whether the generated subform can be dynamically resized, by removing some element of the collection, or by adding new elements (see the ResizeFormListener class). Note that when you use the prototype with javascript, the __prototype__ placeholder must be used carefully: this is the actual key that is used to remap the object server side, so if you change it the Form will create a new element in the collection.

Doctrine

In Doctrine you need to take a good care of the owning side and the inverse side of mapping. The owning side is the entity which will persist the association to the database, and the inverse side is the other entity. When persisting, the owning side is the ONLY which trigger the relation to be saved. It is a model responsibility to keep both relations in sync, during object modification.

When dealing with one-to-many relations, the owning side is the many (e.g. OrderRows in your case), and the one is the inverse side.

Finally, the application need to explicitly mark the entities to persist. Both side of the relation can be marked as persist cascading, so that all reachable entities through relations are persisted as well. During this process, all new entities are automatically persisted, and (in a standard configuration) all "dirty" entities are updated.

The concept of dirty entity is explained well in the official docs. By default, Doctrine automatically detect updated entities, by comparing each property with the original state, and generate UPDATE statement during flush. If this is made explicit for performance improvement (i.e. @ChangeTrackingPolicy("DEFERRED_EXPLICIT")), all entities must be manually persisted, even if the relation is marked as cascading.

Also note that when entities are reloaded from DB, Doctrine use a PersistenCollection instance to handle collection, so you are required to use doctrine collection interface to handle collection of entities.

What to check

To sum up, here an (hopefully complete) list of things to check for proper collection update.

Both side of Doctrine relations are set properly

  1. both the owning side and inverse side should be marked as cascade persist (if not the controller must manually cascade... not recommended, unless too slow);
  2. the collection property MUST be an implementation of Doctrine\Common\Collection, not a simple array;
  3. the models must be mutually updating on each change, so this imply that
  4. the collection object SHOULD NOT be returned as-is to avoid modification by reference.

In your case:

<?php

class Order
{
    /**
    * @var integer
    *
    * @ORM\Column(name="id", type="integer")
    * @ORM\Id
    * @ORM\GeneratedValue(strategy="AUTO")
    */
    private $id;

    /**
     * @var \Doctrine\Common\Collections\Collection
     * @ORM\OneToMany(targetEntity="OrderRow", mappedBy="Order", cascade={"persist"})
     */
    private $orderRows;

    public function __construct()
    {
        // this is required, as Doctrine will replace it by a PersistenCollection on load
        $this->orderRows = new \Doctrine\Common\Collections\ArrayCollection();
    }

    /**
     * Add order row
     *
     * @param  OrderRow $row
     */
    public function addOrderRow(OrderRow $row)
    {
        if (! $this->orderRows->contains($row))
            $this->orderRows[] = $row;

        $row->setOrder($this);
    }

    /**
     * Remove order row
     *
     * @param OrderRow $row
     */
    public function removeOrderRow(OrderRow $row)
    {
        $removed = $this->orderRows->removeElement($row);
        /*
        // you may decide to allow your domain to have spare rows, with order set to null
        if ($removed)
            $row->setOrder(null);
        */

        return $removed;
    }

    /**
     * Get order rows
     * @return OrderRow[]
     */
    public function getOrders()
    {
        // toArray prevent edit by reference, which breaks encapsulation
        return $this->orderRows->toArray();
    }
}

class OrderRows
{
    /**
     * @var integer
     *
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    private $id;

    /**
     * @var Order
     * @ORM\ManyToOne(targetEntity="Order", inversedBy="orderRows", cascade={"persist"})
     * @ORM\JoinColumn(name="order_id, referencedColumnName="id", nullable=false)
     */
    private $order;


    /**
     * Set order
     *
     * @param  Order $order
     */
    public function setOrder(Order $order)
    {
        // avoid infinite loops addOrderRow -> setOrder -> addOrderRow
        if ($this->order === $order) {
            return;
        }

        if (null !== $this->order) {
            // see the comment above about spare order rows
            $this->order->removeOrderRow($this);
        }

        $this->order = $order;
    }

    /**
     * Get order
     *
     * @return Order
     */
    public function getOrder()
    {
        return $this->order;
    }
}

Form collection are configured properly

  1. Make sure that Order id is not exposed by the form (but include in the template the correct GET parameters for the router action)
  2. Make sure that OrderRow order is not present, since this will be updated automatically by the model class
  3. make sure that by_reference is set to false
  4. make sure that both addOrderRow and removeOrderRow are defined in Order class
  5. to speed up debugging, make sure that Order::getOrderRows do not return the collection directly

Here the snippet:

class OrderType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('orderRows', 'collection', [
                'type'           => new OrderRowType(),
                'allow_add'      => true,  // without, new elements are ignored
                'allow_delete'   => true,  // without, deleted elements are not updated
                'by_reference'   => false, // hint Symfony to use addOrderRow and removeOrderRow
                                          // NOTE: both method MUST exist, or Symfony will ignore the option
            ])
        ;
    }
}

class OrderRowType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            // ->add('order') NOT required, the model will handle the setting
            ->add('product', 'product_selector') // service
        ;
    }
}

The contoller must properly update the entity

  1. make sure that the form is created correctly;
  2. if using Form::handleRequest make sure that the HTTP Method matches the Form method attribute
  3. if the form is valid, take care of deleted element of the collection
  4. if the form is valid, persist the entity then flush

In your case you should have an action like this:

public function updateAction(Request $request, $id)
{
    $em = $this->getDoctrine()->getManager();

    $order = $em->getRepository('YourBundle:Order')->find($id);

    if (! $order) {
        throw $this->createNotFoundException('Unable to find Order entity.');
    }

    $previousRows = $order->getOrderRows();

// is a PUT request, so make sure that <input type="hidden" name="_method" value="PUT" /> is present in the template
    $editForm = $this->createForm(new OrderType(), $order, array(
        'method' => 'PUT',
        'action' => $this->generateUrl('order_update', array('id' => $id))
    ));
    $editForm->handleRequest($request);

    if ($editForm->isValid()) {
        // removed rows = previous rows - current rows
        $rowsRemoved = array_udiff($previousRows, $order->getOrderRows(), function ($a, $b) { return $a === $b ? 0 : -1; });

        // removed rows must be deleted manually
        foreach ($rowsRemoved as $row) {
            $em->remove($row);
        }

        // if not cascading, all rows must be persisted as well
        $em->flush();
    }

    return $this->render('YourBundle:Order:edit.html.twig', array(
        'entity'      => $order,
        'edit_form'   => $editForm->createView(),
    ));
}

Hope this helps!

like image 56
giosh94mhz Avatar answered Oct 13 '22 00:10

giosh94mhz