Reputation: 61
When trying to delete a user in my Symfony 6.3 app, I got the following message: A cycle has been detected, so a topological sort is not possible. The getCycle() method provides the list of nodes that form the cycle.
I reviewed all my Entities, with Doctrine attributes. I looked at the #[ORM parameters to ensure that cascade in removal is performed in the correct order and direction. All seemed correct. My User entity has many One-To-Many and Many-To-One relations, and I made sure that the cascade with remove setting is performed from my User entity to the related entities.
You must know that my users have different states (in validation, active, in pause, and exited), and I have to historize (ie keep the history of every change), so I created a StateHisto entity which has a Many-To-One relation with my User entity.
But for performance reason, I had to get the last state of my user kept in an attribut to get it directly. So I added a lastStateHisto attribute to my user entity with a One-To-One relation with StateHisto entity.
Here is an extract from my code:
User entity:
#[Vich\Uploadable]
#[ORM\Table(name: '`user`')]
#[ORM\Entity(repositoryClass: UserRepository::class)]
#[UniqueEntity(fields: ['surname', 'firstname', 'birthDate'], message: 'L\'utilisateur existe déjà')]
#[InheritanceType('SINGLE_TABLE')]
#[DiscriminatorColumn(name: 'discr', type: 'string')]
#[DiscriminatorMap(['user' => 'User', 'apprenant' => 'Apprenant', 'benevole' => 'Benevole', 'enfant' => 'Enfant'])]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
protected $id;
#[ORM\Column(type: 'string', length: 180, unique: true)]
protected $username;
/**
* NOTE: This is not a mapped field of entity metadata, just a simple property.
*
* @var File|null
*/
#[Vich\UploadableField(mapping: 'user_image', fileNameProperty: 'imageName', size: 'imageSize')]
protected ?File $imageFile = null;
/**
* @var string|null
*/
#[ORM\Column(type: 'string', nullable: true)]
protected ?string $imageName = null;
/**
* @var int|null
*/
#[ORM\Column(type: 'integer', nullable: true)]
protected ?int $imageSize = null;
/**
* @var \DateTimeInterface|null
*/
#[ORM\Column(type: 'datetime', nullable: true)]
protected ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: 'json')]
protected $roles = [];
/**
* @var string The hashed password
*/
#[ORM\Column(type: 'string')]
protected $password;
#[ORM\Column(type: 'string', length: 255)]
protected $surname;
#[ORM\Column(type: 'string', length: 255)]
protected $firstname;
#[ORM\Column(type: 'string', length: 255)]
protected $title;
#[ORM\Column(type: 'date')]
protected $birthDate;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected $tel1;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
protected $tel2;
#[ORM\Column(type: 'string', length: 3000, nullable: true)]
protected $comment;
#[ORM\Column(type: 'string', length: 360, unique: true, nullable: true)]
protected $email;
#[ORM\OneToMany(mappedBy: 'user', targetEntity: StateHisto::class, cascade: ['persist', 'remove'], fetch: 'EAGER', orphanRemoval: true)]
#[ORM\JoinColumn()]
/**
* States: 1=In validation, 2=Active, 3=In pause, 4=Inactive
*/
protected $stateHisto;
#[ORM\OneToOne(cascade: ['persist'])]
#[ORM\JoinColumn(nullable: true)]
private ?StateHisto $lastStateHisto = null;
StateHisto entity:
#[ORM\Entity(repositoryClass: StateHistoRepository::class)]
class StateHisto
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column(type: 'integer')]
private $id;
#[ORM\Column(type: 'date')]
private $date;
#[ORM\Column(type: 'integer')]
#[Assert\Choice(1, 2, 3, 4)]
/**
* States: 1=In validation, 2=Active, 3=In pause, 4=Inactive
*/
private $state;
#[ORM\Column(type: 'string', length: 255, nullable: true)]
private $reason;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'stateHisto')]
#[ORM\JoinColumn(nullable: false)]
private $user;
#[ORM\Column(nullable: true)]
private ?int $stdExitReason = null;
Upvotes: 3
Views: 1538
Reputation: 1364
omg, what a weird bug, the same thing happened to me and it was indeed related to a #[ORM\OneToOne(targetEntity: self::class, cascade: ['persist', 'remove'])]
relation with itself.
Underlying logic for the use-case is to fetch the next or previous page (page is the entity). I think this is a bug from doctrine (I reported the issue here).
I fixed the issue by adding the following attribute to my relation
#[ORM\JoinColumn(nullable: true, onDelete: 'CASCADE')]
I am not sure this is an actual fix as another user might want to perform additional checks which then manually assigning null might serve useful. Anyways, I'm done thinking about this, cheers!
Upvotes: 1
Reputation: 496
I was having the same kind of problem, but as I don't have cascade set on the relation so it was enough to just null the field in the PreRemove event.
Example:
class User
{
#[ORM\ManyToOne( targetEntity: 'User' )]
#[ORM\JoinColumn( name: 'user_updated_id', referencedColumnName: 'user_id' )]
private null|User $updatedBy;
#[ORM\PreRemove]
public function onPreRemove( LifecycleEventArgs $args ) : void
{
/* Remove any references to self, as this will throw a CycleDetectedException */
$this->updatedBy = null;
}
}
Upvotes: 1
Reputation: 61
After a while, I realized that the cycle mentioned in the error message was that I have combined a One-To-Many relation and a One-To-One relation on the same entity: StateHisto. This makes it impossible to cascade the delete operation from a user to the StateHisto table.
What needs to be done here it to first, set to null the lastStateHisto attribute (the one holding the One-To-One relation) before launching the removal of the user.
It means that in a controller, you can do this:
$user->setLastStateHisto(null);
$entityManager->persist($user);
$entityManager->flush();
$entityManager->remove($user);
$entityManager->flush();
This way, it works.
Upvotes: 3