From 7229fa1d8fbd532cd4417db59ce03adb36f0f4eb Mon Sep 17 00:00:00 2001 From: Maxime Steinhausser Date: Mon, 7 Dec 2020 17:36:04 +0100 Subject: [PATCH] [Serializer] Allow to provide (de)normalization context in mapping --- .../Serializer/Annotation/Context.php | 93 ++++++++ src/Symfony/Component/Serializer/CHANGELOG.md | 1 + .../Serializer/Mapping/AttributeMetadata.php | 96 +++++++- .../Mapping/AttributeMetadataInterface.php | 30 +++ .../Mapping/Loader/AnnotationLoader.php | 27 +++ .../Mapping/Loader/XmlFileLoader.php | 44 ++++ .../Mapping/Loader/YamlFileLoader.php | 17 ++ .../serializer-mapping-1.0.xsd | 28 ++- .../Normalizer/AbstractNormalizer.php | 14 +- .../Normalizer/AbstractObjectNormalizer.php | 59 ++++- .../Tests/Annotation/ContextTest.php | 217 ++++++++++++++++++ .../Annotations/BadMethodContextDummy.php | 28 +++ .../Fixtures/Annotations/ContextDummy.php | 50 ++++ .../Annotations/ContextDummyParent.php | 30 +++ .../Attributes/BadMethodContextDummy.php | 26 +++ .../Fixtures/Attributes/ContextDummy.php | 42 ++++ .../Attributes/ContextDummyParent.php | 26 +++ .../Tests/Fixtures/serialization.xml | 55 +++++ .../Tests/Fixtures/serialization.yml | 28 +++ .../Tests/Mapping/AttributeMetadataTest.php | 71 ++++++ .../Mapping/Loader/AnnotationLoaderTest.php | 28 +++ .../Features/ContextMappingTestTrait.php | 78 +++++++ .../Mapping/Loader/XmlFileLoaderTest.php | 9 + .../Mapping/Loader/YamlFileLoaderTest.php | 8 + .../Features/ContextMetadataTestTrait.php | 92 ++++++++ .../Tests/Normalizer/ObjectNormalizerTest.php | 2 + .../Component/Serializer/composer.json | 1 + 27 files changed, 1183 insertions(+), 17 deletions(-) create mode 100644 src/Symfony/Component/Serializer/Annotation/Context.php create mode 100644 src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php create mode 100644 src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php create mode 100644 src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php create mode 100644 src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php diff --git a/src/Symfony/Component/Serializer/Annotation/Context.php b/src/Symfony/Component/Serializer/Annotation/Context.php new file mode 100644 index 0000000000..08e1f7cf69 --- /dev/null +++ b/src/Symfony/Component/Serializer/Annotation/Context.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Annotation; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * Annotation class for @Context(). + * + * @Annotation + * @Target({"PROPERTY", "METHOD"}) + * + * @author Maxime Steinhausser + */ +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +final class Context +{ + private $context; + private $normalizationContext; + private $denormalizationContext; + private $groups; + + /** + * @throws InvalidArgumentException + */ + public function __construct(array $options = [], array $context = [], array $normalizationContext = [], array $denormalizationContext = [], array $groups = []) + { + if (!$context) { + if (!array_intersect((array_keys($options)), ['normalizationContext', 'groups', 'context', 'value', 'denormalizationContext'])) { + // gracefully supports context as first, unnamed attribute argument if it cannot be confused with Doctrine-style options + $context = $options; + } else { + // If at least one of the options match, it's likely to be Doctrine-style options. Search for the context inside: + $context = $options['value'] ?? $options['context'] ?? []; + } + } + + $normalizationContext = $options['normalizationContext'] ?? $normalizationContext; + $denormalizationContext = $options['denormalizationContext'] ?? $denormalizationContext; + + foreach (compact(['context', 'normalizationContext', 'denormalizationContext']) as $key => $value) { + if (!\is_array($value)) { + throw new InvalidArgumentException(sprintf('Option "%s" of annotation "%s" must be an array.', $key, static::class)); + } + } + + if (!$context && !$normalizationContext && !$denormalizationContext) { + throw new InvalidArgumentException(sprintf('At least one of the "context", "normalizationContext", or "denormalizationContext" options of annotation "%s" must be provided as a non-empty array.', static::class)); + } + + $groups = (array) ($options['groups'] ?? $groups); + + foreach ($groups as $group) { + if (!\is_string($group)) { + throw new InvalidArgumentException(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "%s".', static::class, get_debug_type($group))); + } + } + + $this->context = $context; + $this->normalizationContext = $normalizationContext; + $this->denormalizationContext = $denormalizationContext; + $this->groups = $groups; + } + + public function getContext(): array + { + return $this->context; + } + + public function getNormalizationContext(): array + { + return $this->normalizationContext; + } + + public function getDenormalizationContext(): array + { + return $this->denormalizationContext; + } + + public function getGroups(): array + { + return $this->groups; + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 8ccc9f4c7d..dde0f2cf21 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -4,6 +4,7 @@ CHANGELOG 5.3 --- + * Add the ability to provide (de)normalization context using metadata (e.g. `@Symfony\Component\Serializer\Annotation\Context`) * deprecated `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead. * added normalization formats to `UidNormalizer` diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php index 732e0bd590..36d1e92b66 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php @@ -59,6 +59,24 @@ class AttributeMetadata implements AttributeMetadataInterface */ public $ignore = false; + /** + * @var array[] Normalization contexts per group name ("*" applies to all groups) + * + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getNormalizationContexts()} instead. + */ + public $normalizationContexts = []; + + /** + * @var array[] Denormalization contexts per group name ("*" applies to all groups) + * + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getDenormalizationContexts()} instead. + */ + public $denormalizationContexts = []; + public function __construct(string $name) { $this->name = $name; @@ -138,6 +156,76 @@ class AttributeMetadata implements AttributeMetadataInterface return $this->ignore; } + /** + * {@inheritdoc} + */ + public function getNormalizationContexts(): array + { + return $this->normalizationContexts; + } + + /** + * {@inheritdoc} + */ + public function getNormalizationContextForGroups(array $groups): array + { + $contexts = []; + foreach ($groups as $group) { + $contexts[] = $this->normalizationContexts[$group] ?? []; + } + + return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts); + } + + /** + * {@inheritdoc} + */ + public function setNormalizationContextForGroups(array $context, array $groups = []): void + { + if (!$groups) { + $this->normalizationContexts['*'] = $context; + } + + foreach ($groups as $group) { + $this->normalizationContexts[$group] = $context; + } + } + + /** + * {@inheritdoc} + */ + public function getDenormalizationContexts(): array + { + return $this->denormalizationContexts; + } + + /** + * {@inheritdoc} + */ + public function getDenormalizationContextForGroups(array $groups): array + { + $contexts = []; + foreach ($groups as $group) { + $contexts[] = $this->denormalizationContexts[$group] ?? []; + } + + return array_merge($this->denormalizationContexts['*'] ?? [], ...$contexts); + } + + /** + * {@inheritdoc} + */ + public function setDenormalizationContextForGroups(array $context, array $groups = []): void + { + if (!$groups) { + $this->denormalizationContexts['*'] = $context; + } + + foreach ($groups as $group) { + $this->denormalizationContexts[$group] = $context; + } + } + /** * {@inheritdoc} */ @@ -157,6 +245,12 @@ class AttributeMetadata implements AttributeMetadataInterface $this->serializedName = $attributeMetadata->getSerializedName(); } + // Overwrite only if both contexts are empty + if (!$this->normalizationContexts && !$this->denormalizationContexts) { + $this->normalizationContexts = $attributeMetadata->getNormalizationContexts(); + $this->denormalizationContexts = $attributeMetadata->getDenormalizationContexts(); + } + if ($ignore = $attributeMetadata->isIgnored()) { $this->ignore = $ignore; } @@ -169,6 +263,6 @@ class AttributeMetadata implements AttributeMetadataInterface */ public function __sleep() { - return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore']; + return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore', 'normalizationContexts', 'denormalizationContexts']; } } diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php index 9e78cf0d31..9e5a1ae2d1 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php @@ -75,4 +75,34 @@ interface AttributeMetadataInterface * Merges an {@see AttributeMetadataInterface} with in the current one. */ public function merge(self $attributeMetadata); + + /** + * Gets all the normalization contexts per group ("*" being the base context applied to all groups). + */ + public function getNormalizationContexts(): array; + + /** + * Gets the computed normalization contexts for given groups. + */ + public function getNormalizationContextForGroups(array $groups): array; + + /** + * Sets the normalization context for given groups. + */ + public function setNormalizationContextForGroups(array $context, array $groups = []): void; + + /** + * Gets all the denormalization contexts per group ("*" being the base context applied to all groups). + */ + public function getDenormalizationContexts(): array; + + /** + * Gets the computed denormalization contexts for given groups. + */ + public function getDenormalizationContextForGroups(array $groups): array; + + /** + * Sets the denormalization context for given groups. + */ + public function setDenormalizationContextForGroups(array $context, array $groups = []): void; } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index 5b0fec2f62..bd0f049c72 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping\Loader; use Doctrine\Common\Annotations\Reader; +use Symfony\Component\Serializer\Annotation\Context; use Symfony\Component\Serializer\Annotation\DiscriminatorMap; use Symfony\Component\Serializer\Annotation\Groups; use Symfony\Component\Serializer\Annotation\Ignore; @@ -19,6 +20,7 @@ use Symfony\Component\Serializer\Annotation\MaxDepth; use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; @@ -36,6 +38,7 @@ class AnnotationLoader implements LoaderInterface Ignore::class => true, MaxDepth::class => true, SerializedName::class => true, + Context::class => true, ]; private $reader; @@ -83,6 +86,8 @@ class AnnotationLoader implements LoaderInterface $attributesMetadata[$property->name]->setSerializedName($annotation->getSerializedName()); } elseif ($annotation instanceof Ignore) { $attributesMetadata[$property->name]->setIgnore(true); + } elseif ($annotation instanceof Context) { + $this->setAttributeContextsForGroups($annotation, $attributesMetadata[$property->name]); } $loaded = true; @@ -130,6 +135,12 @@ class AnnotationLoader implements LoaderInterface $attributeMetadata->setSerializedName($annotation->getSerializedName()); } elseif ($annotation instanceof Ignore) { $attributeMetadata->setIgnore(true); + } elseif ($annotation instanceof Context) { + if (!$accessorOrMutator) { + throw new MappingException(sprintf('Context on "%s::%s()" cannot be added. Context can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $this->setAttributeContextsForGroups($annotation, $attributeMetadata); } $loaded = true; @@ -166,4 +177,20 @@ class AnnotationLoader implements LoaderInterface yield from $this->reader->getPropertyAnnotations($reflector); } } + + private function setAttributeContextsForGroups(Context $annotation, AttributeMetadataInterface $attributeMetadata): void + { + if ($annotation->getContext()) { + $attributeMetadata->setNormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); + $attributeMetadata->setDenormalizationContextForGroups($annotation->getContext(), $annotation->getGroups()); + } + + if ($annotation->getNormalizationContext()) { + $attributeMetadata->setNormalizationContextForGroups($annotation->getNormalizationContext(), $annotation->getGroups()); + } + + if ($annotation->getDenormalizationContext()) { + $attributeMetadata->setDenormalizationContextForGroups($annotation->getDenormalizationContext(), $annotation->getGroups()); + } + } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php index 696007afb8..04cd626ab0 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/XmlFileLoader.php @@ -74,6 +74,25 @@ class XmlFileLoader extends FileLoader if (isset($attribute['ignore'])) { $attributeMetadata->setIgnore((bool) $attribute['ignore']); } + + foreach ($attribute->context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + + foreach ($attribute->normalization_context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + } + + foreach ($attribute->denormalization_context as $node) { + $groups = (array) $node->group; + $context = $this->parseContext($node->entry); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } } if (isset($xml->{'discriminator-map'})) { @@ -136,4 +155,29 @@ class XmlFileLoader extends FileLoader return $classes; } + + private function parseContext(\SimpleXMLElement $nodes): array + { + $context = []; + + foreach ($nodes as $node) { + if (\count($node) > 0) { + if (\count($node->entry) > 0) { + $value = $this->parseContext($node->entry); + } else { + $value = []; + } + } else { + $value = XmlUtils::phpize($node); + } + + if (isset($node['name'])) { + $context[(string) $node['name']] = $value; + } else { + $context[] = $value; + } + } + + return $context; + } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php index ff50e622ee..5975fb334d 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/YamlFileLoader.php @@ -101,6 +101,23 @@ class YamlFileLoader extends FileLoader $attributeMetadata->setIgnore($data['ignore']); } + + foreach ($data['contexts'] ?? [] as $line) { + $groups = $line['groups'] ?? []; + + if ($context = $line['context'] ?? false) { + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + + if ($context = $line['normalization_context'] ?? false) { + $attributeMetadata->setNormalizationContextForGroups($context, $groups); + } + + if ($context = $line['denormalization_context'] ?? false) { + $attributeMetadata->setDenormalizationContextForGroups($context, $groups); + } + } } } diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd index b427a36e36..0228e41ce1 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd +++ b/src/Symfony/Component/Serializer/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd @@ -60,9 +60,12 @@ Contains serialization groups and max depth for attributes. The name of the attribute should be given in the "name" option. ]]> - + - + + + + @@ -81,4 +84,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 4a03ab851a..9e64e607f6 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -237,8 +237,7 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn return false; } - $tmpGroups = $context[self::GROUPS] ?? $this->defaultContext[self::GROUPS] ?? null; - $groups = (\is_array($tmpGroups) || is_scalar($tmpGroups)) ? (array) $tmpGroups : false; + $groups = $this->getGroups($context); $allowedAttributes = []; $ignoreUsed = false; @@ -250,14 +249,14 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn // If you update this check, update accordingly the one in Symfony\Component\PropertyInfo\Extractor\SerializerExtractor::getProperties() if ( !$ignore && - (false === $groups || array_intersect(array_merge($attributeMetadata->getGroups(), ['*']), $groups)) && + ([] === $groups || array_intersect(array_merge($attributeMetadata->getGroups(), ['*']), $groups)) && $this->isAllowedAttribute($classOrObject, $name = $attributeMetadata->getName(), null, $context) ) { $allowedAttributes[] = $attributesAsString ? $name : $attributeMetadata; } } - if (!$ignoreUsed && false === $groups && $allowExtraAttributes) { + if (!$ignoreUsed && [] === $groups && $allowExtraAttributes) { // Backward Compatibility with the code using this method written before the introduction of @Ignore return false; } @@ -265,6 +264,13 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn return $allowedAttributes; } + protected function getGroups(array $context): array + { + $groups = $context[self::GROUPS] ?? $this->defaultContext[self::GROUPS] ?? []; + + return is_scalar($groups) ? (array) $groups : $groups; + } + /** * Is this attribute allowed? * diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 36099aa385..6a151c31b7 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -175,9 +175,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer continue; } - $attributeValue = $this->getAttributeValue($object, $attribute, $format, $context); + $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); + $attributeValue = $this->getAttributeValue($object, $attribute, $format, $attributeContext); if ($maxDepthReached) { - $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $context); + $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $attributeContext); } /** @@ -185,14 +186,14 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer */ $callback = $context[self::CALLBACKS][$attribute] ?? $this->defaultContext[self::CALLBACKS][$attribute] ?? null; if ($callback) { - $attributeValue = $callback($attributeValue, $object, $attribute, $format, $context); + $attributeValue = $callback($attributeValue, $object, $attribute, $format, $attributeContext); } if (null !== $attributeValue && !is_scalar($attributeValue)) { $stack[$attribute] = $attributeValue; } - $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $context); + $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $attributeContext); } foreach ($stack as $attribute => $attributeValue) { @@ -200,7 +201,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer throw new LogicException(sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer.', $attribute)); } - $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $this->createChildContext($context, $attribute, $format)), $class, $format, $context); + $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); + $childContext = $this->createChildContext($attributeContext, $attribute, $format); + + $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $childContext), $class, $format, $attributeContext); } if (isset($context[self::PRESERVE_EMPTY_OBJECTS]) && !\count($data)) { @@ -210,6 +214,39 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer return $data; } + /** + * Computes the normalization context merged with current one. Metadata always wins over global context, as more specific. + */ + private function getAttributeNormalizationContext($object, string $attribute, array $context): array + { + if (null === $metadata = $this->getAttributeMetadata($object, $attribute)) { + return $context; + } + + return array_merge($context, $metadata->getNormalizationContextForGroups($this->getGroups($context))); + } + + /** + * Computes the denormalization context merged with current one. Metadata always wins over global context, as more specific. + */ + private function getAttributeDenormalizationContext(string $class, string $attribute, array $context): array + { + if (null === $metadata = $this->getAttributeMetadata($class, $attribute)) { + return $context; + } + + return array_merge($context, $metadata->getDenormalizationContextForGroups($this->getGroups($context))); + } + + private function getAttributeMetadata($objectOrClass, string $attribute): ?AttributeMetadataInterface + { + if (!$this->classMetadataFactory) { + return null; + } + + return $this->classMetadataFactory->getMetadataFor($objectOrClass)->getAttributesMetadata()[$attribute] ?? null; + } + /** * {@inheritdoc} */ @@ -312,8 +349,10 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); foreach ($normalizedData as $attribute => $value) { + $attributeContext = $this->getAttributeDenormalizationContext($resolvedClass, $attribute, $context); + if ($this->nameConverter) { - $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context); + $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $attributeContext); } if ((false !== $allowedAttributes && !\in_array($attribute, $allowedAttributes)) || !$this->isAllowedAttribute($resolvedClass, $attribute, $format, $context)) { @@ -324,16 +363,16 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer continue; } - if ($context[self::DEEP_OBJECT_TO_POPULATE] ?? $this->defaultContext[self::DEEP_OBJECT_TO_POPULATE] ?? false) { + if ($attributeContext[self::DEEP_OBJECT_TO_POPULATE] ?? $this->defaultContext[self::DEEP_OBJECT_TO_POPULATE] ?? false) { try { - $context[self::OBJECT_TO_POPULATE] = $this->getAttributeValue($object, $attribute, $format, $context); + $attributeContext[self::OBJECT_TO_POPULATE] = $this->getAttributeValue($object, $attribute, $format, $attributeContext); } catch (NoSuchPropertyException $e) { } } - $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $context); + $value = $this->validateAndDenormalize($resolvedClass, $attribute, $value, $format, $attributeContext); try { - $this->setAttributeValue($object, $attribute, $value, $format, $context); + $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); } catch (InvalidArgumentException $e) { throw new NotNormalizableValueException(sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), $e->getCode(), $e); } diff --git a/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php b/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php new file mode 100644 index 0000000000..a79178f1ba --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Annotation/ContextTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Annotation; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\VarDumper\Dumper\CliDumper; +use Symfony\Component\VarDumper\Test\VarDumperTestTrait; + +/** + * @author Maxime Steinhausser + */ +class ContextTest extends TestCase +{ + use VarDumperTestTrait; + + protected function setUp(): void + { + $this->setUpVarDumper([], CliDumper::DUMP_LIGHT_ARRAY | CliDumper::DUMP_TRAILING_COMMA); + } + + /** + * @dataProvider provideTestThrowsOnEmptyContextData + */ + public function testThrowsOnEmptyContext(callable $factory) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of the "context", "normalizationContext", or "denormalizationContext" options of annotation "Symfony\Component\Serializer\Annotation\Context" must be provided as a non-empty array.'); + + $factory(); + } + + public function provideTestThrowsOnEmptyContextData(): iterable + { + yield 'constructor: empty args' => [function () { new Context([]); }]; + + yield 'doctrine-style: value option as empty array' => [function () { new Context(['value' => []]); }]; + yield 'doctrine-style: context option as empty array' => [function () { new Context(['context' => []]); }]; + yield 'doctrine-style: context option not provided' => [function () { new Context(['groups' => ['group_1']]); }]; + + if (\PHP_VERSION_ID >= 80000) { + yield 'named args: empty context' => [function () { + eval('return new Symfony\Component\Serializer\Annotation\Context(context: []);'); + }]; + } + } + + /** + * @dataProvider provideTestThrowsOnNonArrayContextData + */ + public function testThrowsOnNonArrayContext(array $options) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Option "%s" of annotation "%s" must be an array.', key($options), Context::class)); + + new Context($options); + } + + public function provideTestThrowsOnNonArrayContextData(): iterable + { + yield 'non-array context' => [['context' => 'not_an_array']]; + yield 'non-array normalization context' => [['normalizationContext' => 'not_an_array']]; + yield 'non-array denormalization context' => [['normalizationContext' => 'not_an_array']]; + } + + public function testInvalidGroupOption() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "stdClass"', Context::class)); + + new Context(['context' => ['foo' => 'bar'], 'groups' => ['fine', new \stdClass()]]); + } + + public function testInvalidGroupArgument() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('Parameter "groups" of annotation "%s" must be a string or an array of strings. Got "stdClass"', Context::class)); + + new Context([], ['foo' => 'bar'], [], [], ['fine', new \stdClass()]); + } + + public function testAsFirstArg() + { + $context = new Context(['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $context->getContext()); + self::assertEmpty($context->getNormalizationContext()); + self::assertEmpty($context->getDenormalizationContext()); + self::assertEmpty($context->getGroups()); + } + + public function testAsContextArg() + { + $context = new Context([], ['foo' => 'bar']); + + self::assertSame(['foo' => 'bar'], $context->getContext()); + self::assertEmpty($context->getNormalizationContext()); + self::assertEmpty($context->getDenormalizationContext()); + self::assertEmpty($context->getGroups()); + } + + /** + * @dataProvider provideValidInputs + */ + public function testValidInputs(callable $factory, string $expectedDump) + { + self::assertDumpEquals($expectedDump, $factory()); + } + + public function provideValidInputs(): iterable + { + yield 'doctrine-style: with context option' => [ + function () { return new Context(['context' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with context arg' => [ + function () { return new Context([], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with normalization context option' => [ + function () { return new Context(['normalizationContext' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -denormalizationContext: [] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with normalization context arg' => [ + function () { return new Context([], [], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with denormalization context option' => [ + function () { return new Context(['denormalizationContext' => ['foo' => 'bar']]); }, + $expected = << "bar", + ] + -groups: [] +} +DUMP + ]; + + yield 'constructor: with denormalization context arg' => [ + function () { return new Context([], [], [], ['foo' => 'bar']); }, + $expected, + ]; + + yield 'doctrine-style: with groups option as string' => [ + function () { return new Context(['context' => ['foo' => 'bar'], 'groups' => 'a']); }, + << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [ + "a", + ] +} +DUMP + ]; + + yield 'doctrine-style: with groups option as array' => [ + function () { return new Context(['context' => ['foo' => 'bar'], 'groups' => ['a', 'b']]); }, + $expected = << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] + -groups: [ + "a", + "b", + ] +} +DUMP + ]; + + yield 'constructor: with groups arg' => [ + function () { return new Context([], ['foo' => 'bar'], [], [], ['a', 'b']); }, + $expected, + ]; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php new file mode 100644 index 0000000000..77b3884de5 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/BadMethodContextDummy.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class BadMethodContextDummy extends ContextDummyParent +{ + /** + * @Context({ "foo" = "bar" }) + */ + public function badMethod() + { + return 'bad_method'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php new file mode 100644 index 0000000000..804df290f0 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummy.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummy extends ContextDummyParent +{ + /** + * @Context({ "foo" = "value", "bar" = "value", "nested" = { + * "nested_key" = "nested_value", + * }, "array": { "first", "second" } }) + * @Context({ "bar" = "value_for_group_a" }, groups = "a") + */ + public $foo; + + /** + * @Context( + * normalizationContext = { "format" = "d/m/Y" }, + * denormalizationContext = { "format" = "m-d-Y H:i" }, + * groups = {"a", "b"} + * ) + */ + public $bar; + + /** + * @Context(normalizationContext={ "prop" = "dummy_value" }) + */ + public $overriddenParentProperty; + + /** + * @Context({ "method" = "method_with_context" }) + */ + public function getMethodWithContext() + { + return 'method_with_context'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.php new file mode 100644 index 0000000000..b7b286c372 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/ContextDummyParent.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\Tests\Fixtures\Annotations; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummyParent +{ + /** + * @Context(normalizationContext={ "prop" = "dummy_parent_value" }) + */ + public $parentProperty; + + /** + * @Context(normalizationContext={ "prop" = "dummy_parent_value" }) + */ + public $overriddenParentProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php new file mode 100644 index 0000000000..5c6c82e653 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/BadMethodContextDummy.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class BadMethodContextDummy extends ContextDummyParent +{ + #[Context([ "foo" => "bar" ])] + public function badMethod() + { + return 'bad_method'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php new file mode 100644 index 0000000000..447b80d6a9 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummy.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummy extends ContextDummyParent +{ + #[Context(['foo' => 'value', 'bar' => 'value', 'nested' => [ + 'nested_key' => 'nested_value' + ], 'array' => ['first', 'second']])] + #[Context(context: ['bar' => 'value_for_group_a'], groups: ['a'])] + public $foo; + + #[Context( + normalizationContext: ['format' => 'd/m/Y'], + denormalizationContext: ['format' => 'm-d-Y H:i'], + groups: ['a', 'b'], + )] + public $bar; + + #[Context(normalizationContext: ['prop' => 'dummy_value'])] + public $overriddenParentProperty; + + #[Context(['method' => 'method_with_context'])] + public function getMethodWithContext() + { + return 'method_with_context'; + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php new file mode 100644 index 0000000000..9480c953e7 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/ContextDummyParent.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummyParent +{ + #[Context(normalizationContext: ['prop' => 'dummy_parent_value'])] + public $parentProperty; + + #[Context(normalizationContext: ['prop' => 'dummy_parent_value'])] + public $overriddenParentProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml index 635253dd8e..da61e0acce 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.xml @@ -39,4 +39,59 @@ + + + + dummy_parent_value + + + + + dummy_parent_value + + + + + + + + value + value + + nested_value + + + first + second + + + + a + value_for_group_a + + + + + a + b + d/m/Y + + + a + b + m-d-Y H:i + + + + + dummy_value + + + + + method_with_context + + + + diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml index 5b212c8914..80100e8260 100644 --- a/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/serialization.yml @@ -30,3 +30,31 @@ ignore: true ignored2: ignore: true + +Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent: + attributes: + parentProperty: + contexts: + - { normalization_context: { prop: dummy_parent_value } } + overriddenParentProperty: + contexts: + - { normalization_context: { prop: dummy_parent_value } } + +Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummy: + attributes: + foo: + contexts: + - context: { foo: value, bar: value, nested: { nested_key: nested_value }, array: [first, second] } + - context: { bar: value_for_group_a } + groups: [a] + bar: + contexts: + - normalization_context: { format: 'd/m/Y' } + denormalization_context: { format: 'm-d-Y H:i' } + groups: [a, b] + overriddenParentProperty: + contexts: + - normalization_context: { prop: dummy_value } + methodWithContext: + contexts: + - context: { method: method_with_context } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php index 6b8f5864f2..8fc4b8b498 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/AttributeMetadataTest.php @@ -66,6 +66,57 @@ class AttributeMetadataTest extends TestCase $this->assertTrue($attributeMetadata->isIgnored()); } + public function testSetContexts() + { + $metadata = new AttributeMetadata('a1'); + $metadata->setNormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setNormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setNormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame([ + '*' => ['foo' => 'default', 'bar' => 'default'], + 'a' => ['foo' => 'overridden'], + 'b' => ['foo' => 'overridden'], + 'c' => ['bar' => 'overridden'], + ], $metadata->getNormalizationContexts()); + + $metadata->setDenormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setDenormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setDenormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame([ + '*' => ['foo' => 'default', 'bar' => 'default'], + 'a' => ['foo' => 'overridden'], + 'b' => ['foo' => 'overridden'], + 'c' => ['bar' => 'overridden'], + ], $metadata->getDenormalizationContexts()); + } + + public function testGetContextsForGroups() + { + $metadata = new AttributeMetadata('a1'); + + $metadata->setNormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setNormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setNormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame(['foo' => 'default', 'bar' => 'default'], $metadata->getNormalizationContextForGroups([])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getNormalizationContextForGroups(['a'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getNormalizationContextForGroups(['b'])); + self::assertSame(['foo' => 'default', 'bar' => 'overridden'], $metadata->getNormalizationContextForGroups(['c'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'overridden'], $metadata->getNormalizationContextForGroups(['b', 'c'])); + + $metadata->setDenormalizationContextForGroups(['foo' => 'default', 'bar' => 'default'], []); + $metadata->setDenormalizationContextForGroups(['foo' => 'overridden'], ['a', 'b']); + $metadata->setDenormalizationContextForGroups(['bar' => 'overridden'], ['c']); + + self::assertSame(['foo' => 'default', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups([])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups(['a'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'default'], $metadata->getDenormalizationContextForGroups(['b'])); + self::assertSame(['foo' => 'default', 'bar' => 'overridden'], $metadata->getDenormalizationContextForGroups(['c'])); + self::assertSame(['foo' => 'overridden', 'bar' => 'overridden'], $metadata->getDenormalizationContextForGroups(['b', 'c'])); + } + public function testMerge() { $attributeMetadata1 = new AttributeMetadata('a1'); @@ -77,6 +128,8 @@ class AttributeMetadataTest extends TestCase $attributeMetadata2->addGroup('c'); $attributeMetadata2->setMaxDepth(2); $attributeMetadata2->setSerializedName('a3'); + $attributeMetadata2->setNormalizationContextForGroups(['foo' => 'bar'], ['a']); + $attributeMetadata2->setDenormalizationContextForGroups(['baz' => 'qux'], ['c']); $attributeMetadata2->setIgnore(true); @@ -85,9 +138,27 @@ class AttributeMetadataTest extends TestCase $this->assertEquals(['a', 'b', 'c'], $attributeMetadata1->getGroups()); $this->assertEquals(2, $attributeMetadata1->getMaxDepth()); $this->assertEquals('a3', $attributeMetadata1->getSerializedName()); + $this->assertSame(['a' => ['foo' => 'bar']], $attributeMetadata1->getNormalizationContexts()); + $this->assertSame(['c' => ['baz' => 'qux']], $attributeMetadata1->getDenormalizationContexts()); $this->assertTrue($attributeMetadata1->isIgnored()); } + public function testContextsNotMergedIfAlreadyDefined() + { + $attributeMetadata1 = new AttributeMetadata('a1'); + $attributeMetadata1->setNormalizationContextForGroups(['foo' => 'not overridden'], ['a']); + $attributeMetadata1->setDenormalizationContextForGroups(['baz' => 'not overridden'], ['b']); + + $attributeMetadata2 = new AttributeMetadata('a2'); + $attributeMetadata2->setNormalizationContextForGroups(['foo' => 'override'], ['a']); + $attributeMetadata2->setDenormalizationContextForGroups(['baz' => 'override'], ['b']); + + $attributeMetadata1->merge($attributeMetadata2); + + self::assertSame(['a' => ['foo' => 'not overridden']], $attributeMetadata1->getNormalizationContexts()); + self::assertSame(['b' => ['baz' => 'not overridden']], $attributeMetadata1->getDenormalizationContexts()); + } + public function testSerialize() { $attributeMetadata = new AttributeMetadata('attribute'); diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php index b3bbbf812e..a135bfdaab 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTest.php @@ -12,11 +12,13 @@ namespace Symfony\Component\Serializer\Tests\Mapping\Loader; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -24,6 +26,8 @@ use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; */ abstract class AnnotationLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var AnnotationLoader */ @@ -114,7 +118,31 @@ abstract class AnnotationLoaderTest extends TestCase $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); } + public function testLoadContexts() + { + $this->assertLoadedContexts($this->getNamespace().'\ContextDummy', $this->getNamespace().'\ContextDummyParent'); + } + + public function testThrowsOnContextOnInvalidMethod() + { + $class = $this->getNamespace().'\BadMethodContextDummy'; + + $this->expectException(MappingException::class); + $this->expectExceptionMessage(sprintf('Context on "%s::badMethod()" cannot be added', $class)); + + $loader = $this->getLoaderForContextMapping(); + + $classMetadata = new ClassMetadata($class); + + $loader->loadClassMetadata($classMetadata); + } + abstract protected function createLoader(): AnnotationLoader; abstract protected function getNamespace(): string; + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php new file mode 100644 index 0000000000..97c70c1499 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Mapping\Loader\Features; + +use PHPUnit\Framework\Assert; +use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent; + +/** + * @author Maxime Steinhausser + */ +trait ContextMappingTestTrait +{ + abstract protected function getLoaderForContextMapping(): LoaderInterface; + + public function testLoadContexts() + { + $this->assertLoadedContexts(); + } + + public function assertLoadedContexts(string $dummyClass = ContextDummy::class, string $parentClass = ContextDummyParent::class) + { + $loader = $this->getLoaderForContextMapping(); + + $classMetadata = new ClassMetadata($dummyClass); + $parentClassMetadata = new ClassMetadata($parentClass); + + $loader->loadClassMetadata($parentClassMetadata); + $classMetadata->merge($parentClassMetadata); + + $loader->loadClassMetadata($classMetadata); + + $attributes = $classMetadata->getAttributesMetadata(); + + Assert::assertEquals(['*' => ['prop' => 'dummy_parent_value']], $attributes['parentProperty']->getNormalizationContexts()); + Assert::assertEquals(['*' => ['prop' => 'dummy_value']], $attributes['overriddenParentProperty']->getNormalizationContexts()); + + Assert::assertEquals([ + '*' => [ + 'foo' => 'value', + 'bar' => 'value', + 'nested' => ['nested_key' => 'nested_value'], + 'array' => ['first', 'second'], + ], + 'a' => ['bar' => 'value_for_group_a'], + ], $attributes['foo']->getNormalizationContexts()); + Assert::assertSame( + $attributes['foo']->getNormalizationContexts(), + $attributes['foo']->getDenormalizationContexts() + ); + + Assert::assertEquals([ + 'a' => $c = ['format' => 'd/m/Y'], + 'b' => $c, + ], $attributes['bar']->getNormalizationContexts()); + Assert::assertEquals([ + 'a' => $c = ['format' => 'm-d-Y H:i'], + 'b' => $c, + ], $attributes['bar']->getDenormalizationContexts()); + + Assert::assertEquals(['*' => ['method' => 'method_with_context']], $attributes['methodWithContext']->getNormalizationContexts()); + Assert::assertEquals( + $attributes['methodWithContext']->getNormalizationContexts(), + $attributes['methodWithContext']->getDenormalizationContexts() + ); + } +} diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php index d4ed487a20..201cb68ba8 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/XmlFileLoaderTest.php @@ -21,6 +21,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -28,10 +29,13 @@ use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; */ class XmlFileLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var XmlFileLoader */ private $loader; + /** * @var ClassMetadata */ @@ -104,4 +108,9 @@ class XmlFileLoaderTest extends TestCase $this->assertTrue($attributesMetadata['ignored1']->isIgnored()); $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); } + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php index d6fb2fa598..aa235762bd 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -29,6 +30,8 @@ use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; */ class YamlFileLoaderTest extends TestCase { + use ContextMappingTestTrait; + /** * @var YamlFileLoader */ @@ -126,4 +129,9 @@ class YamlFileLoaderTest extends TestCase (new YamlFileLoader(__DIR__.'/../../Fixtures/invalid-ignore.yml'))->loadClassMetadata(new ClassMetadata(IgnoreDummy::class)); } + + protected function getLoaderForContextMapping(): LoaderInterface + { + return $this->loader; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php new file mode 100644 index 0000000000..374cacaf79 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Normalizer\Features; + +use Doctrine\Common\Annotations\AnnotationReader; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +/** + * Test context handling from Serializer metadata. + * + * @author Maxime Steinhausser + */ +trait ContextMetadataTestTrait +{ + public function testContextMetadataNormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); + new Serializer([new DateTimeNormalizer(), $normalizer]); + + $dummy = new ContextMetadataDummy(); + $dummy->date = new \DateTime('2011-07-28T08:44:00.123+00:00'); + + self::assertEquals(['date' => '2011-07-28T08:44:00+00:00'], $normalizer->normalize($dummy)); + + self::assertEquals(['date' => '2011-07-28T08:44:00.123+00:00'], $normalizer->normalize($dummy, null, [ + ObjectNormalizer::GROUPS => 'extended', + ]), 'a specific normalization context is used for this group'); + + self::assertEquals(['date' => '2011-07-28T08:44:00+00:00'], $normalizer->normalize($dummy, null, [ + ObjectNormalizer::GROUPS => 'simple', + ]), 'base denormalization context is unchanged for this group'); + } + + public function testContextMetadataContextDenormalize() + { + $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); + new Serializer([new DateTimeNormalizer(), $normalizer]); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class); + self::assertEquals(new \DateTime('2011-07-28T08:44:00+00:00'), $dummy->date); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], ContextMetadataDummy::class, null, [ + ObjectNormalizer::GROUPS => 'extended', + ]); + self::assertEquals(new \DateTime('2011-07-28T08:44:00+00:00'), $dummy->date, 'base denormalization context is unchanged for this group'); + + /** @var ContextMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '28/07/2011'], ContextMetadataDummy::class, null, [ + ObjectNormalizer::GROUPS => 'simple', + ]); + self::assertEquals('2011-07-28', $dummy->date->format('Y-m-d'), 'a specific denormalization context is used for this group'); + } +} + +class ContextMetadataDummy +{ + /** + * @var \DateTime + * + * @Groups({ "extended", "simple" }) + * @Context({ DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339 }) + * @Context( + * normalizationContext = { DateTimeNormalizer::FORMAT_KEY = \DateTime::RFC3339_EXTENDED }, + * groups = {"extended"} + * ) + * @Context( + * denormalizationContext = { DateTimeNormalizer::FORMAT_KEY = "d/m/Y" }, + * groups = {"simple"} + * ) + */ + public $date; +} diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php index f23bedea1f..860c16f603 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/ObjectNormalizerTest.php @@ -42,6 +42,7 @@ use Symfony\Component\Serializer\Tests\Normalizer\Features\AttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CallbacksTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\CircularReferenceTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\ConstructorArgumentsTestTrait; +use Symfony\Component\Serializer\Tests\Normalizer\Features\ContextMetadataTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -59,6 +60,7 @@ class ObjectNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use ContextMetadataTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; diff --git a/src/Symfony/Component/Serializer/composer.json b/src/Symfony/Component/Serializer/composer.json index b13ee21a2b..629747f174 100644 --- a/src/Symfony/Component/Serializer/composer.json +++ b/src/Symfony/Component/Serializer/composer.json @@ -37,6 +37,7 @@ "symfony/property-info": "^5.3", "symfony/uid": "^5.1", "symfony/validator": "^4.4|^5.0", + "symfony/var-dumper": "^4.4|^5.0", "symfony/var-exporter": "^4.4|^5.0", "symfony/yaml": "^4.4|^5.0" },