Skip to content

Commit d3edb38

Browse files
committed
[LiveComponent] Use TypeInfo Type
1 parent 6d2daab commit d3edb38

File tree

6 files changed

+146
-108
lines changed

6 files changed

+146
-108
lines changed

src/LiveComponent/composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"symfony/security-bundle": "^5.4|^6.0|^7.0",
5151
"symfony/serializer": "^5.4|^6.0|^7.0",
5252
"symfony/twig-bundle": "^5.4|^6.0|^7.0",
53+
"symfony/type-info": "^7.2",
5354
"symfony/validator": "^5.4|^6.0|^7.0",
5455
"zenstruck/browser": "^1.2.0",
5556
"zenstruck/foundry": "^2.0"

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
188188
$container->register('ux.live_component.metadata_factory', LiveComponentMetadataFactory::class)
189189
->setArguments([
190190
new Reference('ux.twig_component.component_factory'),
191-
new Reference('property_info'),
191+
new Reference('type_info.resolver'),
192192
])
193193
->addTag('kernel.reset', ['method' => 'reset'])
194194
;

src/LiveComponent/src/LiveComponentHydrator.php

+92-44
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,15 @@
1818
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1919
use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
2020
use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
21-
use Symfony\Component\PropertyInfo\Type;
2221
use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface;
2322
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2423
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
24+
use Symfony\Component\TypeInfo\Type;
25+
use Symfony\Component\TypeInfo\Type\CollectionType;
26+
use Symfony\Component\TypeInfo\Type\CompositeTypeInterface;
27+
use Symfony\Component\TypeInfo\Type\ObjectType;
28+
use Symfony\Component\TypeInfo\Type\WrappingTypeInterface;
29+
use Symfony\Component\TypeInfo\TypeIdentifier;
2530
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
2631
use Symfony\UX\LiveComponent\Attribute\LiveProp;
2732
use Symfony\UX\LiveComponent\Exception\HydrationException;
@@ -266,50 +271,72 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec
266271
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));
267272
}
268273

269-
if ($propMetadata->collectionValueType()) {
270-
$builtInType = $propMetadata->collectionValueType()->getBuiltinType();
271-
if (Type::BUILTIN_TYPE_OBJECT === $builtInType) {
272-
$type = $propMetadata->collectionValueType()->getClassName().'[]';
273-
} else {
274-
$type = $builtInType.'[]';
275-
}
276-
} else {
277-
$type = $propMetadata->getType();
278-
}
279-
280-
if (null === $type) {
274+
if (null === $type = $propMetadata->getType()) {
281275
throw new \LogicException(\sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName()));
282276
}
283277

284-
return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext());
285-
}
278+
$typeIsCollection = function (Type $t) use (&$typeIsCollection): bool {
279+
return match (true) {
280+
$t instanceof CollectionType => true,
281+
$t instanceof WrappingTypeInterface => $t->wrappedTypeIsSatisfiedBy($typeIsCollection),
282+
default => false,
283+
};
284+
};
285+
$isCollection = $type->isSatisfiedBy($typeIsCollection);
286286

287-
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {
288-
$collectionClass = $propMetadata->collectionValueType()->getClassName();
289-
foreach ($value as $key => $objectItem) {
290-
$value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject);
287+
while ($type instanceof WrappingTypeInterface) {
288+
$type = $type->getWrappedType();
291289
}
290+
291+
$typeString = $type.($typeIsCollection ? '[]' : '');
292+
293+
return $this->serializer->denormalize($value, $typeString, 'json', $propMetadata->serializationContext());
292294
}
293295

294296
// no type? no hydration
295-
if (!$propMetadata->getType()) {
297+
if (null === $type = $propMetadata->getType()) {
296298
return $value;
297299
}
298300

299301
if (null === $value) {
300302
return null;
301303
}
302304

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

307-
// for all other built-ins: int, boolean, array, return as is
308-
if ($propMetadata->isBuiltIn()) {
309-
return $value;
309+
if ($type instanceof CollectionType) {
310+
$collectionValueType = $type->getCollectionValueType();
311+
if ($collectionValueType instanceof CompositeTypeInterface) {
312+
$collectionValueType = $collectionValueType->getTypes()[0];
313+
}
314+
315+
while ($collectionValueType instanceof WrappingTypeInterface) {
316+
$collectionValueType = $collectionValueType->getWrappedType();
317+
}
318+
319+
if ($collectionValueType instanceof ObjectType) {
320+
foreach ($value as $key => $objectItem) {
321+
$value[$key] = $this->hydrateObjectValue($objectItem, $collectionValueType->getClassName(), true, $propMetadata->getFormat(), $parentObject::class, \sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject);
322+
}
323+
}
324+
}
325+
326+
if (\is_string($value) && $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL)) {
327+
return self::coerceStringValue($value, $type, $isNullable);
328+
}
329+
330+
while ($type instanceof WrappingTypeInterface) {
331+
$type = $type->getWrappedType();
310332
}
311333

312-
return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject);
334+
if ($type instanceof ObjectType) {
335+
return $this->hydrateObjectValue($value, $type->getClassName(), $isNullable, $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject);
336+
}
337+
338+
// for all other built-ins: int, boolean, array, return as is
339+
return $value;
313340
}
314341

315342
public function addChecksumToData(array $data): array
@@ -319,18 +346,18 @@ public function addChecksumToData(array $data): array
319346
return $data;
320347
}
321348

322-
private static function coerceStringValue(string $value, string $type, bool $allowsNull): int|float|bool|null
349+
private static function coerceStringValue(string $value, Type $type, bool $isNullable): int|float|bool|null
323350
{
324351
$value = trim($value);
325352

326-
if ('' === $value && $allowsNull) {
353+
if ('' === $value && $isNullable) {
327354
return null;
328355
}
329356

330-
return match ($type) {
331-
'int' => (int) $value,
332-
'float' => (float) $value,
333-
'bool' => self::coerceStringToBoolean($value),
357+
return match (true) {
358+
$type->isIdentifiedBy(TypeIdentifier::INT) => (int) $value,
359+
$type->isIdentifiedBy(TypeIdentifier::FLOAT) => (float) $value,
360+
$type->isIdentifiedBy(TypeIdentifier::BOOL) => self::coerceStringToBoolean($value),
334361
default => throw new \LogicException(\sprintf('Cannot coerce value "%s" to type "%s"', $value, $type)),
335362
};
336363
}
@@ -462,15 +489,35 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
462489
return $value;
463490
}
464491

492+
if (!$type = $propMetadata->getType()) {
493+
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));
494+
}
495+
465496
if (\is_array($value)) {
466-
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {
467-
$collectionClass = $propMetadata->collectionValueType()->getClassName();
468-
foreach ($value as $key => $objectItem) {
469-
if (!$objectItem instanceof $collectionClass) {
470-
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)));
471-
}
497+
if ($type->isNullable()) {
498+
$type = $type->getWrappedType();
499+
}
472500

473-
$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject);
501+
if ($type instanceof CollectionType) {
502+
$collectionValueType = $type->getCollectionValueType();
503+
if ($collectionValueType instanceof CompositeTypeInterface) {
504+
$collectionValueType = $collectionValueType->getTypes()[0];
505+
}
506+
507+
while ($collectionValueType instanceof WrappingTypeInterface) {
508+
$collectionValueType = $collectionValueType->getWrappedType();
509+
}
510+
511+
if ($collectionValueType instanceof ObjectType) {
512+
$collectionClass = $collectionValueType->getClassName();
513+
514+
foreach ($value as $key => $objectItem) {
515+
if (!$objectItem instanceof $collectionClass) {
516+
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)));
517+
}
518+
519+
$value[$key] = $this->dehydrateObjectValue($objectItem, $collectionClass, $propMetadata->getFormat(), $parentObject);
520+
}
474521
}
475522
}
476523

@@ -485,14 +532,15 @@ private function dehydrateValue(mixed $value, LivePropMetadata $propMetadata, ob
485532
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));
486533
}
487534

488-
if (!$propMetadata->getType() || $propMetadata->isBuiltIn()) {
489-
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));
535+
while ($type instanceof WrappingTypeInterface) {
536+
$type = $type->getWrappedType();
490537
}
491538

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

495-
return $this->dehydrateObjectValue($value, $propMetadata->getType(), $propMetadata->getFormat(), $parentObject);
543+
return $value;
496544
}
497545

498546
private function dehydrateObjectValue(object $value, string $classType, ?string $dateFormat, object $parentObject): mixed

src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php

+13-34
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,11 @@
1111

1212
namespace Symfony\UX\LiveComponent\Metadata;
1313

14-
use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
15-
use Symfony\Component\PropertyInfo\Type;
14+
use Symfony\Component\TypeInfo\Exception\UnsupportedException;
15+
use Symfony\Component\TypeInfo\Type\IntersectionType;
16+
use Symfony\Component\TypeInfo\Type\NullableType;
17+
use Symfony\Component\TypeInfo\Type\UnionType;
18+
use Symfony\Component\TypeInfo\TypeResolver\TypeResolverInterface;
1619
use Symfony\Contracts\Service\ResetInterface;
1720
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1821
use Symfony\UX\TwigComponent\ComponentFactory;
@@ -29,7 +32,7 @@ class LiveComponentMetadataFactory implements ResetInterface
2932

3033
public function __construct(
3134
private ComponentFactory $componentFactory,
32-
private PropertyTypeExtractorInterface $propertyTypeExtractor,
35+
private TypeResolverInterface $typeResolver,
3336
) {
3437
}
3538

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

7578
public function createLivePropMetadata(string $className, string $propertyName, \ReflectionProperty $property, LiveProp $liveProp): LivePropMetadata
7679
{
77-
$type = $property->getType();
78-
if ($type instanceof \ReflectionUnionType || $type instanceof \ReflectionIntersectionType) {
79-
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()));
80+
try {
81+
$type = $this->typeResolver->resolve($property);
82+
} catch (UnsupportedException) {
83+
$type = null;
8084
}
8185

82-
$infoTypes = $this->propertyTypeExtractor->getTypes($className, $propertyName) ?? [];
83-
84-
$collectionValueType = null;
85-
foreach ($infoTypes as $infoType) {
86-
if ($infoType->isCollection()) {
87-
foreach ($infoType->getCollectionValueTypes() as $valueType) {
88-
$collectionValueType = $valueType;
89-
break;
90-
}
91-
}
92-
}
93-
94-
if (null === $type && null === $collectionValueType && isset($infoTypes[0])) {
95-
$infoType = Type::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType();
96-
$isTypeBuiltIn = null === $infoTypes[0]->getClassName();
97-
$isTypeNullable = $infoTypes[0]->isNullable();
98-
} else {
99-
$infoType = $type?->getName();
100-
$isTypeBuiltIn = $type?->isBuiltin() ?? false;
101-
$isTypeNullable = $type?->allowsNull() ?? true;
86+
if ($type instanceof UnionType && !$type instanceof NullableType || $type instanceof IntersectionType) {
87+
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));
10288
}
10389

104-
return new LivePropMetadata(
105-
$property->getName(),
106-
$liveProp,
107-
$infoType,
108-
$isTypeBuiltIn,
109-
$isTypeNullable,
110-
$collectionValueType
111-
);
90+
return new LivePropMetadata($property->getName(), $liveProp, $type);
11291
}
11392

11493
/**

src/LiveComponent/src/Metadata/LivePropMetadata.php

+4-22
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111

1212
namespace Symfony\UX\LiveComponent\Metadata;
1313

14-
use Symfony\Component\PropertyInfo\Type;
14+
use Symfony\Component\TypeInfo\Type;
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616

1717
/**
@@ -24,10 +24,7 @@ final class LivePropMetadata
2424
public function __construct(
2525
private string $name,
2626
private LiveProp $liveProp,
27-
private ?string $typeName,
28-
private bool $isBuiltIn,
29-
private bool $allowsNull,
30-
private ?Type $collectionValueType,
27+
private ?Type $type,
3128
) {
3229
}
3330

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

39-
public function getType(): ?string
36+
public function getType(): ?Type
4037
{
41-
return $this->typeName;
42-
}
43-
44-
public function isBuiltIn(): bool
45-
{
46-
return $this->isBuiltIn;
47-
}
48-
49-
public function allowsNull(): bool
50-
{
51-
return $this->allowsNull;
38+
return $this->type;
5239
}
5340

5441
public function urlMapping(): ?UrlMapping
@@ -99,11 +86,6 @@ public function serializationContext(): array
9986
return $this->liveProp->serializationContext();
10087
}
10188

102-
public function collectionValueType(): ?Type
103-
{
104-
return $this->collectionValueType;
105-
}
106-
10789
public function getFormat(): ?string
10890
{
10991
return $this->liveProp->format();

0 commit comments

Comments
 (0)