Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 8 additions & 11 deletions src/Rules/Doctrine/ORM/DqlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
19 changes: 8 additions & 11 deletions src/Rules/Doctrine/ORM/QueryBuilderDqlRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
41 changes: 38 additions & 3 deletions src/Type/Doctrine/GetRepositoryDynamicReturnTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
Expand All @@ -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<object> $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;
}
Expand Down
107 changes: 96 additions & 11 deletions src/Type/Doctrine/ObjectMetadataResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;

Expand All @@ -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
Comment thread
VincentLanglet marked this conversation as resolved.
{
$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);
Comment thread
VincentLanglet marked this conversation as resolved.
Comment on lines +103 to +104
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if you don't use doctrine ; I saw you worked a lot with regex, so I'd like your opinion on this strategy @staabm

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do I see this correctly, that we need this regex-magic, because we cannot get a AST for the DQL since we are in the process of creating a entity manager?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will the DQL always contain a full-qualified class-name in the query?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see a test which contains a DELETE statement.. is this line tested?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that's the reason for the pre-parse step. At this point we need a candidate entity class before handing the DQL to Doctrine's EM-bound parser/metadata validation; otherwise the default manager can reject a tenant entity before we know which manager should parse it. The regex is intentionally a narrow heuristic to find an autoloadable root entity class, then the actual manager choice is delegated to ManagerRegistry::getManagerForClass().

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, not universally. This only supports the autoloadable class-string path. QueryBuilder/repository-generated DQL uses class metadata names, and direct createQuery() inference can resolve those FQCN-style class strings. If the DQL uses a short alias or anything not autoloadable, this falls back to the default configured manager rather than guessing.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I added focused coverage in badaaae using Doctrine's no-FROM form, DELETE QueryResult\MultipleEntityManagers\Tenant\App a, so it exercises the DELETE-specific extraction branch rather than the generic FROM branch.

Verification run in Docker with PHP 8.4:

  • vendor/bin/phpunit tests/Type/Doctrine/MultipleEntityManagersQueryTypeInferenceTest.php -> 9 tests / 9 assertions
  • vendor/bin/phpunit tests/Type/Doctrine -> 280 tests / 8073 assertions
  • git diff --check -> clean

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@stof do you forsee whether this regex-matching is "good enough" for most DQL to identify the used entity-class?
(I am no DQL expert and don't know which syntax people use/is supported)

in case we can think about more DQL syntax edge cases, this might help to add more tests and harden the regex pattern

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
Expand Down Expand Up @@ -99,7 +181,7 @@ public function isTransient(string $className): bool
return true;
}

$objectManager = $this->getObjectManager();
$objectManager = $this->getObjectManagerForClass($className);

try {
if ($objectManager === null) {
Expand Down Expand Up @@ -143,7 +225,7 @@ public function getClassMetadata(string $className): ?ClassMetadata
return null;
}

$objectManager = $this->getObjectManager();
$objectManager = $this->getObjectManagerForClass($className);

try {
if ($objectManager === null) {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Doctrine;

use PHPStan\Testing\TypeInferenceTestCase;

class MultipleEntityManagersQueryTypeInferenceTest extends TypeInferenceTestCase
{

/** @return iterable<mixed> */
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'];
}

}
Loading
Loading