From dcf139f8a072143b4544baf83a882f36109a5b9d Mon Sep 17 00:00:00 2001 From: ondrejj Date: Thu, 18 Jun 2026 17:10:36 +0200 Subject: [PATCH 1/2] ManagerRegistry: reset also persisters in UnitOfWork UnitOfWork is cleared after EntityManager failure, but it keeps cached EntityPersister inside. So if you want to e.g. create other insert query, UoW uses the same persister as before failure. The EntityPersister has its own cache for inserts and it tries to insert previous query with empty values. Using this EntityManager with same persister fails though. --- src/ManagerRegistry.php | 21 ++++++++-- tests/Cases/ManagerRegistry.phpt | 70 +++++++++++++++++++++++++++----- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/src/ManagerRegistry.php b/src/ManagerRegistry.php index bd612aad6..526238005 100644 --- a/src/ManagerRegistry.php +++ b/src/ManagerRegistry.php @@ -3,6 +3,8 @@ namespace Nettrine\ORM; use Doctrine\ORM\EntityManager; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\UnitOfWork; use Doctrine\Persistence\AbstractManagerRegistry; use Doctrine\Persistence\ObjectManagerDecorator; use Doctrine\Persistence\Proxy; @@ -36,19 +38,30 @@ public function __construct( ); } - /** - * @param ObjectManagerDecorator|EntityManager $manager - */ - public static function reopen(ObjectManagerDecorator|EntityManager $manager): void + public static function reopen(EntityManagerInterface $manager): void { // @phpcs:disable Binder::use($manager, function (): void { if ($this instanceof EntityManager) { // @phpstan-ignore-line $this->closed = false; // @phpstan-ignore-line + + $uow = $this->getUnitOfWork(); + Binder::use($uow, function (): void { + if ($this instanceof UnitOfWork) { // @phpstan-ignore-line + $this->persisters = []; + } + }); } elseif ($this instanceof ObjectManagerDecorator) { Binder::use($this->wrapped, function (): void { // @phpstan-ignore-line if ($this instanceof EntityManager) { // @phpstan-ignore-line $this->closed = false; + + $uow = $this->getUnitOfWork(); + Binder::use($uow, function (): void { + if ($this instanceof UnitOfWork) { // @phpstan-ignore-line + $this->persisters = []; + } + }); } }); } diff --git a/tests/Cases/ManagerRegistry.phpt b/tests/Cases/ManagerRegistry.phpt index ed3b3c45c..35ae19a55 100644 --- a/tests/Cases/ManagerRegistry.phpt +++ b/tests/Cases/ManagerRegistry.phpt @@ -4,7 +4,7 @@ use Contributte\Tester\Toolkit; use Contributte\Tester\Utils\ContainerBuilder; use Contributte\Tester\Utils\Neonkit; use Doctrine\DBAL\Connection; -use Doctrine\ORM\EntityManager; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\EntityManagerInterface; use Doctrine\Persistence\ManagerRegistry; use Nette\DI\Compiler; @@ -368,6 +368,7 @@ Toolkit::test(function (): void { $compiler->addConfig([ 'parameters' => [ 'tempDir' => Tests::TEMP_PATH, + 'fixturesDir' => Tests::FIXTURES_PATH, ], ]); $compiler->addConfig(Neonkit::load( @@ -379,6 +380,11 @@ Toolkit::test(function (): void { password: test user: test path: ":memory:" + second: + driver: pdo_sqlite + password: test + user: test + path: ":memory:" nettrine.orm: managers: default: @@ -386,23 +392,67 @@ Toolkit::test(function (): void { mapping: App: type: attributes - directories: [app/Database] - namespace: App\Database + directories: [%fixturesDir%/Entity] + namespace: Tests\Mocks\Entity + second: + connection: second + entityManagerDecoratorClass: Tests\Mocks\DummyEntityManagerDecorator + mapping: + App: + type: attributes + directories: [%fixturesDir%/Entity] + namespace: Tests\Mocks\Entity NEON )); }) ->build(); - /** @var EntityManager $em */ - $em = $container->getByType(EntityManagerInterface::class); + /** @var ManagerRegistry $registry */ + $registry = $container->getByType(ManagerRegistry::class); + + foreach (['default', 'second'] as $managerName) { + /** @var EntityManagerInterface $em */ + $em = $registry->getManager($managerName); + Assert::true($em->isOpen()); + + /** @var Connection $connection */ + $connection = $registry->getConnection($managerName); + + $connection->executeQuery('CREATE TABLE dummy_entity (id integer primary key autoincrement, username string unique not null)'); + + $persister1 = $em->getUnitOfWork()->getEntityPersister(DummyEntity::class); - Assert::true($em->isOpen()); - $em->close(); - Assert::false($em->isOpen()); + $em->persist(new DummyEntity('test')); + $em->flush(); - NettrineManagerRegistry::reopen($em); + Assert::count(0, $persister1->getInserts(), 'Persister should have no inserts, have ' . count($persister1->getInserts())); - Assert::true($em->isOpen()); + // try to create a new entity with the same username - not unique + $em->persist(new DummyEntity('test')); + try { + $em->flush(); + } catch (UniqueConstraintViolationException) { + Assert::false($em->isOpen()); + } + + // after failing queue there is cached last insert + Assert::count(1, $persister1->getInserts(), 'Persister should have 1 insert, have ' . count($persister1->getInserts())); + + NettrineManagerRegistry::reopen($em); + + Assert::true($em->isOpen()); + + $persister2 = $em->getUnitOfWork()->getEntityPersister(DummyEntity::class); + + // check if the persister is different after reopening + Assert::notSame($persister1, $persister2); + + $dummy2 = new DummyEntity('test2'); + $em->persist($dummy2); + $em->flush(); + + Assert::count(2, $em->getRepository(DummyEntity::class)->findAll()); + } }); // Get repository for manager From 063a07d87f70db4110e4e5c9478990ebf418eaa5 Mon Sep 17 00:00:00 2001 From: ondrejj Date: Thu, 18 Jun 2026 18:08:52 +0200 Subject: [PATCH 2/2] CI: fix errors --- src/DI/Helpers/BuilderMan.php | 8 ++++++-- src/DI/OrmExtension.php | 4 ++-- tests/Cases/DI/OrmExtension.schemaIgnoreClasses.phpt | 2 +- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/DI/Helpers/BuilderMan.php b/src/DI/Helpers/BuilderMan.php index 2da59c3ac..380849fff 100644 --- a/src/DI/Helpers/BuilderMan.php +++ b/src/DI/Helpers/BuilderMan.php @@ -99,8 +99,10 @@ public function getServiceDefinitionsByTag(string $tag): array $builder = $this->pass->getContainerBuilder(); $definitions = []; + /** @var array{name: string}|string $tagValue */ foreach ($builder->findByTag($tag) as $serviceName => $tagValue) { - $definitions[(string) $tagValue] = $builder->getDefinition($serviceName); + $name = is_array($tagValue) ? $tagValue['name'] : $tagValue; + $definitions[$name] = $builder->getDefinition($serviceName); } return $definitions; @@ -114,8 +116,10 @@ public function getServiceNamesByTag(string $tag): array $builder = $this->pass->getContainerBuilder(); $definitions = []; + /** @var array{name: string}|string $tagValue */ foreach ($builder->findByTag($tag) as $serviceName => $tagValue) { - $definitions[(string) $tagValue] = $serviceName; + $name = is_array($tagValue) ? $tagValue['name'] : $tagValue; + $definitions[$name] = $serviceName; } return $definitions; diff --git a/src/DI/OrmExtension.php b/src/DI/OrmExtension.php index 3d92fb8a3..e83b7f2cd 100644 --- a/src/DI/OrmExtension.php +++ b/src/DI/OrmExtension.php @@ -22,7 +22,7 @@ * @property-read stdClass $config * @phpstan-type TManagerConfig object{ * entityManagerDecoratorClass: string|null, - * configurationClass: string, + * configurationClass: class-string, * lazyNativeObjects: bool|null, * proxyDir: string|null, * autoGenerateProxyClasses: int|bool|Statement, @@ -86,7 +86,7 @@ public function __construct( public function getConfigSchema(): Schema { $parameters = $this->getContainerBuilder()->parameters; - $proxyDir = isset($parameters['tempDir']) ? $parameters['tempDir'] . '/cache/doctrine/orm/proxies' : null; + $proxyDir = isset($parameters['tempDir']) && is_string($parameters['tempDir']) ? $parameters['tempDir'] . '/cache/doctrine/orm/proxies' : null; $autoGenerateProxy = boolval($parameters['debugMode'] ?? true); $expectService = Expect::anyOf( diff --git a/tests/Cases/DI/OrmExtension.schemaIgnoreClasses.phpt b/tests/Cases/DI/OrmExtension.schemaIgnoreClasses.phpt index 9cf00bee2..639f8ac26 100644 --- a/tests/Cases/DI/OrmExtension.schemaIgnoreClasses.phpt +++ b/tests/Cases/DI/OrmExtension.schemaIgnoreClasses.phpt @@ -1,4 +1,4 @@ -