53

Is there an easy way to check for duplicate keys with Doctrine 2 before doing a flush?

Rowan
  • 5,597
  • 2
  • 22
  • 32
tom
  • 8,189
  • 12
  • 51
  • 70
  • 1
    I don't really have an answer, but I wonder how checking before a flush is that different than doing the flush and handling the error (assuming a duplicate key exists). – Tim Lytle Nov 05 '10 at 02:57
  • On a flush there will be thrown database specific exceptions. – tom Nov 05 '10 at 08:12
  • 5
    Most of the solutions presented here don't take into account the fact that you just *can't* check for duplicates beforehand, because that's not an atomic operation and therefore, you can *still* have duplicate values, if other thread inserts into the table, for example. So the only possible solutions in my mind are either handling the failure manually, or use locking. The former is rather ugly with Doctrine (as the EM gets closed), the latter can have dire consequences performance wise, if you're not careful. I would like to see a good answer to this myself. – Tomáš Plešek Sep 02 '13 at 08:55

9 Answers9

59

You can catch the UniqueConstraintViolationException as such:

use Doctrine\DBAL\Exception\UniqueConstraintViolationException;

// ...

try {
   // ...
   $em->flush();
}
catch (UniqueConstraintViolationException $e) {
    // ....
}
Darryl Hein
  • 142,451
  • 95
  • 218
  • 261
DonCallisto
  • 29,419
  • 9
  • 72
  • 100
  • This has been added in 2014. This should be the way how to do this now. – tom Mar 04 '16 at 07:48
  • This has been available since Doctrine DBAL 2.5 — UniqueConstraintViolationException inherits from ConstraintViolationException see: https://github.com/doctrine/dbal/blob/master/lib/Doctrine/DBAL/Exception/ConstraintViolationException.php#L27 – Mark Fox Mar 16 '16 at 17:35
  • For the current version, catch this instead: \Doctrine\DBAL\Exception\UniqueConstraintViolationException – nicolallias Jun 10 '16 at 16:37
  • 1
    It happens to us when on flush during high traffic. The question I have is what to do in the catch block with the failed insert? – John Linhart Aug 14 '17 at 14:55
  • 6
    @JohnLinhart if you want to keep working with the em, you have to reset it inside of the catch block, for example via `$this->getDoctrine()->resetManager();` – Strayobject Jan 01 '18 at 14:02
21

I use this strategy to check for unique constraints after flush(), may not be what you want, but might help someone else.


When you call flush(), if a unique constrain fails, a PDOException is thrown with the code 23000.

try {
    // ...
    $em->flush();
}
catch( \PDOException $e )
{
    if( $e->getCode() === '23000' )
    {
        echo $e->getMessage();

        // Will output an SQLSTATE[23000] message, similar to:
        // Integrity constraint violation: 1062 Duplicate entry 'x'
        // ... for key 'UNIQ_BB4A8E30E7927C74'
    }

    else throw $e;
}

If you need to get the name of the failing column:

Create table indices with prefixed names, eg. 'unique_'

 * @Entity
 * @Table(name="table_name",
 *      uniqueConstraints={
 *          @UniqueConstraint(name="unique_name",columns={"name"}),
 *          @UniqueConstraint(name="unique_email",columns={"email"})
 *      })

DO NOT specify your columns as unique in the @Column definition

This seems to override the index name with a random one...

 **ie.** Do not have 'unique=true' in your @Column definition

After you regenerate your table (you may need to drop it & rebuild), you should be able to extract the column name from the exception message.

// ...
if( $e->getCode() === '23000' )
{
    if( \preg_match( "%key 'unique_(?P<key>.+)'%", $e->getMessage(), $match ) )
    {
        echo 'Unique constraint failed for key "' . $match[ 'key' ] . '"';
    }

    else throw $e;
}

else throw $e;

Not perfect, but it works...

Peter Johnson
  • 2,673
  • 22
  • 14
  • 4
    I guess that Doctrine changed the exception handling some time ago. I get a PDOException inside a \Doctrine\DBAL\DBALException for this situations. The above code would be something like catch( \Doctrine\DBAL\DBALException $e ){ if( $e->getPrevious()->getCode() === '23000' ) { /* do stuff */ } }. It is important to note that catching this exception is the only way to deal with some situations with high concurrency. A select query for validation it just not enough – paul.ago Sep 11 '13 at 14:03
4

If you're using Symfony2 you can use UniqueEntity(…) with form->isValid() to catch duplicates prior to flush().

I'm on the fence posting this answer here but it seems valuable since a lot of Doctrine user's will also be using Symfony2. To be clear: this uses Symfony's validations class that under the hood is using an entity repository to check (is configurable but defaults to findBy).

On your entity you can add the annotation:

use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;

/**
 * @UniqueEntity("email")
 */
class YourEntity {

Then in your controller, after handing the request to the form you can check your validations.

$form->handleRequest($request);

if ( ! $form->isValid())
{
    if ($email_errors = $form['email']->getErrors())
    {
        foreach($email_errors as $error) {
           // all validation errors related to email
        }
    }
…

I'd recommend combining this with Peter's answer, since your database schema should enforce uniqueness too:

/**
 * @UniqueEntity('email')
 * @Orm\Entity()
 * @Orm\Table(name="table_name",
 *      uniqueConstraints={
 *          @UniqueConstraint(name="unique_email",columns={"email"})
 * })
 */
Mark Fox
  • 8,694
  • 9
  • 53
  • 75
4

I have come across this problem some time ago, too. The main problem is not database specific exceptions but the fact, when an PDOException is thrown the EntityManager is closed. That means you can not be sure what will happen with the data you wanted to flush. But probably it would be not saved in database because I think this is done within a transaction.

So when I was thinking about this problem I came up with this solution, but I did not have time to actually write it yet.

  1. It could be done using event listeners, particularly the onFlush event. This event is invoked before the data is send to database (after the changesets are computed - so you already know which entities were changed).
  2. In this event listener you would have to browse all the changed entities for their keys (for primary it would be looking in the class metadata for @Id).
  3. Then you would have to use a find method with the criteria of your keys. If you would find a result, you have the chance to throw your own exception, which will not close the EntityManager and you are able to catch it in your model and make some corrections to the data before trying the flush again.

The problem with this solution would be that it could generate quite a lot of queries to the database, so it would require quite a lot of optimization. If you want to use such thing only in few places I recommend doing the check on the place where the duplicate might arise. So for example where you want to create an entity and save it:

$user = new User('login');
$presentUsers = $em->getRepository('MyProject\Domain\User')->findBy(array('login' => 'login'));
if (count($presentUsers)>0) {
    // this login is already taken (throw exception)
}
oradwell
  • 392
  • 1
  • 12
2

If you just want to catch duplicate errors. You should not just check the code number

$e->getCode() === '23000'

because this will catch other errors like field 'user' can not be empty. My solution is to check the error message, if it contains the Text 'Duplicate entry'

                try {
                    $em->flush();
                } catch (\Doctrine\DBAL\DBALException $e) {

                    if (is_int(strpos($e->getPrevious()->getMessage(), 'Duplicate entry'))) {
                        $error = 'The name of the site must be a unique name!';
                    } else {
                        //....
                    }
                }
2

In Symfony 2, it actually throws an \Exception, not a \PDOException

try {
    // ...
    $em->flush();
}
catch( \Exception $e )
{
   echo $e->getMessage();
   echo  $e->getCode(); //shows '0'
   ### handle ###

}

$e->getMessage() echos something like following:

An exception occurred while executing 'INSERT INTO (...) VALUES (?, ?, ?, ?)' with params [...]:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry '...' for key 'PRIMARY'

Aris
  • 4,643
  • 1
  • 41
  • 38
0

I would like to add to this specifically regarding PDOExceptions--

The 23000 error code is blanket code for a family of Integrity Constraint Violations that MySQL can return.

Therefore, handling the 23000 error code is not specific enough for some use cases.

For example you may want to react differently to a duplicate record violation than to a missing foreign key violation.

Here is an example of how to deal with this:

try {
     $pdo -> executeDoomedToFailQuery();
} catch(\PDOException $e) {
     // log the actual exception here
     $code = PDOCode::get($e);
     // Decide what to do next based on meaningful MySQL code
}

// ... The PDOCode::get function

public static function get(\PDOException $e) {
    $message = $e -> getMessage();
    $matches = array();
    $code = preg_match('/ (\d\d\d\d) / ', $message, $matches);
    return $code;
}

I realize that this is not as detailed as the question was asking but I find this is very useful in many cases and is not Doctrine2 specific.

Peter M. Elias
  • 1,204
  • 10
  • 22
0

The easiest way should be this :

$product    = $entityManager->getRepository("\Api\Product\Entity\Product")->findBy(array('productName' => $data['product_name']));
if(!empty($product)){
 // duplicate
}
abhilashv
  • 1,418
  • 1
  • 13
  • 18
  • That is not very safe in a high concurrency environment, like checking if usernames have already been registered in a popular website. – Andrew Dec 02 '13 at 05:31
  • I agree. This is the worst way of checking for duplicates, as you can have concurrent requests. – tomazahlin Aug 01 '18 at 09:21
0

I used this and it seems to work. It returns the specific MySQL error number - i.e. 1062 for a duplicate entry - ready for you to handle how you like.

try
{
    $em->flush();
}
catch(\PDOException $e)
{
    $code = $e->errorInfo[1];
    // Do stuff with error code
    echo $code;
}

I tested this with a few other scenarios and it will return other codes too like 1146 (Table doesn't exist) and 1054 (Unknown column).

Jeff S.
  • 1,201
  • 1
  • 14
  • 17