diff --git a/src/Model/ModelManager.php b/src/Model/ModelManager.php index 3543c6bd8..cae7f3e2e 100644 --- a/src/Model/ModelManager.php +++ b/src/Model/ModelManager.php @@ -45,6 +45,8 @@ final class ModelManager implements ModelManagerInterface, LockInterface, ProxyR { public const ID_SEPARATOR = '~'; + private const BATCH_SIZE = 20; + /** * @var EntityManagerInterface[] */ @@ -362,13 +364,15 @@ public function batchDelete(string $class, BaseProxyQueryInterface $query): void $entityManager = $this->getEntityManager($class); $i = 0; + $confirmedDeletionsCount = 0; try { foreach ($query->getDoctrineQuery()->toIterable() as $object) { $entityManager->remove($object); - if (0 === (++$i % 20)) { + if (0 === (++$i % self::BATCH_SIZE)) { $entityManager->flush(); + $confirmedDeletionsCount = $i; $entityManager->clear(); } } @@ -376,8 +380,31 @@ public function batchDelete(string $class, BaseProxyQueryInterface $query): void $entityManager->flush(); $entityManager->clear(); } catch (\PDOException|Exception $exception) { + $id = null; + + if (isset($object)) { + $id = $this->getNormalizedIdentifier($object); + } + + if (null === $id) { + throw new ModelManagerException( + sprintf('Failed to perform batch deletion for "%s" objects', $class), + (int) $exception->getCode(), + $exception + ); + } + + $msg = 'Failed to delete object "%s" (id: %s) while performing batch deletion'; + if ($i > self::BATCH_SIZE) { + $msg .= sprintf(' (%u objects were successfully deleted before this error)', $confirmedDeletionsCount); + } + throw new ModelManagerException( - sprintf('Failed to delete object: %s', $class), + sprintf( + $msg, + $class, + $id + ), (int) $exception->getCode(), $exception ); diff --git a/tests/Model/ModelManagerTest.php b/tests/Model/ModelManagerTest.php index 9cf66344e..635a3c004 100644 --- a/tests/Model/ModelManagerTest.php +++ b/tests/Model/ModelManagerTest.php @@ -18,12 +18,18 @@ use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; use Doctrine\ORM\AbstractQuery; +use Doctrine\ORM\Configuration; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Internal\Hydration\SimpleObjectHydrator; use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\ORM\Mapping\ClassMetadataFactory; use Doctrine\ORM\OptimisticLockException; +use Doctrine\ORM\Query; use Doctrine\ORM\QueryBuilder; +use Doctrine\ORM\UnitOfWork; use Doctrine\Persistence\ManagerRegistry; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\MockObject\Stub\Exception as ExceptionStub; use PHPUnit\Framework\TestCase; use Sonata\AdminBundle\Exception\LockException; use Sonata\AdminBundle\Exception\ModelManagerException; @@ -559,6 +565,138 @@ public function testRemove(\Throwable $exception): void $this->modelManager->delete(new VersionedEntity()); } + /** + * @return iterable|null>> + * + * @phpstan-return iterable, 2: array}> + */ + public function failingBatchDeleteProvider(): iterable + { + yield [ + 'Failed to delete object "Sonata\DoctrineORMAdminBundle\Tests\Fixtures\Entity\VersionedEntity" (id: 42) while' + .' performing batch deletion (20 objects were successfully deleted before this error)', + array_fill(0, 21, new VersionedEntity()), + [null, static::throwException(new Exception())], + ]; + + yield [ + 'Failed to delete object "Sonata\DoctrineORMAdminBundle\Tests\Fixtures\Entity\VersionedEntity" (id: 42) while' + .' performing batch deletion', + [new VersionedEntity(), new VersionedEntity()], + [static::throwException(new Exception())], + ]; + + yield [ + 'Failed to perform batch deletion for "Sonata\DoctrineORMAdminBundle\Tests\Fixtures\Entity\VersionedEntity" objects', + null, + [null], + ]; + } + + /** + * @param array|null $result + * @param array $onConsecutiveFlush + * + * @dataProvider failingBatchDeleteProvider + */ + public function testFailingBatchDelete(string $expectedExceptionMessage, ?array $result, array $onConsecutiveFlush): void + { + $classMetadata = $this->createMock(ClassMetadata::class); + $classMetadata + ->method('getIdentifierValues') + ->willReturn([ + 'id' => 42, + ]); + $classMetadata->expects(static::once()) + ->method('getIdentifierFieldNames') + ->willReturn(['id']); + $classMetadata->table['name'] = 'versioned_entity'; + + $em = $this->setGetMetadataExpectation(VersionedEntity::class, $classMetadata); + $em + ->expects(static::exactly(null === $result ? 0 : \count($result))) + ->method('remove'); + $em + ->expects(static::exactly(null === $result ? 0 : (int) ceil(\count($result) / 20))) + ->method('flush') + ->will(static::onConsecutiveCalls( + ...$onConsecutiveFlush + )); + $em + ->method('getConfiguration') + ->willReturn(new Configuration()); + + $uow = $this->createMock(UnitOfWork::class); + $uow + ->expects(static::exactly(null === $result ? 0 : 1)) + ->method('getEntityState') + ->willReturn(UnitOfWork::STATE_MANAGED); + + $em + ->expects(static::exactly(null === $result ? 0 : 1)) + ->method('getUnitOfWork') + ->willReturn($uow); + + $connection = $this->createMock(Connection::class); + $connection + ->method('getDatabasePlatform') + ->willReturn($this->createStub(AbstractPlatform::class)); + $connection + ->method('getParams') + ->willReturn([]); + + $em + ->method('getConnection') + ->willReturn($connection); + + $hydrator = $this->createMock(SimpleObjectHydrator::class); + $hydrator + ->expects(static::once()) + ->method('toIterable') + ->willReturnCallback(static function () use ($result): iterable { + if (null === $result) { + throw new Exception(); + } + + return $result; + }); + + $em + ->expects(static::once()) + ->method('newHydrator') + ->willReturn($hydrator); + + $queryBuilder = new QueryBuilder($em); + $queryBuilder + ->select('ve') + ->from(VersionedEntity::class, 've'); + + $query = new Query($em); + $query->setDQL($queryBuilder->getDQL()); + + $em + ->expects(static::once()) + ->method('createQuery') + ->willReturn($query); + + $cmf = $this->createMock(ClassMetadataFactory::class); + $cmf + ->expects(static::once()) + ->method('getMetadataFor') + ->with(VersionedEntity::class) + ->willReturn($classMetadata); + + $em + ->expects(static::once()) + ->method('getMetadataFactory') + ->willReturn($cmf); + + $this->expectException(ModelManagerException::class); + $this->expectExceptionMessage($expectedExceptionMessage); + + $this->modelManager->batchDelete(VersionedEntity::class, new ProxyQuery($queryBuilder)); + } + /** * @param string[] $expectedParameters * @param string[] $identifierFieldNames