diff --git a/src/Symfony/Component/Serializer/Context/ChildContextTrait.php b/src/Symfony/Component/Serializer/Context/ChildContextTrait.php new file mode 100644 index 0000000000000..68b7baba44f78 --- /dev/null +++ b/src/Symfony/Component/Serializer/Context/ChildContextTrait.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context; + +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * Create a child context during serialization/deserialization process. + * + * @author Baptiste Leduc + * + * @internal + */ +trait ChildContextTrait +{ + public function createChildContext(array $parentContext, string $attribute, ?string $format = null, array $defaultContext = []): array + { + if (isset($parentContext[AbstractObjectNormalizer::ATTRIBUTES][$attribute])) { + $parentContext[AbstractObjectNormalizer::ATTRIBUTES] = $parentContext[AbstractObjectNormalizer::ATTRIBUTES][$attribute]; + } else { + unset($parentContext[AbstractObjectNormalizer::ATTRIBUTES]); + } + + return $parentContext; + } +} diff --git a/src/Symfony/Component/Serializer/Context/ObjectChildContextTrait.php b/src/Symfony/Component/Serializer/Context/ObjectChildContextTrait.php new file mode 100644 index 0000000000000..b990c0d15935a --- /dev/null +++ b/src/Symfony/Component/Serializer/Context/ObjectChildContextTrait.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context; + +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * Create a child context with cache_key during serialization/deserialization or instantiation process. + * + * @author Baptiste Leduc + * + * @internal + */ +trait ObjectChildContextTrait +{ + use ChildContextTrait { + createChildContext as parentCreateChildContext; + } + + public function createChildContext(array $parentContext, string $attribute, ?string $format = null, array $defaultContext = []): array + { + $parentContext = $this->parentCreateChildContext($parentContext, $attribute, $format, $defaultContext); + $parentContext['cache_key'] = $this->getAttributesCacheKey($parentContext, $format, $defaultContext); + + return $parentContext; + } + + /** + * Builds the cache key for the attributes cache. + * + * The key must be different for every option in the context that could change which attributes should be handled. + * + * @return bool|string + */ + private function getAttributesCacheKey(array $context, ?string $format = null, array $defaultContext = []) + { + foreach ($context[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY] ?? $defaultContext[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY] ?? [] as $key) { + unset($context[$key]); + } + unset($context[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY]); + unset($context['cache_key']); // avoid artificially different keys + + try { + return md5($format.serialize([ + 'context' => $context, + 'ignored' => $context[AbstractObjectNormalizer::IGNORED_ATTRIBUTES] ?? $defaultContext[AbstractObjectNormalizer::IGNORED_ATTRIBUTES] ?? [], + ])); + } catch (\Exception $exception) { + // The context cannot be serialized, skip the cache + return false; + } + } +} diff --git a/src/Symfony/Component/Serializer/Instantiator/Instantiator.php b/src/Symfony/Component/Serializer/Instantiator/Instantiator.php new file mode 100644 index 0000000000000..92602466761f0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Instantiator/Instantiator.php @@ -0,0 +1,301 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Instantiator; + +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Extractor\SerializerExtractor; +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Context\ObjectChildContextTrait; +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareTrait; +use Symfony\Component\Serializer\Normalizer\ObjectToPopulateTrait; + +/** + * Instantiates an object using constructor parameters when needed. + * + * This class also allows to denormalize data into an existing object if + * it is present in the context with the object_to_populate. This object + * is removed from the context before being returned to avoid side effects + * when recursively normalizing an object graph. + * + * @author Jérôme Desjardins + * @author Baptiste Leduc + */ +final class Instantiator implements InstantiatorInterface, DenormalizerAwareInterface +{ + public const ATTRIBUTES = AbstractObjectNormalizer::ATTRIBUTES; + public const IGNORED_ATTRIBUTES = AbstractObjectNormalizer::IGNORED_ATTRIBUTES; + public const OBJECT_TO_POPULATE = AbstractObjectNormalizer::OBJECT_TO_POPULATE; + public const DEFAULT_CONSTRUCTOR_ARGUMENTS = AbstractObjectNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS; + public const INSTANTIATOR_CONSTRUCTOR = 'instantiator_constructor'; + + use ObjectToPopulateTrait; + use DenormalizerAwareTrait; + use ObjectChildContextTrait; + + private $classDiscriminatorResolver; + private $propertyTypeExtractor; + private $propertyListExtractor; + private $nameConverter; + private $propertyAccessor; + + public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, PropertyListExtractorInterface $propertyListExtractor = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null) + { + if (null === $classDiscriminatorResolver && null !== $classMetadataFactory) { + $classDiscriminatorResolver = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); + } + $this->classDiscriminatorResolver = $classDiscriminatorResolver; + + $this->propertyTypeExtractor = $propertyTypeExtractor; + if (null === $propertyListExtractor && null !== $classMetadataFactory) { + $propertyListExtractor = new SerializerExtractor($classMetadataFactory); + } + $this->propertyListExtractor = $propertyListExtractor; + $this->nameConverter = $nameConverter; + $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); + } + + /** + * {@inheritdoc} + */ + public function instantiate(string $class, array $data, array $context, string $format = null): InstantiatorResult + { + if (!\array_key_exists(self::INSTANTIATOR_CONSTRUCTOR, $context)) { + $context[self::INSTANTIATOR_CONSTRUCTOR] = function (array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes): ?\ReflectionMethod { + return $reflectionClass->getConstructor(); + }; + } + + if (null !== $this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { + $mappedClass = $this->handleDiscriminator($class, $data, $mapping); + + if ($mappedClass !== $class) { + return $this->instantiate($mappedClass, $data, $context, $format); + } + + $class = $mappedClass; + } + + if (null !== $object = $this->extractObjectToPopulate($class, $context, self::OBJECT_TO_POPULATE)) { + unset($context[self::OBJECT_TO_POPULATE]); + + return new InstantiatorResult($object, $data, $context); + } + + // clean up even if no match + unset($context[self::OBJECT_TO_POPULATE]); + + $allowedAttributes = $this->propertyListExtractor ? $this->propertyListExtractor->getProperties($class, $context) : null; + $reflectionClass = new \ReflectionClass($class); + $constructor = ($context[self::INSTANTIATOR_CONSTRUCTOR])($data, $class, $context, $reflectionClass, $allowedAttributes); + + if (null === $constructor || !$constructor->isPublic()) { + return new InstantiatorResult($reflectionClass->newInstanceWithoutConstructor(), $data, $context); + } + + $constructorParameters = $constructor->getParameters(); + + $params = []; + foreach ($constructorParameters as $constructorParameter) { + $paramName = $constructorParameter->name; + $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName; + $allowed = (null === $allowedAttributes || \in_array($paramName, $allowedAttributes, true)) && $this->isAllowedAttribute($object, $paramName, $format, $context); + $childContext = $this->createChildContext($context, $paramName, $format); + + if ($allowed && $constructorParameter->isVariadic()) { + if (!\array_key_exists($paramName, $data)) { + $data[$paramName] = []; + } + + if (!\is_array($data[$paramName])) { + throw new RuntimeException(sprintf('Cannot create an instance of "%s" from serialized data because the variadic parameter "%s" can only accept an array.', $class, $constructorParameter->name)); + } + + $variadicParameters = []; + foreach ($data[$paramName] as $parameterData) { + [$currentParameter, $error] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $childContext, $format); + + if (null !== $error) { + return new InstantiatorResult(null, $data, $context, $error); + } else { + $variadicParameters[] = $currentParameter; + } + } + + $params = array_merge($params, $variadicParameters); + unset($data[$key]); + } elseif ($allowed && \array_key_exists($key, $data)) { + $parameterData = $data[$key]; + + if (null === $parameterData && $constructorParameter->allowsNull()) { + $params[] = null; + + unset($data[$key]); + continue; + } + + [$currentParameter, $error] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $childContext, $format); + + if (null !== $error) { + return new InstantiatorResult(null, $data, $context, $error); + } + $params[] = $currentParameter; + unset($data[$key]); + } elseif (\array_key_exists($key, $context[self::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class] ?? [])) { + $params[] = $context[self::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key]; + } elseif ($constructorParameter->isDefaultValueAvailable()) { + $params[] = $constructorParameter->getDefaultValue(); + } else { + return new InstantiatorResult(null, $data, $context, sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name)); + } + } + + if ($constructor->isConstructor()) { + return new InstantiatorResult($reflectionClass->newInstanceArgs($params), $data, $context); + } + + return new InstantiatorResult($constructor->invokeArgs(null, $params), $data, $context); + } + + /** + * @internal + */ + private function handleDiscriminator(string $class, array $data, ClassDiscriminatorMapping $mapping): string + { + if (!isset($data[$mapping->getTypeProperty()])) { + throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class)); + } + + $type = $data[$mapping->getTypeProperty()]; + if (null === ($mappedClass = $mapping->getClassForType($type))) { + throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s".', $type, $class)); + } + + return $mappedClass; + } + + /** + * @internal + */ + private function denormalizeParameter(\ReflectionClass $class, \ReflectionParameter $parameter, $parameterName, $parameterData, array $context, $format = null): array + { + try { + $parameterClass = $parameter->getClass(); + if (null === $parameterClass && null !== $this->propertyTypeExtractor) { + $types = $this->propertyTypeExtractor->getTypes($class->getName(), $parameterName, $context); + + if (null !== $types) { + foreach ($types as $type) { + $collectionValueType = $type->isCollection() ? $type->getCollectionValueType() : null; + + if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) { + $builtinType = Type::BUILTIN_TYPE_OBJECT; + $class = $collectionValueType->getClassName().'[]'; + + if (null !== $collectionKeyType = $type->getCollectionKeyType()) { + $context['key_type'] = $collectionKeyType; + } + } elseif ($type->isCollection() && null !== $collectionValueType && Type::BUILTIN_TYPE_ARRAY === $collectionValueType->getBuiltinType()) { + // get inner type for any nested array + $innerType = $collectionValueType; + + // note that it will break for any other builtinType + $dimensions = '[]'; + while (null !== $innerType->getCollectionValueType() && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { + $dimensions .= '[]'; + $innerType = $innerType->getCollectionValueType(); + } + + if (null !== $innerType->getClassName()) { + // the builtinType is the inner one and the class is the class followed by []...[] + $builtinType = $innerType->getBuiltinType(); + $class = $innerType->getClassName().$dimensions; + } else { + // default fallback (keep it as array) + $builtinType = $type->getBuiltinType(); + $class = $type->getClassName(); + } + } else { + $builtinType = $type->getBuiltinType(); + $class = $type->getClassName(); + } + + if (Type::BUILTIN_TYPE_OBJECT === $builtinType) { + if (null === $this->denormalizer) { + throw new MissingConstructorArgumentsException(sprintf('Could not create object of class "%s" of the parameter "%s".', $class, $parameterName)); + } + + if ($this->denormalizer->supportsDenormalization($parameterData, $class, $format, $context)) { + return [$this->denormalizer->denormalize($parameterData, $class, $format, $context), null]; + } + } + } + } + } + + if (null !== $parameterClass) { + $parameterClassName = $parameter->getClass()->getName(); + + if (null === $this->denormalizer) { + throw new MissingConstructorArgumentsException(sprintf('Could not create object of class "%s" of the parameter "%s".', $parameterClassName, $parameterName)); + } + + $parameterData = $this->denormalizer->denormalize($parameterData, $parameterClassName, $format, $context); + } + } catch (\ReflectionException $e) { + throw new RuntimeException(sprintf('Could not determine the class of the parameter "%s".', $parameterName), 0, $e); + } catch (MissingConstructorArgumentsException $e) { + if (!$parameter->getType()->allowsNull()) { + return [null, $e->getMessage()]; + } + $parameterData = null; + } + + return [$parameterData, null]; + } + + /** + * @param object|string $classOrObject + * + * @internal + */ + private function isAllowedAttribute($classOrObject, string $attribute, string $format = null, array $context = []): bool + { + $ignoredAttributes = $context[self::IGNORED_ATTRIBUTES] ?? $this->defaultContext[self::IGNORED_ATTRIBUTES] ?? []; + if (\in_array($attribute, $ignoredAttributes)) { + return false; + } + + $attributes = $context[self::ATTRIBUTES] ?? $this->defaultContext[self::ATTRIBUTES] ?? null; + if (isset($attributes[$attribute])) { + // Nested attributes + return true; + } + + if (\is_array($attributes)) { + return \in_array($attribute, $attributes, true); + } + + return true; + } +} diff --git a/src/Symfony/Component/Serializer/Instantiator/InstantiatorInterface.php b/src/Symfony/Component/Serializer/Instantiator/InstantiatorInterface.php new file mode 100644 index 0000000000000..1dad91c70b069 --- /dev/null +++ b/src/Symfony/Component/Serializer/Instantiator/InstantiatorInterface.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Instantiator; + +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; + +/** + * Describes the interface to instantiate an object using constructor parameters when needed. + * + * @author Jérôme Desjardins + * @author Baptiste Leduc + */ +interface InstantiatorInterface +{ + /** + * Instantiates a new object. + * + * @throws MissingConstructorArgumentsException When some arguments are missing to use the constructor + */ + public function instantiate(string $class, array $data, array $context, string $format = null): InstantiatorResult; +} diff --git a/src/Symfony/Component/Serializer/Instantiator/InstantiatorResult.php b/src/Symfony/Component/Serializer/Instantiator/InstantiatorResult.php new file mode 100644 index 0000000000000..970881ee4a263 --- /dev/null +++ b/src/Symfony/Component/Serializer/Instantiator/InstantiatorResult.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Instantiator; + +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; + +/** + * Contains the result of an instantiation process. + * + * @author Baptiste Leduc + */ +final class InstantiatorResult +{ + private $object; + private $data; + private $context; + private $error; + + public function __construct(?object $object, array $data, array $context, string $error = null) + { + $this->object = $object; + $this->data = $data; + $this->context = $context; + $this->error = $error; + } + + public function getObject(): ?object + { + return $this->object; + } + + public function getUnusedData(): array + { + return $this->data; + } + + public function getUnusedContext(): array + { + return $this->context; + } + + public function getError(): ?\Exception + { + if (null === $this->error) { + return null; + } + + return new MissingConstructorArgumentsException($this->error); + } + + public function hasFailed(): bool + { + return null === $this->object; + } +} diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 4a03ab851a3a2..12b5f45e6851f 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -11,12 +11,17 @@ namespace Symfony\Component\Serializer\Normalizer; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\Serializer\Context\ChildContextTrait; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Instantiator\Instantiator; +use Symfony\Component\Serializer\Instantiator\InstantiatorInterface; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\SerializerAwareInterface; @@ -27,10 +32,12 @@ * * @author Kévin Dunglas */ -abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface, DenormalizerAwareInterface { use ObjectToPopulateTrait; use SerializerAwareTrait; + use DenormalizerAwareTrait; + use ChildContextTrait; /* constants to configure the context */ @@ -133,10 +140,15 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn */ protected $nameConverter; + /** + * @var Instantiator|null + */ + protected $instantiator; + /** * Sets the {@link ClassMetadataFactoryInterface} to use. */ - public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, array $defaultContext = []) + public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, array $defaultContext = [], InstantiatorInterface $instantiator = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null) { $this->classMetadataFactory = $classMetadataFactory; $this->nameConverter = $nameConverter; @@ -157,6 +169,27 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory if (isset($this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER]) && !\is_callable($this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER])) { throw new InvalidArgumentException(sprintf('Invalid callback found in the "%s" default context option.', self::CIRCULAR_REFERENCE_HANDLER)); } + + if (null === $instantiator) { + $instantiator = new Instantiator($classMetadataFactory, $classDiscriminatorResolver, $propertyTypeExtractor, null, $nameConverter, null); + + if ($this->denormalizer instanceof DenormalizerInterface) { + $instantiator->setDenormalizer($this->denormalizer); + } + } + $this->instantiator = $instantiator; + } + + /** + * @internal + */ + public function setDenormalizer(DenormalizerInterface $denormalizer) + { + $this->denormalizer = $denormalizer; + + // Because we need a denormalizer in the Instantiator and we create it in the construct method, it won't get it. + // So we are obliged to overwrite this method in order to give the denormalizer to the Instantiator. + $this->instantiator->setDenormalizer($denormalizer); } /** @@ -312,9 +345,13 @@ protected function prepareForDenormalization($data) * @param array|bool $allowedAttributes * * @return \ReflectionMethod|null + * + * @deprecated since Symfony 5.3, use "instantiator_constructor" field in context array instead. */ protected function getConstructor(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes) { + trigger_deprecation('symfony/serializer', '5.3', 'The "%s()" method is deprecated. Use "%s" field in context array instead.', __METHOD__, Instantiator::INSTANTIATOR_CONSTRUCTOR); + return $reflectionClass->getConstructor(); } @@ -332,77 +369,20 @@ protected function getConstructor(array &$data, string $class, array &$context, * * @throws RuntimeException * @throws MissingConstructorArgumentsException + * + * @deprecated since Symfony 5.3, Use "Symfony\Component\Serializer\Instantiator\Instantiator::instantiate()" instead. */ protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, string $format = null) { - if (null !== $object = $this->extractObjectToPopulate($class, $context, self::OBJECT_TO_POPULATE)) { - unset($context[self::OBJECT_TO_POPULATE]); + trigger_deprecation('symfony/serializer', '5.3', 'The "%s()" method is deprecated. Use "%s::instantiate()" instead.', __METHOD__, Instantiator::class); - return $object; + if (!\array_key_exists(Instantiator::INSTANTIATOR_CONSTRUCTOR, $context)) { + $context[Instantiator::INSTANTIATOR_CONSTRUCTOR] = \Closure::fromCallable([$this, 'getConstructor']); } - // clean up even if no match - unset($context[static::OBJECT_TO_POPULATE]); - $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes); - if ($constructor) { - if (true !== $constructor->isPublic()) { - return $reflectionClass->newInstanceWithoutConstructor(); - } - - $constructorParameters = $constructor->getParameters(); - - $params = []; - foreach ($constructorParameters as $constructorParameter) { - $paramName = $constructorParameter->name; - $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName; - - $allowed = false === $allowedAttributes || \in_array($paramName, $allowedAttributes); - $ignored = !$this->isAllowedAttribute($class, $paramName, $format, $context); - if ($constructorParameter->isVariadic()) { - if ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) { - if (!\is_array($data[$paramName])) { - throw new RuntimeException(sprintf('Cannot create an instance of "%s" from serialized data because the variadic parameter "%s" can only accept an array.', $class, $constructorParameter->name)); - } - - $variadicParameters = []; - foreach ($data[$paramName] as $parameterData) { - $variadicParameters[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); - } - - $params = array_merge($params, $variadicParameters); - unset($data[$key]); - } - } elseif ($allowed && !$ignored && (isset($data[$key]) || \array_key_exists($key, $data))) { - $parameterData = $data[$key]; - if (null === $parameterData && $constructorParameter->allowsNull()) { - $params[] = null; - // Don't run set for a parameter passed to the constructor - unset($data[$key]); - continue; - } - - // Don't run set for a parameter passed to the constructor - $params[] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); - unset($data[$key]); - } elseif (\array_key_exists($key, $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class] ?? [])) { - $params[] = $context[static::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key]; - } elseif (\array_key_exists($key, $this->defaultContext[self::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class] ?? [])) { - $params[] = $this->defaultContext[self::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key]; - } elseif ($constructorParameter->isDefaultValueAvailable()) { - $params[] = $constructorParameter->getDefaultValue(); - } else { - throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires parameter "%s" to be present.', $class, $constructorParameter->name)); - } - } + $result = $this->instantiator->instantiate($class, $data, $context, $format); - if ($constructor->isConstructor()) { - return $reflectionClass->newInstanceArgs($params); - } else { - return $constructor->invokeArgs(null, $params); - } - } - - return new $class(); + return $result->getObject(); } /** @@ -433,18 +413,4 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara return $parameterData; } - - /** - * @internal - */ - protected function createChildContext(array $parentContext, string $attribute, ?string $format): array - { - if (isset($parentContext[self::ATTRIBUTES][$attribute])) { - $parentContext[self::ATTRIBUTES] = $parentContext[self::ATTRIBUTES][$attribute]; - } else { - unset($parentContext[self::ATTRIBUTES]); - } - - return $parentContext; - } } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index aa1be48cfbaf5..9f32484e04e11 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -15,13 +15,16 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Context\ObjectChildContextTrait; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\ExtraAttributesException; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Instantiator\Instantiator; +use Symfony\Component\Serializer\Instantiator\InstantiatorInterface; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; @@ -35,6 +38,8 @@ */ abstract class AbstractObjectNormalizer extends AbstractNormalizer { + use ObjectChildContextTrait; + /** * Set to true to respect the max depth metadata on fields. */ @@ -103,9 +108,15 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer */ protected $classDiscriminatorResolver; - public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = []) + public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = [], InstantiatorInterface $instantiator = null) { - parent::__construct($classMetadataFactory, $nameConverter, $defaultContext); + if (null === $classDiscriminatorResolver && null !== $classMetadataFactory) { + $classDiscriminatorResolver = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); + } + $this->classDiscriminatorResolver = $classDiscriminatorResolver; + $this->objectClassResolver = $objectClassResolver; + + parent::__construct($classMetadataFactory, $nameConverter, $defaultContext, $instantiator, $propertyTypeExtractor, $classDiscriminatorResolver); if (isset($this->defaultContext[self::MAX_DEPTH_HANDLER]) && !\is_callable($this->defaultContext[self::MAX_DEPTH_HANDLER])) { throw new InvalidArgumentException(sprintf('The "%s" given in the default context is not callable.', self::MAX_DEPTH_HANDLER)); @@ -114,12 +125,6 @@ public function __construct(ClassMetadataFactoryInterface $classMetadataFactory $this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] = array_merge($this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] ?? [], [self::CIRCULAR_REFERENCE_LIMIT_COUNTERS]); $this->propertyTypeExtractor = $propertyTypeExtractor; - - if (null === $classDiscriminatorResolver && null !== $classMetadataFactory) { - $classDiscriminatorResolver = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); - } - $this->classDiscriminatorResolver = $classDiscriminatorResolver; - $this->objectClassResolver = $objectClassResolver; } /** @@ -210,29 +215,6 @@ public function normalize($object, string $format = null, array $context = []) return $data; } - /** - * {@inheritdoc} - */ - protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, string $format = null) - { - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { - if (!isset($data[$mapping->getTypeProperty()])) { - throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class)); - } - - $type = $data[$mapping->getTypeProperty()]; - if (null === ($mappedClass = $mapping->getClassForType($type))) { - throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s".', $type, $class)); - } - - if ($mappedClass !== $class) { - return $this->instantiateObject($data, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); - } - } - - return parent::instantiateObject($data, $class, $context, $reflectionClass, $allowedAttributes, $format); - } - /** * Gets and caches attributes for the given object, format and context. * @@ -299,16 +281,25 @@ public function supportsDenormalization($data, string $type, string $format = nu */ public function denormalize($data, string $type, string $format = null, array $context = []) { - if (!isset($context['cache_key'])) { + if (!\array_key_exists('cache_key', $context)) { $context['cache_key'] = $this->getCacheKey($format, $context); } + if (!\array_key_exists(Instantiator::INSTANTIATOR_CONSTRUCTOR, $context)) { + $context[Instantiator::INSTANTIATOR_CONSTRUCTOR] = \Closure::fromCallable([$this, 'getConstructor']); + } $allowedAttributes = $this->getAllowedAttributes($type, $context, true); $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; - $reflectionClass = new \ReflectionClass($type); - $object = $this->instantiateObject($normalizedData, $type, $context, $reflectionClass, $allowedAttributes, $format); + $instantiatorResult = $this->instantiator->instantiate($type, $normalizedData, $context, $format); + if ($instantiatorResult->hasFailed()) { + throw new MissingConstructorArgumentsException($instantiatorResult->getError()); + } + $object = $instantiatorResult->getObject(); + $normalizedData = $instantiatorResult->getUnusedData(); + $context = $instantiatorResult->getUnusedContext(); + $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); foreach ($normalizedData as $attribute => $value) { @@ -595,23 +586,6 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str return false; } - /** - * Overwritten to update the cache key for the child. - * - * We must not mix up the attribute cache between parent and children. - * - * {@inheritdoc} - * - * @internal - */ - protected function createChildContext(array $parentContext, string $attribute, ?string $format): array - { - $context = parent::createChildContext($parentContext, $attribute, $format); - $context['cache_key'] = $this->getCacheKey($format, $context); - - return $context; - } - /** * Builds the cache key for the attributes cache. * diff --git a/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php index c3ab890951b3a..52521550bc90d 100644 --- a/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/ObjectNormalizer.php @@ -16,6 +16,7 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Instantiator\InstantiatorInterface; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; @@ -34,13 +35,13 @@ class ObjectNormalizer extends AbstractObjectNormalizer private $objectClassResolver; - public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = []) + public function __construct(ClassMetadataFactoryInterface $classMetadataFactory = null, NameConverterInterface $nameConverter = null, PropertyAccessorInterface $propertyAccessor = null, PropertyTypeExtractorInterface $propertyTypeExtractor = null, ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, callable $objectClassResolver = null, array $defaultContext = [], InstantiatorInterface $instantiator = null) { if (!class_exists(PropertyAccess::class)) { throw new LogicException('The ObjectNormalizer class requires the "PropertyAccess" component. Install "symfony/property-access" to use it.'); } - parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); + parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext, $instantiator); $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); diff --git a/src/Symfony/Component/Serializer/Tests/Instantiator/InstantiatorTest.php b/src/Symfony/Component/Serializer/Tests/Instantiator/InstantiatorTest.php new file mode 100644 index 0000000000000..b9870ea5568a6 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Instantiator/InstantiatorTest.php @@ -0,0 +1,145 @@ + 'foo', 'bar' => 'bar', 'baz' => 'baz']; + $context = []; + + $dummyResult = $instantiator->instantiate(DummyWithoutConstructor::class, $data, $context); + + $this->assertInstanceOf(DummyWithoutConstructor::class, $dummyResult->getObject()); + } + + public function testInstantiateWithConstructor() + { + $instantiator = new Instantiator(); + $data = ['foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz']; + $context = []; + + $dummyResult = $instantiator->instantiate(DummyWithConstructor::class, $data, $context); + $dummy = $dummyResult->getObject(); + + $this->assertInstanceOf(DummyWithConstructor::class, $dummy); + $this->assertSame('foo', $dummy->foo); + } + + public function testCannotInstantiate() + { + $instantiator = new Instantiator(); + $data = ['foo' => 'foo']; + $context = []; + + $dummyResult = $instantiator->instantiate(DummyWithExtraConstructor::class, $data, $context); + + $this->assertNull($dummyResult->getObject()); + $this->assertTrue($dummyResult->hasFailed()); + $this->assertEquals('Cannot create an instance of "Symfony\\Component\\Serializer\\Tests\\Instantiator\\DummyWithExtraConstructor" from serialized data because its constructor requires parameter "extra" to be present.', $dummyResult->getError()->getMessage()); + } + + public function testInstantiateWithDefaultArguments() + { + $instantiator = new Instantiator(); + $data = ['foo' => 'foo', 'bar' => 'bar', 'baz' => 'baz']; + $context = [ + AbstractObjectNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS => [ + DummyWithExtraConstructor::class => ['extra' => 'extraData'], + ], + ]; + + $dummyResult = $instantiator->instantiate(DummyWithExtraConstructor::class, $data, $context, null); + $dummy = $dummyResult->getObject(); + + $this->assertInstanceOf(DummyWithExtraConstructor::class, $dummy); + $this->assertSame('foo', $dummy->foo); + $this->assertSame('extraData', $dummy->extra); + } + + public function testInstantiateWithDenormalizationAndDenormalizer() + { + $instantiator = new Instantiator(); + $data = ['foo' => 'foo', 'bar' => ['baz' => 'baz']]; + $context = []; + + $dummyResult = $instantiator->instantiate(DummyWithObjectArgument::class, $data, $context); + + $this->assertNull($dummyResult->getObject()); + $this->assertTrue($dummyResult->hasFailed()); + $this->assertEquals('Could not create object of class "Symfony\\Component\\Serializer\\Tests\\Instantiator\\DummyBar" of the parameter "bar".', $dummyResult->getError()->getMessage()); + } + + public function testInstantiateWithDenormalization() + { + $instantiator = new Instantiator(); + $instantiator->setDenormalizer(new ObjectNormalizer()); + + $data = ['foo' => 'foo', 'bar' => ['baz' => 'baz']]; + $context = []; + + $dummyResult = $instantiator->instantiate(DummyWithObjectArgument::class, $data, $context); + $dummy = $dummyResult->getObject(); + + $this->assertInstanceOf(DummyWithObjectArgument::class, $dummy); + $this->assertSame('foo', $dummy->foo); + $this->assertInstanceOf(DummyBar::class, $dummy->bar); + } +} + +class DummyWithoutConstructor +{ + public $foo; + public $bar; + public $baz; +} + +class DummyWithConstructor +{ + public $foo; + public $bar; + public $quz; + + public function __construct($foo) + { + $this->foo = $foo; + } +} + +class DummyWithExtraConstructor +{ + public $foo; + public $bar; + public $quz; + public $extra; + + public function __construct($foo, $extra) + { + $this->foo = $foo; + $this->extra = $extra; + } +} + +class DummyWithObjectArgument +{ + public $foo; + public $bar; + + public function __construct($foo, DummyBar $bar) + { + $this->foo = $foo; + $this->bar = $bar; + } +} + +class DummyBar +{ + public $baz; +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php index 3aa4d1c63d73b..2502fed07077b 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php @@ -6,6 +6,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Instantiator\Instantiator; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; @@ -14,8 +15,8 @@ use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Tests\Fixtures\AbstractNormalizerDummy; -use Symfony\Component\Serializer\Tests\Fixtures\Dummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Dummy; use Symfony\Component\Serializer\Tests\Fixtures\NullableConstructorArgumentDummy; use Symfony\Component\Serializer\Tests\Fixtures\StaticConstructorDummy; use Symfony\Component\Serializer\Tests\Fixtures\StaticConstructorNormalizer; @@ -122,6 +123,26 @@ public function testObjectWithStaticConstructor() $this->assertNull($dummy->foo); } + public function testObjectWithStaticConstructorFromContext() + { + $normalizer = new ObjectNormalizer(null, null, null, null, null, null, [], null, new Instantiator()); + $dummy = $normalizer->denormalize(['foo' => 'baz'], StaticConstructorDummy::class, null, [ + Instantiator::INSTANTIATOR_CONSTRUCTOR => function (array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes): ?\ReflectionMethod { + $class = $reflectionClass->getName(); + + if (is_a($class, StaticConstructorDummy::class, true)) { + return new \ReflectionMethod($class, 'create'); + } + + return $reflectionClass->getConstructor(); + }, + ]); + + $this->assertInstanceOf(StaticConstructorDummy::class, $dummy); + $this->assertEquals('baz', $dummy->quz); + $this->assertNull($dummy->foo); + } + public function testObjectWithNullableConstructorArgument() { $normalizer = new ObjectNormalizer(); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index 66d578f1ebf85..e30fd27e8ddca 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -28,8 +28,8 @@ use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\OtherSerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74Dummy; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder;