diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 8c9dad62a07..84913a80f8d 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -98,7 +98,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->setArguments([ tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG), new Reference('property_accessor'), - new Reference('property_info'), + new Reference('ux.live_component.metadata_factory'), new Reference('serializer'), '%kernel.secret%', ]) diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index 732336e3494..eae7f368c05 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -16,7 +16,6 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -25,6 +24,7 @@ use Symfony\UX\LiveComponent\Exception\HydrationException; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; use Symfony\UX\LiveComponent\Util\DehydratedProps; use Symfony\UX\TwigComponent\ComponentAttributes; @@ -47,7 +47,7 @@ final class LiveComponentHydrator public function __construct( private iterable $hydrationExtensions, private PropertyAccessorInterface $propertyAccessor, - private PropertyTypeExtractorInterface $propertyTypeExtractor, + private LiveComponentMetadataFactory $liveComponentMetadataFactory, private NormalizerInterface|DenormalizerInterface $normalizer, private string $secret ) { @@ -388,14 +388,14 @@ private function dehydrateObjectValue(object $value, string $classType, ?string } } - $propertiesValues = []; + $hydratedObject = []; foreach ((new \ReflectionClass($classType))->getProperties() as $property) { $propertyValue = $this->propertyAccessor->getValue($value, $property->getName()); - $propMetadata = $this->generateLivePropMetadata($classType, $property->getName()); - $propertiesValues[$property->getName()] = $this->dehydrateValue($propertyValue, $propMetadata, $component); + $propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($classType, $property->getName(), $property); + $hydratedObject[$property->getName()] = $this->dehydrateValue($propertyValue, $propMetadata, $component); } - return $propertiesValues; + return $hydratedObject; } private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $component): mixed @@ -472,9 +472,11 @@ private function hydrateObjectValue(mixed $value, string $className, bool $allow if (\is_array($value)) { $object = new $className(); - foreach ($value as $property => $propertyValue) { - $propMetadata = $this->generateLivePropMetadata($className, $property); - $this->propertyAccessor->setValue($object, $property, $this->hydrateValue($propertyValue, $propMetadata, $component)); + foreach ($value as $propertyName => $propertyValue) { + $reflexionClass = new \ReflectionClass($className); + $property = $reflexionClass->getProperty($propertyName); + $propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($className, $propertyName, $property); + $this->propertyAccessor->setValue($object, $propertyName, $this->hydrateValue($propertyValue, $propMetadata, $component)); } return $object; @@ -572,35 +574,4 @@ private function recursiveKeySort(array &$data): void } ksort($data); } - - private function generateLivePropMetadata(string $className, string $propertyName): LivePropMetadata - { - $reflexionClass = new \ReflectionClass($className); - $property = $reflexionClass->getProperty($propertyName); - - $collectionValueType = null; - $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; - foreach ($infoTypes as $infoType) { - if ($infoType->isCollection()) { - foreach ($infoType->getCollectionValueTypes() as $valueType) { - $collectionValueType = $valueType; - break; - } - } - } - - $type = $property->getType(); - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { - 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())); - } - - return new LivePropMetadata( - $property->getName(), - new LiveProp(true), - $type ? $type->getName() : null, - $type ? $type->isBuiltin() : false, - $type ? $type->allowsNull() : true, - $collectionValueType, - ); - } } diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index 483c69254ca..09e8a33062b 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -59,32 +59,38 @@ public function createPropMetadatas(\ReflectionClass $class): array continue; } - $collectionValueType = null; - $infoTypes = $this->propertyTypeExtractor->getTypes($class->getName(), $property->getName()) ?? []; - foreach ($infoTypes as $infoType) { - if ($infoType->isCollection()) { - foreach ($infoType->getCollectionValueTypes() as $valueType) { - $collectionValueType = $valueType; - break; - } + $metadatas[$property->getName()] = $this->createLivePropMetadata($class->getName(), $property->getName(), $property, $attribute); + } + + return array_values($metadatas); + } + + public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, \ReflectionAttribute $attribute = null): LivePropMetadata + { + $collectionValueType = null; + $infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? []; + foreach ($infoTypes as $infoType) { + if ($infoType->isCollection()) { + foreach ($infoType->getCollectionValueTypes() as $valueType) { + $collectionValueType = $valueType; + break; } } + } - $type = $property->getType(); - if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { - 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())); - } - $metadatas[$property->getName()] = new LivePropMetadata( - $property->getName(), - $attribute->newInstance(), - $type ? $type->getName() : null, - $type ? $type->isBuiltin() : false, - $type ? $type->allowsNull() : true, - $collectionValueType, - ); + $type = $property->getType(); + if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) { + 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())); } - return array_values($metadatas); + return new LivePropMetadata( + $property->getName(), + null !== $attribute ? $attribute->newInstance() : new LiveProp(true), + $type?->getName(), + $type && $type->isBuiltin(), + !$type || $type->allowsNull(), + $collectionValueType, + ); } /** diff --git a/src/LiveComponent/tests/Fixtures/Dto/Address.php b/src/LiveComponent/tests/Fixtures/Dto/Address.php new file mode 100644 index 00000000000..8947915344d --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Dto/Address.php @@ -0,0 +1,9 @@ + false, 'session' => ['storage_factory_id' => 'session.storage.factory.mock_file'], 'http_method_override' => false, + 'property_info' => ['enabled' => true], ]); $c->extension('twig', [ diff --git a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php index b208c401ca7..18a67e5bd1b 100644 --- a/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php +++ b/src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php @@ -19,7 +19,9 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2; use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPostWithSerializationContext; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\CustomerDetails; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Embeddable2; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Money; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Temperature; @@ -783,6 +785,109 @@ public function provideDehydrationHydrationTests(): iterable ; }, 80100]; + yield 'Object: (de)hydrates DTO correctly' => [function () { + return HydrationTest::create(new class() { + #[LiveProp(writable: true)] + public ?Address $address = null; + + public function mount() + { + $this->address = new Address(); + $this->address->address = '1 rue du Bac'; + $this->address->city = 'Paris'; + } + }) + ->mountWith([]) + ->assertDehydratesTo([ + 'address' => [ + 'address' => '1 rue du Bac', + 'city' => 'Paris', + ], + '@checksum' => '2P/KES0BbGw1KNZsOn4CSEpN1HJ1s...+bQPY=', + ]) + ->userUpdatesProps(['address' => ['address' => '4 rue des lilas', 'city' => 'Asnieres']]) + ->assertObjectAfterHydration(function (object $object) { + $this->assertSame($object->address->address, '4 rue des lilas'); + $this->assertSame($object->address->city, 'Asnieres'); + }) + ; + }]; + + yield 'Object: (de)hydrates correctly multidementional DTO' => [function () { + return HydrationTest::create(new class() { + #[LiveProp(writable: true)] + public ?CustomerDetails $customerDetails = null; + + public function mount() + { + $this->customerDetails = new CustomerDetails(); + $this->customerDetails->lastName = 'Matheo'; + $this->customerDetails->firstName = 'Daninos'; + $this->customerDetails->address = new Address(); + $this->customerDetails->address->address = '1 rue du Bac'; + $this->customerDetails->address->city = 'Paris'; + } + }) + ->mountWith([]) + ->assertDehydratesTo([ + 'customerDetails' => [ + 'lastName' => 'Matheo', + 'firstName' => 'Daninos', + 'address' => [ + 'address' => '1 rue du Bac', + 'city' => 'Paris', + ], + ], + '@checksum' => 'tjeTtPH8xCyM2TIxP+FOnRakGHNBE...qQiVA=', + ]) + ->userUpdatesProps(['customerDetails' => ['lastName' => 'Matheo', 'firstName' => 'Daninos', 'address' => ['address' => '3 rue du Bac', 'city' => 'Paris']]]) + ->assertObjectAfterHydration(function (object $object) { + $this->assertSame($object->customerDetails->address->address, '3 rue du Bac'); + $this->assertSame($object->customerDetails->address->city, 'Paris'); + }); + }]; + + yield 'Object: (de)hydrates correctly array of DTO' => [function () { + return HydrationTest::create(new class() { + /** + * @var Symfony\UX\LiveComponent\Tests\Fixtures\Dto\CustomerDetails[] $customerDetailsCollection + */ + #[LiveProp(writable: true)] + public array $customerDetailsCollection = []; + + public function mount() + { + $customerDetails = new CustomerDetails(); + $customerDetails->lastName = 'Matheo'; + $customerDetails->firstName = 'Daninos'; + $customerDetails->address = new Address(); + $customerDetails->address->address = '1 rue du Bac'; + $customerDetails->address->city = 'Paris'; + + $this->customerDetailsCollection[] = $customerDetails; + } + }) + ->mountWith([]) + ->assertDehydratesTo([ + 'customerDetailsCollection' => [ + [ + 'lastName' => 'Matheo', + 'firstName' => 'Daninos', + 'address' => [ + 'address' => '1 rue du Bac', + 'city' => 'Paris', + ], + ], + ], + '@checksum' => 'tjeTtPH8xCyM2TIxP+FOnRakGHNBE...qQiVA=', + ]) + ->userUpdatesProps(['customerDetailsCollection' => [['lastName' => 'Matheo', 'firstName' => 'Daninos', 'address' => ['address' => '3 rue du Bac', 'city' => 'Paris']]]]) + ->assertObjectAfterHydration(function (object $object) { + $this->assertSame($object->customerDetailsCollection[0]->address->address, '3 rue du Bac'); + $this->assertSame($object->customerDetailsCollection[0]->address->city, 'Paris'); + }); + }]; + yield 'Object: using custom normalizer (de)hydrates correctly' => [function () { return HydrationTest::create(new class() { #[LiveProp(useSerializerForHydration: true)]