Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[PropertyInfo][Serializer] Add limited generics support to PhpStanExtractor #47556

Open
wants to merge 1 commit into
base: 7.1
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/Symfony/Component/PropertyInfo/CHANGELOG.md
@@ -1,6 +1,11 @@
CHANGELOG
=========

6.2
---

* Add limited generics support to `PhpStanExtractor`

6.1
---

Expand Down
146 changes: 123 additions & 23 deletions src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php
Expand Up @@ -15,6 +15,10 @@
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
use PHPStan\PhpDocParser\Ast\Type\NullableTypeNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\ConstExprParser;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
Expand Down Expand Up @@ -72,7 +76,7 @@ public function __construct(array $mutatorPrefixes = null, array $accessorPrefix
public function getTypes(string $class, string $property, array $context = []): ?array
{
/** @var PhpDocNode|null $docNode */
[$docNode, $source, $prefix, $declaringClass] = $this->getDocBlock($class, $property);
[$docNode, $source, $prefix, $declaringClass] = $this->getDocBlock($class, $property, $context['normalization_outer_class_property'] ?? null);
$nameScope = $this->nameScopeFactory->create($class, $declaringClass);
if (null === $docNode) {
return null;
Expand Down Expand Up @@ -142,7 +146,9 @@ public function getTypes(string $class, string $property, array $context = []):

public function getTypesFromConstructor(string $class, string $property): ?array
{
if (null === $tagDocNode = $this->getDocBlockFromConstructor($class, $property)) {
[,$tagDocNode] = $this->getDocBlockFromConstructor($class, $property);

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

Expand All @@ -158,7 +164,11 @@ public function getTypesFromConstructor(string $class, string $property): ?array
return $types;
}

private function getDocBlockFromConstructor(string $class, string $property): ?ParamTagValueNode
/**
* @param string $class
* @return array{PhpDocNode|PhpDocTagNode}|null
*/
private function getDocBlockFromConstructor(string $class, string $property): ?array
{
try {
$reflectionClass = new \ReflectionClass($class);
Expand All @@ -171,11 +181,16 @@ private function getDocBlockFromConstructor(string $class, string $property): ?P
}

$rawDocNode = $reflectionConstructor->getDocComment();

if (!$rawDocNode) {
return null;
}

$tokens = new TokenIterator($this->lexer->tokenize($rawDocNode));
$phpDocNode = $this->phpDocParser->parse($tokens);
$tokens->consumeTokenType(Lexer::TOKEN_END);

return $this->filterDocBlockParams($phpDocNode, $property);
return [$phpDocNode, $this->filterDocBlockParams($phpDocNode, $property)];
}

private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam): ?ParamTagValueNode
Expand All @@ -194,21 +209,21 @@ private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam)
/**
* @return array{PhpDocNode|null, int|null, string|null, string|null}
*/
private function getDocBlock(string $class, string $property): array
private function getDocBlock(string $class, string $property, ?string $outerClassProperty = null): array
{
$propertyHash = $class.'::'.$property;
$propertyHash = $class.'::'.$property.'|'.$outerClassProperty;

if (isset($this->docBlocks[$propertyHash])) {
return $this->docBlocks[$propertyHash];
}

$ucFirstProperty = ucfirst($property);

if ([$docBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property)) {
if ([$docBlock, $source, $declaringClass] = $this->getDocBlockFromProperty($class, $property, $outerClassProperty)) {
$data = [$docBlock, $source, null, $declaringClass];
} elseif ([$docBlock, $_, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR)) {
} elseif ([$docBlock, $_, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::ACCESSOR, $outerClassProperty)) {
$data = [$docBlock, self::ACCESSOR, null, $declaringClass];
} elseif ([$docBlock, $prefix, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR)) {
} elseif ([$docBlock, $prefix, $declaringClass] = $this->getDocBlockFromMethod($class, $ucFirstProperty, self::MUTATOR, $outerClassProperty)) {
$data = [$docBlock, self::MUTATOR, $prefix, $declaringClass];
} else {
$data = [null, null, null, null];
Expand All @@ -220,7 +235,7 @@ private function getDocBlock(string $class, string $property): array
/**
* @return array{PhpDocNode, int, string}|null
*/
private function getDocBlockFromProperty(string $class, string $property): ?array
private function getDocBlockFromProperty(string $class, string $property, ?string $outerClassProperty = null): ?array
{
// Use a ReflectionProperty instead of $class to get the parent class if applicable
try {
Expand All @@ -229,31 +244,76 @@ private function getDocBlockFromProperty(string $class, string $property): ?arra
return null;
}

$source = self::PROPERTY;
$propertyDocNode = $reflectionProperty->getDocComment();

if ($reflectionProperty->isPromoted()) {
$constructor = new \ReflectionMethod($class, '__construct');
$rawDocNode = $constructor->getDocComment();
$source = self::MUTATOR;
if (!$propertyDocNode) {
if ($reflectionProperty->isPromoted() && null !== [$phpDocNode, $propertyTagNode] = $this->getDocBlockFromConstructor($class, $property)) {
if ($propertyTagNode !== null) {
$source = self::MUTATOR;
} else {
return null;
}
} else {
return null;
}
} else {
$rawDocNode = $reflectionProperty->getDocComment();
$tokens = new TokenIterator($this->lexer->tokenize($propertyDocNode));
$phpDocNode = $this->phpDocParser->parse($tokens);
$source = self::PROPERTY;
$tokens->consumeTokenType(Lexer::TOKEN_END);
}

if (!$rawDocNode) {
return null;
if ($outerClassProperty !== null) {
$this->resolveGenericTypes($class, $phpDocNode, $outerClassProperty);
}

$tokens = new TokenIterator($this->lexer->tokenize($rawDocNode));
$phpDocNode = $this->phpDocParser->parse($tokens);
$tokens->consumeTokenType(Lexer::TOKEN_END);

return [$phpDocNode, $source, $reflectionProperty->class];
}

private function resolveGenericTypes(string $class, PhpDocNode $docBlock, string $outerClassProperty): void
{
if ($classDocBlock = $this->getDocBlockFromClass($class)) {
// Search @var and @param tags to support promoted properties
if(false !== $propertyTypeTag = current($docBlock->getVarTagValues() + $docBlock->getParamTagValues() + $docBlock->getReturnTagValues())) {
if ([] !== $classDocBlockTemplateTags = $classDocBlock->getTemplateTagValues()) {
if (null === $templatePosition = $this->getTemplateDeclarationOrderPositionOfPropertyTag($classDocBlockTemplateTags, $propertyTypeTag)) {
return;
}

[$outerClass, $outerProperty] = explode('::', $outerClassProperty);
[$outerClassPropertyDocBlock] = $this->docBlocks[$outerClassProperty] ?? $this->getDocBlock($outerClass, $outerProperty);

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

$outerClassPropertyTypeTag = current($outerClassPropertyDocBlock->getVarTagValues() + $outerClassPropertyDocBlock->getParamTagValues() + $docBlock->getReturnTagValues());

if ($outerClassPropertyTypeTag === []) {
return;
}

$genericType = clone $outerClassPropertyTypeTag->type;
$nonNullableGenericType = $genericType instanceof NullableTypeNode ? $genericType->type : $genericType;

if ($nonNullableGenericType instanceof GenericTypeNode) {
$typeVariableType = $nonNullableGenericType->genericTypes[$templatePosition];

if (!\in_array($typeVariableType->name, Type::$builtinTypes, true)) {
$propertyTypeTag->name = '\\' . $this->nameScopeFactory->create(explode('::', $outerClassProperty)[0])->resolveStringName($typeVariableType->name);
}

$propertyTypeTag->type = $typeVariableType;
}
}
}
}
}

/**
* @return array{PhpDocNode, string, string}|null
*/
private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type): ?array
private function getDocBlockFromMethod(string $class, string $ucFirstProperty, int $type, ?string $outerClassProperty = null): ?array
{
$prefixes = self::ACCESSOR === $type ? $this->accessorPrefixes : $this->mutatorPrefixes;
$prefix = null;
Expand Down Expand Up @@ -290,6 +350,46 @@ private function getDocBlockFromMethod(string $class, string $ucFirstProperty, i
$phpDocNode = $this->phpDocParser->parse($tokens);
$tokens->consumeTokenType(Lexer::TOKEN_END);

if (null !== $outerClassProperty) {
$this->resolveGenericTypes($class, $phpDocNode, $outerClassProperty);
}

return [$phpDocNode, $prefix, $reflectionMethod->class];
}

private function getDocBlockFromClass(string $class): ?PhpDocNode
{
// Use a ReflectionProperty instead of $class to get the parent class if applicable
try {
$reflectionClass = new \ReflectionClass($class);
} catch (\ReflectionException $e) {
return null;
}

if (null === $rawDocNode = $reflectionClass->getDocComment() ?: null) {
return null;
}

$tokens = new TokenIterator($this->lexer->tokenize($rawDocNode));
$phpDocNode = $this->phpDocParser->parse($tokens);

$tokens->consumeTokenType(Lexer::TOKEN_END);

return $phpDocNode;
}

/**
* @param TemplateTagValueNode[] $classDocBlockTemplateTags
* @param ParamTagValueNode|VarTagValueNode $propertyTypeTag
*/
private function getTemplateDeclarationOrderPositionOfPropertyTag(array $classDocBlockTemplateTags, mixed $propertyTypeTag): int|null
{
foreach ($classDocBlockTemplateTags as $orderPosition => $classDocBlockTemplateTag) {
if ($classDocBlockTemplateTag->name === $propertyTypeTag->type->name) {
return (int) $orderPosition;
}
}

return null;
}
}
Expand Up @@ -16,12 +16,14 @@
use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor;
use Symfony\Component\PropertyInfo\Tests\Fixtures\DefaultValue;
use Symfony\Component\PropertyInfo\Tests\Fixtures\Dummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\GenericDummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80Dummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80PromotedDummy;
use Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy\RootDummyItem;
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsedInTrait;
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsingTrait;
use Symfony\Component\PropertyInfo\Tests\Fixtures\TypeVariableDummy;
use Symfony\Component\PropertyInfo\Type;

require_once __DIR__.'/../Fixtures/Extractor/DummyNamespace.php';
Expand Down Expand Up @@ -381,7 +383,7 @@ public function unionTypesProvider(): array
['b', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]],
['c', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]],
['d', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])])]],
['e', [new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class, true, [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING)])], [new Type(Type::BUILTIN_TYPE_INT), new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [new Type(Type::BUILTIN_TYPE_INT)], [new Type(Type::BUILTIN_TYPE_STRING, false, null, true, [], [new Type(Type::BUILTIN_TYPE_OBJECT, false, DefaultValue::class)])])]), new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)]],
['e', [new Type(Type::BUILTIN_TYPE_OBJECT, true, Dummy::class, false, [], []), new Type(Type::BUILTIN_TYPE_OBJECT, false, ParentDummy::class)]],
['f', null],
['g', [new Type(Type::BUILTIN_TYPE_ARRAY, false, null, true, [], [new Type(Type::BUILTIN_TYPE_STRING), new Type(Type::BUILTIN_TYPE_INT)])]],
];
Expand Down Expand Up @@ -420,6 +422,42 @@ public function pseudoTypesProvider(): array
];
}

/**
* @dataProvider genericTypeProvider
*/
public function testGenericTypes(string $outerClassProperty,string $innerClassProperty, bool $isNullableProperty, array $type)
{
$this->assertEquals([new Type(Type::BUILTIN_TYPE_OBJECT, $isNullableProperty, TypeVariableDummy::class)], $this->extractor->getTypes(GenericDummy::class, $outerClassProperty));
$this->assertEquals($type, $this->extractor->getTypes(TypeVariableDummy::class, $innerClassProperty, ['normalization_outer_class_property' => GenericDummy::class.'::'.$outerClassProperty]));
}

public function genericTypeProvider(): array
{
return [
['stringProperty', 'property', false, [new Type(Type::BUILTIN_TYPE_STRING)]],
['objectProperty', 'property', false, [new Type(Type::BUILTIN_TYPE_OBJECT, false, \stdClass::class)]],
['nullableObjectProperty', 'property', true, [new Type(Type::BUILTIN_TYPE_OBJECT, false, \stdClass::class)]],
['getterPropertyWithClassLevelTemplateReturnString', 'classLevelTemplateDeclaration', false, [new Type(Type::BUILTIN_TYPE_STRING)]],
];
}

/**
* @dataProvider genericTypePropertyDeclarationProvider
*/
public function testGenericTypeOnDifferentPropertyDeclaration(string $property)
{
$this->assertEquals([new Type(Type::BUILTIN_TYPE_STRING)], $this->extractor->getTypes(TypeVariableDummy::class, $property, ['normalization_outer_class_property' => 'Symfony\Component\PropertyInfo\Tests\Fixtures\GenericDummy::stringProperty']));
}

public function genericTypePropertyDeclarationProvider(): array
{
return [
['property'],
['promotedPropertyWithParamTypeDeclaration'],
['promotedPropertyWithVarTypeDeclaration']
];
}

public function testDummyNamespace()
{
$this->assertEquals(
Expand Down
@@ -0,0 +1,26 @@
<?php

namespace Symfony\Component\PropertyInfo\Tests\Fixtures;

class GenericDummy
{
/**
* @var TypeVariableDummy<string, mixed>
*/
public $stringProperty;

/**
* @var TypeVariableDummy<\stdClass, mixed>
*/
public $objectProperty;

/**
* @var ?TypeVariableDummy<\stdClass, mixed>
*/
public $nullableObjectProperty;

/**
* @var TypeVariableDummy<mixed, string>
*/
public $getterPropertyWithClassLevelTemplateReturnString;
}
@@ -0,0 +1,39 @@
<?php

namespace Symfony\Component\PropertyInfo\Tests\Fixtures;

/**
* @template TClassLevelProperty
* @template TClassLevelMethod
*/
class TypeVariableDummy
{
/**
* @var TClassLevelProperty
*/
public $property;

private mixed $propertyOfGetter;

/**
* @param TClassLevelProperty $promotedPropertyWithParamTypeDeclaration
*/
public function __construct(
public mixed $promotedPropertyWithParamTypeDeclaration,
/**
* @var TClassLevelProperty $promotedPropertyWithVarTypeDeclaration
*/
public mixed $promotedPropertyWithVarTypeDeclaration,
)
{

}

/**
* @return TClassLevelMethod
*/
public function getClassLevelTemplateDeclaration()
{
return $this->propertyOfGetter;
}
}
Expand Up @@ -119,6 +119,14 @@ private function extractTypes(TypeNode $node, NameScope $nameScope): array
return [new Type(Type::BUILTIN_TYPE_STRING)];
}

// Generics of iterable types are extracted as collection to maintain BC
if(
!\in_array($node->type->name, [...Type::$builtinCollectionTypes, 'list', 'non-empty-list','non-empty-array'])
&& !is_subclass_of($nameScope->resolveStringName($node->type->name), \Traversable::class)
) {
return $this->extractTypes($node->type, $nameScope);
}

[$mainType] = $this->extractTypes($node->type, $nameScope);

if (Type::BUILTIN_TYPE_INT === $mainType->getBuiltinType()) {
Expand Down