diff --git a/src/Symfony/Component/Serializer/Annotation/Version.php b/src/Symfony/Component/Serializer/Annotation/Version.php new file mode 100644 index 0000000000000..0d1e9fcf8b149 --- /dev/null +++ b/src/Symfony/Component/Serializer/Annotation/Version.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Annotation; + +/** + * Annotation class for @Version(). + * + * @Annotation + * + * @NamedArgumentConstructor + * + * @Target({"PROPERTY", "METHOD"}) + * + * @author Olivier Michaud + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +final class Version +{ + public function __construct() + { + } +} diff --git a/src/Symfony/Component/Serializer/Annotation/VersionConstraint.php b/src/Symfony/Component/Serializer/Annotation/VersionConstraint.php new file mode 100644 index 0000000000000..264c66b734482 --- /dev/null +++ b/src/Symfony/Component/Serializer/Annotation/VersionConstraint.php @@ -0,0 +1,58 @@ + + * + * 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 @VersionConstraint(). + * + * @Annotation + * + * @NamedArgumentConstructor + * + * @Target({"PROPERTY", "METHOD"}) + * + * @author Olivier Michaud + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +final class VersionConstraint +{ + public function __construct(private readonly ?string $since = null, private readonly ?string $until = null) + { + if ('' === $since) { + throw new InvalidArgumentException(sprintf('Parameter "since" of annotation "%s" must be a non-empty string.', self::class)); + } + if ('' === $until) { + throw new InvalidArgumentException(sprintf('Parameter "until" of annotation "%s" must be a non-empty string.', self::class)); + } + if (null === $since && null === $until) { + throw new InvalidArgumentException(sprintf('At least one of "since" or "until" properties of annotation "%s" have to be defined.', self::class)); + } + } + + public function isVersionCompatible(string $version): bool + { + if ($this->since) { + if (!version_compare($version, $this->since, '>=')) { + return false; + } + } + if ($this->until) { + if (!version_compare($version, $this->until, '<=')) { + return false; + } + } + + return true; + } +} diff --git a/src/Symfony/Component/Serializer/CHANGELOG.md b/src/Symfony/Component/Serializer/CHANGELOG.md index 8154d3688fce8..edea9d97cb900 100644 --- a/src/Symfony/Component/Serializer/CHANGELOG.md +++ b/src/Symfony/Component/Serializer/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +6.4 +--- + + * Add `Version` and `VersionConstraint` annotation/attributes to help versioning on normalize/denormalize operations + 6.3 --- diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php index c77d07e5e38d3..93ff7e47ee1e1 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadata.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping; use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Serializer\Annotation\VersionConstraint; /** * @author Kévin Dunglas @@ -78,6 +79,27 @@ class AttributeMetadata implements AttributeMetadataInterface */ public array $denormalizationContexts = []; + /** + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link isVersion()} instead. + */ + public bool $version = false; + + /** + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getVersionConstraint()} instead. + */ + public ?VersionConstraint $versionConstraint = null; + + /** + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link isVersionCompatible()} instead. + */ + public ?\Closure $versionConstraintCallable = null; + public function __construct(string $name) { $this->name = $name; @@ -144,6 +166,31 @@ public function isIgnored(): bool return $this->ignore; } + public function setVersion(bool $version): void + { + $this->version = $version; + } + + public function isVersion(): bool + { + return $this->version; + } + + public function setVersionConstraint(VersionConstraint $versionConstraint): void + { + $this->versionConstraint = $versionConstraint; + } + + public function getVersionConstraint(): ?VersionConstraint + { + return $this->versionConstraint; + } + + public function isVersionCompatible(string $version): bool + { + return !$this->versionConstraint || $this->versionConstraint->isVersionCompatible($version); + } + public function getNormalizationContexts(): array { return $this->normalizationContexts; @@ -225,6 +272,6 @@ public function merge(AttributeMetadataInterface $attributeMetadata): void */ public function __sleep(): array { - return ['name', 'groups', 'maxDepth', 'serializedName', 'serializedPath', 'ignore', 'normalizationContexts', 'denormalizationContexts']; + return ['name', 'groups', 'maxDepth', 'serializedName', 'serializedPath', 'ignore', 'normalizationContexts', 'denormalizationContexts', 'version', 'versionConstraint']; } } diff --git a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php index 9d430602c50a0..0a54a7e370593 100644 --- a/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php +++ b/src/Symfony/Component/Serializer/Mapping/AttributeMetadataInterface.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping; use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Serializer\Annotation\VersionConstraint; /** * Stores metadata needed for serializing and deserializing attributes. @@ -75,6 +76,20 @@ public function setIgnore(bool $ignore): void; */ public function isIgnored(): bool; + /** + * Sets if this attribute is holding the version. Only one attribute can hold version at once. + */ + public function setVersion(bool $version): void; + + /** + * Gets if this attribute is holding the version. + */ + public function isVersion(): bool; + + public function setVersionConstraint(VersionConstraint $versionConstraint): void; + + public function isVersionCompatible(string $version): bool; + /** * Merges an {@see AttributeMetadataInterface} with in the current one. */ diff --git a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php index 6d03511cf57e2..8d3e39f9d039e 100644 --- a/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php +++ b/src/Symfony/Component/Serializer/Mapping/Loader/AnnotationLoader.php @@ -19,6 +19,9 @@ use Symfony\Component\Serializer\Annotation\MaxDepth; use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Serializer\Annotation\SerializedPath; +use Symfony\Component\Serializer\Annotation\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; @@ -37,6 +40,8 @@ class AnnotationLoader implements LoaderInterface DiscriminatorMap::class, Groups::class, Ignore::class, + Version::class, + VersionConstraint::class, MaxDepth::class, SerializedName::class, SerializedPath::class, @@ -65,6 +70,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } } + $hasVersionProperty = false; foreach ($reflectionClass->getProperties() as $property) { if (!isset($attributesMetadata[$property->name])) { $attributesMetadata[$property->name] = new AttributeMetadata($property->name); @@ -85,6 +91,17 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $attributesMetadata[$property->name]->setSerializedPath($annotation->getSerializedPath()); } elseif ($annotation instanceof Ignore) { $attributesMetadata[$property->name]->setIgnore(true); + } elseif ($annotation instanceof VersionConstraint) { + if (!($property->getType() === null || $property->getType()->allowsNull())) { + throw new LogicException(sprintf('VersionConstraint on "%s::%s()" cannot be added. Property should either have no typehint either be declared as nullable.', $className, $property->name)); + } + $attributesMetadata[$property->name]->setVersionConstraint($annotation); + } elseif ($annotation instanceof Version) { + if ($hasVersionProperty) { + throw new LogicException(sprintf('Version on "%s::%s()" cannot be added. Version holder property can only be set once.', $className, $property->name)); + } + $attributesMetadata[$property->name]->setVersion(true); + $hasVersionProperty = true; } elseif ($annotation instanceof Context) { $this->setAttributeContextsForGroups($annotation, $attributesMetadata[$property->name]); } @@ -114,7 +131,7 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool $classMetadata->addAttributeMetadata($attributeMetadata); } } - + $hasVersionProperty = false; foreach ($this->loadAnnotations($method) as $annotation) { if ($annotation instanceof Groups) { if (!$accessorOrMutator) { @@ -148,6 +165,23 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool } $attributeMetadata->setIgnore(true); + } elseif ($annotation instanceof VersionConstraint) { + if (!$accessorOrMutator) { + throw new MappingException(sprintf('Ignore on "%s::%s()" cannot be added. Ignore can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $attributeMetadata->setVersionConstraint($annotation); + } elseif ($annotation instanceof Version) { + if (!$accessorOrMutator) { + throw new MappingException(sprintf('Ignore on "%s::%s()" cannot be added. Ignore can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + if ($hasVersionProperty) { + throw new LogicException(sprintf('Version on "%s::%s()" cannot be added. Version holder property can only be set once.', $className, $method->name)); + } + + $attributeMetadata->setVersion(true); + $hasVersionProperty = 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)); diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php index 079b1e7a9e9d9..622ad265305dd 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractNormalizer.php @@ -552,4 +552,16 @@ protected function getAttributeMetadata(object|string $objectOrClass, string $at return $this->classMetadataFactory->getMetadataFor($objectOrClass)->getAttributesMetadata()[$attribute] ?? null; } + + /** + * @param array $attributesMetadata + */ + protected function isVersionCompatible(string $version, array $attributesMetadata, string $attributeName): bool + { + if (!isset($attributesMetadata[$attributeName])) { + return true; + } + + return $attributesMetadata[$attributeName]->isVersionCompatible($version); + } } diff --git a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php index 0dba039bc0651..99670f21ef400 100644 --- a/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php +++ b/src/Symfony/Component/Serializer/Normalizer/AbstractObjectNormalizer.php @@ -138,8 +138,6 @@ public function __construct( } /** - * @param array $context - * * @return bool */ public function supportsNormalization(mixed $data, string $format = null /* , array $context = [] */) @@ -206,6 +204,11 @@ public function normalize(mixed $object, string $format = null, array $context = $stack[$attribute] = $this->applyCallbacks($attributeValue, $object, $attribute, $format, $attributeContext); } + $objectVersion = $this->getObjectVersion($object, $stack); + if (null !== $objectVersion) { + $stack = $this->filterAttributesByVersion($objectVersion, $attributesMetadata, $stack); + } + foreach ($stack as $attribute => $attributeValue) { $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); @@ -320,7 +323,9 @@ public function denormalize(mixed $data, string $type, string $format = null, ar } $allowedAttributes = $this->getAllowedAttributes($type, $context, true); + $versionedConstraintAttributeCallables = $this->getVersionedConstraintAttributeCallables($type); $normalizedData = $this->prepareForDenormalization($data); + $extraAttributes = []; $mappedClass = $this->getMappedClass($normalizedData, $type, $context); @@ -336,6 +341,7 @@ public function denormalize(mixed $data, string $type, string $format = null, ar $normalizedData = $this->removeNestedValue($serializedPath->getElements(), $normalizedData); } + $objectVersion = $this->getObjectVersion($type, $normalizedData); $normalizedData = array_merge($normalizedData, $nestedData); $object = $this->instantiateObject($normalizedData, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); @@ -382,6 +388,9 @@ public function denormalize(mixed $data, string $type, string $format = null, ar } $value = $this->applyCallbacks($value, $resolvedClass, $attribute, $format, $attributeContext); + if (null !== $objectVersion) { + $value = $this->getValueByVersion($value, $objectVersion, $attribute, $versionedConstraintAttributeCallables); + } try { $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); @@ -594,6 +603,21 @@ private function validateAndDenormalize(array $types, string $currentClass, stri throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be one of "%s" ("%s" given).', $attribute, $currentClass, implode('", "', array_keys($expectedTypes)), get_debug_type($data)), $data, array_keys($expectedTypes), $context['deserialization_path'] ?? $attribute); } + private function getObjectVersion(object|string $objectOrClass, array $stack): ?string + { + $objectVersion = null; + foreach ($this->classMetadataFactory?->getMetadataFor($objectOrClass)->getAttributesMetadata() ?? [] as $attribute => $attributeMetadata) { + if ($attributeMetadata->isVersion()) { + if (null !== $objectVersion) { + throw new LogicException(sprintf('Too many version ["%s","%s] attributes class "%s".', $attribute, $objectVersion, \is_object($objectOrClass) ? $objectOrClass::class : $objectOrClass)); + } + $objectVersion = $attribute; + } + } + + return $objectVersion ? $stack[$objectVersion] : null; + } + /** * @internal */ @@ -812,4 +836,43 @@ private function getMappedClass(array $data, string $class, array $context): str return $mappedClass; } + + /** + * @param array $attributesMetadata + */ + private function filterAttributesByVersion(string $objectVersion, array $attributesMetadata, array $stack): array + { + return array_filter( + $stack, + fn (string $key) => $this->isVersionCompatible($objectVersion, $attributesMetadata, $key), + \ARRAY_FILTER_USE_KEY + ); + } + + /** + * @param array $callables + */ + private function getValueByVersion(mixed $value, string $objectVersion, string $attribute, array $callables): mixed + { + if (!isset($callables[$attribute])) { + return $value; + } + + return $callables[$attribute]($objectVersion) ? $value : null; + } + + /** + * @param class-string $class + * + * @return array + */ + private function getVersionedConstraintAttributeCallables(string $class): array + { + $attributes = []; + foreach ($this->classMetadataFactory?->getMetadataFor($class)?->getAttributesMetadata() ?? [] as $attribute => $attributeMetadata) { + $attributes[$attribute] = $attributeMetadata->isVersionCompatible(...); + } + + return $attributes; + } } diff --git a/src/Symfony/Component/Serializer/Tests/Annotation/VersionConstraintTest.php b/src/Symfony/Component/Serializer/Tests/Annotation/VersionConstraintTest.php new file mode 100644 index 0000000000000..9343dae8d499f --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Annotation/VersionConstraintTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Annotation; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Annotation\VersionConstraint; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Olivier Michaud + */ +class VersionConstraintTest extends TestCase +{ + /** + * @dataProvider providesNormalizeVersionAndConstraints + */ + public function testVersionConstraintLogic(bool $expectedResult, ?string $version, ?string $since, ?string $until) + { + $versionConstraint = new VersionConstraint(since: $since, until: $until); + $this->assertSame($expectedResult, $versionConstraint->isVersionCompatible($version)); + } + + public static function providesNormalizeVersionAndConstraints(): \Generator + { + yield 'Version in range' => [true, '1.2', '1.1', '1.5']; + yield 'Version below range with both limits' => [false, '0.9', '1.1', '1.5']; + yield 'Version below range only with lower limit' => [false, '0.9', '1.1', null]; + yield 'Version in range only with upper limit' => [true, '0.9', null, '1.5']; + yield 'Version above range with both limits' => [false, '2.0', '1.1', '1.5']; + yield 'Version above range only with upper limit' => [false, '2.0', null, '1.5']; + yield 'Version in range only with low limit ' => [true, '2.0', '1.1', null]; + yield 'No version to no limits' => [false, '', '1.1', '1.5']; + } + + public function testSinceParameterNotEmptyString() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter "since" of annotation "Symfony\Component\Serializer\Annotation\VersionConstraint" must be a non-empty string.'); + new VersionConstraint(since: '', until: '2.0'); + } + + public function testUntilParameterNotEmptyString() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter "until" of annotation "Symfony\Component\Serializer\Annotation\VersionConstraint" must be a non-empty string.'); + new VersionConstraint(since: '1.1', until: ''); + } + + public function testBothSinceAndUntilParameterAreNull() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of "since" or "until" properties of annotation "Symfony\Component\Serializer\Annotation\VersionConstraint" have to be defined.'); + new VersionConstraint(since: null, until: null); + } + +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/DoubleVersionDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/DoubleVersionDummy.php new file mode 100644 index 0000000000000..f5d4ed09a1eb4 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/DoubleVersionDummy.php @@ -0,0 +1,41 @@ + + * + * 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\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class DoubleVersionDummy +{ + private $foo; + + /** + * @Version + */ + public string $objectVersion1; + + /** + * @Version + */ + public string $objectVersion2; + + /** + * @VersionConstraint( + * since="1.1", + * until="1.5" + * ) + */ + public ?string $versionedProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummy.php new file mode 100644 index 0000000000000..b0797cc98cb1e --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummy.php @@ -0,0 +1,45 @@ + + * + * 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\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class VersionDummy +{ + private $foo; + + /** + * @Version + */ + public string $objectVersion; + + /** + * @VersionConstraint( + * since="1.1", + * until="1.5" + * ) + */ + public ?string $versionedProperty; + + /** + * @var string + * @VersionConstraint( + * since="1.1", + * until="1.5" + * ) + */ + public $versionedProperty2; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummyWithNonNullableField.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummyWithNonNullableField.php new file mode 100644 index 0000000000000..a7428804dce71 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Annotations/VersionDummyWithNonNullableField.php @@ -0,0 +1,36 @@ + + * + * 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\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class VersionDummyWithNonNullableField +{ + private $foo; + + /** + * @Version + */ + public string $objectVersion; + + /** + * @VersionConstraint( + * since="1.1", + * until="1.5" + * ) + */ + public string $versionedProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/DoubleVersionDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/DoubleVersionDummy.php new file mode 100644 index 0000000000000..00e2e481e3c26 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/DoubleVersionDummy.php @@ -0,0 +1,32 @@ + + * + * 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\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class DoubleVersionDummy +{ + private $foo; + + #[Version] + public string $objectVersion1; + + #[Version] + public string $objectVersion2; + + #[VersionConstraint(since: '1.1', until: '1.5')] + public ?string $versionedProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummy.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummy.php new file mode 100644 index 0000000000000..cb8d6bffb57e1 --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummy.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\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Annotation\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class VersionDummy +{ + private $foo; + + #[Version] + public string $objectVersion; + + #[VersionConstraint(since: '1.1', until: '1.5')] + public ?string $versionedProperty; + + /** + * @var string + */ + #[VersionConstraint(since: '1.1', until: '1.5')] + public $versionedProperty2; +} diff --git a/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummyWithNonNullableField.php b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummyWithNonNullableField.php new file mode 100644 index 0000000000000..a502ccb7bb4ab --- /dev/null +++ b/src/Symfony/Component/Serializer/Tests/Fixtures/Attributes/VersionDummyWithNonNullableField.php @@ -0,0 +1,29 @@ + + * + * 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\Version; +use Symfony\Component\Serializer\Annotation\VersionConstraint; + +/** + * @author Olivier MICHAUD + */ +class VersionDummyWithNonNullableField +{ + private $foo; + + #[Version] + public string $objectVersion; + + #[VersionConstraint(since: '1.1', until: '1.5')] + public string $versionedProperty; +} diff --git a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php index 2dbd03703a2ce..e01a169d5d463 100644 --- a/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php +++ b/src/Symfony/Component/Serializer/Tests/Mapping/Loader/AnnotationLoaderTestCase.php @@ -135,6 +135,41 @@ public function testLoadIgnore() $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); } + public function testLoadVersionConstraint() + { + $classMetadata = new ClassMetadata($this->getNamespace().'\VersionDummy'); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertTrue($attributesMetadata['versionedProperty']->isVersionCompatible('1.2')); + $this->assertFalse($attributesMetadata['versionedProperty']->isVersionCompatible('0.9')); + $this->assertFalse($attributesMetadata['versionedProperty']->isVersionCompatible('2.1')); + } + + public function testLoadVersionConstraintWithNonNullableField() + { + $this->expectExceptionMessageMatches('!VersionDummyWithNonNullableField::versionedProperty\(\)" cannot be added\. Property should either have no typehint either be declared as nullable\.!'); + $classMetadata = new ClassMetadata($this->getNamespace().'\VersionDummyWithNonNullableField'); + $this->loader->loadClassMetadata($classMetadata); + } + + public function testLoadVersion() + { + $classMetadata = new ClassMetadata($this->getNamespace().'\VersionDummy'); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertTrue($attributesMetadata['objectVersion']->isVersion()); + $this->assertFalse($attributesMetadata['foo']->isVersion()); + } + + public function testLoadVersionWithError() + { + $this->expectExceptionMessageMatches('!DoubleVersionDummy::objectVersion2\(\)" cannot be added\. Version holder property can only be set once\.!'); + $classMetadata = new ClassMetadata($this->getNamespace().'\DoubleVersionDummy'); + $this->loader->loadClassMetadata($classMetadata); + } + public function testLoadContexts() { $this->assertLoadedContexts($this->getNamespace().'\ContextDummy', $this->getNamespace().'\ContextDummyParent'); diff --git a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php index 16e39440e56b3..d76139bb39e92 100644 --- a/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php +++ b/src/Symfony/Component/Serializer/Tests/Normalizer/AbstractNormalizerTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\Serializer\Annotation\VersionConstraint; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; @@ -27,6 +28,7 @@ use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\Tests\Fixtures\AbstractNormalizerDummy; use Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Annotations\VersionDummy as AnnotationsVersionDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; use Symfony\Component\Serializer\Tests\Fixtures\NullableConstructorArgumentDummy; use Symfony\Component\Serializer\Tests\Fixtures\NullableOptionalConstructorArgumentDummy; @@ -266,4 +268,79 @@ public function testIgnore() $this->assertSame([], $normalizer->normalize($dummy)); } + + /** + * @dataProvider providesNormalizeVersionAndConstraints + */ + public function testNormalizeVersionConstraint(?string $version, ?string $since, ?string $until, array $expectedVersionedProperties) + { + $classMetadata = new ClassMetadata(AnnotationsVersionDummy::class); + + $attributeVersionMetadata = new AttributeMetadata('objectVersion'); + $attributeVersionMetadata->setVersion(true); + $classMetadata->addAttributeMetadata($attributeVersionMetadata); + + $attributeVersionConstraintMetadata = new AttributeMetadata('versionedProperty'); + $attributeVersionConstraintMetadata->setVersionConstraint(new VersionConstraint(since: $since, until: $until)); + $classMetadata->addAttributeMetadata($attributeVersionConstraintMetadata); + + $attributeVersionConstraint2Metadata = new AttributeMetadata('versionedProperty2'); + $attributeVersionConstraint2Metadata->setVersionConstraint(new VersionConstraint(since: $since, until: $until)); + $classMetadata->addAttributeMetadata($attributeVersionConstraint2Metadata); + + $this->classMetadata->method('getMetadataFor')->willReturn($classMetadata); + + $dummy = new AnnotationsVersionDummy(); + $dummy->objectVersion = $version; + $dummy->versionedProperty = 'foo'; + $dummy->versionedProperty2 = 'foo2'; + + $normalizer = new PropertyNormalizer($this->classMetadata); + + $this->assertSame([ + 'foo' => null, + 'objectVersion' => $version, + ] + $expectedVersionedProperties, $normalizer->normalize($dummy)); + } + + public static function providesNormalizeVersionAndConstraints(): \Generator + { + yield 'Version in range' => ['1.2', '1.1', '1.5', ['versionedProperty' => 'foo', 'versionedProperty2' => 'foo2']]; + yield 'Version out of range' => ['0.9', '1.1', '1.5', []]; + } + + /** + * @dataProvider providesDenormalizeVersionAndConstraints + */ + public function testDenormalizeVersionConstraint(?string $version, ?string $since, ?string $until, array $normalizedVersionedProperties, ?string $expectedVersionedProperty) + { + $classMetadata = new ClassMetadata(AnnotationsVersionDummy::class); + + $attributeVersionMetadata = new AttributeMetadata('objectVersion'); + $attributeVersionMetadata->setVersion(true); + $classMetadata->addAttributeMetadata($attributeVersionMetadata); + + $attributeVersionConstraintMetadata = new AttributeMetadata('versionedProperty'); + $attributeVersionConstraintMetadata->setVersionConstraint(new VersionConstraint(since: $since, until: $until)); + $classMetadata->addAttributeMetadata($attributeVersionConstraintMetadata); + + $this->classMetadata->method('getMetadataFor')->willReturn($classMetadata); + + $normalized = [ + 'foo' => null, + 'objectVersion' => $version, + 'versionedProperty' => 'foo', + ]; + + $normalizer = new PropertyNormalizer($this->classMetadata); + + $denormalizedObject = $normalizer->denormalize($normalized, AnnotationsVersionDummy::class); + $this->assertSame($expectedVersionedProperty, $denormalizedObject->versionedProperty); + } + + public static function providesDenormalizeVersionAndConstraints(): \Generator + { + yield 'Version in range' => ['1.2', '1.1', '1.5', ['versionedProperty' => 'foo'], 'foo']; + yield 'Version out of range' => ['0.9', '1.1', '1.5', [], null]; + } }