Skip to content

Commit 7595c70

Browse files
author
matheo
committed
Tests and centralize logic in LiveComponentMetadataFactory
1 parent bd7e719 commit 7595c70

File tree

7 files changed

+178
-69
lines changed

7 files changed

+178
-69
lines changed

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
9898
->setArguments([
9999
tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG),
100100
new Reference('property_accessor'),
101-
new Reference('property_info'),
101+
new Reference('ux.live_component.metadata_factory'),
102102
new Reference('serializer'),
103103
'%kernel.secret%',
104104
])

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@
1616
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
1717
use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException;
1818
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
19-
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
2019
use Symfony\Component\PropertyInfo\Type;
2120
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2221
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -25,6 +24,7 @@
2524
use Symfony\UX\LiveComponent\Exception\HydrationException;
2625
use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface;
2726
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata;
27+
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
2828
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
2929
use Symfony\UX\LiveComponent\Util\DehydratedProps;
3030
use Symfony\UX\TwigComponent\ComponentAttributes;
@@ -47,7 +47,7 @@ final class LiveComponentHydrator
4747
public function __construct(
4848
private iterable $hydrationExtensions,
4949
private PropertyAccessorInterface $propertyAccessor,
50-
private PropertyTypeExtractorInterface $propertyTypeExtractor,
50+
private LiveComponentMetadataFactory $liveComponentMetadataFactory,
5151
private NormalizerInterface|DenormalizerInterface $normalizer,
5252
private string $secret
5353
) {
@@ -344,15 +344,21 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
344344
}
345345

346346
if (\is_array($value)) {
347-
foreach ($value as $key => $objectItem) {
348-
$type = \gettype($objectItem);
349-
if ('object' === $type) {
350-
$type = $objectItem::class;
351-
}
347+
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {
348+
$collectionClass = $propMetadata->collectionValueType()->getClassName();
349+
foreach ($value as $key => $objectItem) {
350+
if (!$objectItem instanceof $collectionClass) {
351+
throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" is an array. We determined the array is full of %s objects, but at least on key had a different value of %s', $propMetadata->getName(), $component::class, $collectionClass, get_debug_type($objectItem)));
352+
}
352353

353-
$propMetadata = new LivePropMetadata($key, new LiveProp(true), $type, false, true, null);
354+
$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $component);
355+
}
356+
}
354357

355-
$value[$key] = $this->dehydrateValue($objectItem, $propMetadata, $component);
358+
if (!$this->isValueValidDehydratedValue($value)) {
359+
$badKeys = $this->getNonScalarKeys($value, $propMetadata->getName());
360+
$badKeysText = implode(', ', array_map(fn ($key) => sprintf('%s: %s', $key, $badKeys[$key]), array_keys($badKeys)));
361+
throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" is an array, but it contains one or more keys that are not scalars: %s', $propMetadata->getName(), $component::class, $badKeysText));
356362
}
357363

358364
return $value;
@@ -388,14 +394,14 @@ private function dehydrateObjectValue(object $value, string $classType, ?string
388394
}
389395
}
390396

391-
$propertiesValues = [];
397+
$hydratedObject = [];
392398
foreach ((new \ReflectionClass($classType))->getProperties() as $property) {
393399
$propertyValue = $this->propertyAccessor->getValue($value, $property->getName());
394-
$propMetadata = $this->generateLivePropMetadata($classType, $property->getName());
395-
$propertiesValues[$property->getName()] = $this->dehydrateValue($propertyValue, $propMetadata, $component);
400+
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($classType, $property->getName(), $property);
401+
$hydratedObject[$property->getName()] = $this->dehydrateValue($propertyValue, $propMetadata, $component);
396402
}
397403

398-
return $propertiesValues;
404+
return $hydratedObject;
399405
}
400406

401407
private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $component): mixed
@@ -472,9 +478,11 @@ private function hydrateObjectValue(mixed $value, string $className, bool $allow
472478

473479
if (\is_array($value)) {
474480
$object = new $className();
475-
foreach ($value as $property => $propertyValue) {
476-
$propMetadata = $this->generateLivePropMetadata($className, $property);
477-
$this->propertyAccessor->setValue($object, $property, $this->hydrateValue($propertyValue, $propMetadata, $component));
481+
foreach ($value as $propertyName => $propertyValue) {
482+
$reflexionClass = new \ReflectionClass($className);
483+
$property = $reflexionClass->getProperty($propertyName);
484+
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($className, $propertyName, $property);
485+
$this->propertyAccessor->setValue($object, $propertyName, $this->hydrateValue($propertyValue, $propMetadata, $component));
478486
}
479487

480488
return $object;
@@ -572,35 +580,4 @@ private function recursiveKeySort(array &$data): void
572580
}
573581
ksort($data);
574582
}
575-
576-
private function generateLivePropMetadata(string $className, string $propertyName): LivePropMetadata
577-
{
578-
$reflexionClass = new \ReflectionClass($className);
579-
$property = $reflexionClass->getProperty($propertyName);
580-
581-
$collectionValueType = null;
582-
$infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];
583-
foreach ($infoTypes as $infoType) {
584-
if ($infoType->isCollection()) {
585-
foreach ($infoType->getCollectionValueTypes() as $valueType) {
586-
$collectionValueType = $valueType;
587-
break;
588-
}
589-
}
590-
}
591-
592-
$type = $property->getType();
593-
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
594-
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()));
595-
}
596-
597-
return new LivePropMetadata(
598-
$property->getName(),
599-
new LiveProp(true),
600-
$type ? $type->getName() : null,
601-
$type ? $type->isBuiltin() : false,
602-
$type ? $type->allowsNull() : true,
603-
$collectionValueType,
604-
);
605-
}
606583
}

src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php

Lines changed: 27 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -59,32 +59,38 @@ public function createPropMetadatas(\ReflectionClass $class): array
5959
continue;
6060
}
6161

62-
$collectionValueType = null;
63-
$infoTypes = $this->propertyTypeExtractor->getTypes($class->getName(), $property->getName()) ?? [];
64-
foreach ($infoTypes as $infoType) {
65-
if ($infoType->isCollection()) {
66-
foreach ($infoType->getCollectionValueTypes() as $valueType) {
67-
$collectionValueType = $valueType;
68-
break;
69-
}
62+
$metadatas[$property->getName()] = $this->createLivePropMetadata($class->getName(), $property->getName(), $property, $attribute);
63+
}
64+
65+
return array_values($metadatas);
66+
}
67+
68+
public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, \ReflectionAttribute $attribute = null): LivePropMetadata
69+
{
70+
$collectionValueType = null;
71+
$infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];
72+
foreach ($infoTypes as $infoType) {
73+
if ($infoType->isCollection()) {
74+
foreach ($infoType->getCollectionValueTypes() as $valueType) {
75+
$collectionValueType = $valueType;
76+
break;
7077
}
7178
}
79+
}
7280

73-
$type = $property->getType();
74-
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
75-
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()));
76-
}
77-
$metadatas[$property->getName()] = new LivePropMetadata(
78-
$property->getName(),
79-
$attribute->newInstance(),
80-
$type ? $type->getName() : null,
81-
$type ? $type->isBuiltin() : false,
82-
$type ? $type->allowsNull() : true,
83-
$collectionValueType,
84-
);
81+
$type = $property->getType();
82+
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
83+
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()));
8584
}
8685

87-
return array_values($metadatas);
86+
return new LivePropMetadata(
87+
$property->getName(),
88+
null !== $attribute ? $attribute->newInstance() : new LiveProp(true),
89+
$type?->getName(),
90+
$type && $type->isBuiltin(),
91+
!$type || $type->allowsNull(),
92+
$collectionValueType,
93+
);
8894
}
8995

9096
/**
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;
4+
5+
class Address
6+
{
7+
public string $address;
8+
public string $city;
9+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;
4+
5+
class CustomerDetails
6+
{
7+
public string $firstName;
8+
public string $lastName;
9+
10+
public Address $address;
11+
}

src/LiveComponent/tests/Fixtures/Kernel.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ protected function configureContainer(ContainerConfigurator $c): void
9090
'secrets' => false,
9191
'session' => ['storage_factory_id' => 'session.storage.factory.mock_file'],
9292
'http_method_override' => false,
93+
'property_info' => ['enabled' => true],
9394
]);
9495

9596
$c->extension('twig', [

src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
2020
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
2121
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component3;
22+
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address;
2223
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\BlogPostWithSerializationContext;
24+
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\CustomerDetails;
2325
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Embeddable2;
2426
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Money;
2527
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Temperature;
@@ -783,6 +785,109 @@ public function provideDehydrationHydrationTests(): iterable
783785
;
784786
}, 80100];
785787

788+
yield 'Object: (de)hydrates DTO correctly' => [function () {
789+
return HydrationTest::create(new class() {
790+
#[LiveProp(writable: true)]
791+
public ?Address $address = null;
792+
793+
public function mount()
794+
{
795+
$this->address = new Address();
796+
$this->address->address = '1 rue du Bac';
797+
$this->address->city = 'Paris';
798+
}
799+
})
800+
->mountWith([])
801+
->assertDehydratesTo([
802+
'address' => [
803+
'address' => '1 rue du Bac',
804+
'city' => 'Paris',
805+
],
806+
'@checksum' => '2P/KES0BbGw1KNZsOn4CSEpN1HJ1s...+bQPY=',
807+
])
808+
->userUpdatesProps(['address' => ['address' => '4 rue des lilas', 'city' => 'Asnieres']])
809+
->assertObjectAfterHydration(function (object $object) {
810+
$this->assertSame($object->address->address, '4 rue des lilas');
811+
$this->assertSame($object->address->city, 'Asnieres');
812+
})
813+
;
814+
}];
815+
816+
yield 'Object: (de)hydrates correctly multidementional DTO' => [function () {
817+
return HydrationTest::create(new class() {
818+
#[LiveProp(writable: true)]
819+
public ?CustomerDetails $customerDetails = null;
820+
821+
public function mount()
822+
{
823+
$this->customerDetails = new CustomerDetails();
824+
$this->customerDetails->lastName = 'Matheo';
825+
$this->customerDetails->firstName = 'Daninos';
826+
$this->customerDetails->address = new Address();
827+
$this->customerDetails->address->address = '1 rue du Bac';
828+
$this->customerDetails->address->city = 'Paris';
829+
}
830+
})
831+
->mountWith([])
832+
->assertDehydratesTo([
833+
'customerDetails' => [
834+
'lastName' => 'Matheo',
835+
'firstName' => 'Daninos',
836+
'address' => [
837+
'address' => '1 rue du Bac',
838+
'city' => 'Paris',
839+
],
840+
],
841+
'@checksum' => 'tjeTtPH8xCyM2TIxP+FOnRakGHNBE...qQiVA=',
842+
])
843+
->userUpdatesProps(['customerDetails' => ['lastName' => 'Matheo', 'firstName' => 'Daninos', 'address' => ['address' => '3 rue du Bac', 'city' => 'Paris']]])
844+
->assertObjectAfterHydration(function (object $object) {
845+
$this->assertSame($object->customerDetails->address->address, '3 rue du Bac');
846+
$this->assertSame($object->customerDetails->address->city, 'Paris');
847+
});
848+
}];
849+
850+
yield 'Object: (de)hydrates correctly array of DTO' => [function () {
851+
return HydrationTest::create(new class() {
852+
/**
853+
* @var Symfony\UX\LiveComponent\Tests\Fixtures\Dto\CustomerDetails[] $customerDetailsCollection
854+
*/
855+
#[LiveProp(writable: true)]
856+
public array $customerDetailsCollection = [];
857+
858+
public function mount()
859+
{
860+
$customerDetails = new CustomerDetails();
861+
$customerDetails->lastName = 'Matheo';
862+
$customerDetails->firstName = 'Daninos';
863+
$customerDetails->address = new Address();
864+
$customerDetails->address->address = '1 rue du Bac';
865+
$customerDetails->address->city = 'Paris';
866+
867+
$this->customerDetailsCollection[] = $customerDetails;
868+
}
869+
})
870+
->mountWith([])
871+
->assertDehydratesTo([
872+
'customerDetailsCollection' => [
873+
[
874+
'lastName' => 'Matheo',
875+
'firstName' => 'Daninos',
876+
'address' => [
877+
'address' => '1 rue du Bac',
878+
'city' => 'Paris',
879+
],
880+
],
881+
],
882+
'@checksum' => 'tjeTtPH8xCyM2TIxP+FOnRakGHNBE...qQiVA=',
883+
])
884+
->userUpdatesProps(['customerDetailsCollection' => [['lastName' => 'Matheo', 'firstName' => 'Daninos', 'address' => ['address' => '3 rue du Bac', 'city' => 'Paris']]]])
885+
->assertObjectAfterHydration(function (object $object) {
886+
$this->assertSame($object->customerDetailsCollection[0]->address->address, '3 rue du Bac');
887+
$this->assertSame($object->customerDetailsCollection[0]->address->city, 'Paris');
888+
});
889+
}];
890+
786891
yield 'Object: using custom normalizer (de)hydrates correctly' => [function () {
787892
return HydrationTest::create(new class() {
788893
#[LiveProp(useSerializerForHydration: true)]

0 commit comments

Comments
 (0)