Wednesday, July 4, 2012

Symfony2 Many-To-Many Relation with extra fields Form Handling

This is going to be a solution for Many-To-Many relation that needs to be Many-To-One / One-To-Many instead, since some extra fields need to be saved in the intermediate table. I assume you have basic knowledge of Symfony2, Doctrine. 

For this example I have three tables Product, Order, ProductOrderOrder has many Products, Product has many Orders and ProductOrder table will save the association in an intermediate table.



The source code will create a form like below which shows new Order with list of existing/created Products and user can choose product(s) and create a new Order. In controller class I have just added the create and update action. I hope this helps. 

The point here is when you submit a form, setProduct set all selected products and when you are in EDIT page the getProduct return all the associated products. In updateAction  remove the previous associated products and add new ones. I could not find a way not to remove and add again.  

GitHub: https://github.com/pmoubed/symfony2_tutorial



Promote Your Blog Entity\Order.php

<?php

namespace PMI\TestBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Validator\Constraints as Assert;
use PMI\TestBundle\Entity\ProductOrder;


/**
 * @ORM\Entity
 * @ORM\Table(name="order_")
 */
class Order
{

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     * 
     * @var integer $id
     */
    protected $id;

    /**
     * @ORM\Column(type="string", length="255", name="first_name")
     * @Assert\NotBlank()
     * @var string $name
     * 
     */
    protected $name;

    /**
     * @ORM\OneToMany(targetEntity="ProductOrder", mappedBy="order", cascade={"all"})
     * */
    protected $po;

    protected $products;

    public function __construct()
    {
        $this->po = new ArrayCollection();
        $this->products = new ArrayCollection();
    }

    // Getters and Setters

    public function __toString()
    {
        return $this->name;
    }

    // Important 
    public function getProduct()
    {
        $products = new ArrayCollection();
        
        foreach($this->po as $p)
        {
            $products[] = $p->getProduct();
        }

        return $products;
    }
    // Important
    public function setProduct($products)
    {
        foreach($products as $p)
        {
            $po = new ProductOrder();

            $po->setOrder($this);
            $po->setProduct($p);

            $this->addPo($po);
        }

    }

    public function getOrder()
    {
        return $this;
    }

    public function addPo($ProductOrder)
    {
        $this->po[] = $ProductOrder;
    }
    
    public function removePo($ProductOrder)
    {
        return $this->po->removeElement($ProductOrder);
    }


}

Entity\Product.php

<?php

namespace PMI\TestBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\Mapping as ORM;

use Symfony\Component\Validator\Constraints as Assert;

/**
 * @ORM\Entity
 * @ORM\Table(name="product")
 */
class Product
{

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     * 
     * @var integer $id
     */
    protected $id;

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

  
    /**
     * @ORM\OneToMany(targetEntity="ProductOrder" , mappedBy="product" , cascade={"all"})
     * */
    protected $po;
    

    public function __construct()
    {

    }
    
    // Getters and Setters 
          
    public function __toString()
    {
        return $this->name;
    }



}

Entity\ProductOrder.php

<?php

namespace PMI\TestBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use PMI\LayerBundle\Entity\Composite;


/**
 * @ORM\Entity
 * @ORM\Table(name="p_o")
 * @ORM\HasLifecycleCallbacks()
 */
class ProductOrder
{

    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue(strategy="AUTO")
     *
     * @var integer $id
     */
    protected $id;


    /**
     * @ORM\ManyToOne(targetEntity="Product", inversedBy="po")
     * @ORM\JoinColumn(name="p_id", referencedColumnName="id")
     * */
    protected $product;

    /**
     * @ORM\ManyToOne(targetEntity="Order", inversedBy="po")
     * @ORM\JoinColumn(name="o_id", referencedColumnName="id")
     * */
    protected $order;


    // Getter, Setters, _Construct, __toString 


}

Form\OrderType.php

<?php

namespace PMI\TestBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;


class OrderType extends AbstractType
{

    public function buildForm(FormBuilder $builder , array $options)
    {

        $builder
                ->add('name')
                ->add('Product' , 'entity' , array(
                      'class'    => 'PMITestBundle:Product' ,
                      'property' => 'name' ,
                      'expanded' => true ,
                      'multiple' => true , ))
        ;
    }

    public function getName()
    {
        return 'pmi_testbundle_ordertype';
    }

    public function getDefaultOptions(array $options)
    {
        return array(
            'data_class' => 'PMI\TestBundle\Entity\Order' ,
            'em'         => '' ,
        );
    }


}


Controller\OrderController.php

<?php

namespace PMI\TestBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use PMI\TestBundle\Entity\Order;
use PMI\TestBundle\Form\OrderType;
use PMI\TestBundle\Entity\ProductOrder;
use PMI\TestBundle\Form\ProductOrderType;


/**
 * Order controller.
 *
 * @Route("/order")
 */
class OrderController extends Controller
{


    /**
     * Displays a form to create a new Order entity.
     *
     * @Route("/new", name="test_order_new")
     * @Template()
     */
    public function newAction()
    {
        $entity = new Order();
        $form   = $this->createForm(new OrderType() , $entity);

        return array(
            'entity' => $entity ,
            'form'   => $form->createView()
        );
    }

    /**
     * Creates a new Order entity.
     *
     * @Route("/create", name="test_order_create")
     * @Method("post")
     * @Template("PMITestBundle:Order:new.html.twig")
     */
    public function createAction()
    {
        $order   = new Order();
        $request = $this->getRequest();
        $form    = $this->createForm(new OrderType() , $order);
        $form->bindRequest($request);

        $em = $this->getDoctrine()->getEntityManager();

        if($form->isValid())
        {

            $em->persist($order);
            $em->flush();

return $this->redirect($this->generateUrl('test_order_show' , array( 'id' => $order->getId() )));
        }

        return array(
            'entity' => $order ,
            'form'   => $form->createView()
        );
    }

    /**
     * Displays a form to edit an existing Order entity.
     *
     * @Route("/{id}/edit", name="test_order_edit")
     * @Template()
     */
    public function editAction($id)
    {
        $em = $this->getDoctrine()->getEntityManager();

        $entity = $em->getRepository('PMITestBundle:Order')->find($id);

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


        $editForm = $this->createForm(new OrderType() , $entity , array( 'em' => $em ));

        $deleteForm = $this->createDeleteForm($id);

        return array(
            'entity'      => $entity ,
            'edit_form'   => $editForm->createView() ,
            'delete_form' => $deleteForm->createView() ,
        );
    }

    /**
     * Edits an existing Order entity.
     *
     * @Route("/{id}/update", name="test_order_update")
     * @Method("post")
     * @Template("PMITestBundle:Order:edit.html.twig")
     */
    public function updateAction($id)
    {
        $em = $this->getDoctrine()->getEntityManager();

        /* @var $entity Order */
        $entity = $em->getRepository('PMITestBundle:Order')->find($id);

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

        $editForm   = $this->createForm(new OrderType() , $entity);
        $deleteForm = $this->createDeleteForm($id);

        $previousCollections = $entity->getPo();
        $previousCollections = $previousCollections->toArray();

        $request = $this->getRequest();

        $editForm->bindRequest($request);

        foreach($previousCollections as $po)
        {
            $entity->removePo($po);
        }

        if($editForm->isValid())
        {
            $em->persist($entity);
            $em->flush();

            return $this->redirect($this->generateUrl('test_order_edit' , array( 'id' => $id )));
        }

        return array(
            'entity'      => $entity ,
            'edit_form'   => $editForm->createView() ,
            'delete_form' => $deleteForm->createView() ,
        );
    }


}



28 comments:

  1. thanks for the post.
    I am willing to build something similar but I am struggling to get it up and running.
    I will be happy to get the whole code on github.

    ReplyDelete
    Replies
    1. GitHub address added. You need to add your parameters.ini file and install the vendors.

      Delete
  2. Awesome post! This is a very high quality code. Congrats and many thanks! It is very important to see the way other programmers do things too! You have a very nice way of coding. I am thinking there might be a way to delete the association at each edit using a lifecycle callbacks. Would that be possible with a prepersist?
    One little thing, (orphanRemoval=true) is used on your repository but not in this example. It's important to use it in the entities as otherwise the relation can't be removed.
    Thanks so much for this post.

    ReplyDelete
  3. I don't get why and when the Order::setProduct($products) is getting called. Please clarify

    ReplyDelete
    Replies
    1. When you submit the Order form which Product is part of it. See OrderType.

      You may test this with var_dump($products); exit; in your setProduct function and see the result after you submit the ORDER from.

      Delete
    2. Thanks Pedram,

      found some additional docs on how Symfony forms - doctrine tandem goes about saving associated objects:
      http://symfony.com/doc/2.0/cookbook/form/form_collections.html
      http://symfony.com/doc/2.0/reference/forms/types/collection.html#reference-form-types-by-reference
      http://doctrine-orm.readthedocs.org/en/latest/reference/working-with-associations.html?highlight=cascade
      http://docs.doctrine-project.org/en/latest/reference/unitofwork-associations.html

      It seems that Order setters are getting called when the form object is getting new values from Request variable:
      $editForm->bindRequest($request);
      this call automatically updates the underlying Order entity object:
      http://symfony.com/doc/2.0/reference/forms/types/collection.html#reference-form-types-by-reference
      If by_reference is true (default), the following takes place behind the scenes when you call bindRequest on the form:
      $article->setTitle('...');
      $article->getAuthor()->setName('...');
      $article->getAuthor()->setEmail('...');

      Delete
  4. Exactly what I was looking for, thanks!

    ReplyDelete
  5. Hi. Good article and thanks. I have one question. Suppose if you are using bidirectional many to many association (where there is no need to create third entity, in this case product order). Then may I know how to write controller to create entities.

    ReplyDelete
    Replies
    1. There is a github link in my post, you can find User and Group classes which I think what you are asking for. Let me know if you find it.

      Delete
    2. Thanks Pedram

      You mean following link.
      https://github.com/pmoubed/symfony2_tutorial

      If so, then that is not what I am looking for. Have a look into following link.

      http://docs.doctrine-project.org/projects/doctrine-orm/en/latest/reference/association-mapping.html#many-to-many-bidirectional

      If its bi directional many to many association, then there is no need to create productorder entity. As it is done by doctrine. I tried that doctrine is generating needed database tables. But I am not able write a controller to implement CRUD operations.

      Anyhow thanks for your help.

      Delete
  6. Hi, i'm trying to use your solution but i have a problem: when i update the entity, the old products are not deleted, so if i select in first instance products A and B, and in second instance i deselect A and select C, in my db i will have A, B, C instead of B, C. This is my relevant code in the updateAction:

    $editForm = $this->createForm($sectionType, $entity);
    $previousCollections = $entity->getPO();
    $previousCollections = $previousCollections->toArray();

    $editForm->bind($request);

    foreach($previousCollections as $po)
    {
    $entity->removeComCandidatoSettoriIndustriali($po);
    }

    if ($editForm->isValid()) {
    //more instructions

    $em->persist($entity);
    $em->flush();

    return $this->redirect($this->generateUrl($redirectURL));
    }

    Am I missing something? Would you please help me?

    ReplyDelete
    Replies
    1. obviously, "removeComCandidatoSettoriIndustriali" is the equivalent of "removePO", sorry i forgot to change it

      Delete
  7. I have the same problem,

    foreach($previousCollections as $po)
    {
    $entity->removePo($po);
    }

    This doesn't remove association. I don't find any solution.

    ReplyDelete
  8. This comment has been removed by the author.

    ReplyDelete
  9. I found a solution:

    modify :

    foreach($previousCollections as $po)
    {
    $em->remove($po);
    }

    and delete : "$em->persist($entity);"


    source : http://forum.symfony-project.org/viewtopic.php?f=23&t=35914

    ReplyDelete
  10. good morning
    thanks for this great tutorial but I have a question please:
    why cann't we handle directly many to many relation?
    I get the problem when creating the relation and I don't have the database update schema.

    ReplyDelete
  11. I'm having a problem with this solution where the order (the id) and the product (the id) make up the composite key in ProductOrder. So when I display the form to update the products for an order, I get a constraint violation duplicate entry for key PRIMARY. Do you have any idea how to fix this?

    ReplyDelete
  12. In order for this example to really work you should do something like this:

    OrderController.php

    foreach($previousCollections as $po)
    {
    $entity->removePo($po);
    $em->remove($po); // because the entity is not deleted from the database, because the cascade remove operation it is not triggered on Order entity
    }

    ReplyDelete
  13. how I could display an extra field from ProductOrder in form?

    ReplyDelete
  14. Can you please share with us how to handle a ProductOrder entity which has as an extra field qty ? How we can present the qty to be filled in the same form where the checkbox for products choosing is ?

    With kind regards

    ReplyDelete
    Replies
    1. You may need another GETTER like getProductOrderCount() and use that to show the current count in your view or add that in your form!

      Delete
    2. BTW, I have never done it. so I might be wrong!

      Delete
  15. Here translating articles into Russian: http://symfony.in.ua/symfony2-many-to-many-relation-with-extra-fields-form-handling.html

    ReplyDelete
  16. there is a small flaw, in your Order class, the getProduct method should use $this->products instead of $products. The validation won't work otherwise

    ReplyDelete
    Replies
    1. $products is a empty arrayCollection; I am using $this->po to generate $products.

      Delete
  17. Thank you very much. This tut saved my life!!! :-) Just kidding. Thank you very much and this was exactly what I was looking for!!!

    ReplyDelete