Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

[doctrine] improve repository handler #353

Merged
merged 1 commit into from
Jul 3, 2024
Merged
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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ vendor/bin/psalm-plugin enable psalm/plugin-symfony
### Features

- Detects the `ContainerInterface::get()` result type. Works better if you [configure](#configuration) a compiled container XML file.
- Detects parameter return types from `ContainerInterface::getParameter()`.
- Supports [Service Subscribers](https://github.com/psalm/psalm-plugin-symfony/issues/20). Works only if you [configure](#configuration) a compiled container XML file.
- Detects return types from console arguments (`InputInterface::getArgument()`) and options (`InputInterface::getOption()`).
Enforces to use "InputArgument" and "InputOption" constants as a best practise.
Enforces to use "InputArgument" and "InputOption" constants as a best practice.
- Detects Doctrine repository classes associated to entities when configured via annotations.
- Fixes `PossiblyInvalidArgument` for `Symfony\Component\HttpFoundation\Request::getContent()`.
The plugin determines the real return type by checking the given argument and marks it as either "string" or "resource".
Expand Down
92 changes: 48 additions & 44 deletions src/Handler/DoctrineRepositoryHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,69 +22,73 @@ class DoctrineRepositoryHandler implements AfterMethodCallAnalysisInterface, Aft
{
public static function afterMethodCallAnalysis(AfterMethodCallAnalysisEvent $event): void
{
$expr = $event->getExpr();
$declaring_method_id = $event->getDeclaringMethodId();
$statements_source = $event->getStatementsSource();
if (!in_array($declaring_method_id, ['Doctrine\ORM\EntityManagerInterface::getrepository', 'Doctrine\Persistence\ObjectManager::getrepository'])) {
return;
}

if (in_array($declaring_method_id, ['Doctrine\ORM\EntityManagerInterface::getrepository', 'Doctrine\Persistence\ObjectManager::getrepository'])) {
if (!isset($expr->args[0]->value)) {
return;
}
$expr = $event->getExpr();
if (!isset($expr->args[0]->value)) {
return;
}

$entityName = $expr->args[0]->value;
$entityName = $expr->args[0]->value;
if (!$entityName instanceof Expr\ClassConstFetch) {
if ($entityName instanceof String_) {
$statements_source = $event->getStatementsSource();
IssueBuffer::accepts(
new RepositoryStringShortcut(new CodeLocation($statements_source, $entityName)),
$statements_source->getSuppressedIssues()
);
} elseif ($entityName instanceof Expr\ClassConstFetch) {
/** @psalm-var class-string|null $className */
$className = $entityName->class->getAttribute('resolvedName');
}

if (null === $className) {
return;
}
return;
}

try {
$reflectionClass = new \ReflectionClass($className);

if (\PHP_VERSION_ID >= 80000 && method_exists(\ReflectionClass::class, 'getAttributes')) {
$entityAttributes = $reflectionClass->getAttributes(EntityAnnotation::class);

foreach ($entityAttributes as $entityAttribute) {
$arguments = $entityAttribute->getArguments();

if (isset($arguments['repositoryClass']) && is_string($arguments['repositoryClass'])) {
$event->setReturnTypeCandidate(new Union([new TNamedObject($arguments['repositoryClass'])]));
}
}
}

if (class_exists(AnnotationReader::class)) {
$reader = new AnnotationReader();
$entityAnnotation = $reader->getClassAnnotation(
$reflectionClass,
EntityAnnotation::class
);

if ($entityAnnotation instanceof EntityAnnotation && $entityAnnotation->repositoryClass) {
$event->setReturnTypeCandidate(new Union([new TNamedObject($entityAnnotation->repositoryClass)]));
}
}
} catch (\ReflectionException $e) {
}
/** @psalm-var class-string|null $className */
$className = $entityName->class->getAttribute('resolvedName');
if (null === $className) {
return;
}

try {
$reflectionClass = new \ReflectionClass($className);
} catch (\ReflectionException) {
return;
}

$entityAttributes = $reflectionClass->getAttributes(EntityAnnotation::class);
foreach ($entityAttributes as $entityAttribute) {
$arguments = $entityAttribute->getArguments();

if (isset($arguments['repositoryClass']) && is_string($arguments['repositoryClass'])) {
$event->setReturnTypeCandidate(new Union([new TNamedObject($arguments['repositoryClass'])]));

return;
}
}

if (class_exists(AnnotationReader::class)) {
$reader = new AnnotationReader();
$entityAnnotation = $reader->getClassAnnotation(
$reflectionClass,
EntityAnnotation::class
);

if ($entityAnnotation instanceof EntityAnnotation && $entityAnnotation->repositoryClass) {
$event->setReturnTypeCandidate(new Union([new TNamedObject($entityAnnotation->repositoryClass)]));
}
}
}

public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event)
public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event): void
{
$stmt = $event->getStmt();
$statements_source = $event->getStatementsSource();
$codebase = $event->getCodebase();

$docblock = $stmt->getDocComment();
if ($docblock && false !== strpos((string) $docblock, 'repositoryClass')) {
if ($docblock && str_contains((string) $docblock, 'repositoryClass')) {
try {
$parsedComment = DocComment::parsePreservingLength($docblock);
if (isset($parsedComment->tags['Entity'])) {
Expand All @@ -96,7 +100,7 @@ public static function afterClassLikeVisit(AfterClassLikeVisitEvent $event)
$codebase->queueClassLikeForScanning($repositoryClassName);
$file_storage->referenced_classlikes[strtolower($repositoryClassName)] = $repositoryClassName;
}
} catch (DocblockParseException $e) {
} catch (DocblockParseException) {
}
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/Plugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ public function __invoke(RegistrationInterface $registration, ?\SimpleXMLElement
require_once __DIR__.'/Handler/ConsoleHandler.php';
require_once __DIR__.'/Handler/ContainerDependencyHandler.php';
require_once __DIR__.'/Handler/RequiredSetterHandler.php';
require_once __DIR__.'/Handler/DoctrineQueryBuilderHandler.php';
require_once __DIR__.'/Provider/FormGetErrorsReturnTypeProvider.php';

$registration->registerHooksFromClass(HeaderBagHandler::class);
Expand All @@ -45,16 +44,18 @@ public function __invoke(RegistrationInterface $registration, ?\SimpleXMLElement
$registration->registerHooksFromClass(RequiredSetterHandler::class);

if (class_exists(\Doctrine\ORM\QueryBuilder::class)) {
require_once __DIR__.'/Handler/DoctrineQueryBuilderHandler.php';
$registration->registerHooksFromClass(DoctrineQueryBuilderHandler::class);

require_once __DIR__.'/Handler/DoctrineRepositoryHandler.php';
$registration->registerHooksFromClass(DoctrineRepositoryHandler::class);
}

if (class_exists(AnnotationRegistry::class)) {
require_once __DIR__.'/Handler/DoctrineRepositoryHandler.php';
if (method_exists(AnnotationRegistry::class, 'registerLoader')) {
/** @psalm-suppress DeprecatedMethod */
AnnotationRegistry::registerLoader('class_exists');
}
$registration->registerHooksFromClass(DoctrineRepositoryHandler::class);

require_once __DIR__.'/Handler/AnnotationHandler.php';
$registration->registerHooksFromClass(AnnotationHandler::class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
@symfony-common
Feature: RepositoryClass using attributes

Background:
Given I have issue handlers "UndefinedClass,UnusedVariable" suppressed
And I have Symfony plugin enabled
And I have the following code preamble
"""
<?php
namespace RepositoryClass;

use Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\EntityWithAttributes;
use Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\EntityWithAttributesRepository;
use Doctrine\ORM\EntityManagerInterface;
"""

Scenario: The plugin can find correct repository class from entity
Given I have the following code
"""
class SomeService
{
public function __construct(EntityManagerInterface $entityManager)
{
/** @psalm-trace $repository */
$repository = $entityManager->getRepository(EntityWithAttributes::class);
}
}
"""
When I run Psalm
Then I see these errors
| Type | Message |
| Trace | $repository: Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\EntityWithAttributesRepository |
And I see no other errors

Scenario: Passing variable class does not crash the plugin
Given I have the following code
"""
class SomeService
{
public function __construct(EntityManagerInterface $entityManager)
{
$entity = 'Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine\EntityWithAttributes';
/** @psalm-trace $repository */
$repository = $entityManager->getRepository($entity::class);
}
}
"""
When I run Psalm
Then I see these errors
| Type | Message |
| Trace | $repository: Doctrine\ORM\EntityRepository<object> |
| MixedArgument | Argument 1 of Doctrine\ORM\EntityManagerInterface::getRepository cannot be mixed, expecting class-string |
And I see no other errors
12 changes: 12 additions & 0 deletions tests/fixture/Doctrine/EntityWithAttributes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine;

use Doctrine\ORM\Mapping\Entity;

#[Entity(repositoryClass: EntityWithAttributesRepository::class)]
class EntityWithAttributes
{
}
14 changes: 14 additions & 0 deletions tests/fixture/Doctrine/EntityWithAttributesRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Psalm\SymfonyPsalmPlugin\Tests\Fixture\Doctrine;

use Doctrine\ORM\EntityRepository;

/**
* @extends EntityRepository<EntityWithAttributes>
*/
class EntityWithAttributesRepository extends EntityRepository
{
}