5

I have a fairly simple entity with UniqueEntity validation:

namespace App\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\Mapping\ManyToOne;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Doctrine\ORM\Mapping\HasLifecycleCallbacks;

/**
 * @ORM\Entity(repositoryClass="App\Repository\UserRepository")
 * @UniqueEntity("email", message="Email already in use")
 * 
 *
 */
class User implements UserInterface
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=180, unique=true)
     */
    private $email;

Form (deliberately removed all other form fields)

namespace App\Form;

use App\Entity\Company;
use App\Entity\User;
use Symfony\Bridge\Doctrine\Form\Type\EntityType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\ChoiceList\Loader\CallbackChoiceLoader;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;


class UserType extends AbstractType
{
    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add( 'email', EmailType::class,[
                'label' => 'Email'
            ] );


        $builder->add( 'save', SubmitType::class, [
            'attr' => [
                'class' => 'btn btn-primary',
                'id' => 'btn-user-form'
            ],
            'label' => 'Save'
        ] );

    }

    /**
     * {@inheritdoc}
     */
    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults( [
            'data_class' => 'App\Entity\User'
        ]);

    }

    /**
     * {@inheritdoc}
     */
    public function getBlockPrefix()
    {
        return 'user';
    }
}

and the Controller:

public function edit(User $user, RequestStack $request)
{
    $em = $this->getDoctrine()->getManager();
    $form = $this->createForm( UserType::class, $user);

    $form->handleRequest($request->getCurrentRequest());

    if ($form->isSubmitted() && $form->isValid()){

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

        $this->addFlash('success', 'User updated.');
        return $this->redirectToRoute('user_index');
    }

    return $this->render('user/update.html.twig', [
        'form' => $form->createView(),
        'deleteForm' => $this->createDeleteForm($user)->createView()
    ]);

}

When I save an existing record, I get an error message "Email already in use" (as specified by me)

The following queries are shown in the Symfony Profiler:

SELECT t0.id AS id_1, t0.email AS email_2, t0.firstname AS firstname_3, t0.lastname AS lastname_4, t0.roles AS roles_5, t0.last_ip_address AS last_ip_address_6, t0.last_active AS last_active_7, t0.password AS password_8, t0.reset_token AS reset_token_9, t0.company_id AS company_id_10 FROM user t0 WHERE t0.id = ?
Parameters:
[▼
  1
]
View formatted query    View runnable query    Explain query
2   0.44 ms 
SELECT t0.id AS id_1, t0.email AS email_2, t0.firstname AS firstname_3, t0.lastname AS lastname_4, t0.roles AS roles_5, t0.last_ip_address AS last_ip_address_6, t0.last_active AS last_active_7, t0.password AS password_8, t0.reset_token AS reset_token_9, t0.company_id AS company_id_10 FROM user t0 WHERE t0.id = ?
Parameters:
[▼
  "100"
]
View formatted query    View runnable query    Explain query
3   0.43 ms 
SELECT t0.id AS id_1, t0.name AS name_2 FROM company t0 WHERE t0.id = ?
Parameters:
[▼
  17
]
View formatted query    View runnable query    Explain query
4   0.56 ms 
SELECT c0_.id AS id_0, c0_.name AS name_1 FROM company c0_ WHERE c0_.id IN (?) ORDER BY c0_.name ASC
Parameters:
[▼
  [▼
    "17"
  ]
]
View formatted query    View runnable query    Explain query
5   0.76 ms 
SELECT u0_.id AS id_0, u0_.email AS email_1, u0_.firstname AS firstname_2, u0_.lastname AS lastname_3, u0_.roles AS roles_4, u0_.last_ip_address AS last_ip_address_5, u0_.last_active AS last_active_6, u0_.password AS password_7, u0_.reset_token AS reset_token_8, u0_.company_id AS company_id_9 FROM user u0_ LEFT JOIN company c1_ ON u0_.company_id = c1_.id
Parameters:
[]
View formatted query    View runnable query    Explain query
6   0.31 ms 
SELECT c0_.id AS id_0, c0_.name AS name_1 FROM company c0_ ORDER BY c0_.name ASC
Parameters:
[]
View formatted query    View runnable query    Explain query
7   0.14 ms 
"START TRANSACTION"
Parameters:
[]
View formatted query    View runnable query    Explain query
8   0.49 ms 
UPDATE user SET last_active = ? WHERE id = ?
Parameters:
[▼
  "2020-01-18 07:56:51"
  1
]
View formatted query    View runnable query    Explain query
9   0.77 ms 
"COMMIT"
Parameters:
[]
View formatted query    View runnable query    Explain query
10  0.15 ms 
"START TRANSACTION"
Parameters:
[]
View formatted query    View runnable query    Explain query
11  0.23 ms 
INSERT INTO usage_log (logged, url, user_id, file_type_id) VALUES (?, ?, ?, ?)
Parameters:
[▼
  1 => "2020-01-18 07:56:51"
  2 => "/user/100/edit"
  3 => 1
  4 => null
]
View formatted query    View runnable query    Explain query
12  0.38 ms 
"COMMIT"
Parameters:
[]

There is a listener that caused the

update last_active... and
insert into usage_log

queries. To be sure, this problem is not limited to the User entity, I also have the same problem on another Entity Company when I create a record.

Entity:

<?php

namespace App\Entity;

use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\ManyToMany;
use Doctrine\ORM\Mapping\JoinColumn;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;


/**
 * @ORM\Entity(repositoryClass="App\Repository\CompanyRepository")
 * @UniqueEntity("name", message="Company already saved")
 */
class Company
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=100, unique=true)
     */
    private $name;

    /**
     * @OneToMany(targetEntity="User", mappedBy="Company")
     */
    private $Users;


    /**
     * @ManyToMany(targetEntity="ModelFile", inversedBy="Companies")
     * @JoinColumn(nullable=true)
     */
    private $ModelFile;

Controller:

public function create(RequestStack $request)
{
    $company = new Company();

    $em = $this->getDoctrine()->getManager();
    $form = $this->createForm(CompanyType::class, $company);

    $form->handleRequest($request->getCurrentRequest());

    if ($form->isSubmitted() && $form->isValid()){

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

        $this->addFlash('success', 'Company created.');
        return $this->redirectToRoute('company_index');
    }

    return $this->render('company/create.html.twig', [
        'form' => $form->createView()
    ]);
}

The query log when I create a Company is

    1   0.84 ms 
SELECT t0.id AS id_1, t0.email AS email_2, t0.firstname AS firstname_3, t0.lastname AS lastname_4, t0.roles AS roles_5, t0.last_ip_address AS last_ip_address_6, t0.last_active AS last_active_7, t0.password AS password_8, t0.reset_token AS reset_token_9, t0.company_id AS company_id_10 FROM user t0 WHERE t0.id = ?
Parameters:
[▼
  1
]
View formatted query    View runnable query    Explain query
2   1.22 ms 
SELECT c0_.id AS id_0, c0_.name AS name_1 FROM company c0_ ORDER BY c0_.name ASC
Parameters:
[]
View formatted query    View runnable query    Explain query
3   0.47 ms 
SELECT m0_.id AS id_0, m0_.name AS name_1, m0_.display_name AS display_name_2, m0_.file AS file_3 FROM model_file m0_
Parameters:
[]
View formatted query    View runnable query    Explain query
4   0.13 ms 
"START TRANSACTION"
Parameters:
[]
View formatted query    View runnable query    Explain query
5   0.51 ms 
UPDATE user SET last_active = ? WHERE id = ?
Parameters:
[▼
  "2020-01-18 07:44:29"
  1
]
View formatted query    View runnable query    Explain query
6   0.55 ms 
"COMMIT"
Parameters:
[]
View formatted query    View runnable query    Explain query
7   0.14 ms 
"START TRANSACTION"
Parameters:
[]
View formatted query    View runnable query    Explain query
8   0.34 ms 
INSERT INTO usage_log (logged, url, user_id, file_type_id) VALUES (?, ?, ?, ?)
Parameters:
[▼
  1 => "2020-01-18 07:44:29"
  2 => "/company/create"
  3 => 1
  4 => null
]
View formatted query    View runnable query    Explain query
9   0.30 ms 
"COMMIT"
Parameters:
[]

I'm also provided the Listener code here:

<?php
namespace App\Listener;

use App\Entity\UsageLog;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use \Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpKernel\Event\FilterResponseEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;

class LastActivityListener implements EventSubscriberInterface
{
    private $tokenStorage;
    private $em;
    private $router;

    public function __construct(TokenStorageInterface $tokenStorage, EntityManagerInterface $em, RouterInterface $router)
    {
        $this->tokenStorage = $tokenStorage;
        $this->em = $em;
        $this->router = $router;
    }

    public function onResponse(FilterResponseEvent $event)
    {
        $token = $this->tokenStorage->getToken();

        if ($token && $token->isAuthenticated() && is_a($token->getUser(), User::class) ) {
            $token->getUser()->setLastActive(new \DateTime());
            $this->em->persist($token->getUser());
            $this->em->flush($token->getUser());

            if ($event->getRequest()->get('_route')) {
                $usageLog = new UsageLog();
                $usageLog
                    ->setUrl($this->router->generate($event->getRequest()->get('_route'), $event->getRequest()->attributes->get('_route_params')))
                    ->setUser($token->getUser())
                    ->setLogged(new \DateTime());
                $this->em->persist($usageLog);
                $this->em->flush($usageLog);
            }

        }
    }

    public static function getSubscribedEvents()
    {
        return [
            KernelEvents::RESPONSE => 'onResponse',
        ];
    }
}

I have commented out everything in onResponse and the problem still persists.

What I find strange is the description of the validation failure when I save a company. It lists all Companies as the cause for the failure:

Company Create Validation Failure

It surely does already exist, but on the same record. Am I supposed to use different entities for insert and update or how is UniqueEntity designed to be used?

Symfony 4.2

jdog
  • 2,465
  • 6
  • 40
  • 74
  • It seems your code is correct.but make sure given user is object user entity otherwise it consider new object of user entity. – Mitesh Vasava Apr 17 '19 at 09:24
  • agree with @MiteshVasava check your that user object is not a new user. Plus if you're in an edit method you don't need to persist your object, persist is only useful for new objects not known by doctrine – Snroki Apr 17 '19 at 09:26
  • Could please confirm are there two forms are rendered in update.html.twig? – Mitesh Vasava Apr 17 '19 at 09:37
  • You don't need to persist the user again if they already exist – MylesK Apr 17 '19 at 11:54
  • ok, have removed persists, but the error remains. There is only one form in update.html.twig The user object is loaded by Symfony's autowiring, so not sure how it could be different. – jdog Apr 17 '19 at 20:13
  • Can't see anything wrong with your code. Can you check if the user entity in your form is the same as used by `\Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntityValidator::validate`? Debug this code to see what's happening. What about implementing a custom repositoryMethod? – Stephan Vierkant Jan 16 '20 at 15:17
  • Not sure if relevant, but are you still using Symfony 4.2? – Stephan Vierkant Jan 16 '20 at 15:25
  • @StephanVierkant yes still 4.2. I'm looking for a way to implement simple duplicate detection across many crud screens. Currently entering unique constraint causes Error 500 and implementing a custom solution seems unnecessary when Doctrine Unique validator looks like exactly what I'm looking for. – jdog Jan 16 '20 at 21:26
  • I know. It was not meant as a solution, but as a way to figure out which part of the code is failing. Have you look at the Doctrine queries being executed? And what if you try to load a user without autowiring? Can't see anything wrong with the code you posted. – Stephan Vierkant Jan 17 '20 at 13:01
  • I suggest you to really look at the queries log, like @StephanVierkant mentioned. I tested out quickly the same concept in both sf 4.2 and 5.0 and it worked like a charm. It's either that or it's something else hidden in the parts you left out from copying. At first glance I can see that there are some Lifecycle events, which could be interesting. – Artamiel Jan 17 '20 at 18:36
  • @StephanVierkant have added queries. Also, how do I best compare entities in the controller and the Validator? (I recall having this problem once with form datamappers) – jdog Jan 18 '20 at 08:36

3 Answers3

0

You should be very careful when loading entities into forms:

$form = $this->createForm( UserType::class, $user);

$form->handleRequest($request->getCurrentRequest());

if ($form->isSubmitted() && $form->isValid()) {

Even if your form is invalid, handleRequest has changed your entity and it will be updated at the first ->flush() in your code, unless you are using @ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT") at the top of your entity.


Apart from that, your code is perfectly valid, I'm afraid you will need to give us more context.

If you have a doubt, you can try running the following sample:

src/Entity/Test.php

<?php

namespace App\Entity;

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

/**
 * @ORM\Entity(repositoryClass="App\Repository\TestRepository")
 * @ORM\ChangeTrackingPolicy("DEFERRED_EXPLICIT")
 * @UniqueEntity("test")
 */
class Test
{
    /**
     * @ORM\Id()
     * @ORM\GeneratedValue()
     * @ORM\Column(type="integer")
     */
    private $id;

    /**
     * @ORM\Column(type="string", length=255, unique=true)
     */
    private $test;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getTest(): ?string
    {
        return $this->test;
    }

    public function setTest(string $test): self
    {
        $this->test = $test;

        return $this;
    }
}

src/Controller/TestController.php

<?php

namespace App\Controller;

use App\Entity\Test;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

class TestController extends Controller
{
    /**
     * @Route(path="/test/{id}", name="test")
     *
     * @param string $code
     * @param int    $action
     *
     * @return Response
     */
    public function testAction(Test $test, Request $request)
    {
        $form = $this->createFormBuilder($test, ['data_class' => Test::class])
                     ->add('test', TextType::class)
                     ->add('save', SubmitType::class)
                     ->getForm();

        $form->handleRequest($request);
        if ($form->isSubmitted() && $form->isValid()) {
            $em = $this->getDoctrine()->getManager();
            $em->persist($test);
            $em->flush();

            return $this->redirectToRoute('test', [
                'id' => $test->getId(),
            ]);
        }

        return $this->render('test.html.twig', [
            'form' => $form->createView(),
        ]);
    }
}

templates/test.html.twig

{{ form(form) }}

It may be a stupid remark, but did you check that the unique index is well inserted in your database schema? If not, you may have another user having the same email, explaining why validation fails.

Alain Tiemblo
  • 36,099
  • 17
  • 121
  • 153
  • I tested both having unique=true in entity and no validator, which causes Error 500 as well as Validator and not unique=true, which causes the same issues as described. Definitely in db as constraint – jdog Jan 18 '20 at 08:38
  • Can you try to disable temporarily your ActivityLogListener? – Alain Tiemblo Jan 18 '20 at 12:19
0

we've encountered the same problem and therefore made our custom validator as solution, as we were struggling with several default validation options for this as well.

We check in the validator if an ID already exists, if the ID not exists, then we do the unique email validation. If it does and the email address does not equals our current email address, then the validation passes.

Let me know if someone has a better solution, but that is how we managed to fix this problem.

This is the code for the validation (remove it from the entity above and use it on the property of User.php)

use App\Validator\Constraint\User\UserEmailConstraint;

    /**
     * @var string
     *
     * @Assert\NotNull(message="You need to fill in an email address.")
     *
     * @UserEmailConstraint()
     *
     * @ORM\Column(name="email", type="string", length=255, nullable=false)
     */
    private $email;

The validator

<?php

namespace App\Validator\Constraint\User;

use App\Entity\User;
use App\Repository\UserRepository;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;

/**
 * Class UserEmailValidator.
 */
class UserEmailValidator extends ConstraintValidator
{
    /**
     * @var UserRepository
     */
    private $userRepository;

    /**
     * UserEmailValidator constructor.
     *
     * @param UserRepository $userRepository
     */
    public function __construct(UserRepository $userRepository)
    {
        $this->userRepository = $userRepository;
    }

    /**
     * Check if email is valid, then check if email already exists.
     *
     * @param mixed      $email
     * @param Constraint $constraint
     */
    public function validate($email, Constraint $constraint)
    {
        // This can't be empty
        if (!is_string($email) || strlen(trim($email)) < 1) {
            $this->context->buildViolation($constraint->emailNotProvided)
                ->addViolation();
        // Do the default PHP email validation (correct emailaddress)
        } elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            $this->context->buildViolation($constraint->invalidEmailMessage)
                ->setParameter('{{ value }}', $email)
                ->addViolation();
        } else {
            /** @var User $object */
            $object = $this->context->getObject();

            /** @var User $user */
            $user = $this->userRepository->findOneBy([
                'email' => $email,
            ]);

            // Check whether the name is already in use, but it can be that the name is used by same object
            if (!$user || ($user->getId() === $object->getId())) {
                return;
            }

            /* @var $constraint UserEmailConstraint */
            $this->context->buildViolation($constraint->emailInUseMessage)
                ->setParameter('{{ value }}', $email)
                ->addViolation();
        }
    }
}

The constraint

<?php

namespace App\Validator\Constraint\User;

use Symfony\Component\Validator\Constraint;

/**
 * @Annotation
 * @Target({"PROPERTY", "ANNOTATION"})
 */
class UserEmailConstraint extends Constraint
{
    /**
     * @var string
     */
    public $emailInUseMessage = 'The email: {{ value }}, has already been used.';

    public $invalidEmailMessage = 'The email: {{ value }}, is invalid.';

    public $emailNotProvided = 'You need to fill in an email address.';

    /**
     * @return string
     */
    public function validatedBy()
    {
        return UserEmailValidator::class;
    }
}

0

I know this is late but I too was having this problem. The fix was to add the unique=true to the Column definition. The result uniqueness query then only happens on an insert.

user1730452
  • 155
  • 1
  • 8