Skip to content

Commit fae062d

Browse files
committed
[PropertyInfo] ConstructorExtractor now fallbacks on promoted properties
1 parent 84fcf2f commit fae062d

File tree

8 files changed

+231
-11
lines changed

8 files changed

+231
-11
lines changed

src/Symfony/Bundle/FrameworkBundle/Tests/Functional/SerializerTest.php

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,26 @@
2121
*/
2222
class SerializerTest extends AbstractWebTestCase
2323
{
24-
public function testDeserializeArrayOfObject()
24+
/**
25+
* @dataProvider provideDeserializeArrayOfObjectData
26+
*/
27+
public function testDeserializeArrayOfObject(string $expectedClass, bool $usePromotedProperties, bool $withConstructorExtractor)
2528
{
26-
static::bootKernel(['test_case' => 'Serializer']);
29+
static::bootKernel(['test_case' => 'Serializer', 'root_config' => $withConstructorExtractor ? 'config.yml' : 'config_without_constructor_extractor.yml']);
2730

28-
$result = static::getContainer()->get('serializer.alias')->deserialize('{"bars": [{"id": 1}, {"id": 2}]}', Foo::class, 'json');
31+
$result = static::getContainer()->get('serializer.alias')->deserialize('{"bars": [{"id": 1}, {"id": 2}]}', $expectedClass, 'json');
2932

3033
$bar1 = new Bar();
3134
$bar1->id = 1;
3235
$bar2 = new Bar();
3336
$bar2->id = 2;
3437

35-
$expected = new Foo();
36-
$expected->bars = [$bar1, $bar2];
38+
if ($usePromotedProperties) {
39+
$expected = new ($expectedClass)([$bar1, $bar2]);
40+
} else {
41+
$expected = new $expectedClass();
42+
$expected->bars = [$bar1, $bar2];
43+
}
3744

3845
$this->assertEquals($expected, $result);
3946
}
@@ -64,6 +71,18 @@ protected static function getKernelClass(): string
6471
{
6572
return SerializerKernel::class;
6673
}
74+
75+
public function provideDeserializeArrayOfObjectData(): array
76+
{
77+
return [
78+
['expectedClass' => Foo::class, 'usePromotedProperties' => false, 'withConstructorExtractor' => false],
79+
['expectedClass' => FooVar::class, 'usePromotedProperties' => true, 'withConstructorExtractor' => false],
80+
['expectedClass' => FooParam::class, 'usePromotedProperties' => true, 'withConstructorExtractor' => false],
81+
['expectedClass' => Foo::class, 'usePromotedProperties' => false, 'withConstructorExtractor' => true],
82+
['expectedClass' => FooVar::class, 'usePromotedProperties' => true, 'withConstructorExtractor' => true],
83+
['expectedClass' => FooParam::class, 'usePromotedProperties' => true, 'withConstructorExtractor' => true],
84+
];
85+
}
6786
}
6887

6988
class SerializerKernel extends AppKernel implements CompilerPassInterface
@@ -117,3 +136,25 @@ class Bar
117136
*/
118137
public $id;
119138
}
139+
140+
class FooVar
141+
{
142+
public function __construct(
143+
/**
144+
* @var Bar[]
145+
*/
146+
public array $bars,
147+
) {
148+
}
149+
}
150+
151+
class FooParam
152+
{
153+
/**
154+
* @param Bar[] $bars
155+
*/
156+
public function __construct(
157+
public array $bars,
158+
) {
159+
}
160+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
imports:
2+
- { resource: ../config/default.yml }
3+
4+
framework:
5+
http_method_override: false
6+
translator: true
7+
serializer:
8+
enabled: true
9+
circular_reference_handler: Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Serializer\CircularReferenceHandler
10+
max_depth_handler: Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Serializer\MaxDepthHandler
11+
default_context:
12+
enable_max_depth: true
13+
property_info:
14+
enabled: true
15+
with_constructor_extractor: false
16+
17+
services:
18+
serializer.alias:
19+
alias: serializer
20+
public: true
21+
22+
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Serializer\CircularReferenceHandler: ~
23+
24+
Symfony\Bundle\FrameworkBundle\Tests\Fixtures\Serializer\MaxDepthHandler: ~

src/Symfony/Component/PropertyInfo/Extractor/PhpDocExtractor.php

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -186,12 +186,20 @@ public function getTypesFromConstructor(string $class, string $property): ?array
186186
$docBlock = $this->getDocBlockFromConstructor($class, $property);
187187

188188
if (!$docBlock) {
189-
return null;
189+
// If no @param found in constructor docblock, try the promoted property's @var docblock
190+
if (!$docBlock = $this->getDocBlockFromPromotedProperty($class, $property)) {
191+
return null;
192+
}
193+
194+
// For promoted properties, use @var tag instead of @param
195+
$tags = $docBlock->getTagsByName('var');
196+
} else {
197+
$tags = $docBlock->getTagsByName('param');
190198
}
191199

192200
$types = [];
193201
/** @var DocBlock\Tags\Var_|DocBlock\Tags\Return_|DocBlock\Tags\Param $tag */
194-
foreach ($docBlock->getTagsByName('param') as $tag) {
202+
foreach ($tags as $tag) {
195203
if ($tag && null !== $tag->getType()) {
196204
$types[] = $this->phpDocTypeHelper->getTypes($tag->getType());
197205
}
@@ -266,12 +274,20 @@ public function getType(string $class, string $property, array $context = []): ?
266274
public function getTypeFromConstructor(string $class, string $property): ?Type
267275
{
268276
if (!$docBlock = $this->getDocBlockFromConstructor($class, $property)) {
269-
return null;
277+
// If no @param found in constructor docblock, try the promoted property's @var docblock
278+
if (!$docBlock = $this->getDocBlockFromPromotedProperty($class, $property)) {
279+
return null;
280+
}
281+
282+
// For promoted properties, use @var tag instead of @param
283+
$tags = $docBlock->getTagsByName('var');
284+
} else {
285+
$tags = $docBlock->getTagsByName('param');
270286
}
271287

272288
$types = [];
273289
/** @var DocBlock\Tags\Var_|DocBlock\Tags\Return_|DocBlock\Tags\Param $tag */
274-
foreach ($docBlock->getTagsByName('param') as $tag) {
290+
foreach ($tags as $tag) {
275291
if ($tag instanceof InvalidTag || !$tagType = $tag->getType()) {
276292
continue;
277293
}
@@ -310,6 +326,25 @@ private function getDocBlockFromConstructor(string $class, string $property): ?D
310326
}
311327
}
312328

329+
private function getDocBlockFromPromotedProperty(string $class, string $property): ?DocBlock
330+
{
331+
try {
332+
$reflectionProperty = new \ReflectionProperty($class, $property);
333+
} catch (\ReflectionException) {
334+
return null;
335+
}
336+
337+
if (!$reflectionProperty->isPromoted()) {
338+
return null;
339+
}
340+
341+
try {
342+
return $this->docBlockFactory->create($reflectionProperty, $this->createFromReflector($reflectionProperty->getDeclaringClass()));
343+
} catch (\InvalidArgumentException|\RuntimeException) {
344+
return null;
345+
}
346+
}
347+
313348
private function filterDocBlockParams(DocBlock $docBlock, string $allowedParam): DocBlock
314349
{
315350
$tags = array_values(array_filter($docBlock->getTagsByName('param'), fn ($tag) => $tag instanceof DocBlock\Tags\Param && $allowedParam === $tag->getVariableName()));

src/Symfony/Component/PropertyInfo/Extractor/PhpStanExtractor.php

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ public function getTypesFromConstructor(string $class, string $property): ?array
182182
trigger_deprecation('symfony/property-info', '7.3', 'The "%s()" method is deprecated, use "%s::getTypeFromConstructor()" instead.', __METHOD__, self::class);
183183

184184
if (null === $tagDocNode = $this->getDocBlockFromConstructor($class, $property)) {
185-
return null;
185+
// If no @param found in constructor docblock, try the promoted property's @var docblock
186+
if (null === $tagDocNode = $this->getDocBlockFromPromotedProperty($class, $property)) {
187+
return null;
188+
}
186189
}
187190

188191
$types = [];
@@ -247,7 +250,10 @@ public function getTypeFromConstructor(string $class, string $property): ?Type
247250
{
248251
$declaringClass = $class;
249252
if (!$tagDocNode = $this->getDocBlockFromConstructor($declaringClass, $property)) {
250-
return null;
253+
// If no @param found in constructor docblock, try the promoted property's @var docblock
254+
if (!$tagDocNode = $this->getDocBlockFromPromotedProperty($class, $property)) {
255+
return null;
256+
}
251257
}
252258

253259
$typeContext = $this->typeContextFactory->createFromClassName($class, $declaringClass);
@@ -408,6 +414,38 @@ private function filterDocBlockParams(PhpDocNode $docNode, string $allowedParam)
408414
return $tags[0]->value;
409415
}
410416

417+
private function getDocBlockFromPromotedProperty(string $class, string $property): ?VarTagValueNode
418+
{
419+
try {
420+
$reflectionProperty = new \ReflectionProperty($class, $property);
421+
} catch (\ReflectionException) {
422+
return null;
423+
}
424+
425+
if (!$reflectionProperty->isPromoted()) {
426+
return null;
427+
}
428+
429+
if (!$this->canAccessMemberBasedOnItsVisibility($reflectionProperty)) {
430+
return null;
431+
}
432+
433+
$rawDocNode = $reflectionProperty->getDocComment();
434+
if (!$rawDocNode) {
435+
return null;
436+
}
437+
438+
$phpDocNode = $this->getPhpDocNode($rawDocNode);
439+
440+
$tags = array_values(array_filter($phpDocNode->getTagsByName('@var'), fn ($tagNode) => $tagNode->value instanceof VarTagValueNode));
441+
442+
if (!$tags) {
443+
return null;
444+
}
445+
446+
return $tags[0]->value;
447+
}
448+
411449
/**
412450
* @return array{PhpDocNode|null, int|null, string|null, string|null}
413451
*/

src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpDocExtractorTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Symfony\Component\PropertyInfo\Tests\Fixtures\InvalidDummy;
2323
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
2424
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80Dummy;
25+
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80PromotedDummyWithDocblock;
2526
use Symfony\Component\PropertyInfo\Tests\Fixtures\PseudoTypeDummy;
2627
use Symfony\Component\PropertyInfo\Tests\Fixtures\PseudoTypesDummy;
2728
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\DummyUsedInTrait;
@@ -887,6 +888,23 @@ public static function constructorTypesProvider(): iterable
887888
yield ['mixed', Type::mixed()];
888889
}
889890

891+
/**
892+
* @dataProvider constructorPromotedPropertyWithDocblockProvider
893+
*/
894+
public function testExtractConstructorPromotedPropertyWithDocblock(string $property, ?Type $type)
895+
{
896+
$this->assertEquals($type, $this->extractor->getTypeFromConstructor(Php80PromotedDummyWithDocblock::class, $property));
897+
}
898+
899+
/**
900+
* @return iterable<array{0: string, 1: ?Type}>
901+
*/
902+
public static function constructorPromotedPropertyWithDocblockProvider(): iterable
903+
{
904+
// Test that promoted properties with @var docblock are recognized
905+
yield ['dates', Type::list(Type::object(\DateTimeImmutable::class))];
906+
}
907+
890908
/**
891909
* @dataProvider pseudoTypeProvider
892910
*/

src/Symfony/Component/PropertyInfo/Tests/Extractor/PhpStanExtractorTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
use Symfony\Component\PropertyInfo\Tests\Fixtures\ParentDummy;
3636
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80Dummy;
3737
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80PromotedDummy;
38+
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80PromotedDummyWithDocblock;
3839
use Symfony\Component\PropertyInfo\Tests\Fixtures\PhpStanPseudoTypesDummy;
3940
use Symfony\Component\PropertyInfo\Tests\Fixtures\RootDummy\RootDummyItem;
4041
use Symfony\Component\PropertyInfo\Tests\Fixtures\TraitUsage\AnotherNamespace\DummyInAnotherNamespace;
@@ -947,6 +948,23 @@ public static function constructorTypesProvider(): iterable
947948
yield ['ddd', null];
948949
}
949950

951+
/**
952+
* @dataProvider constructorPromotedPropertyWithDocblockProvider
953+
*/
954+
public function testExtractConstructorPromotedPropertyWithDocblock(string $property, ?Type $type)
955+
{
956+
$this->assertEquals($type, $this->extractor->getTypeFromConstructor(Php80PromotedDummyWithDocblock::class, $property));
957+
}
958+
959+
/**
960+
* @return iterable<array{0: string, 1: ?Type}>
961+
*/
962+
public static function constructorPromotedPropertyWithDocblockProvider(): iterable
963+
{
964+
// Test that promoted properties with @var docblock are recognized
965+
yield ['dates', Type::list(Type::object(\DateTimeImmutable::class))];
966+
}
967+
950968
/**
951969
* @dataProvider constructorTypesOfParentClassProvider
952970
*/

src/Symfony/Component/PropertyInfo/Tests/Extractor/ReflectionExtractorTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php7Dummy;
3131
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php7ParentDummy;
3232
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80Dummy;
33+
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php80PromotedDummyWithDocblock;
3334
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php81Dummy;
3435
use Symfony\Component\PropertyInfo\Tests\Fixtures\Php82Dummy;
3536
use Symfony\Component\PropertyInfo\Tests\Fixtures\SnakeCaseDummy;
@@ -1064,6 +1065,23 @@ public static function extractConstructorTypesProvider(): iterable
10641065
yield ['ddd', null];
10651066
}
10661067

1068+
/**
1069+
* @dataProvider constructorPromotedPropertyWithDocblockProvider
1070+
*/
1071+
public function testExtractConstructorPromotedPropertyWithDocblock(string $property, ?Type $type)
1072+
{
1073+
$this->assertEquals($type, $this->extractor->getTypeFromConstructor(Php80PromotedDummyWithDocblock::class, $property));
1074+
}
1075+
1076+
/**
1077+
* @return iterable<array{0: string, 1: ?Type}>
1078+
*/
1079+
public static function constructorPromotedPropertyWithDocblockProvider(): iterable
1080+
{
1081+
// ReflectionExtractor can only extract native PHP types, not generic types from docblocks
1082+
yield ['dates', Type::array()];
1083+
}
1084+
10671085
/**
10681086
* @dataProvider camelizeProvider
10691087
*/
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\PropertyInfo\Tests\Fixtures;
13+
14+
class Php80PromotedDummyWithDocblock
15+
{
16+
public function __construct(
17+
/**
18+
* @var \DateTimeImmutable[]
19+
*/
20+
private array $dates,
21+
) {
22+
}
23+
24+
public function getDates(): array
25+
{
26+
return $this->dates;
27+
}
28+
}

0 commit comments

Comments
 (0)