Skip to content

Commit 7ae6aa1

Browse files
committed
feature #1090 [LiveComponent] Implement hydratation of DTO object (matheo, WebMamba)
This PR was merged into the 2.x branch. Discussion ---------- [LiveComponent] Implement hydratation of DTO object | Q | A | ------------- | --- | Bug fix? | yes | New feature? | no | Tickets | Fix #955 | License | MIT With this PR you van easily use DTO with your LiveComponents. ```php class CustomerDetails { public string $name; public Address $address; public string $city; } ``` ```php class Address { public string $street; public string $postCode; } ``` ```php #[AsLiveComponent(name: 'CustomerDetails')] class CustomerDetailsComponent { use DefaultActionTrait; #[ExposeInTemplate] public string $hello = 'hello'; #[LiveProp(writable: true)] public ?CustomerDetails $customerDetails = null; public function mount(): void { $this->customerDetails = new CustomerDetails(); $this->customerDetails->name = 'Matheo'; $this->customerDetails->city = 'Paris'; $this->customerDetails->address = new Address(); $this->customerDetails->address->street = '3 rue de la Paix'; $this->customerDetails->address->postCode = '92270'; } #[LiveAction] public function switch(): void { $this->customerDetails = new CustomerDetails(); $this->customerDetails->name = 'Paul'; $this->customerDetails->city = 'Paris'; $this->customerDetails->address = new Address(); $this->customerDetails->address->street = '3 rue des mimosas'; $this->customerDetails->address->postCode = '92270'; } } ``` ```twig <div {{ attributes }}> <p>{{ customerDetails.name }}</p> <p>{{ customerDetails.address.street }}</p> <button data-action="live#action" data-action-name="switch" >Switch</button> </div> ``` Commits ------- 970ba16 fix Doc ci ba53343 fix exeception and use PropertyAccessor to read the value 6e4854d Update docs feb1f44 rewrite errors and renames variable 28e3b39 edit error message 30d4fdb add doc 9d738d6 refactoring and renaming 11b9210 Remove checksum in tests 7595c70 Tests and centralize logic in LiveComponentMetadataFactory bd7e719 use LiveComponentMetadataFactory logic to generate LivePropMetadata bac591e Implement hydratation of DTO object
2 parents 998c287 + 970ba16 commit 7ae6aa1

File tree

8 files changed

+230
-52
lines changed

8 files changed

+230
-52
lines changed

src/LiveComponent/doc/index.rst

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ LiveProp Data Types
242242

243243
LiveProps must be a value that can be sent to JavaScript. Supported values
244244
are scalars (int, float, string, bool, null), arrays (of scalar values), enums,
245-
DateTime objects & Doctrine entity objects.
245+
DateTime objects, Doctrine entity objects, DTOs, or array of DTOs.
246246

247247
See :ref:`hydration` for handling more complex data.
248248

@@ -622,16 +622,42 @@ Note that being able to change the "identity" of an object is something
622622
that works only for objects that are dehydrated to a scalar value (like
623623
persisted entities, which dehydrate to an ``id``).
624624

625-
Hydration, DTO's & the Serializer
626-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
625+
Using DTO's on a LiveProp
626+
~~~~~~~~~~~~~~~~~~~~~~~~~
627+
628+
.. versionadded:: 2.11
629+
630+
The automatic (de)hydration of DTO objects was introduced in LiveComponents 2.11.
631+
632+
You can also use a DTO (i.e. data transfer object / any simple class) with LiveProp as long as the property has the correct type::
633+
634+
class ComponentWithAddressDto
635+
{
636+
public AddressDto $addressDto;
637+
}
638+
639+
To work with a collection of DTOs, specify the collection type inside PHPDoc::
640+
641+
class ComponentWithAddressDto
642+
{
643+
/**
644+
* @var AddressDto[]
645+
/*
646+
public array $addressDtoCollection;
647+
}
648+
649+
Here is how the (de)hydration of DTO objects works:
627650

628-
If you try to use a ``LiveProp`` for some unsupported type (e.g.a DTO object),
629-
it will fail. A best practice is to use simple data.
651+
- It finds all properties on your DTO that are readable and writable and dehydrates each one.
652+
- the PropertyAccess component is used, which means getter and setter methods are supported, in addition to public properties.
653+
- The DTO cannot have any constructor arguments.
630654

631-
But there are two options to make this work:
655+
If this solution doesn't feat your need
632656

633-
1) Hydrating with the Serializer
634-
................................
657+
there are two others options to make this work:
658+
659+
Hydrating with the Serializer
660+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
635661

636662
.. versionadded:: 2.8
637663

@@ -648,8 +674,8 @@ option::
648674

649675
You can also set a ``serializationContext`` option on the ``LiveProp``.
650676

651-
2) Hydrating with Methods: hydrateWith & dehydrateWith
652-
......................................................
677+
Hydrating with Methods: hydrateWith & dehydrateWith
678+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
653679

654680
You can take full control of the hydration process by setting the ``hydrateWith``
655681
and ``dehydrateWith`` options on ``LiveProp``::

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
9999
->setArguments([
100100
tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG),
101101
new Reference('property_accessor'),
102+
new Reference('ux.live_component.metadata_factory'),
102103
new Reference('serializer'),
103104
'%kernel.secret%',
104105
])

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
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\Extractor\ReflectionExtractor;
20+
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
1921
use Symfony\Component\PropertyInfo\Type;
2022
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2123
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
@@ -24,6 +26,7 @@
2426
use Symfony\UX\LiveComponent\Exception\HydrationException;
2527
use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface;
2628
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata;
29+
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
2730
use Symfony\UX\LiveComponent\Metadata\LivePropMetadata;
2831
use Symfony\UX\LiveComponent\Util\DehydratedProps;
2932
use Symfony\UX\TwigComponent\ComponentAttributes;
@@ -46,6 +49,7 @@ final class LiveComponentHydrator
4649
public function __construct(
4750
private iterable $hydrationExtensions,
4851
private PropertyAccessorInterface $propertyAccessor,
52+
private LiveComponentMetadataFactory $liveComponentMetadataFactory,
4953
private NormalizerInterface|DenormalizerInterface $normalizer,
5054
private string $secret
5155
) {
@@ -327,14 +331,14 @@ private function setWritablePaths(array $writablePaths, string $frontendPropName
327331
return $propertyValue;
328332
}
329333

330-
private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, object $component): mixed
334+
private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed
331335
{
332336
if ($method = $propMetadata->dehydrateMethod()) {
333-
if (!method_exists($component, $method)) {
334-
throw new \LogicException(sprintf('The "%s" component has a dehydrateMethod of "%s" but the method does not exist.', $component::class, $method));
337+
if (!method_exists($parentObject, $method)) {
338+
throw new \LogicException(sprintf('The dehydration failed for class "%s" because the "%s" method does not exist.', $parentObject::class, $method));
335339
}
336340

337-
return $component->$method($value);
341+
return $parentObject->$method($value);
338342
}
339343

340344
if ($propMetadata->useSerializerForHydration()) {
@@ -350,38 +354,37 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
350354
$collectionClass = $propMetadata->collectionValueType()->getClassName();
351355
foreach ($value as $key => $objectItem) {
352356
if (!$objectItem instanceof $collectionClass) {
353-
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)));
357+
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(), $parentObject::class, $collectionClass, get_debug_type($objectItem)));
354358
}
355359

356-
$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $component::class, sprintf('%s.%s', $propMetadata->getName(), $key));
360+
$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject);
357361
}
358362
}
359363

360364
if (!$this->isValueValidDehydratedValue($value)) {
361365
$badKeys = $this->getNonScalarKeys($value, $propMetadata->getName());
362366
$badKeysText = implode(', ', array_map(fn ($key) => sprintf('%s: %s', $key, $badKeys[$key]), array_keys($badKeys)));
363-
364-
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));
367+
throw new \LogicException(throw new \LogicException(sprintf('Unable to dehydrate value of type "%s" for property "%s" on component "%s". Change this to a simpler type of an object that can be dehydrated. Or set the hydrateWith/dehydrateWith options in LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer.', get_debug_type($value), $propMetadata->getName(), $parentObject::class)));
365368
}
366369

367370
return $value;
368371
}
369372

370373
if (!\is_object($value)) {
371-
throw new \LogicException(sprintf('Unable to dehydrate value of type "%s" for property "%s" on component "%s". Change this to a simpler type of an object that can be dehydrated. Or set the hydrateWith/dehydrateWith options in LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer.', get_debug_type($value), $propMetadata->getName(), $component::class));
374+
throw new \LogicException(sprintf('Unable to dehydrate value of type "%s" for property "%s" on component "%s". Change this to a simpler type of an object that can be dehydrated. Or set the hydrateWith/dehydrateWith options in LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer.', get_debug_type($value), $propMetadata->getName(), $parentObject::class));
372375
}
373376

374377
if (!$propMetadata->getType() || $propMetadata->isBuiltIn()) {
375-
throw new \LogicException(sprintf('The "%s" property on component "%s" is missing its property-type. Add the "%s" type so the object can be hydrated later.', $propMetadata->getName(), $component::class, $value::class));
378+
throw new \LogicException(sprintf('The "%s" property on component "%s" is missing its property-type. Add the "%s" type so the object can be hydrated later.', $propMetadata->getName(), $parentObject::class, $value::class));
376379
}
377380

378381
// at this point, we have an object and can assume $propMetadata->getType()
379382
// is set correctly (needed for hydration later)
380383

381-
return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $component::class, $propMetadata->getName());
384+
return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $parentObject);
382385
}
383386

384-
private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, string $componentClassForError, string $propertyPathForError): mixed
387+
private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed
385388
{
386389
if ($value instanceof \DateTimeInterface) {
387390
return $value->format($dateFormat ?: \DateTimeInterface::RFC3339);
@@ -397,17 +400,24 @@ private function dehydrateObjectValue(object $value, string $classType, ?string
397400
}
398401
}
399402

400-
throw new \LogicException(sprintf('Unable to dehydrate value of type "%s" for property "%s" on component "%s". Either (1) change this to a simpler value, (2) add the hydrateWith/dehydrateWith options to LiveProp or (3) set "useSerializerForHydration: true" on the LiveProp.', $value::class, $propertyPathForError, $componentClassForError));
403+
$dehydratedObjectValues = [];
404+
foreach ((new PropertyInfoExtractor([new ReflectionExtractor()]))->getProperties($classType) as $property) {
405+
$propertyValue = $this->propertyAccessor->getValue($value, $property);
406+
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($classType, $property, new \ReflectionProperty($classType, $property), new LiveProp());
407+
$dehydratedObjectValues[$property] = $this->dehydrateValue($propertyValue, $propMetadata, $parentObject);
408+
}
409+
410+
return $dehydratedObjectValues;
401411
}
402412

403-
private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $component): mixed
413+
private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed
404414
{
405415
if ($propMetadata->hydrateMethod()) {
406-
if (!method_exists($component, $propMetadata->hydrateMethod())) {
407-
throw new \LogicException(sprintf('The "%s" component has a hydrateMethod of "%s" but the method does not exist.', $component::class, $propMetadata->hydrateMethod()));
416+
if (!method_exists($parentObject, $propMetadata->hydrateMethod())) {
417+
throw new \LogicException(sprintf('The "%s" object has a hydrateMethod of "%s" but the method does not exist.', $parentObject::class, $propMetadata->hydrateMethod()));
408418
}
409419

410-
return $component->{$propMetadata->hydrateMethod()}($value);
420+
return $parentObject->{$propMetadata->hydrateMethod()}($value);
411421
}
412422

413423
if ($propMetadata->useSerializerForHydration()) {
@@ -417,7 +427,7 @@ private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, obje
417427
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {
418428
$collectionClass = $propMetadata->collectionValueType()->getClassName();
419429
foreach ($value as $key => $objectItem) {
420-
$value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $component::class, sprintf('%s.%s', $propMetadata->getName(), $key));
430+
$value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $parentObject::class, sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject);
421431
}
422432
}
423433

@@ -439,10 +449,10 @@ private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, obje
439449
return $value;
440450
}
441451

442-
return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $component::class, $propMetadata->getName());
452+
return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $parentObject::class, $propMetadata->getName(), $parentObject);
443453
}
444454

445-
private function hydrateObjectValue(mixed $value, string $className, bool $allowsNull, string $componentClassForError, string $propertyPathForError): ?object
455+
private function hydrateObjectValue(mixed $value, string $className, bool $allowsNull, string $componentClassForError, string $propertyPathForError, object $component): ?object
446456
{
447457
// enum
448458
if (is_a($className, \BackedEnum::class, true)) {
@@ -472,7 +482,19 @@ private function hydrateObjectValue(mixed $value, string $className, bool $allow
472482
}
473483
}
474484

475-
throw new HydrationException(sprintf('Unable to hydrate value of type "%s" for property "%s" on component "%s". Change this to a simpler value, add the hydrateWith/dehydrateWith options to LiveProp or set "useSerializerForHydration: true" on the LiveProp to use the serializer..', $className, $propertyPathForError, $componentClassForError));
485+
if (\is_array($value)) {
486+
$object = new $className();
487+
foreach ($value as $propertyName => $propertyValue) {
488+
$reflectionClass = new \ReflectionClass($className);
489+
$property = $reflectionClass->getProperty($propertyName);
490+
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($className, $propertyName, $property, new LiveProp());
491+
$this->propertyAccessor->setValue($object, $propertyName, $this->hydrateValue($propertyValue, $propMetadata, $component));
492+
}
493+
494+
return $object;
495+
}
496+
497+
throw new HydrationException(sprintf('Unable to hydrate value of type "%s" for property "%s" on component "%s". it looks like something went wrong by trying to guess your property types.', $className, $propertyPathForError, $componentClassForError));
476498
}
477499

478500
private function isValueValidDehydratedValue(mixed $value): bool

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->newInstance());
63+
}
64+
65+
return array_values($metadatas);
66+
}
67+
68+
public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): 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+
$liveProp,
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', [

0 commit comments

Comments
 (0)