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/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 bfcafd42..c17d7a2c 100644 --- a/src/Type/Doctrine/ObjectMetadataResolver.php +++ b/src/Type/Doctrine/ObjectMetadataResolver.php @@ -7,14 +7,20 @@ 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 Throwable; +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; @@ -23,8 +29,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 +53,99 @@ public function hasObjectManagerLoader(): bool /** @api */ public function getObjectManager(): ?ObjectManager { - if ($this->objectManager === false) { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { + return $objectManagerLoaderResult; + } + + return $objectManagerLoaderResult->getManager(); + } + + /** + * @param class-string $className + */ + public function getObjectManagerForClass(string $className): ?ObjectManager + { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { + return $objectManagerLoaderResult; + } + + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); + if ($objectManager instanceof ObjectManager) { + return $objectManager; + } + + return $this->getObjectManager(); + } + + public function getObjectManagerByName(string $name): ?ObjectManager + { + $objectManagerLoaderResult = $this->getObjectManagerLoaderResult(); + if (!$objectManagerLoaderResult instanceof ManagerRegistry) { return null; } - if ($this->objectManager !== null) { - return $this->objectManager; + try { + return $objectManagerLoaderResult->getManager($name); + } catch (Throwable $e) { + return null; + } + } + + 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) { + if (!class_exists($className)) { + continue; + } + + $objectManager = $objectManagerLoaderResult->getManagerForClass($className); + if ($objectManager !== null) { + return $objectManager; + } + } + + return $this->getObjectManager(); + } + + /** + * @return ObjectManager|ManagerRegistry|null + */ + private function getObjectManagerLoaderResult() + { + if ($this->objectManagerLoaderResult === false) { + return null; + } + + 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); + $objectManagerLoaderResult = $this->loadObjectManager($this->objectManagerLoader); + if ($objectManagerLoaderResult instanceof ManagerRegistry) { + $objectManagers = $objectManagerLoaderResult->getManagers(); + if (count($objectManagers) === 1) { + $objectManagerLoaderResult = reset($objectManagers); + } + } + + $this->objectManagerLoaderResult = $objectManagerLoaderResult; - return $this->objectManager; + return $this->objectManagerLoaderResult; } public function isNativeLazyObjectsEnabled(): bool @@ -99,7 +181,7 @@ public function isTransient(string $className): bool return true; } - $objectManager = $this->getObjectManager(); + $objectManager = $this->getObjectManagerForClass($className); try { if ($objectManager === null) { @@ -143,7 +225,7 @@ public function getClassMetadata(string $className): ?ClassMetadata return null; } - $objectManager = $this->getObjectManager(); + $objectManager = $this->getObjectManagerForClass($className); try { if ($objectManager === null) { @@ -172,7 +254,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/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/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($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($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 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); + } + + public function getManagerForClass($class) + { + 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..b30c1421 --- /dev/null +++ b/tests/Type/Doctrine/data/QueryResult/multipleEntityManagers.php @@ -0,0 +1,73 @@ + $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()); + } + + public function directTenantDelete(EntityManagerInterface $entityManager): void + { + $query = $entityManager->createQuery('DELETE QueryResult\MultipleEntityManagers\Tenant\App a'); + + 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); + } + +} 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..d17fe68e --- /dev/null +++ b/tests/Type/Doctrine/data/named-manager-registry.php @@ -0,0 +1,210 @@ +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 merge($object) + { + return $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 +{ + +}