Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[LiveComponent] Implement hydratation of DTO object #1090

Merged
merged 11 commits into from
Sep 22, 2023
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:
WebMamba marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -98,6 +98,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
65 changes: 44 additions & 21 deletions src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@
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;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
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 @@ -45,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 @@ -322,14 +327,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 @@ -345,38 +350,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 @@ -392,17 +396,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);
}
weaverryan marked this conversation as resolved.
Show resolved Hide resolved

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 @@ -412,7 +423,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 @@ -434,10 +445,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 @@ -467,7 +478,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],
WebMamba marked this conversation as resolved.
Show resolved Hide resolved
]);

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