Skip to content

Commit 42f5180

Browse files
committed
bug #2922 [LiveComponent] Fix BC break when using PropertyTypeExtractorInterface::getType() on a #[LiveProp] property x when getter getX exists (Kocal)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Fix BC break when using `PropertyTypeExtractorInterface::getType()` on a `#[LiveProp]` property `x` when getter `getX` exists | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no <!-- please update src/**/CHANGELOG.md files --> | Docs? | no <!-- required for new features --> | Issues | Fix #2888 <!-- prefix each issue number with "Fix #", no need to create an issue if none exist, explain below instead --> | License | MIT Prevent: ``` 1) Symfony\UX\LiveComponent\Tests\Functional\Form\ComponentWithFormTest::testFormWithLivePropContainingAnEntityImplementingAnInterface LogicException: Cannot dehydrate value typed as interface "Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User" on component "Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithUserInterfaceComponent". Change this to a concrete type that can be dehydrated. Or set the hydrateWith/dehydrateWith options in LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer. ``` Given the LiveComponent: ```php <?php /* * This file is part of the Symfony package. * * (c) Fabien Potencier <[email protected]> * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. */ namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Form\FormInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\ComponentWithFormTrait; use Symfony\UX\LiveComponent\DefaultActionTrait; use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User; use Symfony\UX\LiveComponent\Tests\Fixtures\Form\UserFormType; #[AsLiveComponent('form_with_user_interface', template: 'components/form_with_user_interface.html.twig')] class FormWithUserInterfaceComponent extends AbstractController { use ComponentWithFormTrait; use DefaultActionTrait; #[LiveProp] public User $user; protected function instantiateForm(): FormInterface { return $this->createForm(UserFormType::class, $this->user); } } ``` Dumping `$propMetadata` in `\Symfony\UX\LiveComponent\LiveComponentHydrator::dehydrateValue` gives: ``` Symfony\UX\LiveComponent\Metadata\LivePropMetadata {#240 -name: "user" -liveProp: Symfony\UX\LiveComponent\Attribute\LiveProp {#230 -writable: false -hydrateWith: null -dehydrateWith: null -useSerializerForHydration: false -serializationContext: [] -fieldName: null -format: null -updateFromParent: false -onUpdated: null -url: false -modifier: null } -type: Symfony\Component\TypeInfo\Type\NullableType {#4197 -types: array:2 [ 0 => Symfony\Component\TypeInfo\Type\ObjectType {#5811 -className: "Symfony\Component\Security\Core\User\UserInterface" } 1 => Symfony\Component\TypeInfo\Type\BuiltinType {#4208 -typeIdentifier: Symfony\Component\TypeInfo\TypeIdentifier {#5983 +name: "NULL" +value: "null" } } ] -type: Symfony\Component\TypeInfo\Type\ObjectType {#5811} } } ``` The class name is the `UserInterface` and not `User` 🤔 ## To tests it - With PropertyInfo only: `sfcp req symfony/property-info:^6.4 -W && sfp vendor/bin/simple-phpunit tests/Functional/Form/ComponentWithFormTest.php --filter "testFormWithLivePropContainingAnEntityImplementingAnInterface"` - With PropertyInfo & Type: `sfcp req 'symfony/property-info:7.2.*' 'symfony/type-info:7.2.*' && sfp vendor/bin/simple-phpunit tests/Functional/Form/ComponentWithFormTest.php --filter "testFormWithLivePropContainingAnEntityImplementingAnInterface"` Commits ------- de302ef [LiveComponent] Fix BC break when using `PropertyTypeExtractorInterface::getType()` on a `#[LiveProp]` property `x` when getter `getX` exists
2 parents bc84475 + de302ef commit 42f5180

File tree

8 files changed

+221
-16
lines changed

8 files changed

+221
-16
lines changed

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
191191
->setArguments([
192192
new Reference('ux.twig_component.component_factory'),
193193
new Reference('property_info'),
194+
new Reference('type_info.resolver', ContainerInterface::NULL_ON_INVALID_REFERENCE),
194195
])
195196
->addTag('kernel.reset', ['method' => 'reset'])
196197
;

src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@
1313

1414
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
1515
use Symfony\Component\PropertyInfo\Type as LegacyType;
16-
use Symfony\Component\TypeInfo\Type\IntersectionType;
17-
use Symfony\Component\TypeInfo\Type\NullableType;
18-
use Symfony\Component\TypeInfo\Type\UnionType;
16+
use Symfony\Component\TypeInfo\Type;
17+
use Symfony\Component\TypeInfo\Type\CollectionType;
18+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolver;
1919
use Symfony\Contracts\Service\ResetInterface;
2020
use Symfony\UX\LiveComponent\Attribute\LiveProp;
2121
use Symfony\UX\TwigComponent\ComponentFactory;
@@ -33,7 +33,11 @@ class LiveComponentMetadataFactory implements ResetInterface
3333
public function __construct(
3434
private ComponentFactory $componentFactory,
3535
private PropertyTypeExtractorInterface $propertyTypeExtractor,
36+
private ?TypeResolver $typeResolver = null,
3637
) {
38+
if (method_exists($this->propertyTypeExtractor, 'getType') && !$this->typeResolver) {
39+
throw new \LogicException('Symfony TypeInfo is required to use LiveProps. Try running "composer require symfony/type-info".');
40+
}
3741
}
3842

3943
public function getMetadata(string $name): LiveComponentMetadata
@@ -77,13 +81,13 @@ public function createPropMetadatas(\ReflectionClass $class): array
7781

7882
public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata|LegacyLivePropMetadata
7983
{
84+
$reflectionType = $property->getType();
85+
if ($reflectionType instanceof \ReflectionUnionType || $reflectionType instanceof \ReflectionIntersectionType) {
86+
throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName()));
87+
}
88+
8089
// BC layer when "symfony/type-info" is not available
8190
if (!method_exists($this->propertyTypeExtractor, 'getType')) {
82-
$type = $property->getType();
83-
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
84-
throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property %s in %s.', $property->getName(), $property->getDeclaringClass()->getName()));
85-
}
86-
8791
$infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];
8892

8993
$collectionValueType = null;
@@ -96,14 +100,16 @@ public function createLivePropMetadata(string $className, string $propertyName,
96100
}
97101
}
98102

99-
if (null === $type && null === $collectionValueType && isset($infoTypes[0])) {
103+
if (null === $reflectionType && null === $collectionValueType && isset($infoTypes[0])) {
104+
// If it's an "advanced" type (like a Collection), let's use the PropertyTypeExtractor to get the Type
100105
$infoType = LegacyType::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType();
101106
$isTypeBuiltIn = null === $infoTypes[0]->getClassName();
102107
$isTypeNullable = $infoTypes[0]->isNullable();
103108
} else {
104-
$infoType = $type?->getName();
105-
$isTypeBuiltIn = $type?->isBuiltin() ?? false;
106-
$isTypeNullable = $type?->allowsNull() ?? true;
109+
// Otherwise, we can use the ReflectionType to get the Type
110+
$infoType = $reflectionType?->getName();
111+
$isTypeBuiltIn = $reflectionType?->isBuiltin() ?? false;
112+
$isTypeNullable = $reflectionType?->allowsNull() ?? true;
107113
}
108114

109115
return new LegacyLivePropMetadata(
@@ -115,10 +121,17 @@ public function createLivePropMetadata(string $className, string $propertyName,
115121
$collectionValueType
116122
);
117123
} else {
118-
$type = $this->propertyTypeExtractor->getType($className, $property->getName());
119-
120-
if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) {
121-
throw new \LogicException(\sprintf('Union or intersection types are not supported for LiveProps. You may want to change the type of property "%s" in "%s".', $propertyName, $className));
124+
$infoType = $this->propertyTypeExtractor->getType($className, $property->getName());
125+
126+
if ($infoType instanceof CollectionType) {
127+
// If it's an "advanced" type (like CollectionType), let's use the PropertyTypeExtractor to get the Type
128+
$type = $infoType;
129+
} elseif (null !== $reflectionType) {
130+
// Otherwise, we can use the TypeResolver to convert the ReflectionType to a Type
131+
$type = $this->typeResolver->resolve($reflectionType);
132+
} else {
133+
// If no type is available, we default to mixed
134+
$type = Type::mixed();
122135
}
123136

124137
return new LivePropMetadata($property->getName(), $liveProp, $type);
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\UX\LiveComponent\Tests\Fixtures\Component;
13+
14+
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
15+
use Symfony\Component\Form\FormInterface;
16+
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
17+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
18+
use Symfony\UX\LiveComponent\ComponentWithFormTrait;
19+
use Symfony\UX\LiveComponent\DefaultActionTrait;
20+
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
21+
use Symfony\UX\LiveComponent\Tests\Fixtures\Form\UserFormType;
22+
23+
#[AsLiveComponent('form_with_user_interface', template: 'components/form_with_user_interface.html.twig')]
24+
class FormWithUserInterfaceComponent extends AbstractController
25+
{
26+
use ComponentWithFormTrait;
27+
use DefaultActionTrait;
28+
29+
#[LiveProp]
30+
public User $user;
31+
32+
protected function instantiateForm(): FormInterface
33+
{
34+
return $this->createForm(UserFormType::class, $this->user);
35+
}
36+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\UX\LiveComponent\Tests\Fixtures\Entity;
13+
14+
use Doctrine\ORM\Mapping as ORM;
15+
use Symfony\Component\Security\Core\User\UserInterface;
16+
17+
#[ORM\Entity]
18+
class User implements UserInterface
19+
{
20+
#[ORM\Id]
21+
#[ORM\GeneratedValue]
22+
#[ORM\Column(type: 'integer')]
23+
public $id;
24+
25+
#[ORM\Column(type: 'string', length: 180, unique: true)]
26+
public $username;
27+
28+
public function getRoles(): array
29+
{
30+
return ['ROLE_USER'];
31+
}
32+
33+
public function eraseCredentials(): void
34+
{
35+
// no-op
36+
}
37+
38+
public function getUsername(): string
39+
{
40+
return $this->getUserIdentifier();
41+
}
42+
43+
public function getUserIdentifier(): string
44+
{
45+
return $this->username;
46+
}
47+
48+
public function getPassword(): ?string
49+
{
50+
return null;
51+
}
52+
53+
public function getSalt(): ?string
54+
{
55+
return null;
56+
}
57+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\UX\LiveComponent\Tests\Fixtures\Form;
13+
14+
use Symfony\Component\Form\AbstractType;
15+
use Symfony\Component\Form\Extension\Core\Type\TextType;
16+
use Symfony\Component\Form\FormBuilderInterface;
17+
use Symfony\Component\OptionsResolver\OptionsResolver;
18+
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
19+
20+
class UserFormType extends AbstractType
21+
{
22+
public function buildForm(FormBuilderInterface $builder, array $options)
23+
{
24+
$builder
25+
->add('username', TextType::class)
26+
;
27+
}
28+
29+
public function configureOptions(OptionsResolver $resolver)
30+
{
31+
$resolver->setDefaults([
32+
'data_class' => User::class,
33+
]);
34+
}
35+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
<div{{ attributes }}>
2+
{{ form(form) }}
3+
</div>

src/LiveComponent/tests/Functional/Form/ComponentWithFormTest.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@
1515
use Symfony\Component\DomCrawler\Crawler;
1616
use Symfony\Component\Form\FormFactoryInterface;
1717
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\FormWithCollectionTypeComponent;
18+
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
1819
use Symfony\UX\LiveComponent\Tests\Fixtures\Factory\CategoryFixtureEntityFactory;
1920
use Symfony\UX\LiveComponent\Tests\Fixtures\Form\BlogPostFormType;
2021
use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper;
2122
use Zenstruck\Browser\Test\HasBrowser;
2223
use Zenstruck\Foundry\Test\Factories;
2324
use Zenstruck\Foundry\Test\ResetDatabase;
2425

26+
use function Zenstruck\Foundry\Persistence\persist;
27+
use function Zenstruck\Foundry\Persistence\refresh;
28+
2529
/**
2630
* @author Jakub Caban <[email protected]>
2731
*/
@@ -450,4 +454,38 @@ public function testDataModelAttributeAutomaticallyAdded(): void
450454
->assertElementAttributeContains('form', 'data-model', 'on(change)|*')
451455
;
452456
}
457+
458+
public function testFormWithLivePropContainingAnEntityImplementingAnInterface(): void
459+
{
460+
$user = persist(User::class, ['username' => 'Fabien']);
461+
self::assertInstanceOf(User::class, $user);
462+
self::assertEquals(1, $user->id);
463+
self::assertEquals('Fabien', $user->username);
464+
465+
$mounted = $this->mountComponent('form_with_user_interface', [
466+
'user' => $user,
467+
]);
468+
469+
$dehydrated = $this->dehydrateComponent($mounted)->getProps();
470+
471+
$this->browser()
472+
->post('/_components/form_with_user_interface', [
473+
'body' => [
474+
'data' => json_encode([
475+
'props' => $dehydrated,
476+
'updated' => [
477+
'user_form.username' => 'Nicolas',
478+
'validatedFields' => ['user_form.username'],
479+
],
480+
]),
481+
],
482+
])
483+
->assertStatus(200)
484+
->assertElementAttributeContains('form', 'data-model', 'on(change)|*')
485+
;
486+
487+
refresh($user);
488+
self::assertEquals(1, $user->id);
489+
self::assertEquals('Nicolas', $user->username);
490+
}
453491
}

src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
3838
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity2;
3939
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\ProductFixtureEntity;
40+
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\User;
4041
use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\EmptyStringEnum;
4142
use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\IntEnum;
4243
use Symfony\UX\LiveComponent\Tests\Fixtures\Enum\StringEnum;
@@ -336,6 +337,27 @@ public function onEntireEntityUpdated($oldValue)
336337
;
337338
}];
338339

340+
yield 'Persisted entity: (de)hydration works correctly to/from id, when the entity implements an interface' => [function () {
341+
$user = persist(User::class, [
342+
'username' => 'Fabien',
343+
]);
344+
\assert($user instanceof User);
345+
346+
return HydrationTest::create(new class {
347+
#[LiveProp]
348+
public User $user;
349+
})
350+
->mountWith(['user' => $user])
351+
->assertDehydratesTo(['user' => $user->id])
352+
->assertObjectAfterHydration(function (object $object) use ($user) {
353+
self::assertSame(
354+
$user->id,
355+
$object->user->id
356+
);
357+
})
358+
;
359+
}];
360+
339361
yield 'Persisted entity: writable CAN be changed via id' => [function () {
340362
$entityOriginal = persist(Entity1::class);
341363
$entityNext = persist(Entity1::class);

0 commit comments

Comments
 (0)