April 8, 2014
by Jason Roman





Categories:
Protips

Tags:
Doctrine  PHP  Symfony


Project:
jayroman.com


Be Careful with Doctrine Magic and Symfony

Eager loading on nullable joined columns may cause unexpected behavior while editing entities with Symfony2 forms.

I was playing around with aspects of Doctrine annotations that I never use; namely the fetch mode of my joined columns. Rather than create custom queries, I was trying out fetch="EAGER". Traditionally when you search for an entity, by default Doctrine does not join any tables that are connected by foreign keys (lazy loading). You can force these joins to occur with eager loading. For example:

class BlogPost
{
private $id;

/**
* @var \Bundle\Entity\Category
* @ORM\ManyToOne(targetEntity="Bundle\Entity\Category", fetch="EAGER")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="category_id", referencedColumnName="id")
* })
*/
private $category;

Now any time I select a blog post it will automatically join the category entity. This may be useful if I know I also want the category 100% of the time. However, let's find a case that breaks this. Say I have Create/Edit forms for my blog posts. What if the category is optional and nullable? Simple enough, right? I just configure my form field like this:

$builder
->add('category', null, array(
'required' => false,
'empty_data' => null,
))

This says that the category is not required and to set it to null if I leave the drop-down blank. So you add a blog post, leave the category blank, and everything is fine. Then you add another with a category. Again no problem. But then you realize you made a mistake and want to remove the category altogether from the 2nd post. You go back to your edit form's Category drop-down and select the blank option and click Update.

Then something weird happens. The category doesn't disappear! You figure you made a mistake and try again. The category does NOT unset. You try setting it to a different category and it saves just fine. So what's the problem? Let's look at how Symfony2 deals with edits/updates:

public function updateAction(Request $request, $id)
{
$em = $this->getDoctrine()->getManager();
$em->getRepository('SiteBundle:BlogPost')->find($id);

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

$editForm = $this->createEditForm($entity);
$editForm->handleRequest($request);

See the problem? You pass the BlogPost entity to the form but the Category entity is already eager-loaded. When handleRequest() is called, 'empty_data' => null never gets triggered because the category is already loaded, and the category is never unset!

Oddly enough, if you try hard-coding 'empty_data' to a value like '1' in this same situation, it will actually change the category to that ID even with the eager loading.

The lesson here is to simply never eager load anything directly in the definition of your entity. If you really want to force column joins, create custom query functions in a repository and call those functions whenever you need them. Not only will it save you from unnecessary headaches, but it's simply good practice to never make global assumptions about how you'll select your data.