Thursday, November 22, 2012

Speeding up the rendering of entity dropdowns in forms in Symfony 2

Update: See http://12wiki.blogspot.nl/2012/12/bundle-for-faster-generation-of-entity.html for an improved implementation.

One of the great features of Symfony 2 is the simplicity of rendering a form, even when you're using external entities. The only problem is that rendering a dropdown with 3662 entities is quite slow. In my case this took almost 2 seconds!

In this specific case I am rendering a list of customers during the creation of an order. Here's the code I use:
namespace DotCommerce\MyBundle\Form;

use Symfony\Component\Form\AbstractType;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class OrderType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {

        $builder
            /* ... */
            ->add('customer','entity',array(
                'class' => 'MyBundle:Customer',
                'required' => false,
                'property' => 'fullName',
            ))
   /* ... */
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'DotCommerce\MyBundle\Entity\Order'
        ));
    }

    public function getName()
    {
        return 'dotcommerce_mybundle_ordertype';
    }
}
As you probably have figured out, by reading my previous blogs, I really dislike slow websites, so I started searching for a way to speed things up. A thing I soon found out was that doing a direct query on the database only takes a couple of miliseconds. So if only there was a way to use a query to fill a dropdown in Symfony...
After a couple of hours of trying different solutions, I finally found a way to achieve my goal:
namespace DotCommerce\MyBundle\Form;

use Symfony\Component\Form\AbstractType;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class OrderType extends AbstractType
{
    private $entityManager;

    public function __construct(EntityManager $entityManager)
    {
        $this->entityManager = $entityManager;
    }

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

        $dql = "SELECT c.id, c.fullName from MyBundle:Customer c WHERE c.fullName != '' ORDER BY c.fullName ASC";
        $results = $this->entityManager->createQuery($dql)->getArrayResult();
        $choices = array();
        foreach($results as $result) {
            $choices[$result['id']] = $result['fullName'];
        }

        $builder
            /* ... */
            ->add('customerId','choice',array(
                'choices' => $choices,
                'required' => false,
                'label' => 'Customer'
            ))
            /* ... */
        ;
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'DotCommerce\MyBundle\Entity\Order'
        ));
    }

    public function getName()
    {
        return 'mybundle_azulewebbundle_ordertype';
    }
}
Using this solution saves me 1,6 seconds!
There are however some extra lines of code to write:
  1. The constructor of the form now needs an EntityManager:
    class OrderController extends Controller
    {
     public function newAction()
        {
            $em = $this->getDoctrine()->getManager();
      /* ... */
      $form   = $this->createForm(new OrderType($em), $entity);
      /* ... */
     }
    }
  2. The entity needs an extra field to save the temporary value to:
    class Order {
     /* ... */
        protected $customerId;
    
     /* ... */
     
        public function setCustomerId($id) {
            $this->customerId = $id;
            return $this;
        }
    
        public function getCustomerId() {
            return $this->customerId;
        }
    }
  3. You need to populate the temporary field yourself before rendering the form, and you have to convert it back to a Customer object before saving the Order:
    class OrderController extends Controller
    {
     /* ... */
     public function editAction($id)
     {
      /* ... */
      $entity->setCustomerId($entity->getCustomer()->getId());
      /* ... */
     }
     /* ... */
     public function updateAction(Request $request, $id)
        {
      $em = $this->getDoctrine()->getManager();
    
            $entity = $em->getRepository('MyBundle:Order')->find($id);
      /* ... */
            $editForm   = $this->createForm(new OrderType($em), $entity);
            $editForm->bind($request);
    
            if ($editForm->isValid()) {
       /* ... */
       $customerId = $entity->getCustomerId();
       if($customerId != 0) {
        $customer = $em->getRepository('MyBundle:Customer')->find($customerId);
        $entity->setCustomer($customer);
       }
       $em->persist($order);
       /* ... */
      }
      /* ... */
     }
    }

10 comments:

  1. I think you should create a new field type and use a data converter. check both on symfony documentation, there are cookbook entries.

    ReplyDelete
  2. Hey Tito,
    I looked into this last weekend and I totally agree with you, thanks for the heads-up. I'll also check into https://github.com/symfony/symfony/issues/5098 and see if I can create a graceful solution that can be merged into master.
    - Tom

    ReplyDelete
  3. Hi Tom Maaswinkel,
    I working with two databases(admin and client). My default database is admin. I have got two tables(users and security) in client database. I need to get the values(from one field) from security table to the users(to the another column like a drop-down) table. I referred many sites and gone through many options but no go. Can u please help in fixing this issue. Thanks in advance.

    ReplyDelete
    Replies
    1. Hey Prabhu,
      You mention you have two databases, but both tables (users and security) are in the same database? If this is the case you should be able to use the standard 'entity' formfield to render a dropdown/checkboxes of your users. Check http://symfony.com/doc/current/book/forms.html, http://symfony.com/doc/current/reference/forms/types/entity.html and http://symfony.com/doc/current/book/doctrine.html#relationship-mapping-metadata for more info.

      If something doesn't work because of the multiple databases, I'd suggest to take a look at http://symfony.com/doc/2.0/cookbook/doctrine/multiple_entity_managers.html

      If this doesn't answer your question, let me know by adding a reply, or you can send me an e-mail via my blogspot profile (just click 'View my complete profile' on the right right and then click 'e-mail' on the left of the page).

      Cheers,
      Tom

      Delete
    2. Hi,
      First thank you very much for replying. I am using manytoone relation in ORM. While generating the form for the users table it is trying to get the related field from security table. The security table entity is connecting to the default database(admin). But we have both users and security table in client database(dynamic). Is there any way to point entity to client database(dynamic).

      Delete
  4. Hi,I get the following error


    Catchable Fatal Error: Argument 1 passed to Quindimotos\ProyectoBundle\Form\ClienteType::__construct() must be an instance of Doctrine\ORM\EntityManager, none given, called in C:\wamp\www\proyectofinal\src\Quindimotos\ProyectoBundle\Controller\ClienteController.php on line 71 and defined in C:\wamp\www\proyectofinal\src\Quindimotos\ProyectoBundle\Form\ClienteType.php line 18

    I Can you help me, I don´t know to do.

    ReplyDelete
    Replies
    1. Hey Frcho,

      this has to do with the following lines of code (from the controller):

      $em = $this->getDoctrine()->getManager();
      /* ... */
      $form = $this->createForm(new OrderType($em), $entity);

      Here you need to pass $em to your form class(in my case OrderType)

      Delete
    2. Hi TOM Maaswinkel.

      Me also facing same problem as mention previously by Frcho ...I tried with your suggestion but no improvement.

      Delete
  5. Hi,

    I know this is quite old post, but I just got an issue, using this optimization. After I swap from using class type field in the form builder I got a problem at the saving in db. My entity has an entity field (ManyToOne) which is expecting an object but got an integer. How should I deal with this? I didn't find yet a reliable explanation.
    Thanks in advance.

    ReplyDelete
  6. An interesting bundle forresolve your problem is:
    https://github.com/PUGX/PUGXAutoCompleterBundle

    But also is interesting how to writing-hydration-method:
    http://doctrine.readthedocs.org/en/latest/en/manual/data-hydrators.html#writing-hydration-method

    Odaepo

    ReplyDelete