Skip to content

Commit

Permalink
feature #1090 [LiveComponent] Implement hydratation of DTO object (ma…
Browse files Browse the repository at this point in the history
…theo, 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
  • Loading branch information
weaverryan committed Sep 22, 2023
2 parents 998c287 + 970ba16 commit 7ae6aa1
Show file tree
Hide file tree
Showing 8 changed files with 230 additions and 52 deletions.
46 changes: 36 additions & 10 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ LiveProp Data Types

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

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

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

Hydration, DTO's & the Serializer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Using DTO's on a LiveProp
~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.11

The automatic (de)hydration of DTO objects was introduced in LiveComponents 2.11.

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::

class ComponentWithAddressDto
{
public AddressDto $addressDto;
}

To work with a collection of DTOs, specify the collection type inside PHPDoc::

class ComponentWithAddressDto
{
/**
* @var AddressDto[]
/*
public array $addressDtoCollection;
}

Here is how the (de)hydration of DTO objects works:

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

But there are two options to make this work:
If this solution doesn't feat your need

1) Hydrating with the Serializer
................................
there are two others options to make this work:

Hydrating with the Serializer
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.8

Expand All @@ -648,8 +674,8 @@ option::

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

2) Hydrating with Methods: hydrateWith & dehydrateWith
......................................................
Hydrating with Methods: hydrateWith & dehydrateWith
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

You can take full control of the hydration process by setting the ``hydrateWith``
and ``dehydrateWith`` options on ``LiveProp``::
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->setArguments([
tagged_iterator(LiveComponentBundle::HYDRATION_EXTENSION_TAG),
new Reference('property_accessor'),
new Reference('ux.live_component.metadata_factory'),
new Reference('serializer'),
'%kernel.secret%',
])
Expand Down
64 changes: 43 additions & 21 deletions src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException;
use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
Expand All @@ -24,6 +26,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;
Expand All @@ -46,6 +49,7 @@ final class LiveComponentHydrator
public function __construct(
private iterable $hydrationExtensions,
private PropertyAccessorInterface $propertyAccessor,
private LiveComponentMetadataFactory $liveComponentMetadataFactory,
private NormalizerInterface|DenormalizerInterface $normalizer,
private string $secret
) {
Expand Down Expand Up @@ -327,14 +331,14 @@ private function setWritablePaths(array $writablePaths, string $frontendPropName
return $propertyValue;
}

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

return $component->$method($value);
return $parentObject->$method($value);
}

if ($propMetadata->useSerializerForHydration()) {
Expand All @@ -350,38 +354,37 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
$collectionClass = $propMetadata->collectionValueType()->getClassName();
foreach ($value as $key => $objectItem) {
if (!$objectItem instanceof $collectionClass) {
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)));
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)));
}

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

if (!$this->isValueValidDehydratedValue($value)) {
$badKeys = $this->getNonScalarKeys($value, $propMetadata->getName());
$badKeysText = implode(', ', array_map(fn ($key) => sprintf('%s: %s', $key, $badKeys[$key]), array_keys($badKeys)));

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));
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)));
}

return $value;
}

if (!\is_object($value)) {
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));
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));
}

if (!$propMetadata->getType() || $propMetadata->isBuiltIn()) {
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));
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));
}

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

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

private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, string $componentClassForError, string $propertyPathForError): mixed
private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed
{
if ($value instanceof \DateTimeInterface) {
return $value->format($dateFormat ?: \DateTimeInterface::RFC3339);
Expand All @@ -397,17 +400,24 @@ private function dehydrateObjectValue(object $value, string $classType, ?string
}
}

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));
$dehydratedObjectValues = [];
foreach ((new PropertyInfoExtractor([new ReflectionExtractor()]))->getProperties($classType) as $property) {
$propertyValue = $this->propertyAccessor->getValue($value, $property);
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($classType, $property, new \ReflectionProperty($classType, $property), new LiveProp());
$dehydratedObjectValues[$property] = $this->dehydrateValue($propertyValue, $propMetadata, $parentObject);
}

return $dehydratedObjectValues;
}

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

return $component->{$propMetadata->hydrateMethod()}($value);
return $parentObject->{$propMetadata->hydrateMethod()}($value);
}

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

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

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

private function hydrateObjectValue(mixed $value, string $className, bool $allowsNull, string $componentClassForError, string $propertyPathForError): ?object
private function hydrateObjectValue(mixed $value, string $className, bool $allowsNull, string $componentClassForError, string $propertyPathForError, object $component): ?object
{
// enum
if (is_a($className, \BackedEnum::class, true)) {
Expand Down Expand Up @@ -472,7 +482,19 @@ private function hydrateObjectValue(mixed $value, string $className, bool $allow
}
}

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));
if (\is_array($value)) {
$object = new $className();
foreach ($value as $propertyName => $propertyValue) {
$reflectionClass = new \ReflectionClass($className);
$property = $reflectionClass->getProperty($propertyName);
$propMetadata = $this->liveComponentMetadataFactory->createLivePropMetadata($className, $propertyName, $property, new LiveProp());
$this->propertyAccessor->setValue($object, $propertyName, $this->hydrateValue($propertyValue, $propMetadata, $component));
}

return $object;
}

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));
}

private function isValueValidDehydratedValue(mixed $value): bool
Expand Down
48 changes: 27 additions & 21 deletions src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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->newInstance());
}

return array_values($metadatas);
}

public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): 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(),
$liveProp,
$type?->getName(),
$type && $type->isBuiltin(),
!$type || $type->allowsNull(),
$collectionValueType,
);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions src/LiveComponent/tests/Fixtures/Dto/Address.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;

class Address
{
public string $address;
public string $city;
}
11 changes: 11 additions & 0 deletions src/LiveComponent/tests/Fixtures/Dto/CustomerDetails.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

namespace Symfony\UX\LiveComponent\Tests\Fixtures\Dto;

class CustomerDetails
{
public string $firstName;
public string $lastName;

public Address $address;
}
1 change: 1 addition & 0 deletions src/LiveComponent/tests/Fixtures/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ protected function configureContainer(ContainerConfigurator $c): void
'secrets' => false,
'session' => ['storage_factory_id' => 'session.storage.factory.mock_file'],
'http_method_override' => false,
'property_info' => ['enabled' => true],
]);

$c->extension('twig', [
Expand Down
Loading

0 comments on commit 7ae6aa1

Please sign in to comment.