June 8, 2015
by Jason Roman





Categories:
Protips

Tags:
Doctrine  PHP  Symfony


Symfony Quirks with Doctrine Inheritance and Unique Constraints

How to make sure your base entity's unique constraints are handled how you would expect.

Recently I came across some odd behavior when using Symfony's UniqueEntity constraint on entities which use Doctrine's inheritance model. If you place unique constraints on the base entity (the typical behavior) but then attempt to validate an extended entity, it will only check for uniqueness on the extended entity. This means that the constraint check will not always work, and instead of throwing a NonUniqueResultException it will attempt to run the database query and fail hard on that level.

For reference, see this still-open Symfony issue from 2012: https://github.com/symfony/symfony/issues/4087

For example, I recently used Doctrine inheritance to deal with different types of customer discounts. The discount could either be a hard monetary value like ($5 off), or a percentage (20% off). So I created a base Discount entity that contained all of the common information between discounts, and then extended that to create FlatDiscount and PercentDiscount entities. A user could create their own discounts and provide a name for them, so it made sense to put a Unique constraint on the (user, name) combination on the base entity:

use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
* @ORM\Table(name="discounts")
* @ORM\InheritanceType("JOINED")
* @ORM\DiscriminatorColumn(name="discount_type_code", type="string")
* @ORM\DiscriminatorMap({"flat"="FlatDiscount", "percent"="PercentDiscount"})
* @UniqueEntity({"user", "name"})
*/
abstract class Discount { private $user; private $name; }
Then, the inherited entities:
/**
* @ORM\Table(name="flat_discounts")
*/
class FlatDiscount extends Discount { private $amount; }

/**
* @ORM\Table(name="percent_discounts")
*/
class PercentDiscount extends Discount { private $precentage; }
This all seems pretty straightforward, but say you wanted to insert a flat discount. You would expect the SQL to check the base entity where you defined the constraint, and run a query that looks like this:
SELECT * FROM discount WHERE user_id = 1 AND name = 'My Discount';
However, you instead get a query that looks like this:
SELECT * FROM flat_discount
INNER JOIN discount ON flat_discount.id = discount.id
WHERE discount.user_id = 1 AND discount.name = 'My Discount';
Do you see where the problem is? The unique check is being performed on the inherited entity, not the base. So, if a user is trying to insert a flat discount that already has that same name as a percent discount, the standard Symfony behavior will not catch the unique violation and try to insert into the database. If you still don't see the issue, think about the fact that an INNER JOIN requires an entry to exist in both tables - we only want the check to occur on the base table.

There is a very simple workaround - all you need to do is override the repository call that performs the unique check in the base entity's repository, and then point all of your inherited entities to use the base repository:
/**
* @ORM\Entity(repositoryClass="Path\To\MyBundle\Repository\DiscountRepository")
* @UniqueEntity({"name", "company"}, repositoryMethod="findByUniqueCriteria")
*/
abstract class Discount { }

/**
* @ORM\Entity(repositoryClass="Path\To\MyBundle\Repository\DiscountRepository")
*/
class FlatDiscount extends Discount { }

/**
* @ORM\Entity(repositoryClass="Path\To\MyBundle\Repository\DiscountRepository")
*/
class PercentDiscount extends Discount { }
Now we are set up so that all discounts point to the same repository. If you notice, the UniqueEntity constraint allows you to override the repository method via the repositoryMethod setting. Now, we just add the appropriate Doctrine call in the DiscountRepository:
use Doctrine\ORM\EntityRepository;

class DiscountRepository extends EntityRepository
{
/**
* @param string[] $criteria format: array('user' => <user_id>, 'name' => <name>)
*/
public function findByUniqueCriteria(array $criteria)
{
// would use findOneBy() but Symfony expects a Countable object
return $this->_em->getRepository('MyBundle:Discount')->findBy($criteria);
}
}
You do not have to specify user or name individually - Symfony automatically passes the necessary array of criteria to your repository method which can be plugged right into the Doctrine magic findBy() method. Notice that we are explicitly polling the main Discount entity.

If you still need or want to keep separate repositories for your inherited entities, you will have to duplicate this function in each repository class.


comments powered by Disqus