0

Problem

Is there a way that Doctrine recognizes the existing objects when persist with cascade={"persist"} on a ManyToOne relation instead and do not fail when trying to insert it again and thus violate the unique key rule?

Description

I am trying to create a Location Entity that can reference a parent to obtain this kind of structure : enter image description here

To do this, I have the following code on my entity :

/**
 * Abstract class representing a location
 *
 * @ORM\Entity
 * @ORM\InheritanceType("SINGLE_TABLE")
 * @ORM\DiscriminatorColumn(name="type", type="string")
 * @ORM\DiscriminatorMap({"COUNTRY" = "CountryLocation", "REGION" = "RegionLocation", "DEPARTMENT" = "DepartmentLocation"})
 * @ORM\Table(name="ss_locations")
 *
 * @package Locations
 */
abstract class ALocation {
  
  /**
   * A type that determines the location type
   */
  protected ?string $type = null;
  
  /**
   * The location ID
   *
   * @ORM\Id
   * @ORM\GeneratedValue
   * @ORM\Column(type="integer", options={"unsigned"=true})
   *
   * @var int
   */
  protected int $id;
  
  /**
   * The location's slug identifier
   *
   * @ORM\Column(type="string")
   *
   * @example "Pays de la Loire" region's slug will be "pays-de-la-loire"
   *
   * @var string
   */
  protected string $slug;
  
  /**
   * The location path through its parent's slugs
   *
   * @ORM\Column(type="string", unique=true)
   *
   * @example "Loire-Atlantique" department's path would be "france/pays-de-la-loire/loire-atlantique"
   *
   * @var string
   */
  protected string $path;
  
  /**
   * The name location's
   *
   * @ORM\Column(type="string")
   *
   * @var string
   */
  protected string $name;
  
  /**
   * The parent location instance
   *
   * @ORM\ManyToOne(targetEntity="ALocation", cascade={"persist"})
   * @ORM\JoinColumn(name="parent", referencedColumnName="id")
   *
   * @var ALocation|null
   */
  protected ?ALocation $parent = null;

  // ...

}

// Example of child class 

/**
 * Class DepartmentLocation
 *
 * @ORM\Entity
 *
 * @package Locations
 */
class DepartmentLocation extends ALocation {
  const TYPE = "DEPARTMENT";
  
  /**
   * @inheritdoc
   */
  protected ?string $type = "DEPARTMENT";

  // ...
}


The table creation goes well, but when I try to persist one location I got these errors :

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'FR' for key 'PRIMARY')
Warning: Île-de-France cannot be inserted in DB : reason(An exception occurred while executing 'INSERT INTO ss_locations (iso_code, name, parent_id, type) VALUES (?, ?, ?, ?)' with params ["FR", "France", null, "COUNTRY"]:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'FR' for key 'PRIMARY')
Warning: Paris cannot be inserted in DB : reason(An exception occurred while executing 'INSERT INTO ss_locations (iso_code, name, parent_id, type) VALUES (?, ?, ?, ?)' with params ["FR", "France", null, "COUNTRY"]:

Here is an example of the Database content wanted enter image description here

And here is how I try to persist it :

// ...

  foreach ( $locations as $location ) :
    try {
      DoctrineEntityManager::get()->persist($location);
      DoctrineEntityManager::get()->flush();
    } catch ( Throwable $e ) {}
  }

// ...
Arthur Eudeline
  • 575
  • 3
  • 18

2 Answers2

0

After searches, I managed to use EntityManager::merge( $entity ); followed by a EntityManager::flush(); call to get it work, just like described here https://stackoverflow.com/a/46689619/8027308.

// ...

foreach ( $locations as $location ) :
  // Persist the main location
  try {
    DoctrineEntityManager::get()->merge($location);
    DoctrineEntityManager::get()->flush();
  } catch ( Throwable $e ) {}
endforeach;

// ...

However, the EntityManager::merge( $entity ) is being marked deprecated and will be removed from Doctrine 3+. There is no official alternative yet and it probably won't be.

Workarounds

1. EntityManager::getUnitOfWork()::registerManaged()

I tried the alternative proposed here https://stackoverflow.com/a/65050577/8027308, but using EntityManager::getUnitOfWork()::registerManaged() didn't work in my case and caused the same error as before SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'FR' for key 'PRIMARY'). Moreover, this alternative required one or two more dependencies to convert entity data into array. Here is the code that produced the error :


use Symfony\Component\Serializer\Serializer;
use Symfony\Component\Serializer\Normalizer;

// ...  

$serializer = (new Serializer([new Normalizer\ObjectNormalizer()], []));
foreach ( $locations as $location ) :
  // Persist the main location
  try {
    DoctrineEntityManager::get()->getUnitOfWork()->registerManaged(
      $location, // The entity object
      [ $location->getIsoCode() ], // The entity identifiers
      $serializer->normalize($location, null) // Gets the entity data as array
    );
    DoctrineEntityManager::get()->flush();
  } catch ( Throwable $e ) {}
endforeach;

// ...

2. Let Doctrine do a pre-query to check if the parent entity exists

Otherwise, you can disable cascade={"persist"} on your entity property and do the cascade by yourself, using a Doctrine to perform a pre-query to check whether or not the entity already exists in DB or not :

// ...

/**
 * Recursively save all the parents into the DB
 * 
 * @param ALocation $location The location to save the parents from
 *                            
 * @return void
 */  
function persistParents( ALocation $location ) : void {
  // If we don't have any parent, no need to go further
  if ( ! $location->hasParent() )
    return;
  
  $parent = $location->getParent();
  
  // Recursion to save all parents
  if ($parent->hasParent())
    persistParents($parent);
  
  // Try to get the parent from the DB
  $parentRecord = DoctrineEntityManager::get()->getRepository( ALocation::class )->find( $parent->getIsoCode() );
  
  // If we succeed, we set the parent on the location and exit
  if ( ! is_null($parentRecord) ) {
    $location->setParent( $parentRecord );
  
    return;
  }
  
  // Otherwise, we save it into the DB 
  try {
    DoctrineEntityManager::get()->persist( $parent );
    DoctrineEntityManager::get()->flush();
  } catch (Throwable $e) {}
  
  return;
}

foreach ( $locations as $location ) :
  // Saves all the parents first
  if ($location->hasParent())
    persistParents( $location );
  
  // Then persist the main location
  try {
    DoctrineEntityManager::get()->persist($location);
    DoctrineEntityManager::get()->flush();
  } catch ( Throwable $e ) {}
endforeach;

// ...

3. Use good old INSERT ... ON DUPLICATE KEY UPDATE

Previous workarounds want only to use Doctrine, but a safer and cleaner solution will be to use native SQL query such as described here: https://stackoverflow.com/a/4205207/8027308

Arthur Eudeline
  • 575
  • 3
  • 18
0

The problem is not your Doctrine configuration but how you create your object, if you look at those errors messages you'll find that Doctrine try to insert the same data for France and Paris, try to add $type property without doctrine mapping to

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'FR' for key 'PRIMARY')
Warning: Île-de-France cannot be inserted in DB : reason(An exception occurred while executing 'INSERT INTO ss_locations (iso_code, name, parent_id, type) VALUES (?, ?, ?, ?)' with params ["FR", "France", null, "COUNTRY"]:

SQLSTATE[23000]: Integrity constraint violation: 1062 Duplicate entry 'FR' for key 'PRIMARY')
Warning: Paris cannot be inserted in DB : reason(An exception occurred while executing 'INSERT INTO ss_locations (iso_code, name, parent_id, type) VALUES (?, ?, ?, ?)' with params ["FR", "France", null, "COUNTRY"]:

Update

As discussed in replies, the main problem came from the way of constructing entities, the solution was to create unique objects for parent entities then attach them to all children (working with references)

$france = new CountryLocation('France');
$iledefrance = new DepartmentLocation('Ile-de-france');
$iledefrance->setParent($france);
$paris = new CityLocation('Paris');
$paris->setParent($iledefrance);
....
ybenhssaien
  • 3,427
  • 1
  • 10
  • 12
  • Each child class redefine the `TYPE` const to be either `"COUNTRY"`, `"REGION"` or `"DEPARTMENT"` and is used by Doctrine as a discriminator column, I tried to convert it as a `static $type` instead of const but it didn't worked better : ```php /** * A type that determines the location type */ static ?string $type = null; ``` – Arthur Eudeline Jul 01 '21 at 09:50
  • I am not sure that doctrine use const in discrimination map, try to declare a simple property `protect $type` and declare it setter and getter, to make sure everything is ok, you may dump the entity before flushing the Entity Manager – ybenhssaien Jul 01 '21 at 14:36
  • Ok, so I tried with a `protect $type` non-static property and it didn't worked better... I'll update my original post with the modifications – Arthur Eudeline Jul 01 '21 at 15:53
  • Up-to-date ! :) – Arthur Eudeline Jul 01 '21 at 16:10
  • You may refactor the creation of entities, for Doctrine, every object is managed internally based on a hash from object itself, if you create a new Country object for both Paris and Ile-de-France Doctrine will try to persist twice France as they are two different PHP objects, you may try to prepare entities `$locations` in the way of creating all parent objects first then attach them (`$children->setParent($parent)`) to childrens (Country -> Region -> Department), in this case no needs to flush everytime – ybenhssaien Jul 01 '21 at 18:39
  • Ok thanks ! Then I have a few more questions : 1) if I pass object references instead of plain objects, will it work better ? 2) will it be ok then to fetch an object from Doctrine and use `setParent` on a new object to link them together and finally use `persist` ? I’ll try your approach tomorrow – Arthur Eudeline Jul 01 '21 at 18:53
  • Every php object is passed by reference, when persisting it with doctrine it will be updated by the data from database (example : id for new entity), and since you use the same object everywhere (example: France object into Paris and Ile-de-France objects) Doctrine will not persist France twice since it already a managed object (reference : https://www.doctrine-project.org/projects/doctrine-orm/en/2.9/reference/working-with-objects.html#persisting-entities), so logically don't need to fetch then persist – ybenhssaien Jul 02 '21 at 08:51
  • Ok, I just tried both approaches, all works fine if I built my objects using references instead of just the same values objects and I also tried to save from parent and to save from child after binding it to an existing parent! Thank you so much! Would you like to update your answer to resolve the post? or do you want me to update mine and tag you? – Arthur Eudeline Jul 02 '21 at 09:06
  • Sorry for this late response, I will update my response, glad to hear that is working fine with objects ^^ – ybenhssaien Jul 04 '21 at 11:38
  • actually it didn’t really I forgot that I have a function that checks in the DB before but even with it it fails so I’ve let it go and used MySQL update on duplicate for this part… 3 days of trying killed me sorry :’) – Arthur Eudeline Jul 04 '21 at 11:41
  • I believe sometimes should start from the beginning when it become complex, restart with a clean code to have a clear vision on what happen .. still available if any kind of help needed – ybenhssaien Jul 04 '21 at 11:46
  • Yeah the problem is that is not the easiest project to start with, I was trying to convert very old objects extracted from a wordpress to then convert them in new objects entities and then insert them in db with doctrine but not so simple ahah, thanks a lot for your help anyway, very nice of you ! – Arthur Eudeline Jul 04 '21 at 11:48
  • But you should still update your answer with parent approach and object links, it may help newcomers someday :D – Arthur Eudeline Jul 04 '21 at 11:49