From 5c3489da011769c9c6d71bae887885cebf474fa9 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Fri, 22 May 2026 14:33:16 +0200 Subject: [PATCH 1/8] Support query inference with multiple entity managers --- README.md | 19 +++ src/Rules/Doctrine/ORM/DqlRule.php | 19 ++- .../Doctrine/ORM/QueryBuilderDqlRule.php | 19 ++- .../CreateQueryDynamicReturnTypeExtension.php | 2 +- src/Type/Doctrine/ObjectMetadataResolver.php | 84 ++++++++++-- ...lderGetQueryDynamicReturnTypeExtension.php | 2 +- ...leEntityManagersQueryTypeInferenceTest.php | 35 +++++ .../EntitiesMultipleManagers/Main/User.php | 23 ++++ .../EntitiesMultipleManagers/Tenant/App.php | 23 ++++ .../config-multiple-entity-managers.neon | 6 + .../QueryResult/entity-manager-multiple.php | 128 ++++++++++++++++++ .../QueryResult/multipleEntityManagers.php | 52 +++++++ 12 files changed, 377 insertions(+), 35 deletions(-) create mode 100644 tests/Type/Doctrine/MultipleEntityManagersQueryTypeInferenceTest.php create mode 100644 tests/Type/Doctrine/data/QueryResult/EntitiesMultipleManagers/Main/User.php create mode 100644 tests/Type/Doctrine/data/QueryResult/EntitiesMultipleManagers/Tenant/App.php create mode 100644 tests/Type/Doctrine/data/QueryResult/config-multiple-entity-managers.neon create mode 100644 tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php create mode 100644 tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php diff --git a/README.md b/README.md index e4e69ad9..356de552 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,25 @@ $kernel->boot(); return $kernel->getContainer()->get('doctrine')->getManager(); ``` +If your application uses multiple entity managers, return the Doctrine manager +registry from the loader. PHPStan Doctrine will use it to pick the object manager +that owns the entity being analysed: + +```php +// tests/object-manager.php + +use App\Kernel; +use Symfony\Component\Dotenv\Dotenv; + +require __DIR__ . '/../vendor/autoload.php'; + +(new Dotenv())->bootEnv(__DIR__ . '/../.env'); + +$kernel = new Kernel($_SERVER['APP_ENV'], (bool) $_SERVER['APP_DEBUG']); +$kernel->boot(); +return $kernel->getContainer()->get('doctrine'); +``` + ## Query type inference This extension can infer the result type of DQL queries when an `objectManagerLoader` is provided. diff --git a/src/Rules/Doctrine/ORM/DqlRule.php b/src/Rules/Doctrine/ORM/DqlRule.php index 23f4da41..7066cb41 100644 --- a/src/Rules/Doctrine/ORM/DqlRule.php +++ b/src/Rules/Doctrine/ORM/DqlRule.php @@ -58,19 +58,16 @@ public function processNode(Node $node, Scope $scope): array return []; } - $objectManager = $this->objectMetadataResolver->getObjectManager(); - if ($objectManager === null) { - return []; - } - if (!$objectManager instanceof $entityManagerInterface) { - return []; - } - - /** @var EntityManagerInterface $objectManager */ - $objectManager = $objectManager; - $messages = []; foreach ($dqls as $dql) { + $objectManager = $this->objectMetadataResolver->getObjectManagerForDql($dql->getValue()); + if (!$objectManager instanceof $entityManagerInterface) { + continue; + } + + /** @var EntityManagerInterface $objectManager */ + $objectManager = $objectManager; + $query = $objectManager->createQuery($dql->getValue()); try { $query->getAST(); diff --git a/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php b/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php index 69f9791a..89b79a85 100644 --- a/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php +++ b/src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php @@ -92,21 +92,18 @@ public function processNode(Node $node, Scope $scope): array return []; } - $objectManager = $this->objectMetadataResolver->getObjectManager(); - if ($objectManager === null) { - return []; - } - $entityManagerInterface = 'Doctrine\ORM\EntityManagerInterface'; - if (!$objectManager instanceof $entityManagerInterface) { - return []; - } - - /** @var EntityManagerInterface $objectManager */ - $objectManager = $objectManager; $messages = []; foreach ($dqls as $dql) { + $objectManager = $this->objectMetadataResolver->getObjectManagerForDql($dql->getValue()); + if (!$objectManager instanceof $entityManagerInterface) { + continue; + } + + /** @var EntityManagerInterface $objectManager */ + $objectManager = $objectManager; + try { $objectManager->createQuery($dql->getValue())->getAST(); } catch (QueryException $e) { diff --git a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php index b78f8467..fb591c2a 100644 --- a/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/CreateQueryDynamicReturnTypeExtension.php @@ -89,7 +89,7 @@ public function getTypeFromMethodCall( if ($type instanceof ConstantStringType) { $queryString = $type->getValue(); - $em = $this->objectMetadataResolver->getObjectManager(); + $em = $this->objectMetadataResolver->getObjectManagerForDql($queryString); if (!$em instanceof EntityManagerInterface) { return new QueryType($queryString, null, null); } diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index bfcafd42..318d69db 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -7,14 +7,17 @@ use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\MappingException; +use Doctrine\Persistence\ManagerRegistry; use Doctrine\Persistence\ObjectManager; use PHPStan\Doctrine\Mapping\ClassMetadataFactory; use PHPStan\ShouldNotHappenException; use ReflectionException; +use function array_merge; use function class_exists; use function is_file; use function is_readable; use function method_exists; +use function preg_match_all; use function sprintf; use const PHP_VERSION_ID; @@ -23,8 +26,8 @@ final class ObjectMetadataResolver private ?string $objectManagerLoader = null; - /** @var ObjectManager|false|null */ - private $objectManager; + /** @var ObjectManager|ManagerRegistry|false|null */ + private $objectManagerLoaderResult; private ?ClassMetadataFactory $metadataFactory = null; @@ -47,23 +50,79 @@ public function hasObjectManagerLoader(): bool /** @api */ public function getObjectManager(): ?ObjectManager { - if ($this->objectManager === false) { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if ($objectManagerLoaderResult instanceof ManagerRegistry) { + $objectManager = $objectManagerLoaderResult->getManager(); + if (!$objectManager instanceof ObjectManager) { + return null; + } + + return $objectManager; + } + + if ($objectManagerLoaderResult instanceof ObjectManager) { + return $objectManagerLoaderResult; + } + + return null; + } + + /** + * @param class-string $className + */ + public function getObjectManagerForClass(string $className): ?ObjectManager + { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if ($objectManagerLoaderResult instanceof ManagerRegistry) { + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); + if ($objectManager instanceof ObjectManager) { + return $objectManager; + } + } + + return $this->getObjectManager(); + } + + public function getObjectManagerForDql(string $dql): ?ObjectManager + { + preg_match_all('~\b(?:FROM|UPDATE)\s+([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)(?:\s+|$)~i', $dql, $matches); + preg_match_all('~\bDELETE\s+(?:FROM\s+)?([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)(?:\s+|$)~i', $dql, $deleteMatches); + foreach (array_merge($matches[1], $deleteMatches[1]) as $className) { + if (!class_exists($className)) { + continue; + } + + $objectManager = $this->getObjectManagerForClass($className); + if ($objectManager !== null) { + return $objectManager; + } + } + + return $this->getObjectManager(); + } + + /** + * @return ObjectManager|ManagerRegistry|null + */ + private function getObjectManagerLoaderResult() + { + if ($this->objectManagerLoaderResult === false) { return null; } - if ($this->objectManager !== null) { - return $this->objectManager; + if ($this->objectManagerLoaderResult !== null) { + return $this->objectManagerLoaderResult; } if ($this->objectManagerLoader === null) { - $this->objectManager = false; + $this->objectManagerLoaderResult = false; return null; } - $this->objectManager = $this->loadObjectManager($this->objectManagerLoader); + $this->objectManagerLoaderResult = $this->loadObjectManager($this->objectManagerLoader); - return $this->objectManager; + return $this->objectManagerLoaderResult; } public function isNativeLazyObjectsEnabled(): bool @@ -99,7 +158,7 @@ public function isTransient(string $className): bool return true; } - $objectManager = $this->getObjectManager(); + $objectManager = $this->getObjectManagerForClass($className); try { if ($objectManager === null) { @@ -143,7 +202,7 @@ public function getClassMetadata(string $className): ?ClassMetadata return null; } - $objectManager = $this->getObjectManager(); + $objectManager = $this->getObjectManagerForClass($className); try { if ($objectManager === null) { @@ -172,7 +231,10 @@ public function getClassMetadata(string $className): ?ClassMetadata return $ormMetadata; } - private function loadObjectManager(string $objectManagerLoader): ?ObjectManager + /** + * @return ObjectManager|ManagerRegistry|null + */ + private function loadObjectManager(string $objectManagerLoader) { if (!is_file($objectManagerLoader)) { throw new ShouldNotHappenException(sprintf( diff --git a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php index 98fdefb3..a075246b 100644 --- a/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/QueryBuilder/QueryBuilderGetQueryDynamicReturnTypeExtension.php @@ -191,7 +191,7 @@ public function getTypeFromMethodCall( private function getQueryType(string $dql): Type { - $em = $this->objectMetadataResolver->getObjectManager(); + $em = $this->objectMetadataResolver->getObjectManagerForDql($dql); if (!$em instanceof EntityManagerInterface) { return new QueryType($dql, null); } diff --git a/tests/Type/Doctrine/MultipleEntityManagersQueryTypeInferenceTest.php b/tests/Type/Doctrine/MultipleEntityManagersQueryTypeInferenceTest.php new file mode 100644 index 00000000..458eb066 --- /dev/null +++ b/tests/Type/Doctrine/MultipleEntityManagersQueryTypeInferenceTest.php @@ -0,0 +1,35 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/QueryResult/multipleEntityManagers.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/QueryResult/config-multiple-entity-managers.neon']; + } + +} diff --git a/tests/Type/Doctrine/data/QueryResult/EntitiesMultipleManagers/Main/User.php b/tests/Type/Doctrine/data/QueryResult/EntitiesMultipleManagers/Main/User.php new file mode 100644 index 00000000..20e9412c --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/EntitiesMultipleManagers/Main/User.php @@ -0,0 +1,23 @@ +setProxyDir(__DIR__); + $config->setProxyNamespace('PHPstan\Doctrine\OrmProxies'); + $config->setMetadataCache(new ArrayCachePool()); + $config->setMetadataDriverImpl(new AnnotationDriver( + new AnnotationReader(), + [$path], + )); + + return new EntityManager( + DriverManager::getConnection([ + 'driver' => 'pdo_sqlite', + 'memory' => true, + ]), + $config, + ); +}; + +$defaultManager = $createEntityManager( + __DIR__ . '/EntitiesMultipleManagers/Main', +); +$tenantManager = $createEntityManager( + __DIR__ . '/EntitiesMultipleManagers/Tenant', +); + +return new class ($defaultManager, $tenantManager) implements ManagerRegistry { + + private EntityManager $defaultManager; + + private EntityManager $tenantManager; + + public function __construct(EntityManager $defaultManager, EntityManager $tenantManager) + { + $this->defaultManager = $defaultManager; + $this->tenantManager = $tenantManager; + } + + public function getDefaultConnectionName() + { + return 'default'; + } + + public function getConnection(?string $name = null) + { + return $this->getManager($name)->getConnection(); + } + + public function getConnections() + { + return [ + 'default' => $this->defaultManager->getConnection(), + 'tenant' => $this->tenantManager->getConnection(), + ]; + } + + public function getConnectionNames() + { + return [ + 'default' => 'default', + 'tenant' => 'tenant', + ]; + } + + public function getDefaultManagerName() + { + return 'default'; + } + + public function getManager(?string $name = null) + { + if ($name === 'tenant') { + return $this->tenantManager; + } + + return $this->defaultManager; + } + + public function getManagers() + { + return [ + 'default' => $this->defaultManager, + 'tenant' => $this->tenantManager, + ]; + } + + public function resetManager(?string $name = null) + { + return $this->getManager($name); + } + + public function getManagerNames() + { + return [ + 'default' => 'default', + 'tenant' => 'tenant', + ]; + } + + public function getRepository(string $persistentObject, ?string $persistentManagerName = null): ObjectRepository + { + return $this->getManager($persistentManagerName)->getRepository($persistentObject); + } + + public function getManagerForClass(string $class): ?ObjectManager + { + foreach ($this->getManagers() as $manager) { + if (!$manager->getMetadataFactory()->isTransient($class)) { + return $manager; + } + } + + return null; + } + +}; diff --git a/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php new file mode 100644 index 00000000..bd2728b4 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php @@ -0,0 +1,52 @@ + $repository + */ + public function userRepository(EntityRepository $repository): void + { + $query = $repository->createQueryBuilder('u')->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + assertType('list', $query->getResult()); + } + + /** + * @param EntityRepository $repository + */ + public function tenantRepository(EntityRepository $repository): void + { + $query = $repository->createQueryBuilder('a')->getQuery(); + + assertType('Doctrine\ORM\Query', $query); + assertType('list', $query->getResult()); + } + + public function directTenantQuery(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('SELECT a FROM QueryResult\MultipleEntityManagers\Tenant\App a'); + + assertType('Doctrine\ORM\Query', $query); + assertType('list', $query->getResult()); + } + + public function directDefaultQuery(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('SELECT u FROM QueryResult\MultipleEntityManagers\Main\User u'); + + assertType('Doctrine\ORM\Query', $query); + assertType('list', $query->getResult()); + } + +} From 4166de04b50b149d321104d48a945f8c8504e6f3 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Fri, 22 May 2026 14:37:38 +0200 Subject: [PATCH 2/8] Fix ManagerRegistry fixture compatibility --- src/Type/Doctrine/ObjectMetadataResolver.php | 7 +------ .../data/QueryResult/entity-manager-multiple.php | 12 +++++------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index 318d69db..05c01aff 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -52,12 +52,7 @@ public function getObjectManager(): ?ObjectManager { $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); if ($objectManagerLoaderResult instanceof ManagerRegistry) { - $objectManager = $objectManagerLoaderResult->getManager(); - if (!$objectManager instanceof ObjectManager) { - return null; - } - - return $objectManager; + return $objectManagerLoaderResult->getManager(); } if ($objectManagerLoaderResult instanceof ObjectManager) { diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php b/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php index 47b04dd1..e0ffcba5 100644 --- a/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php +++ b/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php @@ -7,8 +7,6 @@ use Doctrine\ORM\EntityManager; use Doctrine\ORM\Mapping\Driver\AnnotationDriver; use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use Doctrine\Persistence\ObjectRepository; $createEntityManager = static function (string $path): EntityManager { $config = new Configuration(); @@ -53,7 +51,7 @@ public function getDefaultConnectionName() return 'default'; } - public function getConnection(?string $name = null) + public function getConnection($name = null) { return $this->getManager($name)->getConnection(); } @@ -79,7 +77,7 @@ public function getDefaultManagerName() return 'default'; } - public function getManager(?string $name = null) + public function getManager($name = null) { if ($name === 'tenant') { return $this->tenantManager; @@ -96,7 +94,7 @@ public function getManagers() ]; } - public function resetManager(?string $name = null) + public function resetManager($name = null) { return $this->getManager($name); } @@ -109,12 +107,12 @@ public function getManagerNames() ]; } - public function getRepository(string $persistentObject, ?string $persistentManagerName = null): ObjectRepository + public function getRepository($persistentObject, $persistentManagerName = null) { return $this->getManager($persistentManagerName)->getRepository($persistentObject); } - public function getManagerForClass(string $class): ?ObjectManager + public function getManagerForClass($class) { foreach ($this->getManagers() as $manager) { if (!$manager->getMetadataFactory()->isTransient($class)) { From 01537719a683e415f92cf120e252fa0cbb818431 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Fri, 22 May 2026 14:41:36 +0200 Subject: [PATCH 3/8] Implement legacy ManagerRegistry alias method --- .../Doctrine/data/QueryResult/entity-manager-multiple.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php b/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php index e0ffcba5..8845701d 100644 --- a/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php +++ b/tests/Type/Doctrine/data/QueryResult/entity-manager-multiple.php @@ -107,6 +107,11 @@ public function getManagerNames() ]; } + public function getAliasNamespace($alias) + { + throw new LogicException('Alias namespaces are not used in this test fixture.'); + } + public function getRepository($persistentObject, $persistentManagerName = null) { return $this->getManager($persistentManagerName)->getRepository($persistentObject); From b10343ce53bab2b8a727b761aa6907bad101e5c7 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Sun, 24 May 2026 19:07:00 +0200 Subject: [PATCH 4/8] Support named manager repository inference --- ...etRepositoryDynamicReturnTypeExtension.php | 41 +++- src/Type/Doctrine/ObjectMetadataResolver.php | 36 +++ ...amedManagerRepositoryTypeInferenceTest.php | 35 +++ .../Doctrine/data/named-manager-registry.php | 205 ++++++++++++++++++ .../data/named-manager-repository.neon | 5 + .../Doctrine/data/namedManagerRepository.php | 41 ++++ 6 files changed, 360 insertions(+), 3 deletions(-) create mode 100644 tests/Type/Doctrine/NamedManagerRepositoryTypeInferenceTest.php create mode 100644 tests/Type/Doctrine/data/named-manager-registry.php create mode 100644 tests/Type/Doctrine/data/named-manager-repository.neon create mode 100644 tests/Type/Doctrine/data/namedManagerRepository.php diff --git a/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php b/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php index 5a4f3e26..55588aa5 100644 --- a/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php +++ b/src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php @@ -104,9 +104,10 @@ public function getTypeFromMethodCall( } $repositoryTypes = []; + $managerName = $this->getManagerName($scope, $methodCall->getArgs()); foreach ($objectNames as $objectName) { try { - $repositoryClass = $this->getRepositoryClass($objectName, $defaultRepositoryClass); + $repositoryClass = $this->getRepositoryClass($objectName, $defaultRepositoryClass, $managerName); } catch (\Doctrine\Persistence\Mapping\MappingException | MappingException | AnnotationException $e) { return $this->getDefaultReturnType($scope, $methodCall->getArgs(), $methodReflection, $defaultRepositoryClass); } @@ -138,7 +139,24 @@ private function getDefaultReturnType(Scope $scope, array $args, MethodReflectio return $defaultType; } - private function getRepositoryClass(string $className, string $defaultRepositoryClass): string + /** + * @param Arg[] $args + */ + private function getManagerName(Scope $scope, array $args): ?string + { + if (count($args) < 2) { + return null; + } + + $managerNames = $scope->getType($args[1]->value)->getConstantStrings(); + if (count($managerNames) !== 1) { + return null; + } + + return $managerNames[0]->getValue(); + } + + private function getRepositoryClass(string $className, string $defaultRepositoryClass, ?string $managerName): string { if (!$this->reflectionProvider->hasClass($className)) { return $defaultRepositoryClass; @@ -149,12 +167,29 @@ private function getRepositoryClass(string $className, string $defaultRepository return $defaultRepositoryClass; } + if ($managerName !== null) { + $objectManager = $this->metadataResolver->getObjectManagerByName($managerName); + if ($objectManager !== null) { + $metadata = $objectManager->getClassMetadata($classReflection->getName()); + $odmMetadataClass = 'Doctrine\ODM\MongoDB\Mapping\ClassMetadata'; + if ($metadata instanceof $odmMetadataClass) { + /** @var ClassMetadata $odmMetadata */ + $odmMetadata = $metadata; + return $odmMetadata->customRepositoryClassName ?? $defaultRepositoryClass; + } + + if ($metadata instanceof \Doctrine\ORM\Mapping\ClassMetadata) { + return $metadata->customRepositoryClassName ?? $defaultRepositoryClass; + } + } + } + $metadata = $this->metadataResolver->getClassMetadata($classReflection->getName()); if ($metadata !== null) { return $metadata->customRepositoryClassName ?? $defaultRepositoryClass; } - $objectManager = $this->metadataResolver->getObjectManager(); + $objectManager = $this->metadataResolver->getObjectManagerForClass($classReflection->getName()); if ($objectManager === null) { return $defaultRepositoryClass; } diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index 05c01aff..019a3ff4 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -14,10 +14,12 @@ use ReflectionException; use function array_merge; use function class_exists; +use function count; use function is_file; use function is_readable; use function method_exists; use function preg_match_all; +use function reset; use function sprintf; use const PHP_VERSION_ID; @@ -68,7 +70,16 @@ public function getObjectManager(): ?ObjectManager public function getObjectManagerForClass(string $className): ?ObjectManager { $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if ($objectManagerLoaderResult instanceof ObjectManager) { + return $objectManagerLoaderResult; + } + if ($objectManagerLoaderResult instanceof ManagerRegistry) { + $singleObjectManager = $this->getSingleObjectManager($objectManagerLoaderResult); + if ($singleObjectManager !== null) { + return $singleObjectManager; + } + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); if ($objectManager instanceof ObjectManager) { return $objectManager; @@ -78,6 +89,20 @@ public function getObjectManagerForClass(string $className): ?ObjectManager return $this->getObjectManager(); } + public function getObjectManagerByName(string $name): ?ObjectManager + { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { + return null; + } + + try { + return $objectManagerLoaderResult->getManager($name); + } catch (\Throwable $e) { + return null; + } + } + public function getObjectManagerForDql(string $dql): ?ObjectManager { preg_match_all('~\b(?:FROM|UPDATE)\s+([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)(?:\s+|$)~i', $dql, $matches); @@ -96,6 +121,17 @@ public function getObjectManagerForDql(string $dql): ?ObjectManager return $this->getObjectManager(); } + private function getSingleObjectManager(ManagerRegistry $managerRegistry): ?ObjectManager + { + $objectManagers = $managerRegistry->getManagers(); + if (count($objectManagers) !== 1) { + return null; + } + + $objectManager = reset($objectManagers); + return $objectManager; + } + /** * @return ObjectManager|ManagerRegistry|null */ diff --git a/tests/Type/Doctrine/NamedManagerRepositoryTypeInferenceTest.php b/tests/Type/Doctrine/NamedManagerRepositoryTypeInferenceTest.php new file mode 100644 index 00000000..e9d98120 --- /dev/null +++ b/tests/Type/Doctrine/NamedManagerRepositoryTypeInferenceTest.php @@ -0,0 +1,35 @@ + */ + public function dataFileAsserts(): iterable + { + yield from $this->gatherAssertTypes(__DIR__ . '/data/namedManagerRepository.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void + { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** @return string[] */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/data/named-manager-repository.neon']; + } + +} diff --git a/tests/Type/Doctrine/data/named-manager-registry.php b/tests/Type/Doctrine/data/named-manager-registry.php new file mode 100644 index 00000000..0d797e4d --- /dev/null +++ b/tests/Type/Doctrine/data/named-manager-registry.php @@ -0,0 +1,205 @@ +customRepositoryClassName = $repositoryClass; + + $metadataFactory = new class ($metadata) implements ClassMetadataFactory { + + private ClassMetadata $metadata; + + public function __construct(ClassMetadata $metadata) + { + $this->metadata = $metadata; + } + + public function getAllMetadata() + { + return [$this->metadata]; + } + + public function getMetadataFor($className) + { + return $this->metadata; + } + + public function hasMetadataFor($className) + { + return $className === SharedEntity::class; + } + + public function setMetadataFor($className, $class) + { + } + + public function isTransient($className) + { + return $className !== SharedEntity::class; + } + + }; + + return new class ($metadata, $metadataFactory) implements ObjectManager { + + private ClassMetadata $metadata; + + private ClassMetadataFactory $metadataFactory; + + public function __construct(ClassMetadata $metadata, ClassMetadataFactory $metadataFactory) + { + $this->metadata = $metadata; + $this->metadataFactory = $metadataFactory; + } + + public function find($className, $id) + { + return null; + } + + public function persist($object) + { + } + + public function remove($object) + { + } + + public function clear($objectName = null) + { + } + + public function detach($object) + { + } + + public function refresh($object) + { + } + + public function flush() + { + } + + public function getRepository($className) + { + throw new LogicException('Repository instances are not needed by this type inference fixture.'); + } + + public function getClassMetadata($className) + { + return $this->metadata; + } + + public function getMetadataFactory() + { + return $this->metadataFactory; + } + + public function initializeObject($obj) + { + } + + public function contains($object) + { + return false; + } + + }; +}; + +$defaultManager = $createManager(DefaultSharedRepository::class); +$tenantManager = $createManager(TenantSharedRepository::class); + +return new class ($defaultManager, $tenantManager) implements ManagerRegistry { + + private ObjectManager $defaultManager; + + private ObjectManager $tenantManager; + + public function __construct(ObjectManager $defaultManager, ObjectManager $tenantManager) + { + $this->defaultManager = $defaultManager; + $this->tenantManager = $tenantManager; + } + + public function getDefaultConnectionName() + { + return 'default'; + } + + public function getConnection($name = null) + { + throw new LogicException('Connections are not used in this type inference fixture.'); + } + + public function getConnections() + { + return []; + } + + public function getConnectionNames() + { + return []; + } + + public function getDefaultManagerName() + { + return 'default'; + } + + public function getManager($name = null) + { + if ($name === 'tenant') { + return $this->tenantManager; + } + + return $this->defaultManager; + } + + public function getManagers() + { + return [ + 'default' => $this->defaultManager, + 'tenant' => $this->tenantManager, + ]; + } + + public function resetManager($name = null) + { + return $this->getManager($name); + } + + public function getManagerNames() + { + return [ + 'default' => 'default', + 'tenant' => 'tenant', + ]; + } + + public function getRepository($persistentObject, $persistentManagerName = null) + { + return $this->getManager($persistentManagerName)->getRepository($persistentObject); + } + + public function getManagerForClass($class) + { + return $class === SharedEntity::class ? $this->defaultManager : null; + } + + public function getAliasNamespace($alias) + { + throw new LogicException('Alias namespaces are not used in this type inference fixture.'); + } + +}; diff --git a/tests/Type/Doctrine/data/named-manager-repository.neon b/tests/Type/Doctrine/data/named-manager-repository.neon new file mode 100644 index 00000000..3dbf3eea --- /dev/null +++ b/tests/Type/Doctrine/data/named-manager-repository.neon @@ -0,0 +1,5 @@ +includes: + - ../../../../extension.neon +parameters: + doctrine: + objectManagerLoader: named-manager-registry.php diff --git a/tests/Type/Doctrine/data/namedManagerRepository.php b/tests/Type/Doctrine/data/namedManagerRepository.php new file mode 100644 index 00000000..3c8d223c --- /dev/null +++ b/tests/Type/Doctrine/data/namedManagerRepository.php @@ -0,0 +1,41 @@ +', $registry->getRepository(SharedEntity::class, 'default')); + assertType(TenantSharedRepository::class . '<' . SharedEntity::class . '>', $registry->getRepository(SharedEntity::class, 'tenant')); + } + +} + +class SharedEntity +{ + +} + +/** + * @template T of object + * @extends EntityRepository + */ +class DefaultSharedRepository extends EntityRepository +{ + +} + +/** + * @template T of object + * @extends EntityRepository + */ +class TenantSharedRepository extends EntityRepository +{ + +} From 84d78125e1486b5e89c69906cccabea0056e0c03 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Sun, 24 May 2026 19:21:40 +0200 Subject: [PATCH 5/8] Fix named manager CI compatibility --- src/Type/Doctrine/ObjectMetadataResolver.php | 6 +++--- tests/Type/Doctrine/data/named-manager-registry.php | 5 +++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index 019a3ff4..10809a38 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -12,6 +12,7 @@ use PHPStan\Doctrine\Mapping\ClassMetadataFactory; use PHPStan\ShouldNotHappenException; use ReflectionException; +use Throwable; use function array_merge; use function class_exists; use function count; @@ -98,7 +99,7 @@ public function getObjectManagerByName(string $name): ?ObjectManager try { return $objectManagerLoaderResult->getManager($name); - } catch (\Throwable $e) { + } catch (Throwable $e) { return null; } } @@ -128,8 +129,7 @@ private function getSingleObjectManager(ManagerRegistry $managerRegistry): ?Obje return null; } - $objectManager = reset($objectManagers); - return $objectManager; + return reset($objectManagers); } /** diff --git a/tests/Type/Doctrine/data/named-manager-registry.php b/tests/Type/Doctrine/data/named-manager-registry.php index 0d797e4d..d17fe68e 100644 --- a/tests/Type/Doctrine/data/named-manager-registry.php +++ b/tests/Type/Doctrine/data/named-manager-registry.php @@ -70,6 +70,11 @@ public function persist($object) { } + public function merge($object) + { + return $object; + } + public function remove($object) { } From 3568a875d49e59e7c3231d53ceb776b2b3d81a38 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Sun, 24 May 2026 23:26:55 +0200 Subject: [PATCH 6/8] Simplify ManagerRegistry resolver fallback --- src/Type/Doctrine/ObjectMetadataResolver.php | 50 ++++++++------------ 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/src/Type/Doctrine/ObjectMetadataResolver.php b/src/Type/Doctrine/ObjectMetadataResolver.php index 10809a38..c17d7a2c 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -54,15 +54,11 @@ public function hasObjectManagerLoader(): bool public function getObjectManager(): ?ObjectManager { $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); - if ($objectManagerLoaderResult instanceof ManagerRegistry) { - return $objectManagerLoaderResult->getManager(); - } - - if ($objectManagerLoaderResult instanceof ObjectManager) { + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { return $objectManagerLoaderResult; } - return null; + return $objectManagerLoaderResult->getManager(); } /** @@ -71,20 +67,13 @@ public function getObjectManager(): ?ObjectManager public function getObjectManagerForClass(string $className): ?ObjectManager { $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); - if ($objectManagerLoaderResult instanceof ObjectManager) { + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { return $objectManagerLoaderResult; } - if ($objectManagerLoaderResult instanceof ManagerRegistry) { - $singleObjectManager = $this->getSingleObjectManager($objectManagerLoaderResult); - if ($singleObjectManager !== null) { - return $singleObjectManager; - } - - $objectManager = $objectManagerLoaderResult->getManagerForClass($className); - if ($objectManager instanceof ObjectManager) { - return $objectManager; - } + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); + if ($objectManager instanceof ObjectManager) { + return $objectManager; } return $this->getObjectManager(); @@ -106,6 +95,11 @@ public function getObjectManagerByName(string $name): ?ObjectManager public function getObjectManagerForDql(string $dql): ?ObjectManager { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { + return $objectManagerLoaderResult; + } + preg_match_all('~\b(?:FROM|UPDATE)\s+([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)(?:\s+|$)~i', $dql, $matches); preg_match_all('~\bDELETE\s+(?:FROM\s+)?([\\\\A-Za-z_][\\\\A-Za-z0-9_]*)(?:\s+|$)~i', $dql, $deleteMatches); foreach (array_merge($matches[1], $deleteMatches[1]) as $className) { @@ -113,7 +107,7 @@ public function getObjectManagerForDql(string $dql): ?ObjectManager continue; } - $objectManager = $this->getObjectManagerForClass($className); + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); if ($objectManager !== null) { return $objectManager; } @@ -122,16 +116,6 @@ public function getObjectManagerForDql(string $dql): ?ObjectManager return $this->getObjectManager(); } - private function getSingleObjectManager(ManagerRegistry $managerRegistry): ?ObjectManager - { - $objectManagers = $managerRegistry->getManagers(); - if (count($objectManagers) !== 1) { - return null; - } - - return reset($objectManagers); - } - /** * @return ObjectManager|ManagerRegistry|null */ @@ -151,7 +135,15 @@ private function getObjectManagerLoaderResult() return null; } - $this->objectManagerLoaderResult = $this->loadObjectManager($this->objectManagerLoader); + $objectManagerLoaderResult = $this->loadObjectManager($this->objectManagerLoader); + if ($objectManagerLoaderResult instanceof ManagerRegistry) { + $objectManagers = $objectManagerLoaderResult->getManagers(); + if (count($objectManagers) === 1) { + $objectManagerLoaderResult = reset($objectManagers); + } + } + + $this->objectManagerLoaderResult = $objectManagerLoaderResult; return $this->objectManagerLoaderResult; } From badaaaee02d73eb07a79354591f1594282bb439a Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Mon, 25 May 2026 23:53:51 +0200 Subject: [PATCH 7/8] Add DELETE coverage for multiple entity managers --- .../Doctrine/data/QueryResult/multipleEntityManagers.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php index bd2728b4..16b2e412 100644 --- a/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php +++ b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php @@ -49,4 +49,11 @@ public function directDefaultQuery(EntityManagerInterface $entityManager): void assertType('list', $query->getResult()); } + public function directTenantDelete(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('DELETE QueryResult\MultipleEntityManagers\Tenant\App a'); + + assertType('Doctrine\ORM\Query', $query); + } + } From 0194cef20457d69816f242613f50b6bbf25bcd46 Mon Sep 17 00:00:00 2001 From: prerichandyouknowit Date: Tue, 26 May 2026 19:03:48 +0200 Subject: [PATCH 8/8] Expand DQL coverage for multiple entity managers --- .../data/QueryResult/multipleEntityManagers.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php index 16b2e412..b30c1421 100644 --- a/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php +++ b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php @@ -56,4 +56,18 @@ public function directTenantDelete(EntityManagerInterface $entityManager): void assertType('Doctrine\ORM\Query', $query); } + public function directTenantDeleteWithFrom(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('DELETE FROM QueryResult\MultipleEntityManagers\Tenant\App a'); + + assertType('Doctrine\ORM\Query', $query); + } + + public function directTenantUpdate(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('UPDATE QueryResult\MultipleEntityManagers\Tenant\App a SET a.id = :id'); + + assertType('Doctrine\ORM\Query', $query); + } + }