Search code examples
symfonydoctrine-ormdoctrine-extensions

Combining @Gedmo\NestedTree and @ORM\UniqueEntity


I'm creating a folder structure implemented with the NestedTree behaviour. Furthermore, I don't want that two folders may have the same name if they are siblings. For this, I use the combination of @UniqueEntity and @UniqueConstraint annotations, but it does not work.

First my entity (stripped to the minimum since it is 100% identical to the NestedTree defaults) :

/**
 * @ORM\Entity
 * @Gedmo\Tree(type="nested")
 * @ORM\Entity(repositoryClass="Gedmo\Tree\Entity\Repository\NestedTreeRepository")
 * @UniqueEntity(fields={"parent", "name"})
 * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="uniq_url", columns={"parent_id", "name"})})
 */
class Folder
{
    /**
     * @ORM\Column(type="string", nullable=false)
     */
    protected $name;

    /**
     * @Gedmo\TreeParent
     * @ORM\ManyToOne(targetEntity="Folder", inversedBy="children")
     * @ORM\JoinColumn(name="parent_id", referencedColumnName="id", onDelete="SET NULL")
     */
    protected $parent;
}

First try (ignoreNull = true)

When I create two folders with the same name, I have an integrity constraint violation, meaning that the @UniqueConstraints in the database worked but that the @UniqueEntity didn't :

Integrity constraint violation: 1062 Duplicate entry 'name_of_folder' for key 'uniq_url' 

Second try (ignoreNull = false)

I also tried with the ignoreNull key set to false (the default is true) :

@UniqueEntity(fields={"parent", "name"}, ignoreNull=false)

but then I get this error :

Warning: ReflectionProperty::getValue() expects parameter 1 to be object, null given in vendor/doctrine/orm/lib/Doctrine/ORM/Mapping/ClassMetadataInfo.php line 670

I've nailed the error down to these lines in Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator :

        $criteria[$fieldName] = $class->reflFields[$fieldName]->getValue($entity);

        if ($constraint->ignoreNull && null === $criteria[$fieldName]) {
            return;
        }

        if ($class->hasAssociation($fieldName)) {
            /* Ensure the Proxy is initialized before using reflection to
             * read its identifiers. This is necessary because the wrapped
             * getter methods in the Proxy are being bypassed.
             */
            $em->initializeObject($criteria[$fieldName]);

            $relatedClass = $em->getClassMetadata($class->getAssociationTargetClass($fieldName));
            //problem
            $relatedId = $relatedClass->getIdentifierValues($criteria[$fieldName]);

            if (count($relatedId) > 1) {
                throw new ConstraintDefinitionException(
                    "Associated entities are not allowed to have more than one identifier field to be " .
                    "part of a unique constraint in: " . $class->getName() . "#" . $fieldName
                );
            }
            $criteria[$fieldName] = array_pop($relatedId);
        }

The problem appears on the line marked with //problem. It appears that $criteria[$fieldName] === null is the reason of the error.

So here I am, not knowing what to do... Does anybody have an idea on what's going on ?

Thank you.


Solution

  • There is no easy way to get out of this situation. I finally went my own way and created a validator :

    Entity

    /**
     * @ORM\Entity(repositoryClass="Ibiz\DoctrineExtensionsBundle\Entity\Repository\NestedTreeRepository")
     * @Gedmo\Tree(type="nested")
     * @ORM\Table(uniqueConstraints={@ORM\UniqueConstraint(name="uniq_url", columns={"parent_id", "name"})})
     * @IbizAssert\UniquePath("getName")
     */
    class Folder
    {
        /**
         * @ORM\Column(type="string", nullable=false)
         */
        protected $name;
    
        public function getName()
        {
            return $this->name;
        }
    
    }
    

    Validator/Constraints/UniquePath.php

    namespace Ibiz\DoctrineExtensionsBundle\Validator\Constraints;
    
    use Symfony\Component\Validator\Constraint;
    
    /**
     * @Annotation
     */
    class UniquePath extends Constraint
    {
        public $em = null;
        public $errorMethod = null;
        public $message = 'The name "%name%" already exists.';
        public $service = 'ibiz.validator.unique_path';
    
        public function validatedBy()
        {
            return $this->service;
        }
    
        public function getRequiredOptions()
        {
            return array('errorMethod');
        }
    
        public function getDefaultOption()
        {
            return 'errorMethod';
        }
    
        public function getTargets()
        {
            return self::CLASS_CONSTRAINT;
        }
    }
    

    Validator/Constraints/UniquePathValidator.php

    namespace Ibiz\DoctrineExtensionsBundle\Validator\Constraints;
    
    use Doctrine\Common\Persistence\ManagerRegistry;
    use Symfony\Component\Validator\Constraint;
    use Symfony\Component\Validator\ConstraintValidator;
    use Symfony\Component\Validator\Exception\ConstraintDefinitionException;
    use Symfony\Component\Validator\Exception\UnexpectedTypeException;
    
    class UniquePathValidator extends ConstraintValidator
    {
        private $registry;
    
        public function __construct(ManagerRegistry $registry)
        {
            $this->registry = $registry;
        }
    
        public function validate($entity, Constraint $constraint)
        {
            if ($constraint->errorMethod === null)
            {
                throw new ConstraintDefinitionException('ErrorMethod should be set');
            } else if (!is_string($constraint->errorMethod)) {
                throw new UnexpectedTypeException($constraint->errorMethod, 'string');
            }
    
            if ($constraint->em) {
                $em = $this->registry->getManager($constraint->em);
            } else {
                $em = $this->registry->getManagerForClass(get_class($entity));
            }
    
            $className = $this->context->getClassName();
            $repo = $em->getRepository($className);
    
            $count = $repo->getSameNameSiblingsCount($entity);
    
            if ($count != 0) {
                $this->context->addViolation($constraint->message, array('%name%' => $entity->{$constraint->errorMethod}()));
            }
        }
    }
    

    Entity/Repository/NestedTreeRepository.php

    namespace Ibiz\DoctrineExtensionsBundle\Entity\Repository;
    
    use Gedmo\Tree\Entity\Repository\NestedTreeRepository as BaseRepository;
    
    class NestedTreeRepository extends BaseRepository
    {
        public function getSameNameSiblingsCountQueryBuilder($node)
        {
            $meta = $this->getClassMetadata();
            if (!$node instanceof $meta->name) {
                throw new InvalidArgumentException("Node is not related to this repository");
            }
    
            $config = $this->listener->getConfiguration($this->_em, $meta->name);
            $qb = $this->_em->createQueryBuilder();
    
            $qb->select($qb->expr()->count('n.id'))
                ->from($config['useObjectClass'], 'n');
            if ($node->getParent() === null) {
                $qb->where($qb->expr()->andx(
                       $qb->expr()->eq('n.name', ':name'),
                       $qb->expr()->isNull('n.parent')
                   ))
                   ->setParameters(array(
                       'name' => $node->getName(),
                   ));
            } else {
                $qb->leftJoin('n.parent', 'p')
                   ->where($qb->expr()->andx(
                       $qb->expr()->eq('n.name', ':name'),
                       $qb->expr()->eq('p.name', ':parent')
                   ))
                   ->setParameters(array(
                       'name' => $node->getName(),
                       'parent' => $node->getParent()->getName(),
                   ));
            }
    
            return $qb;
        }
    
        public function getSameNameSiblingsCountQuery($node)
        {
            return $this->getSameNameSiblingsCountQueryBuilder($node)->getQuery();
        }
    
        public function getSameNameSiblingsCount($node)
        {
            return $this->getSameNameSiblingsCountQuery($node)->getSingleScalarResult();
        }
    }