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] Use TypeInfo Type #2607

Open
wants to merge 1 commit into
base: 2.x
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/LiveComponent/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"php": ">=8.1",
"symfony/deprecation-contracts": "^2.5|^3.0",
"symfony/property-access": "^5.4.5|^6.0|^7.0",
"symfony/property-info": "^5.4|^6.0|^7.0",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This package is a hard dependency, so it has to be moved to the require section.

"symfony/stimulus-bundle": "^2.9",
"symfony/type-info": "^7.2",
"symfony/ux-twig-component": "^2.8",
"twig/twig": "^3.8.0"
},
Expand All @@ -46,7 +48,6 @@
"symfony/framework-bundle": "^5.4|^6.0|^7.0",
"symfony/options-resolver": "^5.4|^6.0|^7.0",
"symfony/phpunit-bridge": "^6.1|^7.0",
"symfony/property-info": "^5.4|^6.0|^7.0",
"symfony/security-bundle": "^5.4|^6.0|^7.0",
"symfony/serializer": "^5.4|^6.0|^7.0",
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
$container->register('ux.live_component.metadata_factory', LiveComponentMetadataFactory::class)
->setArguments([
new Reference('ux.twig_component.component_factory'),
new Reference('property_info'),
new Reference('type_info.resolver'),
])
->addTag('kernel.reset', ['method' => 'reset'])
;
Expand Down
133 changes: 90 additions & 43 deletions src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,15 @@
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\Exception\ExceptionInterface as SerializerExceptionInterface;
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
use Symfony\Component\TypeInfo\Type;
use Symfony\Component\TypeInfo\Type\CollectionType;
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
use Symfony\Component\TypeInfo\Type\ObjectType;
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
use Symfony\Component\TypeInfo\TypeIdentifier;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\Exception\HydrationException;
Expand Down Expand Up @@ -266,50 +271,71 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec
throw new \LogicException(\sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the given serializer does not implement DenormalizerInterface.', $propMetadata->getName(), $parentObject::class));
}

if ($propMetadata->collectionValueType()) {
$builtInType = $propMetadata->collectionValueType()->getBuiltinType();
if (Type::BUILTIN_TYPE_OBJECT === $builtInType) {
$type = $propMetadata->collectionValueType()->getClassName().'[]';
} else {
$type = $builtInType.'[]';
}
} else {
$type = $propMetadata->getType();
if (null === $type = $propMetadata->getType()) {
throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName()));
}

if (null === $type) {
throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName()));
if ($type->isNullable()) {
$type = $type->getWrappedType();
}

return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext());
}
if ($isCollection = $type instanceof CollectionType) {
$type = $type->getCollectionValueType();
}

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, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject);
while ($type instanceof WrappingTypeInterface) {
$type = $type->getWrappedType();
}

$typeString = $type.($isCollection ? '[]' : '');

return $this->serializer->denormalize($value, $typeString, 'json', $propMetadata->serializationContext());
}

// no type? no hydration
if (!$propMetadata->getType()) {
if (null === $type = $propMetadata->getType()) {
return $value;
}

if (null === $value) {
return null;
}

if (\is_string($value) && $propMetadata->isBuiltIn() && \in_array($propMetadata->getType(), ['int', 'float', 'bool'], true)) {
return self::coerceStringValue($value, $propMetadata->getType(), $propMetadata->allowsNull());
if ($isNullable = $type->isNullable()) {
$type = $type->getWrappedType();
}

// for all other built-ins: int, boolean, array, return as is
if ($propMetadata->isBuiltIn()) {
return $value;
if ($type instanceof CollectionType) {
$collectionValueType = $type->getCollectionValueType();
if ($collectionValueType instanceof CompositeTypeInterface) {
$collectionValueType = $collectionValueType->getTypes()[0];
}

while ($collectionValueType instanceof WrappingTypeInterface) {
$collectionValueType = $collectionValueType->getWrappedType();
}

if ($collectionValueType instanceof ObjectType) {
foreach ($value as $key => $objectItem) {
$value[$key] = $this->hydrateObjectValue($objectItem, $collectionValueType->getClassName(), true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject);
}
}
}

if (\is_string($value) && $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL)) {
return self::coerceStringValue($value, $type, $isNullable);
}

while ($type instanceof WrappingTypeInterface) {
$type = $type->getWrappedType();
}

if ($type instanceof ObjectType) {
return $this->hydrateObjectValue($value, $type->getClassName(), $isNullable, $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject);
}

return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject);
// for all other built-ins: int, boolean, array, return as is
return $value;
}

public function addChecksumToData(array $data): array
Expand All @@ -319,18 +345,18 @@ public function addChecksumToData(array $data): array
return $data;
}

private static function coerceStringValue(string $value, string $type, bool $allowsNull): int|float|bool|null
private static function coerceStringValue(string $value, Type $type, bool $isNullable): int|float|bool|null
{
$value = trim($value);

if ('' === $value && $allowsNull) {
if ('' === $value && $isNullable) {
return null;
}

return match ($type) {
'int' => (int) $value,
'float' => (float) $value,
'bool' => self::coerceStringToBoolean($value),
return match (true) {
$type->isIdentifiedBy(TypeIdentifier::INT) => (int) $value,
$type->isIdentifiedBy(TypeIdentifier::FLOAT) => (float) $value,
$type->isIdentifiedBy(TypeIdentifier::BOOL) => self::coerceStringToBoolean($value),
default => throw new \LogicException(\sprintf('Cannot coerce value "%s" to type "%s"', $value, $type)),
};
}
Expand Down Expand Up @@ -462,15 +488,35 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
return $value;
}

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

if (\is_array($value)) {
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {
$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 one key had a different value of %s', $propMetadata->getName(), $parentObject::class, $collectionClass, get_debug_type($objectItem)));
}
if ($type->isNullable()) {
$type = $type->getWrappedType();
}

$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject);
if ($type instanceof CollectionType) {
$collectionValueType = $type->getCollectionValueType();
if ($collectionValueType instanceof CompositeTypeInterface) {
$collectionValueType = $collectionValueType->getTypes()[0];
}

while ($collectionValueType instanceof WrappingTypeInterface) {
$collectionValueType = $collectionValueType->getWrappedType();
}

if ($collectionValueType instanceof ObjectType) {
$collectionClass = $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 one key had a different value of %s', $propMetadata->getName(), $parentObject::class, $collectionClass, get_debug_type($objectItem)));
}

$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject);
}
}
}

Expand All @@ -485,14 +531,15 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
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(), $parentObject::class, $value::class));
while ($type instanceof WrappingTypeInterface) {
$type = $type->getWrappedType();
}

// at this point, we have an object and can assume $propMetadata->getType()
// is set correctly (needed for hydration later)
if ($type instanceof ObjectType) {
return $this->dehydrateObjectValue($value, $type->getClassName(), $propMetadata->getFormat(), $parentObject);
}

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

private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed
Expand Down
47 changes: 13 additions & 34 deletions src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@

namespace Symfony\UX\LiveComponent\Metadata;

use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
use Symfony\Component\TypeInfo\Type\IntersectionType;
use Symfony\Component\TypeInfo\Type\NullableType;
use Symfony\Component\TypeInfo\Type\UnionType;
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
use Symfony\Contracts\Service\ResetInterface;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\TwigComponent\ComponentFactory;
Expand All @@ -29,7 +32,7 @@ class LiveComponentMetadataFactory implements ResetInterface

public function __construct(
private ComponentFactory $componentFactory,
private PropertyTypeExtractorInterface $propertyTypeExtractor,
private TypeResolverInterface $typeResolver,
) {
}

Expand Down Expand Up @@ -74,41 +77,17 @@ public function createPropMetadatas(\ReflectionClass $class): array

public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata
{
$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()));
try {
$type = $this->typeResolver->resolve($property);
} catch (UnsupportedException) {
$type = null;
}

$infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];

$collectionValueType = null;
foreach ($infoTypes as $infoType) {
if ($infoType->isCollection()) {
foreach ($infoType->getCollectionValueTypes() as $valueType) {
$collectionValueType = $valueType;
break;
}
}
}

if (null === $type && null === $collectionValueType && isset($infoTypes[0])) {
$infoType = Type::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType();
$isTypeBuiltIn = null === $infoTypes[0]->getClassName();
$isTypeNullable = $infoTypes[0]->isNullable();
} else {
$infoType = $type?->getName();
$isTypeBuiltIn = $type?->isBuiltin() ?? false;
$isTypeNullable = $type?->allowsNull() ?? true;
if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) {
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".', $propertyName, $className));
}

return new LivePropMetadata(
$property->getName(),
$liveProp,
$infoType,
$isTypeBuiltIn,
$isTypeNullable,
$collectionValueType
);
return new LivePropMetadata($property->getName(), $liveProp, $type);
}

/**
Expand Down
26 changes: 4 additions & 22 deletions src/LiveComponent/src/Metadata/LivePropMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

namespace Symfony\UX\LiveComponent\Metadata;

use Symfony\Component\PropertyInfo\Type;
use Symfony\Component\TypeInfo\Type;
use Symfony\UX\LiveComponent\Attribute\LiveProp;

/**
Expand All @@ -24,10 +24,7 @@ final class LivePropMetadata
public function __construct(
private string $name,
private LiveProp $liveProp,
private ?string $typeName,
private bool $isBuiltIn,
private bool $allowsNull,
private ?Type $collectionValueType,
private ?Type $type,
) {
}

Expand All @@ -36,19 +33,9 @@ public function getName(): string
return $this->name;
}

public function getType(): ?string
public function getType(): ?Type
{
return $this->typeName;
}

public function isBuiltIn(): bool
{
return $this->isBuiltIn;
}

public function allowsNull(): bool
{
return $this->allowsNull;
return $this->type;
}

public function urlMapping(): ?UrlMapping
Expand Down Expand Up @@ -99,11 +86,6 @@ public function serializationContext(): array
return $this->liveProp->serializationContext();
}

public function collectionValueType(): ?Type
{
return $this->collectionValueType;
}

public function getFormat(): ?string
{
return $this->liveProp->format();
Expand Down
Loading
Loading