diff --git a/Annotation/Context.php b/Annotation/Context.php index d3258b9fc..962169788 100644 --- a/Annotation/Context.php +++ b/Annotation/Context.php @@ -11,91 +11,13 @@ namespace Symfony\Component\Serializer\Annotation; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +// do not deprecate in 6.4/7.0, to make it easier for the ecosystem to support 6.4, 7.4 and 8.0 simultaneously -/** - * Annotation class for @Context(). - * - * @Annotation - * @NamedArgumentConstructor - * @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; - - /** - * @param string|string[] $groups - * - * @throws InvalidArgumentException - */ - public function __construct(array $options = [], array $context = [], array $normalizationContext = [], array $denormalizationContext = [], $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 { - trigger_deprecation('symfony/serializer', '5.3', 'Passing an array of properties as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); - - // 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'] ?? []; - } - } - if (!\is_string($groups) && !\is_array($groups)) { - throw new \TypeError(sprintf('"%s": Expected parameter $groups to be a string or an array of strings, got "%s".', __METHOD__, get_debug_type($groups))); - } - - $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; - } +class_exists(\Symfony\Component\Serializer\Attribute\Context::class); - public function getGroups(): array +if (false) { + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] + class Context extends \Symfony\Component\Serializer\Attribute\Context { - return $this->groups; } } diff --git a/Annotation/DiscriminatorMap.php b/Annotation/DiscriminatorMap.php index 6bf061833..c1a3021de 100644 --- a/Annotation/DiscriminatorMap.php +++ b/Annotation/DiscriminatorMap.php @@ -11,65 +11,11 @@ namespace Symfony\Component\Serializer\Annotation; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +class_exists(\Symfony\Component\Serializer\Attribute\DiscriminatorMap::class); -/** - * Annotation class for @DiscriminatorMap(). - * - * @Annotation - * @NamedArgumentConstructor - * @Target({"CLASS"}) - * - * @author Samuel Roze - */ -#[\Attribute(\Attribute::TARGET_CLASS)] -class DiscriminatorMap -{ - /** - * @var string - */ - private $typeProperty; - - /** - * @var array - */ - private $mapping; - - /** - * @param string $typeProperty - * - * @throws InvalidArgumentException - */ - public function __construct($typeProperty, ?array $mapping = null) - { - if (\is_array($typeProperty)) { - trigger_deprecation('symfony/serializer', '5.3', 'Passing an array as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); - - $mapping = $typeProperty['mapping'] ?? null; - $typeProperty = $typeProperty['typeProperty'] ?? null; - } elseif (!\is_string($typeProperty)) { - throw new \TypeError(sprintf('"%s": Argument $typeProperty was expected to be a string or array, got "%s".', __METHOD__, get_debug_type($typeProperty))); - } - - if (empty($typeProperty)) { - throw new InvalidArgumentException(sprintf('Parameter "typeProperty" of annotation "%s" cannot be empty.', static::class)); - } - - if (empty($mapping)) { - throw new InvalidArgumentException(sprintf('Parameter "mapping" of annotation "%s" cannot be empty.', static::class)); - } - - $this->typeProperty = $typeProperty; - $this->mapping = $mapping; - } - - public function getTypeProperty(): string - { - return $this->typeProperty; - } - - public function getMapping(): array +if (false) { + #[\Attribute(\Attribute::TARGET_CLASS)] + class DiscriminatorMap extends \Symfony\Component\Serializer\Attribute\DiscriminatorMap { - return $this->mapping; } } diff --git a/Annotation/Groups.php b/Annotation/Groups.php index 5b65e9f05..642626f7f 100644 --- a/Annotation/Groups.php +++ b/Annotation/Groups.php @@ -11,57 +11,11 @@ namespace Symfony\Component\Serializer\Annotation; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +class_exists(\Symfony\Component\Serializer\Attribute\Groups::class); -/** - * Annotation class for @Groups(). - * - * @Annotation - * @NamedArgumentConstructor - * @Target({"PROPERTY", "METHOD"}) - * - * @author Kévin Dunglas - */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] -class Groups -{ - /** - * @var string[] - */ - private $groups; - - /** - * @param string|string[] $groups - */ - public function __construct($groups) - { - if (\is_string($groups)) { - $groups = (array) $groups; - } elseif (!\is_array($groups)) { - throw new \TypeError(sprintf('"%s": Parameter $groups is expected to be a string or an array of strings, got "%s".', __METHOD__, get_debug_type($groups))); - } elseif (isset($groups['value'])) { - trigger_deprecation('symfony/serializer', '5.3', 'Passing an array of properties as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); - - $groups = (array) $groups['value']; - } - if (empty($groups)) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" cannot be empty.', static::class)); - } - - foreach ($groups as $group) { - if (!\is_string($group) || '' === $group) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a string or an array of non-empty strings.', static::class)); - } - } - - $this->groups = $groups; - } - - /** - * @return string[] - */ - public function getGroups() +if (false) { + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)] + class Groups extends \Symfony\Component\Serializer\Attribute\Groups { - return $this->groups; } } diff --git a/Annotation/Ignore.php b/Annotation/Ignore.php index b09e7007a..178eb4e6b 100644 --- a/Annotation/Ignore.php +++ b/Annotation/Ignore.php @@ -11,15 +11,11 @@ namespace Symfony\Component\Serializer\Annotation; -/** - * Annotation class for @Ignore(). - * - * @Annotation - * @Target({"PROPERTY", "METHOD"}) - * - * @author Kévin Dunglas - */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] -final class Ignore -{ +class_exists(\Symfony\Component\Serializer\Attribute\Ignore::class); + +if (false) { + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class Ignore extends \Symfony\Component\Serializer\Attribute\Ignore + { + } } diff --git a/Annotation/MaxDepth.php b/Annotation/MaxDepth.php index e0cc41ebd..ee1bc1ae7 100644 --- a/Annotation/MaxDepth.php +++ b/Annotation/MaxDepth.php @@ -11,48 +11,11 @@ namespace Symfony\Component\Serializer\Annotation; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +class_exists(\Symfony\Component\Serializer\Attribute\MaxDepth::class); -/** - * Annotation class for @MaxDepth(). - * - * @Annotation - * @NamedArgumentConstructor - * @Target({"PROPERTY", "METHOD"}) - * - * @author Kévin Dunglas - */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] -class MaxDepth -{ - /** - * @var int - */ - private $maxDepth; - - /** - * @param int $maxDepth - */ - public function __construct($maxDepth) - { - if (\is_array($maxDepth)) { - trigger_deprecation('symfony/serializer', '5.3', 'Passing an array as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); - - if (!isset($maxDepth['value'])) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" should be set.', static::class)); - } - $maxDepth = $maxDepth['value']; - } - - if (!\is_int($maxDepth) || $maxDepth <= 0) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a positive integer.', static::class)); - } - - $this->maxDepth = $maxDepth; - } - - public function getMaxDepth() +if (false) { + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class MaxDepth extends \Symfony\Component\Serializer\Attribute\MaxDepth { - return $this->maxDepth; } } diff --git a/Annotation/SerializedName.php b/Annotation/SerializedName.php index 874d955f1..167064a0b 100644 --- a/Annotation/SerializedName.php +++ b/Annotation/SerializedName.php @@ -11,48 +11,11 @@ namespace Symfony\Component\Serializer\Annotation; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; +class_exists(\Symfony\Component\Serializer\Attribute\SerializedName::class); -/** - * Annotation class for @SerializedName(). - * - * @Annotation - * @NamedArgumentConstructor - * @Target({"PROPERTY", "METHOD"}) - * - * @author Fabien Bourigault - */ -#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] -final class SerializedName -{ - /** - * @var string - */ - private $serializedName; - - /** - * @param string $serializedName - */ - public function __construct($serializedName) - { - if (\is_array($serializedName)) { - trigger_deprecation('symfony/serializer', '5.3', 'Passing an array as first argument to "%s" is deprecated. Use named arguments instead.', __METHOD__); - - if (!isset($serializedName['value'])) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" should be set.', static::class)); - } - $serializedName = $serializedName['value']; - } - - if (!\is_string($serializedName) || empty($serializedName)) { - throw new InvalidArgumentException(sprintf('Parameter of annotation "%s" must be a non-empty string.', static::class)); - } - - $this->serializedName = $serializedName; - } - - public function getSerializedName(): string +if (false) { + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class SerializedName extends \Symfony\Component\Serializer\Attribute\SerializedName { - return $this->serializedName; } } diff --git a/Annotation/SerializedPath.php b/Annotation/SerializedPath.php new file mode 100644 index 000000000..76e4c7eff --- /dev/null +++ b/Annotation/SerializedPath.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Annotation; + +class_exists(\Symfony\Component\Serializer\Attribute\SerializedPath::class); + +if (false) { + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class SerializedPath extends \Symfony\Component\Serializer\Attribute\SerializedPath + { + } +} diff --git a/Attribute/Context.php b/Attribute/Context.php new file mode 100644 index 000000000..892af481a --- /dev/null +++ b/Attribute/Context.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Maxime Steinhausser + */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] +class Context +{ + private array $groups; + + /** + * @param array $context The common context to use when serializing or deserializing + * @param array $normalizationContext The context to use when serializing + * @param array $denormalizationContext The context to use when deserializing + * @param string|string[] $groups The groups to use when serializing or deserializing + * + * @throws InvalidArgumentException + */ + public function __construct( + private readonly array $context = [], + private readonly array $normalizationContext = [], + private readonly array $denormalizationContext = [], + string|array $groups = [], + ) { + if (!$context && !$normalizationContext && !$denormalizationContext) { + throw new InvalidArgumentException(\sprintf('At least one of the "context", "normalizationContext", or "denormalizationContext" options must be provided as a non-empty array to "%s".', static::class)); + } + + $this->groups = (array) $groups; + + foreach ($this->groups as $group) { + if (!\is_string($group)) { + throw new InvalidArgumentException(\sprintf('Parameter "groups" given to "%s" must be a string or an array of strings, "%s" given.', static::class, get_debug_type($group))); + } + } + } + + 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; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\Context::class, false)) { + class_alias(Context::class, \Symfony\Component\Serializer\Annotation\Context::class); +} diff --git a/Attribute/DiscriminatorMap.php b/Attribute/DiscriminatorMap.php new file mode 100644 index 000000000..48d0842aa --- /dev/null +++ b/Attribute/DiscriminatorMap.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Samuel Roze + */ +#[\Attribute(\Attribute::TARGET_CLASS)] +class DiscriminatorMap +{ + /** + * @param string $typeProperty The property holding the type discriminator + * @param array $mapping The mapping between types and classes (i.e. ['admin_user' => AdminUser::class]) + * + * @throws InvalidArgumentException + */ + public function __construct( + private readonly string $typeProperty, + private readonly array $mapping, + ) { + if (!$typeProperty) { + throw new InvalidArgumentException(\sprintf('Parameter "typeProperty" given to "%s" cannot be empty.', static::class)); + } + + if (!$mapping) { + throw new InvalidArgumentException(\sprintf('Parameter "mapping" given to "%s" cannot be empty.', static::class)); + } + } + + public function getTypeProperty(): string + { + return $this->typeProperty; + } + + public function getMapping(): array + { + return $this->mapping; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\DiscriminatorMap::class, false)) { + class_alias(DiscriminatorMap::class, \Symfony\Component\Serializer\Annotation\DiscriminatorMap::class); +} diff --git a/Attribute/Groups.php b/Attribute/Groups.php new file mode 100644 index 000000000..8747949a4 --- /dev/null +++ b/Attribute/Groups.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Kévin Dunglas + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS)] +class Groups +{ + /** + * @var string[] + */ + private readonly array $groups; + + /** + * @param string|string[] $groups The groups to define on the attribute target + */ + public function __construct(string|array $groups) + { + $this->groups = (array) $groups; + + if (!$this->groups) { + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" cannot be empty.', static::class)); + } + + foreach ($this->groups as $group) { + if (!\is_string($group) || '' === $group) { + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a string or an array of non-empty strings.', static::class)); + } + } + } + + /** + * @return string[] + */ + public function getGroups(): array + { + return $this->groups; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\Groups::class, false)) { + class_alias(Groups::class, \Symfony\Component\Serializer\Annotation\Groups::class); +} diff --git a/Attribute/Ignore.php b/Attribute/Ignore.php new file mode 100644 index 000000000..bfc48b71e --- /dev/null +++ b/Attribute/Ignore.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +/** + * @author Kévin Dunglas + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +class Ignore +{ +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\Ignore::class, false)) { + class_alias(Ignore::class, \Symfony\Component\Serializer\Annotation\Ignore::class); +} diff --git a/Attribute/MaxDepth.php b/Attribute/MaxDepth.php new file mode 100644 index 000000000..16d6a8120 --- /dev/null +++ b/Attribute/MaxDepth.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Kévin Dunglas + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +class MaxDepth +{ + /** + * @param int $maxDepth The maximum serialization depth + */ + public function __construct(private readonly int $maxDepth) + { + if ($maxDepth <= 0) { + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a positive integer.', static::class)); + } + } + + public function getMaxDepth(): int + { + return $this->maxDepth; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\MaxDepth::class, false)) { + class_alias(MaxDepth::class, \Symfony\Component\Serializer\Annotation\MaxDepth::class); +} diff --git a/Attribute/SerializedName.php b/Attribute/SerializedName.php new file mode 100644 index 000000000..f1c6cefe2 --- /dev/null +++ b/Attribute/SerializedName.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Fabien Bourigault + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +class SerializedName +{ + /** + * @param string $serializedName The name of the property as it will be serialized + */ + public function __construct(private readonly string $serializedName) + { + if ('' === $serializedName) { + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a non-empty string.', self::class)); + } + } + + public function getSerializedName(): string + { + return $this->serializedName; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\SerializedName::class, false)) { + class_alias(SerializedName::class, \Symfony\Component\Serializer\Annotation\SerializedName::class); +} diff --git a/Attribute/SerializedPath.php b/Attribute/SerializedPath.php new file mode 100644 index 000000000..71254bfb9 --- /dev/null +++ b/Attribute/SerializedPath.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Attribute; + +use Symfony\Component\PropertyAccess\Exception\InvalidPropertyPathException; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Tobias Bönner + */ +#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] +class SerializedPath +{ + private PropertyPath $serializedPath; + + /** + * @param string $serializedPath A path using a valid PropertyAccess syntax where the value is stored in a normalized representation + */ + public function __construct(string $serializedPath) + { + try { + $this->serializedPath = new PropertyPath($serializedPath); + } catch (InvalidPropertyPathException $pathException) { + throw new InvalidArgumentException(\sprintf('Parameter given to "%s" must be a valid property path.', self::class)); + } + } + + public function getSerializedPath(): PropertyPath + { + return $this->serializedPath; + } +} + +if (!class_exists(\Symfony\Component\Serializer\Annotation\SerializedPath::class, false)) { + class_alias(SerializedPath::class, \Symfony\Component\Serializer\Annotation\SerializedPath::class); +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 1890e54a8..4c36d5885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,108 @@ CHANGELOG ========= +7.2 +--- + + * Deprecate the `csv_escape_char` context option of `CsvEncoder`, the `CsvEncoder::ESCAPE_CHAR_KEY` constant + and the `CsvEncoderContextBuilder::withEscapeChar()` method, following its deprecation in PHP 8.4 + * Add `SnakeCaseToCamelCaseNameConverter` + * Support subclasses of `\DateTime` and `\DateTimeImmutable` for denormalization + * Add the `UidNormalizer::NORMALIZATION_FORMAT_RFC9562` constant + * Add support for configuring multiple serializer instances with different + default contexts, name converters, sets of normalizers and encoders + * Add support for collection profiles of multiple serializer instances + * Deprecate `AdvancedNameConverterInterface`, use `NameConverterInterface` instead + +7.1 +--- + + * Add arguments `$class`, `$format` and `$context` to `NameConverterInterface::normalize()` and `NameConverterInterface::denormalize()` + * Add `DateTimeNormalizer::CAST_KEY` context option + * Add `AbstractNormalizer::FILTER_BOOL` context option + * Add `CamelCaseToSnakeCaseNameConverter::REQUIRE_SNAKE_CASE_PROPERTIES` context option + * Deprecate `AbstractNormalizerContextBuilder::withDefaultContructorArguments(?array $defaultContructorArguments)`, use `withDefaultConstructorArguments(?array $defaultConstructorArguments)` instead (note the missing `s` character in Contructor word in deprecated method) + * Add `XmlEncoder::CDATA_WRAPPING_PATTERN` context option + +7.0 +--- + + * Add method `getSupportedTypes()` to `DenormalizerInterface` and `NormalizerInterface` + * Remove denormalization support for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead + * Denormalizing to an abstract class in `UidNormalizer` now throws an `\Error` + * Remove `ContextAwareDenormalizerInterface`, use `DenormalizerInterface` instead + * Remove `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead + * Remove `CacheableSupportsMethodInterface`, use `NormalizerInterface` and `DenormalizerInterface` instead + * Require explicit argument when calling `AttributeMetadata::setSerializedName()` and `ClassMetadata::setClassDiscriminatorMapping()` + * Add argument `$context` to `NormalizerInterface::supportsNormalization()` and `DenormalizerInterface::supportsDenormalization()` + * Remove Doctrine annotations support in favor of native attributes + * Remove `AnnotationLoader`, use `AttributeLoader` instead + +6.4 +--- + + * Add `TranslatableNormalizer` + * Allow `Context` attribute to target classes + * Deprecate Doctrine annotations support in favor of native attributes + * Allow the `Groups` attribute/annotation on classes + * JsonDecode: Add `json_decode_detailed_errors` option + * Make `ProblemNormalizer` give details about Messenger's `ValidationFailedException` + * Add `XmlEncoder::CDATA_WRAPPING` context option + * Deprecate `AnnotationLoader`, use `AttributeLoader` instead + * Add aliases for all classes in the `Annotation` namespace to `Attribute` + +6.3 +--- + + * Add `AbstractNormalizer::REQUIRE_ALL_PROPERTIES` context flag to require all properties to be listed in the input instead of falling back to null for nullable ones + * Add `XmlEncoder::SAVE_OPTIONS` context option + * Add `BackedEnumNormalizer::ALLOW_INVALID_VALUES` context option + * Add `UnsupportedFormatException` which is thrown when there is no decoder for a given format + * Add method `getSupportedTypes(?string $format)` to `NormalizerInterface` and `DenormalizerInterface` + * Make `ProblemNormalizer` give details about `ValidationFailedException` and `PartialDenormalizationException` + * Deprecate `CacheableSupportsMethodInterface` in favor of the new `getSupportedTypes(?string $format)` methods + * The following Normalizer classes will become final in 7.0: + * `ConstraintViolationListNormalizer` + * `CustomNormalizer` + * `DataUriNormalizer` + * `DateIntervalNormalizer` + * `DateTimeNormalizer` + * `DateTimeZoneNormalizer` + * `GetSetMethodNormalizer` + * `JsonSerializableNormalizer` + * `ObjectNormalizer` + * `PropertyNormalizer` + +6.2 +--- + + * Add support for constructor promoted properties to `Context` attribute + * Add context option `PropertyNormalizer::NORMALIZE_VISIBILITY` with bitmask flags `PropertyNormalizer::NORMALIZE_PUBLIC`, `PropertyNormalizer::NORMALIZE_PROTECTED`, `PropertyNormalizer::NORMALIZE_PRIVATE` + * Add method `withNormalizeVisibility` to `PropertyNormalizerContextBuilder` + * Deprecate calling `AttributeMetadata::setSerializedName()`, `ClassMetadata::setClassDiscriminatorMapping()` without arguments + * Change the signature of `AttributeMetadataInterface::setSerializedName()` to `setSerializedName(?string)` + * Change the signature of `ClassMetadataInterface::setClassDiscriminatorMapping()` to `setClassDiscriminatorMapping(?ClassDiscriminatorMapping)` + * Add option YamlEncoder::YAML_INDENTATION to YamlEncoder constructor options to configure additional indentation for each level of nesting. This allows configuring indentation in the service configuration. + * Add `SerializedPath` annotation to flatten nested attributes + +6.1 +--- + + * Add `TraceableSerializer`, `TraceableNormalizer`, `TraceableEncoder` and `SerializerDataCollector` to integrate with the web profiler + * Add the ability to create contexts using context builders + * Set `Context` annotation as not final + * Deprecate `ContextAwareNormalizerInterface`, use `NormalizerInterface` instead + * Deprecate `ContextAwareDenormalizerInterface`, use `DenormalizerInterface` instead + * Deprecate supporting denormalization for `AbstractUid` in `UidNormalizer`, use one of `AbstractUid` child class instead + * Deprecate denormalizing to an abstract class in `UidNormalizer` + * Add support for `can*()` methods to `ObjectNormalizer` + +6.0 +--- + + * Remove `ArrayDenormalizer::setSerializer()`, call `setDenormalizer()` instead + * Remove the ability to create instances of the annotation classes by passing an array of parameters, use named arguments instead + 5.4 --- diff --git a/CacheWarmer/CompiledClassMetadataCacheWarmer.php b/CacheWarmer/CompiledClassMetadataCacheWarmer.php index de12fa1be..379a2a380 100644 --- a/CacheWarmer/CompiledClassMetadataCacheWarmer.php +++ b/CacheWarmer/CompiledClassMetadataCacheWarmer.php @@ -21,26 +21,15 @@ */ final class CompiledClassMetadataCacheWarmer implements CacheWarmerInterface { - private $classesToCompile; - - private $classMetadataFactory; - - private $classMetadataFactoryCompiler; - - private $filesystem; - - public function __construct(array $classesToCompile, ClassMetadataFactoryInterface $classMetadataFactory, ClassMetadataFactoryCompiler $classMetadataFactoryCompiler, Filesystem $filesystem) - { - $this->classesToCompile = $classesToCompile; - $this->classMetadataFactory = $classMetadataFactory; - $this->classMetadataFactoryCompiler = $classMetadataFactoryCompiler; - $this->filesystem = $filesystem; + public function __construct( + private readonly array $classesToCompile, + private readonly ClassMetadataFactoryInterface $classMetadataFactory, + private readonly ClassMetadataFactoryCompiler $classMetadataFactoryCompiler, + private readonly Filesystem $filesystem, + ) { } - /** - * {@inheritdoc} - */ - public function warmUp($cacheDir): array + public function warmUp(string $cacheDir, ?string $buildDir = null): array { $metadatas = []; @@ -55,9 +44,6 @@ public function warmUp($cacheDir): array return []; } - /** - * {@inheritdoc} - */ public function isOptional(): bool { return true; diff --git a/Command/DebugCommand.php b/Command/DebugCommand.php new file mode 100644 index 000000000..7df4d6bc8 --- /dev/null +++ b/Command/DebugCommand.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Command; + +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Helper\Dumper; +use Symfony\Component\Console\Helper\Table; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; + +/** + * A console command to debug Serializer information. + * + * @author Loïc Frémont + */ +#[AsCommand(name: 'debug:serializer', description: 'Display serialization information for classes')] +class DebugCommand extends Command +{ + public function __construct(private readonly ClassMetadataFactoryInterface $serializer) + { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('class', InputArgument::REQUIRED, 'A fully qualified class name') + ->setHelp("The %command.name% 'App\Entity\Dummy' command dumps the serializer groups for the dummy class.") + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $class = $input->getArgument('class'); + + if (!class_exists($class)) { + $io = new SymfonyStyle($input, $output); + $io->error(\sprintf('Class "%s" was not found.', $class)); + + return Command::FAILURE; + } + + $this->dumpSerializerDataForClass($input, $output, $class); + + return Command::SUCCESS; + } + + private function dumpSerializerDataForClass(InputInterface $input, OutputInterface $output, string $class): void + { + $io = new SymfonyStyle($input, $output); + $title = \sprintf('%s', $class); + $rows = []; + $dump = new Dumper($output); + + $classMetadata = $this->serializer->getMetadataFor($class); + + foreach ($this->getAttributesData($classMetadata) as $propertyName => $data) { + $rows[] = [ + $propertyName, + $dump($data), + ]; + } + + $io->section($title); + + if (!$rows) { + $io->text('No Serializer data were found for this class.'); + + return; + } + + $table = new Table($output); + $table->setHeaders(['Property', 'Options']); + $table->setRows($rows); + $table->render(); + } + + /** + * @return array> + */ + private function getAttributesData(ClassMetadataInterface $classMetadata): array + { + $data = []; + + foreach ($classMetadata->getAttributesMetadata() as $attributeMetadata) { + $data[$attributeMetadata->getName()] = [ + 'groups' => $attributeMetadata->getGroups(), + 'maxDepth' => $attributeMetadata->getMaxDepth(), + 'serializedName' => $attributeMetadata->getSerializedName(), + 'serializedPath' => $attributeMetadata->getSerializedPath() ? (string) $attributeMetadata->getSerializedPath() : null, + 'ignore' => $attributeMetadata->isIgnored(), + 'normalizationContexts' => $attributeMetadata->getNormalizationContexts(), + 'denormalizationContexts' => $attributeMetadata->getDenormalizationContexts(), + ]; + } + + return $data; + } +} diff --git a/Context/ContextBuilderInterface.php b/Context/ContextBuilderInterface.php new file mode 100644 index 000000000..4366d07bc --- /dev/null +++ b/Context/ContextBuilderInterface.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\Context; + +/** + * Common interface for context builders. + * + * @author Mathias Arlaud + * @author Robin Chalas + */ +interface ContextBuilderInterface +{ + /** + * @param self|array $context + */ + public function withContext(self|array $context): static; + + /** + * @return array + */ + public function toArray(): array; +} diff --git a/Context/ContextBuilderTrait.php b/Context/ContextBuilderTrait.php new file mode 100644 index 000000000..d7ce5becf --- /dev/null +++ b/Context/ContextBuilderTrait.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context; + +/** + * @author Mathias Arlaud + */ +trait ContextBuilderTrait +{ + /** + * @var array + */ + private array $context = []; + + protected function with(string $key, mixed $value): static + { + $instance = new static(); + $instance->context = array_merge($this->context, [$key => $value]); + + return $instance; + } + + /** + * @param ContextBuilderInterface|array $context + */ + public function withContext(ContextBuilderInterface|array $context): static + { + if ($context instanceof ContextBuilderInterface) { + $context = $context->toArray(); + } + + $instance = new static(); + $instance->context = array_merge($this->context, $context); + + return $instance; + } + + /** + * @return array + */ + public function toArray(): array + { + return $this->context; + } +} diff --git a/Context/Encoder/CsvEncoderContextBuilder.php b/Context/Encoder/CsvEncoderContextBuilder.php new file mode 100644 index 000000000..9f0d6da6f --- /dev/null +++ b/Context/Encoder/CsvEncoderContextBuilder.php @@ -0,0 +1,139 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Encoder; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * A helper providing autocompletion for available CsvEncoder options. + * + * @author Mathias Arlaud + */ +final class CsvEncoderContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the column delimiter character. + * + * Must be a single character. + * + * @throws InvalidArgumentException + */ + public function withDelimiter(?string $delimiter): static + { + if (null !== $delimiter && 1 !== \strlen($delimiter)) { + throw new InvalidArgumentException(\sprintf('The "%s" delimiter must be a single character.', $delimiter)); + } + + return $this->with(CsvEncoder::DELIMITER_KEY, $delimiter); + } + + /** + * Configures the field enclosure character. + * + * Must be a single character. + * + * @throws InvalidArgumentException + */ + public function withEnclosure(?string $enclosure): static + { + if (null !== $enclosure && 1 !== \strlen($enclosure)) { + throw new InvalidArgumentException(\sprintf('The "%s" enclosure must be a single character.', $enclosure)); + } + + return $this->with(CsvEncoder::ENCLOSURE_KEY, $enclosure); + } + + /** + * Configures the escape character. + * + * Must be empty or a single character. + * + * @deprecated since Symfony 7.2, to be removed in 8.0 + * + * @throws InvalidArgumentException + */ + public function withEscapeChar(?string $escapeChar): static + { + trigger_deprecation('symfony/serializer', '7.2', 'The "%s" method is deprecated. It will be removed in 8.0.', __METHOD__); + + if (null !== $escapeChar && \strlen($escapeChar) > 1) { + throw new InvalidArgumentException(\sprintf('The "%s" escape character must be empty or a single character.', $escapeChar)); + } + + return $this->with(CsvEncoder::ESCAPE_CHAR_KEY, $escapeChar); + } + + /** + * Configures the key separator when (un)flattening arrays. + */ + public function withKeySeparator(?string $keySeparator): static + { + return $this->with(CsvEncoder::KEY_SEPARATOR_KEY, $keySeparator); + } + + /** + * Configures the headers. + * + * @param list|null $headers + */ + public function withHeaders(?array $headers): static + { + return $this->with(CsvEncoder::HEADERS_KEY, $headers); + } + + /** + * Configures whether formulas should be escaped. + */ + public function withEscapedFormulas(?bool $escapedFormulas): static + { + return $this->with(CsvEncoder::ESCAPE_FORMULAS_KEY, $escapedFormulas); + } + + /** + * Configures whether the decoded result should be considered as a collection + * or as a single element. + */ + public function withAsCollection(?bool $asCollection): static + { + return $this->with(CsvEncoder::AS_COLLECTION_KEY, $asCollection); + } + + /** + * Configures whether the input (or output) is containing (or will contain) headers. + */ + public function withNoHeaders(?bool $noHeaders): static + { + return $this->with(CsvEncoder::NO_HEADERS_KEY, $noHeaders); + } + + /** + * Configures the end of line characters. + */ + public function withEndOfLine(?string $endOfLine): static + { + return $this->with(CsvEncoder::END_OF_LINE, $endOfLine); + } + + /** + * Configures whether to add the UTF-8 Byte Order Mark (BOM) + * at the beginning of the encoded result or not. + */ + public function withOutputUtf8Bom(?bool $outputUtf8Bom): static + { + return $this->with(CsvEncoder::OUTPUT_UTF8_BOM_KEY, $outputUtf8Bom); + } +} diff --git a/Context/Encoder/JsonEncoderContextBuilder.php b/Context/Encoder/JsonEncoderContextBuilder.php new file mode 100644 index 000000000..0ebd70269 --- /dev/null +++ b/Context/Encoder/JsonEncoderContextBuilder.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Encoder; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Encoder\JsonDecode; +use Symfony\Component\Serializer\Encoder\JsonEncode; + +/** + * A helper providing autocompletion for available JsonEncoder options. + * + * @author Mathias Arlaud + */ +final class JsonEncoderContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the json_encode flags bitmask. + * + * @see https://www.php.net/manual/en/json.constants.php + * + * @param positive-int|null $options + */ + public function withEncodeOptions(?int $options): static + { + return $this->with(JsonEncode::OPTIONS, $options); + } + + /** + * Configures the json_decode flags bitmask. + * + * @see https://www.php.net/manual/en/json.constants.php + * + * @param positive-int|null $options + */ + public function withDecodeOptions(?int $options): static + { + return $this->with(JsonDecode::OPTIONS, $options); + } + + /** + * Configures whether decoded objects will be given as + * associative arrays or as nested stdClass. + */ + public function withAssociative(?bool $associative): static + { + return $this->with(JsonDecode::ASSOCIATIVE, $associative); + } + + /** + * Configures the maximum recursion depth. + * + * Must be strictly positive. + * + * @param positive-int|null $recursionDepth + */ + public function withRecursionDepth(?int $recursionDepth): static + { + return $this->with(JsonDecode::RECURSION_DEPTH, $recursionDepth); + } +} diff --git a/Context/Encoder/XmlEncoderContextBuilder.php b/Context/Encoder/XmlEncoderContextBuilder.php new file mode 100644 index 000000000..0fd1f2f44 --- /dev/null +++ b/Context/Encoder/XmlEncoderContextBuilder.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Encoder; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Encoder\XmlEncoder; + +/** + * A helper providing autocompletion for available XmlEncoder options. + * + * @author Mathias Arlaud + */ +final class XmlEncoderContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures whether the decoded result should be considered as a collection + * or as a single element. + */ + public function withAsCollection(?bool $asCollection): static + { + return $this->with(XmlEncoder::AS_COLLECTION, $asCollection); + } + + /** + * Configures node types to ignore while decoding. + * + * @see https://www.php.net/manual/en/dom.constants.php + * + * @param list|null $decoderIgnoredNodeTypes + */ + public function withDecoderIgnoredNodeTypes(?array $decoderIgnoredNodeTypes): static + { + return $this->with(XmlEncoder::DECODER_IGNORED_NODE_TYPES, $decoderIgnoredNodeTypes); + } + + /** + * Configures node types to ignore while encoding. + * + * @see https://www.php.net/manual/en/dom.constants.php + * + * @param list|null $encoderIgnoredNodeTypes + */ + public function withEncoderIgnoredNodeTypes(?array $encoderIgnoredNodeTypes): static + { + return $this->with(XmlEncoder::ENCODER_IGNORED_NODE_TYPES, $encoderIgnoredNodeTypes); + } + + /** + * Configures the DOMDocument encoding. + * + * @see https://www.php.net/manual/en/class.domdocument.php#domdocument.props.encoding + */ + public function withEncoding(?string $encoding): static + { + return $this->with(XmlEncoder::ENCODING, $encoding); + } + + /** + * Configures whether to encode with indentation and extra space. + * + * @see https://php.net/manual/en/class.domdocument.php#domdocument.props.formatoutput + */ + public function withFormatOutput(?bool $formatOutput): static + { + return $this->with(XmlEncoder::FORMAT_OUTPUT, $formatOutput); + } + + /** + * Configures the DOMDocument::loadXml options bitmask. + * + * @see https://www.php.net/manual/en/libxml.constants.php + * + * @param positive-int|null $loadOptions + */ + public function withLoadOptions(?int $loadOptions): static + { + return $this->with(XmlEncoder::LOAD_OPTIONS, $loadOptions); + } + + /** + * Configures the DOMDocument::saveXml options bitmask. + * + * @see https://www.php.net/manual/en/libxml.constants.php + * + * @param positive-int|null $saveOptions + */ + public function withSaveOptions(?int $saveOptions): static + { + return $this->with(XmlEncoder::SAVE_OPTIONS, $saveOptions); + } + + /** + * Configures whether to keep empty nodes. + */ + public function withRemoveEmptyTags(?bool $removeEmptyTags): static + { + return $this->with(XmlEncoder::REMOVE_EMPTY_TAGS, $removeEmptyTags); + } + + /** + * Configures name of the root node. + */ + public function withRootNodeName(?string $rootNodeName): static + { + return $this->with(XmlEncoder::ROOT_NODE_NAME, $rootNodeName); + } + + /** + * Configures whether the document will be standalone. + * + * @see https://php.net/manual/en/class.domdocument.php#domdocument.props.xmlstandalone + */ + public function withStandalone(?bool $standalone): static + { + return $this->with(XmlEncoder::STANDALONE, $standalone); + } + + /** + * Configures whether casting numeric string attributes to integers or floats. + */ + public function withTypeCastAttributes(?bool $typeCastAttributes): static + { + return $this->with(XmlEncoder::TYPE_CAST_ATTRIBUTES, $typeCastAttributes); + } + + /** + * Configures the version number of the document. + * + * @see https://php.net/manual/en/class.domdocument.php#domdocument.props.xmlversion + */ + public function withVersion(?string $version): static + { + return $this->with(XmlEncoder::VERSION, $version); + } + + /** + * Configures whether to wrap strings within CDATA sections. + */ + public function withCdataWrapping(?bool $cdataWrapping): static + { + return $this->with(XmlEncoder::CDATA_WRAPPING, $cdataWrapping); + } + + /** + * Configures the pattern used to evaluate if a CDATA section should be added. + */ + public function withCdataWrappingPattern(?string $cdataWrappingPattern): static + { + return $this->with(XmlEncoder::CDATA_WRAPPING_PATTERN, $cdataWrappingPattern); + } +} diff --git a/Context/Encoder/YamlEncoderContextBuilder.php b/Context/Encoder/YamlEncoderContextBuilder.php new file mode 100644 index 000000000..63efb71d7 --- /dev/null +++ b/Context/Encoder/YamlEncoderContextBuilder.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Encoder; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Encoder\YamlEncoder; + +/** + * A helper providing autocompletion for available YamlEncoder options. + * + * Note that the "indentation" setting is not offered in this builder because + * it can only be set during the construction of the YamlEncoder, but not per + * call. + * + * @author Mathias Arlaud + */ +final class YamlEncoderContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the threshold to switch to inline YAML. + */ + public function withInlineThreshold(?int $inlineThreshold): static + { + return $this->with(YamlEncoder::YAML_INLINE, $inlineThreshold); + } + + /** + * Configures the indentation level. + * + * Must be positive. + * + * @param int<0, max>|null $indentLevel + */ + public function withIndentLevel(?int $indentLevel): static + { + return $this->with(YamlEncoder::YAML_INDENT, $indentLevel); + } + + /** + * Configures \Symfony\Component\Yaml\Dumper::dump flags bitmask. + * + * @see \Symfony\Component\Yaml\Yaml + */ + public function withFlags(?int $flags): static + { + return $this->with(YamlEncoder::YAML_FLAGS, $flags); + } + + /** + * Configures whether to preserve empty objects "{}" or to convert them to null. + */ + public function withPreservedEmptyObjects(?bool $preserveEmptyObjects): static + { + return $this->with(YamlEncoder::PRESERVE_EMPTY_OBJECTS, $preserveEmptyObjects); + } +} diff --git a/Context/Normalizer/AbstractNormalizerContextBuilder.php b/Context/Normalizer/AbstractNormalizerContextBuilder.php new file mode 100644 index 000000000..a63e1a507 --- /dev/null +++ b/Context/Normalizer/AbstractNormalizerContextBuilder.php @@ -0,0 +1,188 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +/** + * A helper providing autocompletion for available AbstractNormalizer options. + * + * @author Mathias Arlaud + */ +abstract class AbstractNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures how many loops of circular reference to allow while normalizing. + * + * The value 1 means that when we encounter the same object a + * second time, we consider that a circular reference. + * + * You can raise this value for special cases, e.g. in combination with the + * max depth setting of the object normalizer. + * + * Must be strictly positive. + * + * @param positive-int|null $circularReferenceLimit + */ + public function withCircularReferenceLimit(?int $circularReferenceLimit): static + { + return $this->with(AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT, $circularReferenceLimit); + } + + /** + * Configures an object to be updated instead of creating a new instance. + * + * If you have a nested structure, child objects will be overwritten with + * new instances unless you set AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE to true. + */ + public function withObjectToPopulate(?object $objectToPopulate): static + { + return $this->with(AbstractNormalizer::OBJECT_TO_POPULATE, $objectToPopulate); + } + + /** + * Configures groups containing attributes to (de)normalize. + * + * Eg: ['group1', 'group2'] + * + * @param list|string|null $groups + */ + public function withGroups(array|string|null $groups): static + { + if (null === $groups) { + return $this->with(AbstractNormalizer::GROUPS, null); + } + + return $this->with(AbstractNormalizer::GROUPS, (array) $groups); + } + + /** + * Configures attributes to (de)normalize. + * + * For nested structures, this list needs to reflect the object tree. + * + * Eg: ['foo', 'bar', 'object' => ['baz']] + * + * @param array|null $attributes + * + * @throws InvalidArgumentException + */ + public function withAttributes(?array $attributes): static + { + $it = new \RecursiveIteratorIterator(new \RecursiveArrayIterator($attributes ?? []), \RecursiveIteratorIterator::LEAVES_ONLY); + + foreach ($it as $attribute) { + if (!\is_string($attribute)) { + throw new InvalidArgumentException(\sprintf('Each attribute must be a string, "%s" given.', get_debug_type($attribute))); + } + } + + return $this->with(AbstractNormalizer::ATTRIBUTES, $attributes); + } + + /** + * If AbstractNormalizer::ATTRIBUTES are specified, and the source has fields that are not part of that list, + * configures whether to ignore those attributes or throw an ExtraAttributesException. + */ + public function withAllowExtraAttributes(?bool $allowExtraAttributes): static + { + return $this->with(AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES, $allowExtraAttributes); + } + + /** + * @deprecated since Symfony 7.1, use withDefaultConstructorArguments(?array $defaultConstructorArguments)" instead + * + * @param array>|null $defaultContructorArguments + */ + public function withDefaultContructorArguments(?array $defaultContructorArguments): static + { + trigger_deprecation('symfony/serializer', '7.1', 'The "%s()" method is deprecated, use "withDefaultConstructorArguments(?array $defaultConstructorArguments)" instead.', __METHOD__); + + return self::withDefaultConstructorArguments($defaultContructorArguments); + } + + /** + * Configures a hashmap of classes containing hashmaps of constructor argument => default value. + * + * The names need to match the parameter names in the constructor arguments. + * + * Eg: [Foo::class => ['foo' => true, 'bar' => 0]] + * + * @param array>|null $defaultConstructorArguments + */ + public function withDefaultConstructorArguments(?array $defaultConstructorArguments): static + { + return $this->with(AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS, $defaultConstructorArguments); + } + + /** + * Configures an hashmap of field name => callable to normalize this field. + * + * The callable is called if the field is encountered with the arguments: + * + * - mixed $attributeValue value of this field + * - object $object the whole object being normalized + * - string $attributeName name of the attribute being normalized + * - string $format the requested format + * - array $context the serialization context + * + * @param array|null $callbacks + */ + public function withCallbacks(?array $callbacks): static + { + return $this->with(AbstractNormalizer::CALLBACKS, $callbacks); + } + + /** + * Configures an handler to call when a circular reference has been detected. + * + * If no handler is specified, a CircularReferenceException is thrown. + * + * The method will be called with ($object, $format, $context) and its + * return value is returned as the result of the normalize call. + */ + public function withCircularReferenceHandler(?callable $circularReferenceHandler): static + { + return $this->with(AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER, $circularReferenceHandler); + } + + /** + * Configures attributes to be skipped when normalizing an object tree. + * + * This list is applied to each element of nested structures. + * + * Eg: ['foo', 'bar'] + * + * Note: The behaviour for nested structures is different from ATTRIBUTES + * for historical reason. Aligning the behaviour would be a BC break. + * + * @param list|null $ignoredAttributes + */ + public function withIgnoredAttributes(?array $ignoredAttributes): static + { + return $this->with(AbstractNormalizer::IGNORED_ATTRIBUTES, $ignoredAttributes); + } + + /** + * Configures requiring all properties to be listed in the input instead + * of falling back to null for nullable ones. + */ + public function withRequireAllProperties(?bool $requireAllProperties = true): static + { + return $this->with(AbstractNormalizer::REQUIRE_ALL_PROPERTIES, $requireAllProperties); + } +} diff --git a/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php b/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php new file mode 100644 index 000000000..45b47bc1b --- /dev/null +++ b/Context/Normalizer/AbstractObjectNormalizerContextBuilder.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * A helper providing autocompletion for available AbstractObjectNormalizer options. + * + * @author Mathias Arlaud + */ +abstract class AbstractObjectNormalizerContextBuilder extends AbstractNormalizerContextBuilder +{ + /** + * Configures whether to respect the max depth metadata on fields. + */ + public function withEnableMaxDepth(?bool $enableMaxDepth): static + { + return $this->with(AbstractObjectNormalizer::ENABLE_MAX_DEPTH, $enableMaxDepth); + } + + /** + * Configures a pattern to keep track of the current depth. + * + * Must contain exactly two string placeholders. + * + * @throws InvalidArgumentException + */ + public function withDepthKeyPattern(?string $depthKeyPattern): static + { + if (null === $depthKeyPattern) { + return $this->with(AbstractObjectNormalizer::DEPTH_KEY_PATTERN, null); + } + + // This will match every occurrences of sprintf specifiers + $matches = []; + preg_match_all('/(?[a-z])/', $depthKeyPattern, $matches); + + if (2 !== \count($matches['specifier']) || 's' !== $matches['specifier'][0] || 's' !== $matches['specifier'][1]) { + throw new InvalidArgumentException(\sprintf('The depth key pattern "%s" is not valid. You must set exactly two string placeholders.', $depthKeyPattern)); + } + + return $this->with(AbstractObjectNormalizer::DEPTH_KEY_PATTERN, $depthKeyPattern); + } + + /** + * Configures whether verifying types match during denormalization. + */ + public function withDisableTypeEnforcement(?bool $disableTypeEnforcement): static + { + return $this->with(AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT, $disableTypeEnforcement); + } + + /** + * Configures whether fields with the value `null` should be output during normalization. + */ + public function withSkipNullValues(?bool $skipNullValues): static + { + return $this->with(AbstractObjectNormalizer::SKIP_NULL_VALUES, $skipNullValues); + } + + /** + * Configures whether uninitialized typed class properties should be excluded during normalization. + */ + public function withSkipUninitializedValues(?bool $skipUninitializedValues): static + { + return $this->with(AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES, $skipUninitializedValues); + } + + /** + * Configures a callback to allow to set a value for an attribute when the max depth has + * been reached. + * + * If no callback is given, the attribute is skipped. If a callable is + * given, its return value is used (even if null). + * + * The arguments are: + * + * - mixed $attributeValue value of this field + * - object $object the whole object being normalized + * - string $attributeName name of the attribute being normalized + * - string $format the requested format + * - array $context the serialization context + */ + public function withMaxDepthHandler(?callable $maxDepthHandler): static + { + return $this->with(AbstractObjectNormalizer::MAX_DEPTH_HANDLER, $maxDepthHandler); + } + + /** + * Configures which context key are not relevant to determine which attributes + * of an object to (de)normalize. + * + * @param list|null $excludeFromCacheKeys + */ + public function withExcludeFromCacheKeys(?array $excludeFromCacheKeys): static + { + return $this->with(AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY, $excludeFromCacheKeys); + } + + /** + * Configures whether to tell the denormalizer to also populate existing objects on + * attributes of the main object. + * + * Setting this to true is only useful if you also specify the root object + * in AbstractNormalizer::OBJECT_TO_POPULATE. + */ + public function withDeepObjectToPopulate(?bool $deepObjectToPopulate): static + { + return $this->with(AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE, $deepObjectToPopulate); + } + + /** + * Configures whether an empty object should be kept as an object (in + * JSON: {}) or converted to a list (in JSON: []). + */ + public function withPreserveEmptyObjects(?bool $preserveEmptyObjects): static + { + return $this->with(AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS, $preserveEmptyObjects); + } +} diff --git a/Context/Normalizer/BackedEnumNormalizerContextBuilder.php b/Context/Normalizer/BackedEnumNormalizerContextBuilder.php new file mode 100644 index 000000000..ca1a4f50a --- /dev/null +++ b/Context/Normalizer/BackedEnumNormalizerContextBuilder.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\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; + +/** + * A helper providing autocompletion for available BackedEnumNormalizer options. + * + * @author Nicolas PHILIPPE + */ +final class BackedEnumNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures if invalid values are allowed in denormalization. + * They will be denormalized into `null` values. + */ + public function withAllowInvalidValues(bool $allowInvalidValues): static + { + return $this->with(BackedEnumNormalizer::ALLOW_INVALID_VALUES, $allowInvalidValues); + } +} diff --git a/Context/Normalizer/ConstraintViolationListNormalizerContextBuilder.php b/Context/Normalizer/ConstraintViolationListNormalizerContextBuilder.php new file mode 100644 index 000000000..fd1d7c4f5 --- /dev/null +++ b/Context/Normalizer/ConstraintViolationListNormalizerContextBuilder.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer; + +/** + * A helper providing autocompletion for available ConstraintViolationList options. + * + * @author Mathias Arlaud + */ +final class ConstraintViolationListNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configure the instance field of normalized data. + */ + public function withInstance(mixed $instance): static + { + return $this->with(ConstraintViolationListNormalizer::INSTANCE, $instance); + } + + /** + * Configure the status field of normalized data. + */ + public function withStatus(?int $status): static + { + return $this->with(ConstraintViolationListNormalizer::STATUS, $status); + } + + /** + * Configure the title field of normalized data. + */ + public function withTitle(?string $title): static + { + return $this->with(ConstraintViolationListNormalizer::TITLE, $title); + } + + /** + * Configure the type field of normalized data. + */ + public function withType(?string $type): static + { + return $this->with(ConstraintViolationListNormalizer::TYPE, $type); + } + + /** + * Configures the payload fields which will act as an allowlist + * for the payload field of normalized data. + * + * Eg: ['foo', 'bar'] + * + * @param list|null $payloadFields + */ + public function withPayloadFields(?array $payloadFields): static + { + return $this->with(ConstraintViolationListNormalizer::PAYLOAD_FIELDS, $payloadFields); + } +} diff --git a/Context/Normalizer/DateIntervalNormalizerContextBuilder.php b/Context/Normalizer/DateIntervalNormalizerContextBuilder.php new file mode 100644 index 000000000..98636577a --- /dev/null +++ b/Context/Normalizer/DateIntervalNormalizerContextBuilder.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\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer; + +/** + * A helper providing autocompletion for available DateIntervalNormalizer options. + * + * @author Mathias Arlaud + */ +final class DateIntervalNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the format of the interval. + * + * @see https://php.net/manual/en/dateinterval.format.php + */ + public function withFormat(?string $format): static + { + return $this->with(DateIntervalNormalizer::FORMAT_KEY, $format); + } +} diff --git a/Context/Normalizer/DateTimeNormalizerContextBuilder.php b/Context/Normalizer/DateTimeNormalizerContextBuilder.php new file mode 100644 index 000000000..de83b1245 --- /dev/null +++ b/Context/Normalizer/DateTimeNormalizerContextBuilder.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; + +/** + * A helper providing autocompletion for available DateTimeNormalizer options. + * + * @author Mathias Arlaud + */ +final class DateTimeNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the format of the date. + * + * @see https://secure.php.net/manual/en/datetime.format.php + */ + public function withFormat(?string $format): static + { + return $this->with(DateTimeNormalizer::FORMAT_KEY, $format); + } + + /** + * Configures the timezone of the date. + * + * It could be either a \DateTimeZone or a string + * that will be used to construct the \DateTimeZone + * + * @see https://secure.php.net/manual/en/class.datetimezone.php + * + * @throws InvalidArgumentException + */ + public function withTimezone(\DateTimeZone|string|null $timezone): static + { + if (null === $timezone) { + return $this->with(DateTimeNormalizer::TIMEZONE_KEY, null); + } + + if (\is_string($timezone)) { + try { + $timezone = new \DateTimeZone($timezone); + } catch (\Exception $e) { + throw new InvalidArgumentException(\sprintf('The "%s" timezone is invalid.', $timezone), previous: $e); + } + } + + return $this->with(DateTimeNormalizer::TIMEZONE_KEY, $timezone); + } + + /** + * @param 'int'|'float'|null $cast + */ + public function withCast(?string $cast): static + { + return $this->with(DateTimeNormalizer::CAST_KEY, $cast); + } +} diff --git a/Context/Normalizer/FormErrorNormalizerContextBuilder.php b/Context/Normalizer/FormErrorNormalizerContextBuilder.php new file mode 100644 index 000000000..4cd2ddb88 --- /dev/null +++ b/Context/Normalizer/FormErrorNormalizerContextBuilder.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\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Normalizer\FormErrorNormalizer; + +/** + * A helper providing autocompletion for available FormErrorNormalizer options. + * + * @author Mathias Arlaud + */ +final class FormErrorNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the title of the normalized data. + */ + public function withTitle(?string $title): static + { + return $this->with(FormErrorNormalizer::TITLE, $title); + } + + /** + * Configures the type of the normalized data. + */ + public function withType(?string $type): static + { + return $this->with(FormErrorNormalizer::TYPE, $type); + } + + /** + * Configures the code of the normalized data. + */ + public function withStatusCode(?int $statusCode): static + { + return $this->with(FormErrorNormalizer::CODE, $statusCode); + } +} diff --git a/Context/Normalizer/GetSetMethodNormalizerContextBuilder.php b/Context/Normalizer/GetSetMethodNormalizerContextBuilder.php new file mode 100644 index 000000000..a7d2f8244 --- /dev/null +++ b/Context/Normalizer/GetSetMethodNormalizerContextBuilder.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +/** + * A helper providing autocompletion for available GetSetMethodNormalizer options. + * + * @author Mathias Arlaud + */ +final class GetSetMethodNormalizerContextBuilder extends AbstractObjectNormalizerContextBuilder +{ +} diff --git a/Context/Normalizer/JsonSerializableNormalizerContextBuilder.php b/Context/Normalizer/JsonSerializableNormalizerContextBuilder.php new file mode 100644 index 000000000..249e74ff0 --- /dev/null +++ b/Context/Normalizer/JsonSerializableNormalizerContextBuilder.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +/** + * A helper providing autocompletion for available JsonSerializableNormalizer options. + * + * @author Mathias Arlaud + */ +final class JsonSerializableNormalizerContextBuilder extends AbstractNormalizerContextBuilder +{ +} diff --git a/Context/Normalizer/ObjectNormalizerContextBuilder.php b/Context/Normalizer/ObjectNormalizerContextBuilder.php new file mode 100644 index 000000000..eb8d15710 --- /dev/null +++ b/Context/Normalizer/ObjectNormalizerContextBuilder.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +/** + * A helper providing autocompletion for available ObjectNormalizer options. + * + * @author Mathias Arlaud + */ +final class ObjectNormalizerContextBuilder extends AbstractObjectNormalizerContextBuilder +{ +} diff --git a/Context/Normalizer/ProblemNormalizerContextBuilder.php b/Context/Normalizer/ProblemNormalizerContextBuilder.php new file mode 100644 index 000000000..b525a91ad --- /dev/null +++ b/Context/Normalizer/ProblemNormalizerContextBuilder.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\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; + +/** + * A helper providing autocompletion for available ProblemNormalizer options. + * + * @author Mathias Arlaud + */ +final class ProblemNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configure the title field of normalized data. + */ + public function withTitle(?string $title): static + { + return $this->with(ProblemNormalizer::TITLE, $title); + } + + /** + * Configure the type field of normalized data. + */ + public function withType(?string $type): static + { + return $this->with(ProblemNormalizer::TYPE, $type); + } + + /** + * Configure the status field of normalized data. + */ + public function withStatusCode(int|string|null $statusCode): static + { + return $this->with(ProblemNormalizer::STATUS, $statusCode); + } +} diff --git a/Context/Normalizer/PropertyNormalizerContextBuilder.php b/Context/Normalizer/PropertyNormalizerContextBuilder.php new file mode 100644 index 000000000..71d5ed045 --- /dev/null +++ b/Context/Normalizer/PropertyNormalizerContextBuilder.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\Context\Normalizer; + +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; + +/** + * A helper providing autocompletion for available PropertyNormalizer options. + * + * @author Mathias Arlaud + */ +final class PropertyNormalizerContextBuilder extends AbstractObjectNormalizerContextBuilder +{ + /** + * Configures whether fields should be output based on visibility. + */ + public function withNormalizeVisibility(int $normalizeVisibility): static + { + return $this->with(PropertyNormalizer::NORMALIZE_VISIBILITY, $normalizeVisibility); + } +} diff --git a/Context/Normalizer/UidNormalizerContextBuilder.php b/Context/Normalizer/UidNormalizerContextBuilder.php new file mode 100644 index 000000000..b809fe3eb --- /dev/null +++ b/Context/Normalizer/UidNormalizerContextBuilder.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\Context\Normalizer; + +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\UidNormalizer; + +/** + * A helper providing autocompletion for available UidNormalizer options. + * + * @author Mathias Arlaud + */ +final class UidNormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the uuid format for normalization. + * + * @throws InvalidArgumentException + */ + public function withNormalizationFormat(?string $normalizationFormat): static + { + if (null !== $normalizationFormat && !\in_array($normalizationFormat, UidNormalizer::NORMALIZATION_FORMATS, true)) { + throw new InvalidArgumentException(\sprintf('The "%s" normalization format is not valid.', $normalizationFormat)); + } + + return $this->with(UidNormalizer::NORMALIZATION_FORMAT_KEY, $normalizationFormat); + } +} diff --git a/Context/Normalizer/UnwrappingDenormalizerContextBuilder.php b/Context/Normalizer/UnwrappingDenormalizerContextBuilder.php new file mode 100644 index 000000000..2945cb961 --- /dev/null +++ b/Context/Normalizer/UnwrappingDenormalizerContextBuilder.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Context\Normalizer; + +use Symfony\Component\PropertyAccess\Exception\InvalidPropertyPathException; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; + +/** + * A helper providing autocompletion for available UnwrappingDenormalizer options. + * + * @author Mathias Arlaud + */ +final class UnwrappingDenormalizerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures the path of wrapped data during denormalization. + * + * Eg: [foo].bar[bar] + * + * @see https://symfony.com/doc/current/components/property_access.html + * + * @throws InvalidArgumentException + */ + public function withUnwrapPath(?string $unwrapPath): static + { + if (null === $unwrapPath) { + return $this->with(UnwrappingDenormalizer::UNWRAP_PATH, null); + } + + try { + new PropertyPath($unwrapPath); + } catch (InvalidPropertyPathException $e) { + throw new InvalidArgumentException(\sprintf('The "%s" property path is not valid.', $unwrapPath), previous: $e); + } + + return $this->with(UnwrappingDenormalizer::UNWRAP_PATH, $unwrapPath); + } +} diff --git a/Context/SerializerContextBuilder.php b/Context/SerializerContextBuilder.php new file mode 100644 index 000000000..a6359be98 --- /dev/null +++ b/Context/SerializerContextBuilder.php @@ -0,0 +1,39 @@ + + * + * 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\DenormalizerInterface; +use Symfony\Component\Serializer\Serializer; + +/** + * A helper providing autocompletion for available Serializer options. + * + * @author Mathias Arlaud + */ +final class SerializerContextBuilder implements ContextBuilderInterface +{ + use ContextBuilderTrait; + + /** + * Configures whether an empty array should be transformed to an + * object (in JSON: {}) or to a list (in JSON: []). + */ + public function withEmptyArrayAsObject(?bool $emptyArrayAsObject): static + { + return $this->with(Serializer::EMPTY_ARRAY_AS_OBJECT, $emptyArrayAsObject); + } + + public function withCollectDenormalizationErrors(?bool $collectDenormalizationErrors): static + { + return $this->with(DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS, $collectDenormalizationErrors); + } +} diff --git a/DataCollector/SerializerDataCollector.php b/DataCollector/SerializerDataCollector.php new file mode 100644 index 000000000..e87c51ca1 --- /dev/null +++ b/DataCollector/SerializerDataCollector.php @@ -0,0 +1,259 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\DataCollector; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\DataCollector\DataCollector; +use Symfony\Component\HttpKernel\DataCollector\LateDataCollectorInterface; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\VarDumper\Cloner\Data; + +/** + * @author Mathias Arlaud + * + * @final + */ +class SerializerDataCollector extends DataCollector implements LateDataCollectorInterface +{ + private const DATA_TEMPLATE = [ + 'serialize' => [], + 'deserialize' => [], + 'normalize' => [], + 'denormalize' => [], + 'encode' => [], + 'decode' => [], + ]; + + private array $dataGroupedByName; + private array $collected = []; + + public function reset(): void + { + $this->data = []; + unset($this->dataGroupedByName); + $this->collected = []; + } + + public function collect(Request $request, Response $response, ?\Throwable $exception = null): void + { + // Everything is collected during the request, and formatted on kernel terminate. + } + + public function getName(): string + { + return 'serializer'; + } + + public function getData(?string $name = null): Data|array + { + return null === $name ? $this->data : $this->getDataGroupedByName()[$name]; + } + + public function getHandledCount(?string $name = null): int + { + return array_sum(array_map('count', $this->getData($name))); + } + + public function getTotalTime(): float + { + $totalTime = 0; + + foreach ($this->data as $handled) { + $totalTime += array_sum(array_map(fn (array $el): float => $el['time'], $handled)); + } + + return $totalTime; + } + + public function getSerializerNames(): array + { + return array_keys($this->getDataGroupedByName()); + } + + public function collectSerialize(string $traceId, mixed $data, string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time', 'caller', 'name'), + ['method' => 'serialize'], + ); + } + + public function collectDeserialize(string $traceId, mixed $data, string $type, string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'type', 'context', 'time', 'caller', 'name'), + ['method' => 'deserialize'], + ); + } + + public function collectNormalize(string $traceId, mixed $data, ?string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time', 'caller', 'name'), + ['method' => 'normalize'], + ); + } + + public function collectDenormalize(string $traceId, mixed $data, string $type, ?string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'type', 'context', 'time', 'caller', 'name'), + ['method' => 'denormalize'], + ); + } + + public function collectEncode(string $traceId, mixed $data, ?string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time', 'caller', 'name'), + ['method' => 'encode'], + ); + } + + public function collectDecode(string $traceId, mixed $data, ?string $format, array $context, float $time, array $caller, string $name): void + { + unset($context[TraceableSerializer::DEBUG_TRACE_ID]); + + $this->collected[$traceId] = array_merge( + $this->collected[$traceId] ?? [], + compact('data', 'format', 'context', 'time', 'caller', 'name'), + ['method' => 'decode'], + ); + } + + public function collectNormalization(string $traceId, string $normalizer, float $time, string $name): void + { + $method = 'normalize'; + + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time', 'name'); + } + + public function collectDenormalization(string $traceId, string $normalizer, float $time, string $name): void + { + $method = 'denormalize'; + + $this->collected[$traceId]['normalization'][] = compact('normalizer', 'method', 'time', 'name'); + } + + public function collectEncoding(string $traceId, string $encoder, float $time, string $name): void + { + $method = 'encode'; + + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time', 'name'); + } + + public function collectDecoding(string $traceId, string $encoder, float $time, string $name): void + { + $method = 'decode'; + + $this->collected[$traceId]['encoding'][] = compact('encoder', 'method', 'time', 'name'); + } + + public function lateCollect(): void + { + $this->data = self::DATA_TEMPLATE; + + foreach ($this->collected as $collected) { + if (!isset($collected['data'])) { + continue; + } + + $data = [ + 'data' => $this->cloneVar($collected['data']), + 'dataType' => get_debug_type($collected['data']), + 'type' => $collected['type'] ?? null, + 'format' => $collected['format'], + 'time' => $collected['time'], + 'context' => $this->cloneVar($collected['context']), + 'normalization' => [], + 'encoding' => [], + 'caller' => $collected['caller'] ?? null, + 'name' => $collected['name'], + ]; + + if (isset($collected['normalization'])) { + $mainNormalization = array_pop($collected['normalization']); + + $data['normalizer'] = ['time' => $mainNormalization['time']] + $this->getMethodLocation($mainNormalization['normalizer'], $mainNormalization['method']); + + foreach ($collected['normalization'] as $normalization) { + if (!isset($data['normalization'][$normalization['normalizer']])) { + $data['normalization'][$normalization['normalizer']] = ['time' => 0, 'calls' => 0] + $this->getMethodLocation($normalization['normalizer'], $normalization['method']); + } + + ++$data['normalization'][$normalization['normalizer']]['calls']; + $data['normalization'][$normalization['normalizer']]['time'] += $normalization['time']; + } + } + + if (isset($collected['encoding'])) { + $mainEncoding = array_pop($collected['encoding']); + + $data['encoder'] = ['time' => $mainEncoding['time']] + $this->getMethodLocation($mainEncoding['encoder'], $mainEncoding['method']); + + foreach ($collected['encoding'] as $encoding) { + if (!isset($data['encoding'][$encoding['encoder']])) { + $data['encoding'][$encoding['encoder']] = ['time' => 0, 'calls' => 0] + $this->getMethodLocation($encoding['encoder'], $encoding['method']); + } + + ++$data['encoding'][$encoding['encoder']]['calls']; + $data['encoding'][$encoding['encoder']]['time'] += $encoding['time']; + } + } + + $this->data[$collected['method']][] = $data; + } + } + + private function getDataGroupedByName(): array + { + if (!isset($this->dataGroupedByName)) { + $this->dataGroupedByName = []; + + foreach ($this->data as $method => $items) { + foreach ($items as $item) { + $this->dataGroupedByName[$item['name']] ??= self::DATA_TEMPLATE; + $this->dataGroupedByName[$item['name']][$method][] = $item; + } + } + } + + return $this->dataGroupedByName; + } + + private function getMethodLocation(string $class, string $method): array + { + $reflection = new \ReflectionClass($class); + + return [ + 'class' => $reflection->getShortName(), + 'file' => $reflection->getFileName(), + 'line' => $reflection->getMethod($method)->getStartLine(), + ]; + } +} diff --git a/Debug/TraceableEncoder.php b/Debug/TraceableEncoder.php new file mode 100644 index 000000000..39e75e34f --- /dev/null +++ b/Debug/TraceableEncoder.php @@ -0,0 +1,110 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Encoder\NormalizationAwareInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Collects some data about encoding. + * + * @author Mathias Arlaud + * + * @final + */ +class TraceableEncoder implements EncoderInterface, DecoderInterface, SerializerAwareInterface +{ + public function __construct( + private EncoderInterface|DecoderInterface $encoder, + private SerializerDataCollector $dataCollector, + private readonly string $serializerName = 'default', + ) { + } + + public function encode(mixed $data, string $format, array $context = []): string + { + if (!$this->encoder instanceof EncoderInterface) { + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, EncoderInterface::class)); + } + + $startTime = microtime(true); + $encoded = $this->encoder->encode($data, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectEncoding($traceId, $this->encoder::class, $time, $this->serializerName); + } + + return $encoded; + } + + public function supportsEncoding(string $format, array $context = []): bool + { + if (!$this->encoder instanceof EncoderInterface) { + return false; + } + + return $this->encoder->supportsEncoding($format, $context); + } + + public function decode(string $data, string $format, array $context = []): mixed + { + if (!$this->encoder instanceof DecoderInterface) { + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested encoder doesn\'t implements "%s".', __METHOD__, DecoderInterface::class)); + } + + $startTime = microtime(true); + $encoded = $this->encoder->decode($data, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectDecoding($traceId, $this->encoder::class, $time, $this->serializerName); + } + + return $encoded; + } + + public function supportsDecoding(string $format, array $context = []): bool + { + if (!$this->encoder instanceof DecoderInterface) { + return false; + } + + return $this->encoder->supportsDecoding($format, $context); + } + + public function setSerializer(SerializerInterface $serializer): void + { + if (!$this->encoder instanceof SerializerAwareInterface) { + return; + } + + $this->encoder->setSerializer($serializer); + } + + public function needsNormalization(): bool + { + return !$this->encoder instanceof NormalizationAwareInterface; + } + + /** + * Proxies all method calls to the original encoder. + */ + public function __call(string $method, array $arguments): mixed + { + return $this->encoder->{$method}(...$arguments); + } +} diff --git a/Debug/TraceableNormalizer.php b/Debug/TraceableNormalizer.php new file mode 100644 index 000000000..1b143e295 --- /dev/null +++ b/Debug/TraceableNormalizer.php @@ -0,0 +1,129 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Collects some data about normalization. + * + * @author Mathias Arlaud + * + * @final + */ +class TraceableNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, NormalizerAwareInterface, DenormalizerAwareInterface +{ + public function __construct( + private NormalizerInterface|DenormalizerInterface $normalizer, + private SerializerDataCollector $dataCollector, + private readonly string $serializerName = 'default', + ) { + } + + public function getSupportedTypes(?string $format): array + { + return $this->normalizer->getSupportedTypes($format); + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + if (!$this->normalizer instanceof NormalizerInterface) { + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, NormalizerInterface::class)); + } + + $startTime = microtime(true); + $normalized = $this->normalizer->normalize($object, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectNormalization($traceId, $this->normalizer::class, $time, $this->serializerName); + } + + return $normalized; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + if (!$this->normalizer instanceof NormalizerInterface) { + return false; + } + + return $this->normalizer->supportsNormalization($data, $format, $context); + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + if (!$this->normalizer instanceof DenormalizerInterface) { + throw new \BadMethodCallException(\sprintf('The "%s()" method cannot be called as nested normalizer doesn\'t implements "%s".', __METHOD__, DenormalizerInterface::class)); + } + + $startTime = microtime(true); + $denormalized = $this->normalizer->denormalize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + if ($traceId = ($context[TraceableSerializer::DEBUG_TRACE_ID] ?? null)) { + $this->dataCollector->collectDenormalization($traceId, $this->normalizer::class, $time, $this->serializerName); + } + + return $denormalized; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + if (!$this->normalizer instanceof DenormalizerInterface) { + return false; + } + + return $this->normalizer->supportsDenormalization($data, $type, $format, $context); + } + + public function setSerializer(SerializerInterface $serializer): void + { + if (!$this->normalizer instanceof SerializerAwareInterface) { + return; + } + + $this->normalizer->setSerializer($serializer); + } + + public function setNormalizer(NormalizerInterface $normalizer): void + { + if (!$this->normalizer instanceof NormalizerAwareInterface) { + return; + } + + $this->normalizer->setNormalizer($normalizer); + } + + public function setDenormalizer(DenormalizerInterface $denormalizer): void + { + if (!$this->normalizer instanceof DenormalizerAwareInterface) { + return; + } + + $this->normalizer->setDenormalizer($denormalizer); + } + + /** + * Proxies all method calls to the original normalizer. + */ + public function __call(string $method, array $arguments): mixed + { + return $this->normalizer->{$method}(...$arguments); + } +} diff --git a/Debug/TraceableSerializer.php b/Debug/TraceableSerializer.php new file mode 100644 index 000000000..a05bf4bf8 --- /dev/null +++ b/Debug/TraceableSerializer.php @@ -0,0 +1,186 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Debug; + +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * Collects some data about serialization. + * + * @author Mathias Arlaud + * + * @final + */ +class TraceableSerializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, EncoderInterface, DecoderInterface +{ + public const DEBUG_TRACE_ID = 'debug_trace_id'; + + public function __construct( + private SerializerInterface&NormalizerInterface&DenormalizerInterface&EncoderInterface&DecoderInterface $serializer, + private SerializerDataCollector $dataCollector, + private readonly string $serializerName = 'default', + ) { + } + + public function serialize(mixed $data, string $format, array $context = []): string + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->serialize($data, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, SerializerInterface::class); + + $this->dataCollector->collectSerialize($traceId, $data, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->deserialize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, SerializerInterface::class); + + $this->dataCollector->collectDeserialize($traceId, $data, $type, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->normalize($object, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, NormalizerInterface::class); + + $this->dataCollector->collectNormalize($traceId, $object, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->denormalize($data, $type, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, DenormalizerInterface::class); + + $this->dataCollector->collectDenormalize($traceId, $data, $type, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function encode(mixed $data, string $format, array $context = []): string + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->encode($data, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, EncoderInterface::class); + + $this->dataCollector->collectEncode($traceId, $data, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function decode(string $data, string $format, array $context = []): mixed + { + $context[self::DEBUG_TRACE_ID] = $traceId = bin2hex(random_bytes(4)); + + $startTime = microtime(true); + $result = $this->serializer->decode($data, $format, $context); + $time = microtime(true) - $startTime; + + $caller = $this->getCaller(__FUNCTION__, DecoderInterface::class); + + $this->dataCollector->collectDecode($traceId, $data, $format, $context, $time, $caller, $this->serializerName); + + return $result; + } + + public function getSupportedTypes(?string $format): array + { + return $this->serializer->getSupportedTypes($format); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $this->serializer->supportsNormalization($data, $format, $context); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return $this->serializer->supportsDenormalization($data, $type, $format, $context); + } + + public function supportsEncoding(string $format, array $context = []): bool + { + return $this->serializer->supportsEncoding($format, $context); + } + + public function supportsDecoding(string $format, array $context = []): bool + { + return $this->serializer->supportsDecoding($format, $context); + } + + /** + * Proxies all method calls to the original serializer. + */ + public function __call(string $method, array $arguments): mixed + { + return $this->serializer->{$method}(...$arguments); + } + + private function getCaller(string $method, string $interface): array + { + $trace = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 8); + + $file = $trace[0]['file']; + $line = $trace[0]['line']; + + for ($i = 1; $i < 8; ++$i) { + if (isset($trace[$i]['class'], $trace[$i]['function']) + && $method === $trace[$i]['function'] + && is_a($trace[$i]['class'], $interface, true) + ) { + $file = $trace[$i]['file']; + $line = $trace[$i]['line']; + + break; + } + } + + $name = str_replace('\\', '/', $file); + $name = substr($name, strrpos($name, '/') + 1); + + return compact('name', 'file', 'line'); + } +} diff --git a/DependencyInjection/SerializerPass.php b/DependencyInjection/SerializerPass.php index e4f6912f7..179b7a3d9 100644 --- a/DependencyInjection/SerializerPass.php +++ b/DependencyInjection/SerializerPass.php @@ -16,6 +16,11 @@ use Symfony\Component\DependencyInjection\Compiler\PriorityTaggedServiceTrait; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Exception\RuntimeException; +use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\SerializerInterface; /** * Adds all services with the tags "serializer.encoder" and "serializer.normalizer" as @@ -28,50 +33,201 @@ class SerializerPass implements CompilerPassInterface { use PriorityTaggedServiceTrait; - private $serializerService; - private $normalizerTag; - private $encoderTag; + private const NAME_CONVERTER_METADATA_AWARE_ID = 'serializer.name_converter.metadata_aware'; - public function __construct(string $serializerService = 'serializer', string $normalizerTag = 'serializer.normalizer', string $encoderTag = 'serializer.encoder') + public function process(ContainerBuilder $container): void { - if (0 < \func_num_args()) { - trigger_deprecation('symfony/serializer', '5.3', 'Configuring "%s" is deprecated.', __CLASS__); + if (!$container->hasDefinition('serializer')) { + return; + } + + $namedSerializers = $container->hasParameter('.serializer.named_serializers') + ? $container->getParameter('.serializer.named_serializers') : []; + + $this->createNamedSerializerTags($container, 'serializer.normalizer', 'include_built_in_normalizers', $namedSerializers); + $this->createNamedSerializerTags($container, 'serializer.encoder', 'include_built_in_encoders', $namedSerializers); + + if (!$normalizers = $this->findAndSortTaggedServices('serializer.normalizer.default', $container)) { + throw new RuntimeException('You must tag at least one service as "serializer.normalizer" to use the "serializer" service.'); + } + + if (!$encoders = $this->findAndSortTaggedServices('serializer.encoder.default', $container)) { + throw new RuntimeException('You must tag at least one service as "serializer.encoder" to use the "serializer" service.'); } - $this->serializerService = $serializerService; - $this->normalizerTag = $normalizerTag; - $this->encoderTag = $encoderTag; + $defaultContext = []; + if ($container->hasParameter('serializer.default_context')) { + $defaultContext = $container->getParameter('serializer.default_context'); + $container->getParameterBag()->remove('serializer.default_context'); + $container->getDefinition('serializer')->setArgument('$defaultContext', $defaultContext); + } + + /** @var ?string $circularReferenceHandler */ + $circularReferenceHandler = $container->hasParameter('.serializer.circular_reference_handler') + ? $container->getParameter('.serializer.circular_reference_handler') : null; + + /** @var ?string $maxDepthHandler */ + $maxDepthHandler = $container->hasParameter('.serializer.max_depth_handler') + ? $container->getParameter('.serializer.max_depth_handler') : null; + + $this->bindDefaultContext($container, array_merge($normalizers, $encoders), $defaultContext, $circularReferenceHandler, $maxDepthHandler); + + $this->configureSerializer($container, 'serializer', $normalizers, $encoders, 'default'); + + if ($namedSerializers) { + $this->configureNamedSerializers($container, $circularReferenceHandler, $maxDepthHandler); + } } - public function process(ContainerBuilder $container) + private function createNamedSerializerTags(ContainerBuilder $container, string $tagName, string $configName, array $namedSerializers): void { - if (!$container->hasDefinition($this->serializerService)) { - return; + $serializerNames = array_keys($namedSerializers); + $withBuiltIn = array_filter($serializerNames, fn (string $name) => $namedSerializers[$name][$configName] ?? false); + + foreach ($container->findTaggedServiceIds($tagName) as $serviceId => $tags) { + $definition = $container->getDefinition($serviceId); + + foreach ($tags as $tag) { + $names = (array) ($tag['serializer'] ?? []); + + if (!$names) { + $names = ['default']; + } elseif (\in_array('*', $names, true)) { + $names = array_unique(['default', ...$serializerNames]); + } + + if ($tag['built_in'] ?? false) { + $names = array_unique(['default', ...$names, ...$withBuiltIn]); + } + + unset($tag['serializer'], $tag['built_in']); + + foreach ($names as $name) { + $definition->addTag($tagName.'.'.$name, $tag); + } + } } + } - if (!$normalizers = $this->findAndSortTaggedServices($this->normalizerTag, $container)) { - throw new RuntimeException(sprintf('You must tag at least one service as "%s" to use the "%s" service.', $this->normalizerTag, $this->serializerService)); + private function bindDefaultContext(ContainerBuilder $container, array $services, array $defaultContext, ?string $circularReferenceHandler, ?string $maxDepthHandler): void + { + foreach ($services as $id) { + $definition = $container->getDefinition((string) $id); + + $context = $defaultContext; + if (is_a($definition->getClass(), ObjectNormalizer::class, true)) { + if (null !== $circularReferenceHandler) { + $context += ['circular_reference_handler' => new Reference($circularReferenceHandler)]; + } + if (null !== $maxDepthHandler) { + $context += ['max_depth_handler' => new Reference($maxDepthHandler)]; + } + } + + $definition->setBindings(['array $defaultContext' => new BoundArgument($context, false)] + $definition->getBindings()); } + } - $serializerDefinition = $container->getDefinition($this->serializerService); - $serializerDefinition->replaceArgument(0, $normalizers); + private function configureSerializer(ContainerBuilder $container, string $id, array $normalizers, array $encoders, string $serializerName): void + { + if ($container->getParameter('kernel.debug') && $container->hasDefinition('serializer.data_collector')) { + foreach ($normalizers as $i => $normalizer) { + $normalizers[$i] = $container->register('.debug.serializer.normalizer.'.$normalizer, TraceableNormalizer::class) + ->setArguments([$normalizer, new Reference('serializer.data_collector'), $serializerName]); + } - if (!$encoders = $this->findAndSortTaggedServices($this->encoderTag, $container)) { - throw new RuntimeException(sprintf('You must tag at least one service as "%s" to use the "%s" service.', $this->encoderTag, $this->serializerService)); + foreach ($encoders as $i => $encoder) { + $encoders[$i] = $container->register('.debug.serializer.encoder.'.$encoder, TraceableEncoder::class) + ->setArguments([$encoder, new Reference('serializer.data_collector'), $serializerName]); + } } + $serializerDefinition = $container->getDefinition($id); + $serializerDefinition->replaceArgument(0, $normalizers); $serializerDefinition->replaceArgument(1, $encoders); + } - if (!$container->hasParameter('serializer.default_context')) { - return; + private function configureNamedSerializers(ContainerBuilder $container, ?string $circularReferenceHandler, ?string $maxDepthHandler): void + { + $defaultSerializerNameConverter = $container->hasParameter('.serializer.name_converter') + ? $container->getParameter('.serializer.name_converter') : null; + + foreach ($container->getParameter('.serializer.named_serializers') as $serializerName => $config) { + $config += ['default_context' => [], 'name_converter' => null]; + $serializerId = 'serializer.'.$serializerName; + + if (!$normalizers = $this->findAndSortTaggedServices('serializer.normalizer.'.$serializerName, $container)) { + throw new RuntimeException(\sprintf('The named serializer "%1$s" requires at least one registered normalizer. Tag the normalizers as "serializer.normalizer" with the "serializer" attribute set to "%1$s".', $serializerName)); + } + + if (!$encoders = $this->findAndSortTaggedServices('serializer.encoder.'.$serializerName, $container)) { + throw new RuntimeException(\sprintf('The named serializer "%1$s" requires at least one registered encoder. Tag the encoders as "serializer.encoder" with the "serializer" attribute set to "%1$s".', $serializerName)); + } + + $config['name_converter'] = $defaultSerializerNameConverter !== $config['name_converter'] + ? $this->buildChildNameConverterDefinition($container, $config['name_converter']) + : self::NAME_CONVERTER_METADATA_AWARE_ID; + + $normalizers = $this->buildChildDefinitions($container, $serializerName, $normalizers, $config); + $encoders = $this->buildChildDefinitions($container, $serializerName, $encoders, $config); + + $this->bindDefaultContext($container, array_merge($normalizers, $encoders), $config['default_context'], $circularReferenceHandler, $maxDepthHandler); + + $container->registerChild($serializerId, 'serializer')->setArgument('$defaultContext', $config['default_context']); + $container->registerAliasForArgument($serializerId, SerializerInterface::class, $serializerName.'.serializer'); + + $this->configureSerializer($container, $serializerId, $normalizers, $encoders, $serializerName); + + if ($container->getParameter('kernel.debug') && $container->hasDefinition('debug.serializer')) { + $container->registerChild($debugId = 'debug.'.$serializerId, 'debug.serializer') + ->setDecoratedService($serializerId) + ->replaceArgument(0, new Reference($debugId.'.inner')) + ->replaceArgument(2, $serializerName); + } + } + } + + private function buildChildNameConverterDefinition(ContainerBuilder $container, ?string $nameConverter): ?string + { + $childId = self::NAME_CONVERTER_METADATA_AWARE_ID.'.'.ContainerBuilder::hash($nameConverter); + + if (!$container->hasDefinition($childId)) { + $childDefinition = $container->registerChild($childId, self::NAME_CONVERTER_METADATA_AWARE_ID.'.abstract'); + if (null !== $nameConverter) { + $childDefinition->addArgument(new Reference($nameConverter)); + } + } + + return $childId; + } + + private function buildChildDefinitions(ContainerBuilder $container, string $serializerName, array $services, array $config): array + { + foreach ($services as &$id) { + $childId = $id.'.'.$serializerName; + + $definition = $container->registerChild($childId, (string) $id) + ->setClass($container->getDefinition((string) $id)->getClass()) + ; + + if (null !== $nameConverterIndex = $this->findNameConverterIndex($container, (string) $id)) { + $definition->replaceArgument($nameConverterIndex, new Reference($config['name_converter'])); + } + + $id = new Reference($childId); } - $defaultContext = $container->getParameter('serializer.default_context'); - foreach (array_keys(array_merge($container->findTaggedServiceIds($this->normalizerTag), $container->findTaggedServiceIds($this->encoderTag))) as $service) { - $definition = $container->getDefinition($service); - $definition->setBindings(['array $defaultContext' => new BoundArgument($defaultContext, false)] + $definition->getBindings()); + return $services; + } + + private function findNameConverterIndex(ContainerBuilder $container, string $id): int|string|null + { + foreach ($container->getDefinition($id)->getArguments() as $index => $argument) { + if ($argument instanceof Reference && self::NAME_CONVERTER_METADATA_AWARE_ID === (string) $argument) { + return $index; + } } - $container->getParameterBag()->remove('serializer.default_context'); + return null; } } diff --git a/Encoder/ChainDecoder.php b/Encoder/ChainDecoder.php index bba46d846..8df5cd8b8 100644 --- a/Encoder/ChainDecoder.php +++ b/Encoder/ChainDecoder.php @@ -24,30 +24,29 @@ */ class ChainDecoder implements ContextAwareDecoderInterface { - protected $decoders = []; - protected $decoderByFormat = []; - - public function __construct(array $decoders = []) - { - $this->decoders = $decoders; - } + /** + * @var array + */ + private array $decoderByFormat = []; /** - * {@inheritdoc} + * @param array $decoders */ - final public function decode(string $data, string $format, array $context = []) + public function __construct( + private readonly array $decoders = [], + ) { + } + + final public function decode(string $data, string $format, array $context = []): mixed { return $this->getDecoder($format, $context)->decode($data, $format, $context); } - /** - * {@inheritdoc} - */ public function supportsDecoding(string $format, array $context = []): bool { try { $this->getDecoder($format, $context); - } catch (RuntimeException $e) { + } catch (RuntimeException) { return false; } @@ -79,6 +78,6 @@ private function getDecoder(string $format, array $context): DecoderInterface } } - throw new RuntimeException(sprintf('No decoder found for format "%s".', $format)); + throw new RuntimeException(\sprintf('No decoder found for format "%s".', $format)); } } diff --git a/Encoder/ChainEncoder.php b/Encoder/ChainEncoder.php index a3b33c5b7..3621471b4 100644 --- a/Encoder/ChainEncoder.php +++ b/Encoder/ChainEncoder.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Encoder; +use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Exception\RuntimeException; /** @@ -24,30 +25,29 @@ */ class ChainEncoder implements ContextAwareEncoderInterface { - protected $encoders = []; - protected $encoderByFormat = []; - - public function __construct(array $encoders = []) - { - $this->encoders = $encoders; - } + /** + * @var array + */ + private array $encoderByFormat = []; /** - * {@inheritdoc} + * @param array $encoders */ - final public function encode($data, string $format, array $context = []): string + public function __construct( + private readonly array $encoders = [], + ) { + } + + final public function encode(mixed $data, string $format, array $context = []): string { return $this->getEncoder($format, $context)->encode($data, $format, $context); } - /** - * {@inheritdoc} - */ public function supportsEncoding(string $format, array $context = []): bool { try { $this->getEncoder($format, $context); - } catch (RuntimeException $e) { + } catch (RuntimeException) { return false; } @@ -61,6 +61,10 @@ public function needsNormalization(string $format, array $context = []): bool { $encoder = $this->getEncoder($format, $context); + if ($encoder instanceof TraceableEncoder) { + return $encoder->needsNormalization(); + } + if (!$encoder instanceof NormalizationAwareInterface) { return true; } @@ -97,6 +101,6 @@ private function getEncoder(string $format, array $context): EncoderInterface } } - throw new RuntimeException(sprintf('No encoder found for format "%s".', $format)); + throw new RuntimeException(\sprintf('No encoder found for format "%s".', $format)); } } diff --git a/Encoder/ContextAwareDecoderInterface.php b/Encoder/ContextAwareDecoderInterface.php index ce665f2eb..4b63dbc03 100644 --- a/Encoder/ContextAwareDecoderInterface.php +++ b/Encoder/ContextAwareDecoderInterface.php @@ -19,9 +19,7 @@ interface ContextAwareDecoderInterface extends DecoderInterface { /** - * {@inheritdoc} - * * @param array $context options that decoders have access to */ - public function supportsDecoding(string $format, array $context = []); + public function supportsDecoding(string $format, array $context = []): bool; } diff --git a/Encoder/ContextAwareEncoderInterface.php b/Encoder/ContextAwareEncoderInterface.php index 17789e88e..027cf8393 100644 --- a/Encoder/ContextAwareEncoderInterface.php +++ b/Encoder/ContextAwareEncoderInterface.php @@ -19,9 +19,7 @@ interface ContextAwareEncoderInterface extends EncoderInterface { /** - * {@inheritdoc} - * * @param array $context options that encoders have access to */ - public function supportsEncoding(string $format, array $context = []); + public function supportsEncoding(string $format, array $context = []): bool; } diff --git a/Encoder/CsvEncoder.php b/Encoder/CsvEncoder.php index a2d4df909..3902b5613 100644 --- a/Encoder/CsvEncoder.php +++ b/Encoder/CsvEncoder.php @@ -25,6 +25,9 @@ class CsvEncoder implements EncoderInterface, DecoderInterface public const FORMAT = 'csv'; public const DELIMITER_KEY = 'csv_delimiter'; public const ENCLOSURE_KEY = 'csv_enclosure'; + /** + * @deprecated since Symfony 7.2, to be removed in 8.0 + */ public const ESCAPE_CHAR_KEY = 'csv_escape_char'; public const KEY_SEPARATOR_KEY = 'csv_key_separator'; public const HEADERS_KEY = 'csv_headers'; @@ -38,7 +41,7 @@ class CsvEncoder implements EncoderInterface, DecoderInterface private const FORMULAS_START_CHARACTERS = ['=', '-', '+', '@', "\t", "\r"]; - private $defaultContext = [ + private array $defaultContext = [ self::DELIMITER_KEY => ',', self::ENCLOSURE_KEY => '"', self::ESCAPE_CHAR_KEY => '', @@ -53,23 +56,20 @@ class CsvEncoder implements EncoderInterface, DecoderInterface public function __construct(array $defaultContext = []) { - $this->defaultContext = array_merge($this->defaultContext, $defaultContext); - - if (\PHP_VERSION_ID < 70400 && '' === $this->defaultContext[self::ESCAPE_CHAR_KEY]) { - $this->defaultContext[self::ESCAPE_CHAR_KEY] = '\\'; + if (\array_key_exists(self::ESCAPE_CHAR_KEY, $defaultContext)) { + trigger_deprecation('symfony/serializer', '7.2', 'Setting the "csv_escape_char" option is deprecated. The option will be removed in 8.0.'); } + + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * {@inheritdoc} - */ - public function encode($data, string $format, array $context = []) + public function encode(mixed $data, string $format, array $context = []): string { $handle = fopen('php://temp,', 'w+'); if (!is_iterable($data)) { $data = [[$data]]; - } elseif (empty($data)) { + } elseif (!$data) { $data = [[]]; } else { // Sequential arrays of arrays are considered as collections @@ -126,18 +126,12 @@ public function encode($data, string $format, array $context = []) return $value; } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format) + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } - /** - * {@inheritdoc} - */ - public function decode(string $data, string $format, array $context = []) + public function decode(string $data, string $format, array $context = []): mixed { $handle = fopen('php://temp', 'r+'); fwrite($handle, $data); @@ -210,7 +204,7 @@ public function decode(string $data, string $format, array $context = []) return $result; } - if (empty($result) || isset($result[1])) { + if (!$result || isset($result[1])) { return $result; } @@ -218,10 +212,7 @@ public function decode(string $data, string $format, array $context = []) return $result[0]; } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format) + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } @@ -229,7 +220,7 @@ public function supportsDecoding(string $format) /** * Flattens an array and generates keys including the path. */ - private function flatten(iterable $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false) + private function flatten(iterable $array, array &$result, string $keySeparator, string $parentKey = '', bool $escapeFormulas = false): void { foreach ($array as $key => $value) { if (is_iterable($value)) { @@ -257,7 +248,7 @@ private function getCsvOptions(array $context): array $asCollection = $context[self::AS_COLLECTION_KEY] ?? $this->defaultContext[self::AS_COLLECTION_KEY]; if (!\is_array($headers)) { - throw new InvalidArgumentException(sprintf('The "%s" context variable must be an array or null, given "%s".', self::HEADERS_KEY, get_debug_type($headers))); + throw new InvalidArgumentException(\sprintf('The "%s" context variable must be an array or null, given "%s".', self::HEADERS_KEY, get_debug_type($headers))); } return [$delimiter, $enclosure, $escapeChar, $keySeparator, $headers, $escapeFormulas, $outputBom, $asCollection]; diff --git a/Encoder/DecoderInterface.php b/Encoder/DecoderInterface.php index 84a84ad1f..09ec9434d 100644 --- a/Encoder/DecoderInterface.php +++ b/Encoder/DecoderInterface.php @@ -30,18 +30,14 @@ interface DecoderInterface * are encouraged to document which formats they support in a non-inherited * phpdoc comment. * - * @return mixed - * * @throws UnexpectedValueException */ - public function decode(string $data, string $format, array $context = []); + public function decode(string $data, string $format, array $context = []): mixed; /** * Checks whether the deserializer can decode from given format. * * @param string $format Format name - * - * @return bool */ - public function supportsDecoding(string $format); + public function supportsDecoding(string $format): bool; } diff --git a/Encoder/EncoderInterface.php b/Encoder/EncoderInterface.php index 0ce4636be..e0f303b1e 100644 --- a/Encoder/EncoderInterface.php +++ b/Encoder/EncoderInterface.php @@ -25,18 +25,14 @@ interface EncoderInterface * @param string $format Format name * @param array $context Options that normalizers/encoders have access to * - * @return string - * * @throws UnexpectedValueException */ - public function encode($data, string $format, array $context = []); + public function encode(mixed $data, string $format, array $context = []): string; /** * Checks whether the serializer can encode to given format. * * @param string $format Format name - * - * @return bool */ - public function supportsEncoding(string $format); + public function supportsEncoding(string $format): bool; } diff --git a/Encoder/JsonDecode.php b/Encoder/JsonDecode.php index 7ba4bffde..b50538175 100644 --- a/Encoder/JsonDecode.php +++ b/Encoder/JsonDecode.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Serializer\Encoder; +use Seld\JsonLint\JsonParser; use Symfony\Component\Serializer\Exception\NotEncodableValueException; +use Symfony\Component\Serializer\Exception\UnsupportedException; /** * Decodes JSON data. @@ -20,13 +22,16 @@ */ class JsonDecode implements DecoderInterface { - protected $serializer; - /** * True to return the result as an associative array, false for a nested stdClass hierarchy. */ public const ASSOCIATIVE = 'json_decode_associative'; + /** + * True to enable seld/jsonlint as a source for more specific error messages when json_decode fails. + */ + public const DETAILED_ERROR_MESSAGES = 'json_decode_detailed_errors'; + public const OPTIONS = 'json_decode_options'; /** @@ -34,8 +39,9 @@ class JsonDecode implements DecoderInterface */ public const RECURSION_DEPTH = 'json_decode_recursion_depth'; - private $defaultContext = [ + private array $defaultContext = [ self::ASSOCIATIVE => false, + self::DETAILED_ERROR_MESSAGES => false, self::OPTIONS => 0, self::RECURSION_DEPTH => 512, ]; @@ -66,13 +72,15 @@ public function __construct(array $defaultContext = []) * json_decode_options: integer * Specifies additional options as per documentation for json_decode * - * @return mixed + * json_decode_detailed_errors: bool + * If true, enables seld/jsonlint as a source for more specific error messages when json_decode fails. + * If false or not specified, this method will use default error messages from PHP's json_decode * * @throws NotEncodableValueException * * @see https://php.net/json_decode */ - public function decode(string $data, string $format, array $context = []) + public function decode(string $data, string $format, array $context = []): mixed { $associative = $context[self::ASSOCIATIVE] ?? $this->defaultContext[self::ASSOCIATIVE]; $recursionDepth = $context[self::RECURSION_DEPTH] ?? $this->defaultContext[self::RECURSION_DEPTH]; @@ -84,21 +92,27 @@ public function decode(string $data, string $format, array $context = []) throw new NotEncodableValueException($e->getMessage(), 0, $e); } - if (\PHP_VERSION_ID >= 70300 && (\JSON_THROW_ON_ERROR & $options)) { + if (\JSON_THROW_ON_ERROR & $options) { return $decodedData; } - if (\JSON_ERROR_NONE !== json_last_error()) { - throw new NotEncodableValueException(json_last_error_msg()); + if (\JSON_ERROR_NONE === json_last_error()) { + return $decodedData; } + $errorMessage = json_last_error_msg(); - return $decodedData; + if (!($context[self::DETAILED_ERROR_MESSAGES] ?? $this->defaultContext[self::DETAILED_ERROR_MESSAGES])) { + throw new NotEncodableValueException($errorMessage); + } + + if (!class_exists(JsonParser::class)) { + throw new UnsupportedException(\sprintf('Enabling "%s" serializer option requires seld/jsonlint. Try running "composer require seld/jsonlint".', self::DETAILED_ERROR_MESSAGES)); + } + + throw new NotEncodableValueException((new JsonParser())->lint($data)?->getMessage() ?: $errorMessage); } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format) + public function supportsDecoding(string $format): bool { return JsonEncoder::FORMAT === $format; } diff --git a/Encoder/JsonEncode.php b/Encoder/JsonEncode.php index 88a837c60..3e0ddced3 100644 --- a/Encoder/JsonEncode.php +++ b/Encoder/JsonEncode.php @@ -20,10 +20,13 @@ */ class JsonEncode implements EncoderInterface { + /** + * Configure the JSON flags bitmask. + */ public const OPTIONS = 'json_encode_options'; - private $defaultContext = [ - self::OPTIONS => 0, + private array $defaultContext = [ + self::OPTIONS => \JSON_PRESERVE_ZERO_FRACTION, ]; public function __construct(array $defaultContext = []) @@ -31,12 +34,7 @@ public function __construct(array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * Encodes PHP data to a JSON string. - * - * {@inheritdoc} - */ - public function encode($data, string $format, array $context = []) + public function encode(mixed $data, string $format, array $context = []): string { $options = $context[self::OPTIONS] ?? $this->defaultContext[self::OPTIONS]; @@ -46,7 +44,7 @@ public function encode($data, string $format, array $context = []) throw new NotEncodableValueException($e->getMessage(), 0, $e); } - if (\PHP_VERSION_ID >= 70300 && (\JSON_THROW_ON_ERROR & $options)) { + if (\JSON_THROW_ON_ERROR & $options) { return $encodedJson; } @@ -57,10 +55,7 @@ public function encode($data, string $format, array $context = []) return $encodedJson; } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format) + public function supportsEncoding(string $format): bool { return JsonEncoder::FORMAT === $format; } diff --git a/Encoder/JsonEncoder.php b/Encoder/JsonEncoder.php index 288c44912..5727e7be0 100644 --- a/Encoder/JsonEncoder.php +++ b/Encoder/JsonEncoder.php @@ -20,10 +20,10 @@ class JsonEncoder implements EncoderInterface, DecoderInterface { public const FORMAT = 'json'; - protected $encodingImpl; - protected $decodingImpl; + protected JsonEncode $encodingImpl; + protected JsonDecode $decodingImpl; - private $defaultContext = [ + private array $defaultContext = [ JsonDecode::ASSOCIATIVE => true, ]; @@ -34,38 +34,26 @@ public function __construct(?JsonEncode $encodingImpl = null, ?JsonDecode $decod $this->decodingImpl = $decodingImpl ?? new JsonDecode($this->defaultContext); } - /** - * {@inheritdoc} - */ - public function encode($data, string $format, array $context = []) + public function encode(mixed $data, string $format, array $context = []): string { $context = array_merge($this->defaultContext, $context); return $this->encodingImpl->encode($data, self::FORMAT, $context); } - /** - * {@inheritdoc} - */ - public function decode(string $data, string $format, array $context = []) + public function decode(string $data, string $format, array $context = []): mixed { $context = array_merge($this->defaultContext, $context); return $this->decodingImpl->decode($data, self::FORMAT, $context); } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format) + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format) + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } diff --git a/Encoder/XmlEncoder.php b/Encoder/XmlEncoder.php index 86ab8a70b..e1a816380 100644 --- a/Encoder/XmlEncoder.php +++ b/Encoder/XmlEncoder.php @@ -44,23 +44,34 @@ class XmlEncoder implements EncoderInterface, DecoderInterface, NormalizationAwa public const FORMAT_OUTPUT = 'xml_format_output'; /** - * A bit field of LIBXML_* constants. + * A bit field of LIBXML_* constants for loading XML documents. */ public const LOAD_OPTIONS = 'load_options'; + + /** + * A bit field of LIBXML_* constants for saving XML documents. + */ + public const SAVE_OPTIONS = 'save_options'; + public const REMOVE_EMPTY_TAGS = 'remove_empty_tags'; public const ROOT_NODE_NAME = 'xml_root_node_name'; public const STANDALONE = 'xml_standalone'; public const TYPE_CAST_ATTRIBUTES = 'xml_type_cast_attributes'; public const VERSION = 'xml_version'; + public const CDATA_WRAPPING = 'cdata_wrapping'; + public const CDATA_WRAPPING_PATTERN = 'cdata_wrapping_pattern'; - private $defaultContext = [ + private array $defaultContext = [ self::AS_COLLECTION => false, self::DECODER_IGNORED_NODE_TYPES => [\XML_PI_NODE, \XML_COMMENT_NODE], self::ENCODER_IGNORED_NODE_TYPES => [], self::LOAD_OPTIONS => \LIBXML_NONET | \LIBXML_NOBLANKS, + self::SAVE_OPTIONS => 0, self::REMOVE_EMPTY_TAGS => false, self::ROOT_NODE_NAME => 'response', self::TYPE_CAST_ATTRIBUTES => true, + self::CDATA_WRAPPING => true, + self::CDATA_WRAPPING_PATTERN => '/[<>&]/', ]; public function __construct(array $defaultContext = []) @@ -68,15 +79,12 @@ public function __construct(array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * {@inheritdoc} - */ - public function encode($data, string $format, array $context = []) + public function encode(mixed $data, string $format, array $context = []): string { $encoderIgnoredNodeTypes = $context[self::ENCODER_IGNORED_NODE_TYPES] ?? $this->defaultContext[self::ENCODER_IGNORED_NODE_TYPES]; $ignorePiNode = \in_array(\XML_PI_NODE, $encoderIgnoredNodeTypes, true); if ($data instanceof \DOMDocument) { - return $data->saveXML($ignorePiNode ? $data->documentElement : null); + return $this->saveXml($data, $ignorePiNode ? $data->documentElement : null); } $xmlRootNodeName = $context[self::ROOT_NODE_NAME] ?? $this->defaultContext[self::ROOT_NODE_NAME]; @@ -91,31 +99,22 @@ public function encode($data, string $format, array $context = []) $this->appendNode($dom, $data, $format, $context, $xmlRootNodeName); } - return $dom->saveXML($ignorePiNode ? $dom->documentElement : null); + return $this->saveXml($dom, $ignorePiNode ? $dom->documentElement : null, $context[self::SAVE_OPTIONS] ?? $this->defaultContext[self::SAVE_OPTIONS]); } - /** - * {@inheritdoc} - */ - public function decode(string $data, string $format, array $context = []) + public function decode(string $data, string $format, array $context = []): mixed { if ('' === trim($data)) { throw new NotEncodableValueException('Invalid XML data, it cannot be empty.'); } $internalErrors = libxml_use_internal_errors(true); - if (\LIBXML_VERSION < 20900) { - $disableEntities = libxml_disable_entity_loader(true); - } libxml_clear_errors(); $dom = new \DOMDocument(); $dom->loadXML($data, $context[self::LOAD_OPTIONS] ?? $this->defaultContext[self::LOAD_OPTIONS]); libxml_use_internal_errors($internalErrors); - if (\LIBXML_VERSION < 20900) { - libxml_disable_entity_loader($disableEntities); - } if ($error = libxml_get_last_error()) { libxml_clear_errors(); @@ -153,23 +152,16 @@ public function decode(string $data, string $format, array $context = []) } $data = array_merge($this->parseXmlAttributes($rootNode, $context), ['#' => $rootNode->nodeValue]); - $data = $this->addXmlNamespaces($data, $rootNode, $dom); - return $data; + return $this->addXmlNamespaces($data, $rootNode, $dom); } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format) + public function supportsEncoding(string $format): bool { return self::FORMAT === $format; } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format) + public function supportsDecoding(string $format): bool { return self::FORMAT === $format; } @@ -203,18 +195,11 @@ final protected function appendCData(\DOMNode $node, string $val): bool return true; } - /** - * @param \DOMDocumentFragment $fragment - */ - final protected function appendDocumentFragment(\DOMNode $node, $fragment): bool + final protected function appendDocumentFragment(\DOMNode $node, \DOMDocumentFragment $fragment): bool { - if ($fragment instanceof \DOMDocumentFragment) { - $node->appendChild($fragment); + $node->appendChild($fragment); - return true; - } - - return false; + return true; } final protected function appendComment(\DOMNode $node, string $data): bool @@ -229,17 +214,15 @@ final protected function appendComment(\DOMNode $node, string $data): bool */ final protected function isElementNameValid(string $name): bool { - return $name && - !str_contains($name, ' ') && - preg_match('#^[\pL_][\pL0-9._:-]*$#ui', $name); + return $name + && !str_contains($name, ' ') + && preg_match('#^[\pL_][\pL0-9._:-]*$#ui', $name); } /** * Parse the input DOMNode into an array or a string. - * - * @return array|string */ - private function parseXml(\DOMNode $node, array $context = []) + private function parseXml(\DOMNode $node, array $context = []): array|string { $data = $this->parseXmlAttributes($node, $context); @@ -301,10 +284,8 @@ private function parseXmlAttributes(\DOMNode $node, array $context = []): array /** * Parse the input DOMNode value (content and children) into an array or a string. - * - * @return array|string */ - private function parseXmlValue(\DOMNode $node, array $context = []) + private function parseXmlValue(\DOMNode $node, array $context = []): array|string { if (!$node->hasChildNodes()) { return $node->nodeValue; @@ -356,11 +337,9 @@ private function addXmlNamespaces(array $data, \DOMNode $node, \DOMDocument $doc /** * Parse the data and convert it to DOMElements. * - * @param array|object $data - * * @throws NotEncodableValueException */ - private function buildXml(\DOMNode $parentNode, $data, string $format, array $context, ?string $xmlRootNodeName = null): bool + private function buildXml(\DOMNode $parentNode, mixed $data, string $format, array $context, ?string $xmlRootNodeName = null): bool { $append = true; $removeEmptyTags = $context[self::REMOVE_EMPTY_TAGS] ?? $this->defaultContext[self::REMOVE_EMPTY_TAGS] ?? false; @@ -409,7 +388,7 @@ private function buildXml(\DOMNode $parentNode, $data, string $format, array $co if (\is_object($data)) { if (null === $this->serializer) { - throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); + throw new BadMethodCallException(\sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); } $data = $this->serializer->normalize($data, $format, $context); @@ -428,15 +407,13 @@ private function buildXml(\DOMNode $parentNode, $data, string $format, array $co return $this->appendNode($parentNode, $data, $format, $context, 'data'); } - throw new NotEncodableValueException('An unexpected value could not be serialized: '.(!\is_resource($data) ? var_export($data, true) : sprintf('%s resource', get_resource_type($data)))); + throw new NotEncodableValueException('An unexpected value could not be serialized: '.(!\is_resource($data) ? var_export($data, true) : \sprintf('%s resource', get_resource_type($data)))); } /** * Selects the type of node to create and appends it to the parent. - * - * @param array|object $data */ - private function appendNode(\DOMNode $parentNode, $data, string $format, array $context, string $nodeName, ?string $key = null): bool + private function appendNode(\DOMNode $parentNode, mixed $data, string $format, array $context, string $nodeName, ?string $key = null): bool { $dom = $parentNode instanceof \DOMDocument ? $parentNode : $parentNode->ownerDocument; $node = $dom->createElement($nodeName); @@ -455,9 +432,9 @@ private function appendNode(\DOMNode $parentNode, $data, string $format, array $ /** * Checks if a value contains any characters which would require CDATA wrapping. */ - private function needsCdataWrapping(string $val): bool + private function needsCdataWrapping(string $val, array $context): bool { - return preg_match('/[<>&]/', $val); + return ($context[self::CDATA_WRAPPING] ?? $this->defaultContext[self::CDATA_WRAPPING]) && preg_match($context[self::CDATA_WRAPPING_PATTERN] ?? $this->defaultContext[self::CDATA_WRAPPING_PATTERN], $val); } /** @@ -465,7 +442,7 @@ private function needsCdataWrapping(string $val): bool * * @throws NotEncodableValueException */ - private function selectNodeType(\DOMNode $node, $val, string $format, array $context): bool + private function selectNodeType(\DOMNode $node, mixed $val, string $format, array $context): bool { if (\is_array($val)) { return $this->buildXml($node, $val, $format, $context); @@ -479,13 +456,13 @@ private function selectNodeType(\DOMNode $node, $val, string $format, array $con $node->appendChild($child); } elseif (\is_object($val)) { if (null === $this->serializer) { - throw new BadMethodCallException(sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); + throw new BadMethodCallException(\sprintf('The serializer needs to be set to allow "%s()" to be used with object data.', __METHOD__)); } return $this->selectNodeType($node, $this->serializer->normalize($val, $format, $context), $format, $context); } elseif (is_numeric($val)) { return $this->appendText($node, (string) $val); - } elseif (\is_string($val) && $this->needsCdataWrapping($val)) { + } elseif (\is_string($val) && $this->needsCdataWrapping($val, $context)) { return $this->appendCData($node, $val); } elseif (\is_string($val)) { return $this->appendText($node, $val); @@ -522,4 +499,23 @@ private function createDomDocument(array $context): \DOMDocument return $document; } + + /** + * @throws NotEncodableValueException + */ + private function saveXml(\DOMDocument $document, ?\DOMNode $node = null, ?int $options = null): string + { + $prevErrorHandler = set_error_handler(static function ($type, $message, $file, $line, $context = []) use (&$prevErrorHandler) { + if (\E_ERROR === $type || \E_WARNING === $type) { + throw new NotEncodableValueException($message); + } + + return $prevErrorHandler ? $prevErrorHandler($type, $message, $file, $line, $context) : false; + }); + try { + return $document->saveXML($node, $options); + } finally { + restore_error_handler(); + } + } } diff --git a/Encoder/YamlEncoder.php b/Encoder/YamlEncoder.php index 7291c6278..1013129db 100644 --- a/Encoder/YamlEncoder.php +++ b/Encoder/YamlEncoder.php @@ -11,8 +11,10 @@ namespace Symfony\Component\Serializer\Encoder; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Yaml\Dumper; +use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Parser; use Symfony\Component\Yaml\Yaml; @@ -28,13 +30,23 @@ class YamlEncoder implements EncoderInterface, DecoderInterface public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects'; + /** + * Override the amount of spaces to use for indentation of nested nodes. + * + * This option only works in the constructor, not in calls to `encode`. + */ + public const YAML_INDENTATION = 'yaml_indentation'; + public const YAML_INLINE = 'yaml_inline'; + /** + * Initial indentation for root element. + */ public const YAML_INDENT = 'yaml_indent'; public const YAML_FLAGS = 'yaml_flags'; - private $dumper; - private $parser; - private $defaultContext = [ + private readonly Dumper $dumper; + private readonly Parser $parser; + private array $defaultContext = [ self::YAML_INLINE => 0, self::YAML_INDENT => 0, self::YAML_FLAGS => 0, @@ -43,50 +55,46 @@ class YamlEncoder implements EncoderInterface, DecoderInterface public function __construct(?Dumper $dumper = null, ?Parser $parser = null, array $defaultContext = []) { if (!class_exists(Dumper::class)) { - throw new RuntimeException('The YamlEncoder class requires the "Yaml" component. Install "symfony/yaml" to use it.'); + throw new RuntimeException('The YamlEncoder class requires the "Yaml" component. Try running "composer require symfony/yaml".'); } - $this->dumper = $dumper ?? new Dumper(); + if (!$dumper) { + $dumper = \array_key_exists(self::YAML_INDENTATION, $defaultContext) ? new Dumper($defaultContext[self::YAML_INDENTATION]) : new Dumper(); + } + $this->dumper = $dumper; $this->parser = $parser ?? new Parser(); + unset($defaultContext[self::YAML_INDENTATION]); $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } - /** - * {@inheritdoc} - */ - public function encode($data, string $format, array $context = []) + public function encode(mixed $data, string $format, array $context = []): string { $context = array_merge($this->defaultContext, $context); - if (isset($context[self::PRESERVE_EMPTY_OBJECTS])) { + if ($context[self::PRESERVE_EMPTY_OBJECTS] ?? false) { $context[self::YAML_FLAGS] |= Yaml::DUMP_OBJECT_AS_MAP; } return $this->dumper->dump($data, $context[self::YAML_INLINE], $context[self::YAML_INDENT], $context[self::YAML_FLAGS]); } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format) + public function supportsEncoding(string $format): bool { return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format; } - /** - * {@inheritdoc} - */ - public function decode(string $data, string $format, array $context = []) + public function decode(string $data, string $format, array $context = []): mixed { $context = array_merge($this->defaultContext, $context); - return $this->parser->parse($data, $context[self::YAML_FLAGS]); + try { + return $this->parser->parse($data, $context[self::YAML_FLAGS]); + } catch (ParseException $e) { + throw new NotEncodableValueException($e->getMessage(), $e->getCode(), $e); + } } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format) + public function supportsDecoding(string $format): bool { return self::FORMAT === $format || self::ALTERNATIVE_FORMAT === $format; } diff --git a/Exception/ExtraAttributesException.php b/Exception/ExtraAttributesException.php index cee352d49..639808b10 100644 --- a/Exception/ExtraAttributesException.php +++ b/Exception/ExtraAttributesException.php @@ -18,23 +18,19 @@ */ class ExtraAttributesException extends RuntimeException { - private $extraAttributes; - - public function __construct(array $extraAttributes, ?\Throwable $previous = null) - { - $msg = sprintf('Extra attributes are not allowed ("%s" %s unknown).', implode('", "', $extraAttributes), \count($extraAttributes) > 1 ? 'are' : 'is'); - - $this->extraAttributes = $extraAttributes; + public function __construct( + private readonly array $extraAttributes, + ?\Throwable $previous = null, + ) { + $msg = \sprintf('Extra attributes are not allowed ("%s" %s unknown).', implode('", "', $extraAttributes), \count($extraAttributes) > 1 ? 'are' : 'is'); parent::__construct($msg, 0, $previous); } /** * Get the extra attributes that are not allowed. - * - * @return array */ - public function getExtraAttributes() + public function getExtraAttributes(): array { return $this->extraAttributes; } diff --git a/Exception/MissingConstructorArgumentsException.php b/Exception/MissingConstructorArgumentsException.php index 2f6ca569c..ad3f5c82b 100644 --- a/Exception/MissingConstructorArgumentsException.php +++ b/Exception/MissingConstructorArgumentsException.php @@ -17,14 +17,16 @@ class MissingConstructorArgumentsException extends RuntimeException { /** - * @var string[] + * @param string[] $missingArguments + * @param class-string|null $class */ - private $missingArguments; - - public function __construct(string $message, int $code = 0, ?\Throwable $previous = null, array $missingArguments = []) - { - $this->missingArguments = $missingArguments; - + public function __construct( + string $message, + int $code = 0, + ?\Throwable $previous = null, + private array $missingArguments = [], + private ?string $class = null, + ) { parent::__construct($message, $code, $previous); } @@ -35,4 +37,12 @@ public function getMissingConstructorArguments(): array { return $this->missingArguments; } + + /** + * @return class-string|null + */ + public function getClass(): ?string + { + return $this->class; + } } diff --git a/Exception/NotNormalizableValueException.php b/Exception/NotNormalizableValueException.php index 7b2a1c7bc..4b956f1b0 100644 --- a/Exception/NotNormalizableValueException.php +++ b/Exception/NotNormalizableValueException.php @@ -16,22 +16,23 @@ */ class NotNormalizableValueException extends UnexpectedValueException { - private $currentType; - private $expectedTypes; - private $path; - private $useMessageForUser = false; + private ?string $currentType = null; + private ?array $expectedTypes = null; + private ?string $path = null; + private bool $useMessageForUser = false; /** - * @param bool $useMessageForUser If the message passed to this exception is something that can be shown - * safely to your user. In other words, avoid catching other exceptions and - * passing their message directly to this class. + * @param list $expectedTypes + * @param bool $useMessageForUser If the message passed to this exception is something that can be shown + * safely to your user. In other words, avoid catching other exceptions and + * passing their message directly to this class. */ - public static function createForUnexpectedDataType(string $message, $data, array $expectedTypes, ?string $path = null, bool $useMessageForUser = false, int $code = 0, ?\Throwable $previous = null): self + public static function createForUnexpectedDataType(string $message, mixed $data, array $expectedTypes, ?string $path = null, bool $useMessageForUser = false, int $code = 0, ?\Throwable $previous = null): self { $self = new self($message, $code, $previous); $self->currentType = get_debug_type($data); - $self->expectedTypes = $expectedTypes; + $self->expectedTypes = array_map(strval(...), $expectedTypes); $self->path = $path; $self->useMessageForUser = $useMessageForUser; diff --git a/Exception/PartialDenormalizationException.php b/Exception/PartialDenormalizationException.php index fdb838be7..5edc146cf 100644 --- a/Exception/PartialDenormalizationException.php +++ b/Exception/PartialDenormalizationException.php @@ -16,20 +16,23 @@ */ class PartialDenormalizationException extends UnexpectedValueException { - private $data; - private $errors; - - public function __construct($data, array $errors) - { - $this->data = $data; - $this->errors = $errors; + /** + * @param NotNormalizableValueException[] $errors + */ + public function __construct( + private mixed $data, + private array $errors, + ) { } - public function getData() + public function getData(): mixed { return $this->data; } + /** + * @return NotNormalizableValueException[] + */ public function getErrors(): array { return $this->errors; diff --git a/Exception/UnexpectedPropertyException.php b/Exception/UnexpectedPropertyException.php new file mode 100644 index 000000000..03e42fca5 --- /dev/null +++ b/Exception/UnexpectedPropertyException.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\Exception; + +/** + * UnexpectedPropertyException. + * + * @author Aurélien Pillevesse + */ +class UnexpectedPropertyException extends \UnexpectedValueException implements ExceptionInterface +{ + public function __construct( + public readonly string $property, + ?\Throwable $previous = null, + ) { + $msg = \sprintf('Property is not allowed ("%s" is unknown).', $this->property); + + parent::__construct($msg, 0, $previous); + } +} diff --git a/Exception/UnsupportedFormatException.php b/Exception/UnsupportedFormatException.php new file mode 100644 index 000000000..9b87bcc5b --- /dev/null +++ b/Exception/UnsupportedFormatException.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Exception; + +/** + * @author Konstantin Myakshin + */ +class UnsupportedFormatException extends NotEncodableValueException +{ +} diff --git a/Extractor/ObjectPropertyListExtractor.php b/Extractor/ObjectPropertyListExtractor.php index 2e7bf4c05..42a546805 100644 --- a/Extractor/ObjectPropertyListExtractor.php +++ b/Extractor/ObjectPropertyListExtractor.php @@ -18,21 +18,18 @@ */ final class ObjectPropertyListExtractor implements ObjectPropertyListExtractorInterface { - private $propertyListExtractor; - private $objectClassResolver; + private \Closure $objectClassResolver; - public function __construct(PropertyListExtractorInterface $propertyListExtractor, ?callable $objectClassResolver = null) - { - $this->propertyListExtractor = $propertyListExtractor; - $this->objectClassResolver = $objectClassResolver; + public function __construct( + private PropertyListExtractorInterface $propertyListExtractor, + ?callable $objectClassResolver = null, + ) { + $this->objectClassResolver = ($objectClassResolver ?? 'get_class')(...); } - /** - * {@inheritdoc} - */ public function getProperties(object $object, array $context = []): ?array { - $class = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); + $class = ($this->objectClassResolver)($object); return $this->propertyListExtractor->getProperties($class, $context); } diff --git a/Mapping/AttributeMetadata.php b/Mapping/AttributeMetadata.php index 0823a11b0..12d1cf6bf 100644 --- a/Mapping/AttributeMetadata.php +++ b/Mapping/AttributeMetadata.php @@ -11,9 +11,9 @@ namespace Symfony\Component\Serializer\Mapping; +use Symfony\Component\PropertyAccess\PropertyPath; + /** - * {@inheritdoc} - * * @author Kévin Dunglas */ class AttributeMetadata implements AttributeMetadataInterface @@ -23,41 +23,42 @@ class AttributeMetadata implements AttributeMetadataInterface * class' serialized representation. Do not access it. Use * {@link getName()} instead. */ - public $name; + public string $name; /** * @internal This property is public in order to reduce the size of the * class' serialized representation. Do not access it. Use * {@link getGroups()} instead. */ - public $groups = []; + public array $groups = []; /** - * @var int|null - * * @internal This property is public in order to reduce the size of the * class' serialized representation. Do not access it. Use * {@link getMaxDepth()} instead. */ - public $maxDepth; + public ?int $maxDepth = null; /** - * @var string|null - * * @internal This property is public in order to reduce the size of the * class' serialized representation. Do not access it. Use * {@link getSerializedName()} instead. */ - public $serializedName; + public ?string $serializedName = null; + + /** + * @internal This property is public in order to reduce the size of the + * class' serialized representation. Do not access it. Use + * {@link getSerializedPath()} instead. + */ + public ?PropertyPath $serializedPath = null; /** - * @var bool - * * @internal This property is public in order to reduce the size of the * class' serialized representation. Do not access it. Use * {@link isIgnored()} instead. */ - public $ignore = false; + public bool $ignore = false; /** * @var array[] Normalization contexts per group name ("*" applies to all groups) @@ -66,7 +67,7 @@ class AttributeMetadata implements AttributeMetadataInterface * class' serialized representation. Do not access it. Use * {@link getNormalizationContexts()} instead. */ - public $normalizationContexts = []; + public array $normalizationContexts = []; /** * @var array[] Denormalization contexts per group name ("*" applies to all groups) @@ -75,98 +76,75 @@ class AttributeMetadata implements AttributeMetadataInterface * class' serialized representation. Do not access it. Use * {@link getDenormalizationContexts()} instead. */ - public $denormalizationContexts = []; + public array $denormalizationContexts = []; public function __construct(string $name) { $this->name = $name; } - /** - * {@inheritdoc} - */ public function getName(): string { return $this->name; } - /** - * {@inheritdoc} - */ - public function addGroup(string $group) + public function addGroup(string $group): void { - if (!\in_array($group, $this->groups)) { + if (!\in_array($group, $this->groups, true)) { $this->groups[] = $group; } } - /** - * {@inheritdoc} - */ public function getGroups(): array { return $this->groups; } - /** - * {@inheritdoc} - */ - public function setMaxDepth(?int $maxDepth) + public function setMaxDepth(?int $maxDepth): void { $this->maxDepth = $maxDepth; } - /** - * {@inheritdoc} - */ - public function getMaxDepth() + public function getMaxDepth(): ?int { return $this->maxDepth; } - /** - * {@inheritdoc} - */ - public function setSerializedName(?string $serializedName = null) + public function setSerializedName(?string $serializedName): void { $this->serializedName = $serializedName; } - /** - * {@inheritdoc} - */ public function getSerializedName(): ?string { return $this->serializedName; } - /** - * {@inheritdoc} - */ - public function setIgnore(bool $ignore) + public function setSerializedPath(?PropertyPath $serializedPath = null): void + { + $this->serializedPath = $serializedPath; + } + + public function getSerializedPath(): ?PropertyPath + { + return $this->serializedPath; + } + + public function setIgnore(bool $ignore): void { $this->ignore = $ignore; } - /** - * {@inheritdoc} - */ public function isIgnored(): bool { return $this->ignore; } - /** - * {@inheritdoc} - */ public function getNormalizationContexts(): array { return $this->normalizationContexts; } - /** - * {@inheritdoc} - */ public function getNormalizationContextForGroups(array $groups): array { $contexts = []; @@ -177,9 +155,6 @@ public function getNormalizationContextForGroups(array $groups): array return array_merge($this->normalizationContexts['*'] ?? [], ...$contexts); } - /** - * {@inheritdoc} - */ public function setNormalizationContextForGroups(array $context, array $groups = []): void { if (!$groups) { @@ -191,17 +166,11 @@ public function setNormalizationContextForGroups(array $context, array $groups = } } - /** - * {@inheritdoc} - */ public function getDenormalizationContexts(): array { return $this->denormalizationContexts; } - /** - * {@inheritdoc} - */ public function getDenormalizationContextForGroups(array $groups): array { $contexts = []; @@ -212,9 +181,6 @@ public function getDenormalizationContextForGroups(array $groups): array return array_merge($this->denormalizationContexts['*'] ?? [], ...$contexts); } - /** - * {@inheritdoc} - */ public function setDenormalizationContextForGroups(array $context, array $groups = []): void { if (!$groups) { @@ -226,24 +192,16 @@ public function setDenormalizationContextForGroups(array $context, array $groups } } - /** - * {@inheritdoc} - */ - public function merge(AttributeMetadataInterface $attributeMetadata) + public function merge(AttributeMetadataInterface $attributeMetadata): void { foreach ($attributeMetadata->getGroups() as $group) { $this->addGroup($group); } // Overwrite only if not defined - if (null === $this->maxDepth) { - $this->maxDepth = $attributeMetadata->getMaxDepth(); - } - - // Overwrite only if not defined - if (null === $this->serializedName) { - $this->serializedName = $attributeMetadata->getSerializedName(); - } + $this->maxDepth ??= $attributeMetadata->getMaxDepth(); + $this->serializedName ??= $attributeMetadata->getSerializedName(); + $this->serializedPath ??= $attributeMetadata->getSerializedPath(); // Overwrite only if both contexts are empty if (!$this->normalizationContexts && !$this->denormalizationContexts) { @@ -261,8 +219,8 @@ public function merge(AttributeMetadataInterface $attributeMetadata) * * @return string[] */ - public function __sleep() + public function __sleep(): array { - return ['name', 'groups', 'maxDepth', 'serializedName', 'ignore', 'normalizationContexts', 'denormalizationContexts']; + return ['name', 'groups', 'maxDepth', 'serializedName', 'serializedPath', 'ignore', 'normalizationContexts', 'denormalizationContexts']; } } diff --git a/Mapping/AttributeMetadataInterface.php b/Mapping/AttributeMetadataInterface.php index 57ce746db..9d430602c 100644 --- a/Mapping/AttributeMetadataInterface.php +++ b/Mapping/AttributeMetadataInterface.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Serializer\Mapping; +use Symfony\Component\PropertyAccess\PropertyPath; + /** * Stores metadata needed for serializing and deserializing attributes. * @@ -30,7 +32,7 @@ public function getName(): string; /** * Adds this attribute to the given group. */ - public function addGroup(string $group); + public function addGroup(string $group): void; /** * Gets groups of this attribute. @@ -42,29 +44,31 @@ public function getGroups(): array; /** * Sets the serialization max depth for this attribute. */ - public function setMaxDepth(?int $maxDepth); + public function setMaxDepth(?int $maxDepth): void; /** * Gets the serialization max depth for this attribute. - * - * @return int|null */ - public function getMaxDepth(); + public function getMaxDepth(): ?int; /** * Sets the serialization name for this attribute. */ - public function setSerializedName(?string $serializedName = null); + public function setSerializedName(?string $serializedName): void; /** * Gets the serialization name for this attribute. */ public function getSerializedName(): ?string; + public function setSerializedPath(?PropertyPath $serializedPath): void; + + public function getSerializedPath(): ?PropertyPath; + /** * Sets if this attribute must be ignored or not. */ - public function setIgnore(bool $ignore); + public function setIgnore(bool $ignore): void; /** * Gets if this attribute is ignored or not. @@ -74,7 +78,7 @@ public function isIgnored(): bool; /** * Merges an {@see AttributeMetadataInterface} with in the current one. */ - public function merge(self $attributeMetadata); + public function merge(self $attributeMetadata): void; /** * Gets all the normalization contexts per group ("*" being the base context applied to all groups). diff --git a/Mapping/ClassDiscriminatorFromClassMetadata.php b/Mapping/ClassDiscriminatorFromClassMetadata.php index 6f69c890b..4677e19d5 100644 --- a/Mapping/ClassDiscriminatorFromClassMetadata.php +++ b/Mapping/ClassDiscriminatorFromClassMetadata.php @@ -18,20 +18,13 @@ */ class ClassDiscriminatorFromClassMetadata implements ClassDiscriminatorResolverInterface { - /** - * @var ClassMetadataFactoryInterface - */ - private $classMetadataFactory; - private $mappingForMappedObjectCache = []; + private array $mappingForMappedObjectCache = []; - public function __construct(ClassMetadataFactoryInterface $classMetadataFactory) - { - $this->classMetadataFactory = $classMetadataFactory; + public function __construct( + private readonly ClassMetadataFactoryInterface $classMetadataFactory, + ) { } - /** - * {@inheritdoc} - */ public function getMappingForClass(string $class): ?ClassDiscriminatorMapping { if ($this->classMetadataFactory->hasMetadataFor($class)) { @@ -41,10 +34,7 @@ public function getMappingForClass(string $class): ?ClassDiscriminatorMapping return null; } - /** - * {@inheritdoc} - */ - public function getMappingForMappedObject($object): ?ClassDiscriminatorMapping + public function getMappingForMappedObject(object|string $object): ?ClassDiscriminatorMapping { if ($this->classMetadataFactory->hasMetadataFor($object)) { $metadata = $this->classMetadataFactory->getMetadataFor($object); @@ -54,7 +44,7 @@ public function getMappingForMappedObject($object): ?ClassDiscriminatorMapping } } - $cacheKey = \is_object($object) ? \get_class($object) : $object; + $cacheKey = \is_object($object) ? $object::class : $object; if (!\array_key_exists($cacheKey, $this->mappingForMappedObjectCache)) { $this->mappingForMappedObjectCache[$cacheKey] = $this->resolveMappingForMappedObject($object); } @@ -62,10 +52,7 @@ public function getMappingForMappedObject($object): ?ClassDiscriminatorMapping return $this->mappingForMappedObjectCache[$cacheKey]; } - /** - * {@inheritdoc} - */ - public function getTypeForMappedObject($object): ?string + public function getTypeForMappedObject(object|string $object): ?string { if (null === $mapping = $this->getMappingForMappedObject($object)) { return null; @@ -74,7 +61,7 @@ public function getTypeForMappedObject($object): ?string return $mapping->getMappedObjectType($object); } - private function resolveMappingForMappedObject($object) + private function resolveMappingForMappedObject(object|string $object): ?ClassDiscriminatorMapping { $reflectionClass = new \ReflectionClass($object); if ($parentClass = $reflectionClass->getParentClass()) { diff --git a/Mapping/ClassDiscriminatorMapping.php b/Mapping/ClassDiscriminatorMapping.php index ffaa8eab9..260575a41 100644 --- a/Mapping/ClassDiscriminatorMapping.php +++ b/Mapping/ClassDiscriminatorMapping.php @@ -16,14 +16,13 @@ */ class ClassDiscriminatorMapping { - private $typeProperty; - private $typesMapping; - - public function __construct(string $typeProperty, array $typesMapping = []) - { - $this->typeProperty = $typeProperty; - $this->typesMapping = $typesMapping; - + /** + * @param array $typesMapping + */ + public function __construct( + private readonly string $typeProperty, + private array $typesMapping = [], + ) { uasort($this->typesMapping, static function (string $a, string $b): int { if (is_a($a, $b, true)) { return -1; @@ -47,10 +46,7 @@ public function getClassForType(string $type): ?string return $this->typesMapping[$type] ?? null; } - /** - * @param object|string $object - */ - public function getMappedObjectType($object): ?string + public function getMappedObjectType(object|string $object): ?string { foreach ($this->typesMapping as $type => $typeClass) { if (is_a($object, $typeClass, true)) { diff --git a/Mapping/ClassDiscriminatorResolverInterface.php b/Mapping/ClassDiscriminatorResolverInterface.php index 22d76fafb..83a12d845 100644 --- a/Mapping/ClassDiscriminatorResolverInterface.php +++ b/Mapping/ClassDiscriminatorResolverInterface.php @@ -20,13 +20,7 @@ interface ClassDiscriminatorResolverInterface { public function getMappingForClass(string $class): ?ClassDiscriminatorMapping; - /** - * @param object|string $object - */ - public function getMappingForMappedObject($object): ?ClassDiscriminatorMapping; + public function getMappingForMappedObject(object|string $object): ?ClassDiscriminatorMapping; - /** - * @param object|string $object - */ - public function getTypeForMappedObject($object): ?string; + public function getTypeForMappedObject(object|string $object): ?string; } diff --git a/Mapping/ClassMetadata.php b/Mapping/ClassMetadata.php index 51c934742..b635ee10b 100644 --- a/Mapping/ClassMetadata.php +++ b/Mapping/ClassMetadata.php @@ -12,8 +12,6 @@ namespace Symfony\Component\Serializer\Mapping; /** - * {@inheritdoc} - * * @author Kévin Dunglas */ class ClassMetadata implements ClassMetadataInterface @@ -23,7 +21,7 @@ class ClassMetadata implements ClassMetadataInterface * class' serialized representation. Do not access it. Use * {@link getName()} instead. */ - public $name; + public string $name; /** * @var AttributeMetadataInterface[] @@ -32,21 +30,16 @@ class ClassMetadata implements ClassMetadataInterface * class' serialized representation. Do not access it. Use * {@link getAttributesMetadata()} instead. */ - public $attributesMetadata = []; + public array $attributesMetadata = []; - /** - * @var \ReflectionClass - */ - private $reflClass; + private ?\ReflectionClass $reflClass = null; /** - * @var ClassDiscriminatorMapping|null - * * @internal This property is public in order to reduce the size of the * class' serialized representation. Do not access it. Use * {@link getClassDiscriminatorMapping()} instead. */ - public $classDiscriminatorMapping; + public ?ClassDiscriminatorMapping $classDiscriminatorMapping = null; /** * Constructs a metadata for the given class. @@ -57,34 +50,22 @@ public function __construct(string $class, ?ClassDiscriminatorMapping $classDisc $this->classDiscriminatorMapping = $classDiscriminatorMapping; } - /** - * {@inheritdoc} - */ public function getName(): string { return $this->name; } - /** - * {@inheritdoc} - */ - public function addAttributeMetadata(AttributeMetadataInterface $attributeMetadata) + public function addAttributeMetadata(AttributeMetadataInterface $attributeMetadata): void { $this->attributesMetadata[$attributeMetadata->getName()] = $attributeMetadata; } - /** - * {@inheritdoc} - */ public function getAttributesMetadata(): array { return $this->attributesMetadata; } - /** - * {@inheritdoc} - */ - public function merge(ClassMetadataInterface $classMetadata) + public function merge(ClassMetadataInterface $classMetadata): void { foreach ($classMetadata->getAttributesMetadata() as $attributeMetadata) { if (isset($this->attributesMetadata[$attributeMetadata->getName()])) { @@ -95,30 +76,17 @@ public function merge(ClassMetadataInterface $classMetadata) } } - /** - * {@inheritdoc} - */ public function getReflectionClass(): \ReflectionClass { - if (!$this->reflClass) { - $this->reflClass = new \ReflectionClass($this->getName()); - } - - return $this->reflClass; + return $this->reflClass ??= new \ReflectionClass($this->getName()); } - /** - * {@inheritdoc} - */ public function getClassDiscriminatorMapping(): ?ClassDiscriminatorMapping { return $this->classDiscriminatorMapping; } - /** - * {@inheritdoc} - */ - public function setClassDiscriminatorMapping(?ClassDiscriminatorMapping $mapping = null) + public function setClassDiscriminatorMapping(?ClassDiscriminatorMapping $mapping): void { $this->classDiscriminatorMapping = $mapping; } @@ -128,7 +96,7 @@ public function setClassDiscriminatorMapping(?ClassDiscriminatorMapping $mapping * * @return string[] */ - public function __sleep() + public function __sleep(): array { return [ 'name', diff --git a/Mapping/ClassMetadataInterface.php b/Mapping/ClassMetadataInterface.php index 0c6b86900..3f380d2ff 100644 --- a/Mapping/ClassMetadataInterface.php +++ b/Mapping/ClassMetadataInterface.php @@ -32,19 +32,19 @@ public function getName(): string; /** * Adds an {@link AttributeMetadataInterface}. */ - public function addAttributeMetadata(AttributeMetadataInterface $attributeMetadata); + public function addAttributeMetadata(AttributeMetadataInterface $attributeMetadata): void; /** * Gets the list of {@link AttributeMetadataInterface}. * - * @return AttributeMetadataInterface[] + * @return array */ public function getAttributesMetadata(): array; /** * Merges a {@link ClassMetadataInterface} in the current one. */ - public function merge(self $classMetadata); + public function merge(self $classMetadata): void; /** * Returns a {@link \ReflectionClass} instance for this class. @@ -53,5 +53,5 @@ public function getReflectionClass(): \ReflectionClass; public function getClassDiscriminatorMapping(): ?ClassDiscriminatorMapping; - public function setClassDiscriminatorMapping(?ClassDiscriminatorMapping $mapping = null); + public function setClassDiscriminatorMapping(?ClassDiscriminatorMapping $mapping): void; } diff --git a/Mapping/Factory/CacheClassMetadataFactory.php b/Mapping/Factory/CacheClassMetadataFactory.php index a85bdb184..61c2fb606 100644 --- a/Mapping/Factory/CacheClassMetadataFactory.php +++ b/Mapping/Factory/CacheClassMetadataFactory.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping\Factory; use Psr\Cache\CacheItemPoolInterface; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; /** * Caches metadata using a PSR-6 implementation. @@ -23,27 +24,17 @@ class CacheClassMetadataFactory implements ClassMetadataFactoryInterface use ClassResolverTrait; /** - * @var ClassMetadataFactoryInterface + * @var array */ - private $decorated; + private array $loadedClasses = []; - /** - * @var CacheItemPoolInterface - */ - private $cacheItemPool; - - private $loadedClasses = []; - - public function __construct(ClassMetadataFactoryInterface $decorated, CacheItemPoolInterface $cacheItemPool) - { - $this->decorated = $decorated; - $this->cacheItemPool = $cacheItemPool; + public function __construct( + private readonly ClassMetadataFactoryInterface $decorated, + private readonly CacheItemPoolInterface $cacheItemPool, + ) { } - /** - * {@inheritdoc} - */ - public function getMetadataFor($value) + public function getMetadataFor(string|object $value): ClassMetadataInterface { $class = $this->getClass($value); @@ -64,10 +55,7 @@ public function getMetadataFor($value) return $this->loadedClasses[$class] = $metadata; } - /** - * {@inheritdoc} - */ - public function hasMetadataFor($value) + public function hasMetadataFor(mixed $value): bool { return $this->decorated->hasMetadataFor($value); } diff --git a/Mapping/Factory/ClassMetadataFactory.php b/Mapping/Factory/ClassMetadataFactory.php index b55c070fb..fc5b2e71e 100644 --- a/Mapping/Factory/ClassMetadataFactory.php +++ b/Mapping/Factory/ClassMetadataFactory.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Mapping\Factory; use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; /** @@ -23,22 +24,17 @@ class ClassMetadataFactory implements ClassMetadataFactoryInterface { use ClassResolverTrait; - private $loader; - /** - * @var array + * @var array */ - private $loadedClasses; + private array $loadedClasses; - public function __construct(LoaderInterface $loader) - { - $this->loader = $loader; + public function __construct( + private readonly LoaderInterface $loader, + ) { } - /** - * {@inheritdoc} - */ - public function getMetadataFor($value) + public function getMetadataFor(string|object $value): ClassMetadataInterface { $class = $this->getClass($value); @@ -64,10 +60,7 @@ public function getMetadataFor($value) return $this->loadedClasses[$class] = $classMetadata; } - /** - * {@inheritdoc} - */ - public function hasMetadataFor($value) + public function hasMetadataFor(mixed $value): bool { return \is_object($value) || (\is_string($value) && (class_exists($value) || interface_exists($value, false))); } diff --git a/Mapping/Factory/ClassMetadataFactoryCompiler.php b/Mapping/Factory/ClassMetadataFactoryCompiler.php index 81dd4b932..1e9202b7d 100644 --- a/Mapping/Factory/ClassMetadataFactoryCompiler.php +++ b/Mapping/Factory/ClassMetadataFactoryCompiler.php @@ -48,6 +48,7 @@ private function generateDeclaredClassMetadata(array $classMetadatas): string $attributeMetadata->getGroups(), $attributeMetadata->getMaxDepth(), $attributeMetadata->getSerializedName(), + $attributeMetadata->getSerializedPath(), ]; } @@ -56,7 +57,7 @@ private function generateDeclaredClassMetadata(array $classMetadatas): string $classMetadata->getClassDiscriminatorMapping()->getTypesMapping(), ] : null; - $compiled .= sprintf("\n'%s' => %s,", $classMetadata->getName(), VarExporter::export([ + $compiled .= \sprintf("\n'%s' => %s,", $classMetadata->getName(), VarExporter::export([ $attributesMetadata, $classDiscriminatorMapping, ])); diff --git a/Mapping/Factory/ClassMetadataFactoryInterface.php b/Mapping/Factory/ClassMetadataFactoryInterface.php index 7ef91a823..169db634f 100644 --- a/Mapping/Factory/ClassMetadataFactoryInterface.php +++ b/Mapping/Factory/ClassMetadataFactoryInterface.php @@ -34,20 +34,12 @@ interface ClassMetadataFactoryInterface * {@link \Symfony\Component\Serializer\Mapping\Loader\LoaderInterface::loadClassMetadata()} method for further * configuration. At last, the new object is returned. * - * @param string|object $value - * - * @return ClassMetadataInterface - * * @throws InvalidArgumentException */ - public function getMetadataFor($value); + public function getMetadataFor(string|object $value): ClassMetadataInterface; /** * Checks if class has metadata. - * - * @param mixed $value - * - * @return bool */ - public function hasMetadataFor($value); + public function hasMetadataFor(mixed $value): bool; } diff --git a/Mapping/Factory/ClassResolverTrait.php b/Mapping/Factory/ClassResolverTrait.php index 0a65da5d5..d7037f894 100644 --- a/Mapping/Factory/ClassResolverTrait.php +++ b/Mapping/Factory/ClassResolverTrait.php @@ -25,24 +25,18 @@ trait ClassResolverTrait /** * Gets a class name for a given class or instance. * - * @param object|string $value - * * @throws InvalidArgumentException If the class does not exist */ - private function getClass($value): string + private function getClass(object|string $value): string { if (\is_string($value)) { if (!class_exists($value) && !interface_exists($value, false)) { - throw new InvalidArgumentException(sprintf('The class or interface "%s" does not exist.', $value)); + throw new InvalidArgumentException(\sprintf('The class or interface "%s" does not exist.', $value)); } return ltrim($value, '\\'); } - if (!\is_object($value)) { - throw new InvalidArgumentException(sprintf('Cannot create metadata for non-objects. Got: "%s".', get_debug_type($value))); - } - - return \get_class($value); + return $value::class; } } diff --git a/Mapping/Factory/CompiledClassMetadataFactory.php b/Mapping/Factory/CompiledClassMetadataFactory.php index 3adf65f05..ec25d7440 100644 --- a/Mapping/Factory/CompiledClassMetadataFactory.php +++ b/Mapping/Factory/CompiledClassMetadataFactory.php @@ -21,33 +21,29 @@ */ final class CompiledClassMetadataFactory implements ClassMetadataFactoryInterface { - private $compiledClassMetadata = []; + private array $compiledClassMetadata = []; - private $loadedClasses = []; + private array $loadedClasses = []; - private $classMetadataFactory; - - public function __construct(string $compiledClassMetadataFile, ClassMetadataFactoryInterface $classMetadataFactory) - { + public function __construct( + string $compiledClassMetadataFile, + private readonly ClassMetadataFactoryInterface $classMetadataFactory, + ) { if (!file_exists($compiledClassMetadataFile)) { throw new \RuntimeException("File \"{$compiledClassMetadataFile}\" could not be found."); } $compiledClassMetadata = require $compiledClassMetadataFile; if (!\is_array($compiledClassMetadata)) { - throw new \RuntimeException(sprintf('Compiled metadata must be of the type array, %s given.', \gettype($compiledClassMetadata))); + throw new \RuntimeException(\sprintf('Compiled metadata must be of the type array, %s given.', \gettype($compiledClassMetadata))); } $this->compiledClassMetadata = $compiledClassMetadata; - $this->classMetadataFactory = $classMetadataFactory; } - /** - * {@inheritdoc} - */ - public function getMetadataFor($value): ClassMetadataInterface + public function getMetadataFor(string|object $value): ClassMetadataInterface { - $className = \is_object($value) ? \get_class($value) : $value; + $className = \is_object($value) ? $value::class : $value; if (!isset($this->compiledClassMetadata[$className])) { return $this->classMetadataFactory->getMetadataFor($value); @@ -70,12 +66,9 @@ public function getMetadataFor($value): ClassMetadataInterface return $this->loadedClasses[$className]; } - /** - * {@inheritdoc} - */ - public function hasMetadataFor($value): bool + public function hasMetadataFor(mixed $value): bool { - $className = \is_object($value) ? \get_class($value) : $value; + $className = \is_object($value) ? $value::class : $value; return isset($this->compiledClassMetadata[$className]) || $this->classMetadataFactory->hasMetadataFor($value); } diff --git a/Mapping/Loader/AnnotationLoader.php b/Mapping/Loader/AnnotationLoader.php deleted file mode 100644 index 0137575cd..000000000 --- a/Mapping/Loader/AnnotationLoader.php +++ /dev/null @@ -1,213 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -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; -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; - -/** - * Loader for Doctrine annotations and PHP 8 attributes. - * - * @author Kévin Dunglas - * @author Alexander M. Turek - */ -class AnnotationLoader implements LoaderInterface -{ - private const KNOWN_ANNOTATIONS = [ - DiscriminatorMap::class, - Groups::class, - Ignore::class, - MaxDepth::class, - SerializedName::class, - Context::class, - ]; - - private $reader; - - public function __construct(?Reader $reader = null) - { - $this->reader = $reader; - } - - /** - * {@inheritdoc} - */ - public function loadClassMetadata(ClassMetadataInterface $classMetadata) - { - $reflectionClass = $classMetadata->getReflectionClass(); - $className = $reflectionClass->name; - $loaded = false; - - $attributesMetadata = $classMetadata->getAttributesMetadata(); - - foreach ($this->loadAnnotations($reflectionClass) as $annotation) { - if ($annotation instanceof DiscriminatorMap) { - $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( - $annotation->getTypeProperty(), - $annotation->getMapping() - )); - } - } - - foreach ($reflectionClass->getProperties() as $property) { - if (!isset($attributesMetadata[$property->name])) { - $attributesMetadata[$property->name] = new AttributeMetadata($property->name); - $classMetadata->addAttributeMetadata($attributesMetadata[$property->name]); - } - - if ($property->getDeclaringClass()->name === $className) { - foreach ($this->loadAnnotations($property) as $annotation) { - if ($annotation instanceof Groups) { - foreach ($annotation->getGroups() as $group) { - $attributesMetadata[$property->name]->addGroup($group); - } - } elseif ($annotation instanceof MaxDepth) { - $attributesMetadata[$property->name]->setMaxDepth($annotation->getMaxDepth()); - } elseif ($annotation instanceof SerializedName) { - $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; - } - } - } - - foreach ($reflectionClass->getMethods() as $method) { - if ($method->getDeclaringClass()->name !== $className) { - continue; - } - - if (0 === stripos($method->name, 'get') && $method->getNumberOfRequiredParameters()) { - continue; /* matches the BC behavior in `Symfony\Component\Serializer\Normalizer\ObjectNormalizer::extractAttributes` */ - } - - $accessorOrMutator = preg_match('/^(get|is|has|set)(.+)$/i', $method->name, $matches); - if ($accessorOrMutator) { - $attributeName = lcfirst($matches[2]); - - if (isset($attributesMetadata[$attributeName])) { - $attributeMetadata = $attributesMetadata[$attributeName]; - } else { - $attributesMetadata[$attributeName] = $attributeMetadata = new AttributeMetadata($attributeName); - $classMetadata->addAttributeMetadata($attributeMetadata); - } - } - - foreach ($this->loadAnnotations($method) as $annotation) { - if ($annotation instanceof Groups) { - if (!$accessorOrMutator) { - throw new MappingException(sprintf('Groups on "%s::%s()" cannot be added. Groups can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); - } - - foreach ($annotation->getGroups() as $group) { - $attributeMetadata->addGroup($group); - } - } elseif ($annotation instanceof MaxDepth) { - if (!$accessorOrMutator) { - throw new MappingException(sprintf('MaxDepth on "%s::%s()" cannot be added. MaxDepth can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); - } - - $attributeMetadata->setMaxDepth($annotation->getMaxDepth()); - } elseif ($annotation instanceof SerializedName) { - if (!$accessorOrMutator) { - throw new MappingException(sprintf('SerializedName on "%s::%s()" cannot be added. SerializedName can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); - } - - $attributeMetadata->setSerializedName($annotation->getSerializedName()); - } elseif ($annotation instanceof Ignore) { - if ($accessorOrMutator) { - $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; - } - } - - return $loaded; - } - - /** - * @param \ReflectionClass|\ReflectionMethod|\ReflectionProperty $reflector - */ - public function loadAnnotations(object $reflector): iterable - { - if (\PHP_VERSION_ID >= 80000) { - foreach ($reflector->getAttributes() as $attribute) { - if ($this->isKnownAttribute($attribute->getName())) { - yield $attribute->newInstance(); - } - } - } - - if (null === $this->reader) { - return; - } - - if ($reflector instanceof \ReflectionClass) { - yield from $this->reader->getClassAnnotations($reflector); - } - if ($reflector instanceof \ReflectionMethod) { - yield from $this->reader->getMethodAnnotations($reflector); - } - if ($reflector instanceof \ReflectionProperty) { - 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()); - } - } - - private function isKnownAttribute(string $attributeName): bool - { - foreach (self::KNOWN_ANNOTATIONS as $knownAnnotation) { - if (is_a($attributeName, $knownAnnotation, true)) { - return true; - } - } - - return false; - } -} diff --git a/Mapping/Loader/AttributeLoader.php b/Mapping/Loader/AttributeLoader.php new file mode 100644 index 000000000..13c59d1c3 --- /dev/null +++ b/Mapping/Loader/AttributeLoader.php @@ -0,0 +1,224 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Mapping\Loader; + +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; +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; + +/** + * Loader for PHP attributes. + * + * @author Kévin Dunglas + * @author Alexander M. Turek + * @author Alexandre Daubois + */ +class AttributeLoader implements LoaderInterface +{ + private const KNOWN_ATTRIBUTES = [ + DiscriminatorMap::class, + Groups::class, + Ignore::class, + MaxDepth::class, + SerializedName::class, + SerializedPath::class, + Context::class, + ]; + + public function __construct() + { + } + + public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool + { + $reflectionClass = $classMetadata->getReflectionClass(); + $className = $reflectionClass->name; + $loaded = false; + $classGroups = []; + $classContextAttribute = null; + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + foreach ($this->loadAttributes($reflectionClass) as $attribute) { + match (true) { + $attribute instanceof DiscriminatorMap => $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping($attribute->getTypeProperty(), $attribute->getMapping())), + $attribute instanceof Groups => $classGroups = $attribute->getGroups(), + $attribute instanceof Context => $classContextAttribute = $attribute, + default => null, + }; + } + + foreach ($reflectionClass->getProperties() as $property) { + if (!isset($attributesMetadata[$property->name])) { + $attributesMetadata[$property->name] = new AttributeMetadata($property->name); + $classMetadata->addAttributeMetadata($attributesMetadata[$property->name]); + } + + $attributeMetadata = $attributesMetadata[$property->name]; + if ($property->getDeclaringClass()->name === $className) { + if ($classContextAttribute) { + $this->setAttributeContextsForGroups($classContextAttribute, $attributeMetadata); + } + + foreach ($classGroups as $group) { + $attributeMetadata->addGroup($group); + } + + foreach ($this->loadAttributes($property) as $attribute) { + $loaded = true; + + if ($attribute instanceof Groups) { + foreach ($attribute->getGroups() as $group) { + $attributeMetadata->addGroup($group); + } + + continue; + } + + match (true) { + $attribute instanceof MaxDepth => $attributeMetadata->setMaxDepth($attribute->getMaxDepth()), + $attribute instanceof SerializedName => $attributeMetadata->setSerializedName($attribute->getSerializedName()), + $attribute instanceof SerializedPath => $attributeMetadata->setSerializedPath($attribute->getSerializedPath()), + $attribute instanceof Ignore => $attributeMetadata->setIgnore(true), + $attribute instanceof Context => $this->setAttributeContextsForGroups($attribute, $attributeMetadata), + default => null, + }; + } + } + } + + foreach ($reflectionClass->getMethods() as $method) { + if ($method->getDeclaringClass()->name !== $className) { + continue; + } + + if (0 === stripos($method->name, 'get') && $method->getNumberOfRequiredParameters()) { + continue; /* matches the BC behavior in `Symfony\Component\Serializer\Normalizer\ObjectNormalizer::extractAttributes` */ + } + + $accessorOrMutator = preg_match('/^(get|is|has|set)(.+)$/i', $method->name, $matches); + if ($accessorOrMutator && !ctype_lower($matches[2][0])) { + $attributeName = lcfirst($matches[2]); + + if (isset($attributesMetadata[$attributeName])) { + $attributeMetadata = $attributesMetadata[$attributeName]; + } else { + $attributesMetadata[$attributeName] = $attributeMetadata = new AttributeMetadata($attributeName); + $classMetadata->addAttributeMetadata($attributeMetadata); + } + } + + foreach ($this->loadAttributes($method) as $attribute) { + if ($attribute instanceof Groups) { + if (!$accessorOrMutator) { + throw new MappingException(\sprintf('Groups on "%s::%s()" cannot be added. Groups can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + foreach ($attribute->getGroups() as $group) { + $attributeMetadata->addGroup($group); + } + } elseif ($attribute instanceof MaxDepth) { + if (!$accessorOrMutator) { + throw new MappingException(\sprintf('MaxDepth on "%s::%s()" cannot be added. MaxDepth can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $attributeMetadata->setMaxDepth($attribute->getMaxDepth()); + } elseif ($attribute instanceof SerializedName) { + if (!$accessorOrMutator) { + throw new MappingException(\sprintf('SerializedName on "%s::%s()" cannot be added. SerializedName can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $attributeMetadata->setSerializedName($attribute->getSerializedName()); + } elseif ($attribute instanceof SerializedPath) { + if (!$accessorOrMutator) { + throw new MappingException(\sprintf('SerializedPath on "%s::%s()" cannot be added. SerializedPath can only be added on methods beginning with "get", "is", "has" or "set".', $className, $method->name)); + } + + $attributeMetadata->setSerializedPath($attribute->getSerializedPath()); + } elseif ($attribute instanceof Ignore) { + if ($accessorOrMutator) { + $attributeMetadata->setIgnore(true); + } + } elseif ($attribute 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($attribute, $attributeMetadata); + } + + $loaded = true; + } + } + + return $loaded; + } + + private function loadAttributes(\ReflectionMethod|\ReflectionClass|\ReflectionProperty $reflector): iterable + { + foreach ($reflector->getAttributes() as $attribute) { + if ($this->isKnownAttribute($attribute->getName())) { + try { + yield $attribute->newInstance(); + } catch (\Error $e) { + if (\Error::class !== $e::class) { + throw $e; + } + $on = match (true) { + $reflector instanceof \ReflectionClass => ' on class '.$reflector->name, + $reflector instanceof \ReflectionMethod => \sprintf(' on "%s::%s()"', $reflector->getDeclaringClass()->name, $reflector->name), + $reflector instanceof \ReflectionProperty => \sprintf(' on "%s::$%s"', $reflector->getDeclaringClass()->name, $reflector->name), + default => '', + }; + + throw new MappingException(\sprintf('Could not instantiate attribute "%s"%s.', $attribute->getName(), $on), 0, $e); + } + } + } + } + + private function setAttributeContextsForGroups(Context $attribute, AttributeMetadataInterface $attributeMetadata): void + { + $context = $attribute->getContext(); + $groups = $attribute->getGroups(); + $normalizationContext = $attribute->getNormalizationContext(); + $denormalizationContext = $attribute->getDenormalizationContext(); + + if ($normalizationContext || $context) { + $attributeMetadata->setNormalizationContextForGroups($normalizationContext ?: $context, $groups); + } + + if ($denormalizationContext || $context) { + $attributeMetadata->setDenormalizationContextForGroups($denormalizationContext ?: $context, $groups); + } + } + + private function isKnownAttribute(string $attributeName): bool + { + foreach (self::KNOWN_ATTRIBUTES as $knownAttribute) { + if (is_a($attributeName, $knownAttribute, true)) { + return true; + } + } + + return false; + } +} diff --git a/Mapping/Loader/FileLoader.php b/Mapping/Loader/FileLoader.php index f4947ef4c..ac68d21e8 100644 --- a/Mapping/Loader/FileLoader.php +++ b/Mapping/Loader/FileLoader.php @@ -20,23 +20,20 @@ */ abstract class FileLoader implements LoaderInterface { - protected $file; - /** * @param string $file The mapping file to load * * @throws MappingException if the mapping file does not exist or is not readable */ - public function __construct(string $file) - { + public function __construct( + protected string $file, + ) { if (!is_file($file)) { - throw new MappingException(sprintf('The mapping file "%s" does not exist.', $file)); + throw new MappingException(\sprintf('The mapping file "%s" does not exist.', $file)); } if (!is_readable($file)) { - throw new MappingException(sprintf('The mapping file "%s" is not readable.', $file)); + throw new MappingException(\sprintf('The mapping file "%s" is not readable.', $file)); } - - $this->file = $file; } } diff --git a/Mapping/Loader/LoaderChain.php b/Mapping/Loader/LoaderChain.php index aa428a3bd..41abf8cec 100644 --- a/Mapping/Loader/LoaderChain.php +++ b/Mapping/Loader/LoaderChain.php @@ -27,8 +27,6 @@ */ class LoaderChain implements LoaderInterface { - private $loaders; - /** * Accepts a list of LoaderInterface instances. * @@ -36,21 +34,16 @@ class LoaderChain implements LoaderInterface * * @throws MappingException If any of the loaders does not implement LoaderInterface */ - public function __construct(array $loaders) + public function __construct(private readonly array $loaders) { foreach ($loaders as $loader) { if (!$loader instanceof LoaderInterface) { - throw new MappingException(sprintf('Class "%s" is expected to implement LoaderInterface.', get_debug_type($loader))); + throw new MappingException(\sprintf('Class "%s" is expected to implement LoaderInterface.', get_debug_type($loader))); } } - - $this->loaders = $loaders; } - /** - * {@inheritdoc} - */ - public function loadClassMetadata(ClassMetadataInterface $metadata) + public function loadClassMetadata(ClassMetadataInterface $metadata): bool { $success = false; @@ -64,7 +57,7 @@ public function loadClassMetadata(ClassMetadataInterface $metadata) /** * @return LoaderInterface[] */ - public function getLoaders() + public function getLoaders(): array { return $this->loaders; } diff --git a/Mapping/Loader/LoaderInterface.php b/Mapping/Loader/LoaderInterface.php index 1310a7169..4801e4764 100644 --- a/Mapping/Loader/LoaderInterface.php +++ b/Mapping/Loader/LoaderInterface.php @@ -20,8 +20,5 @@ */ interface LoaderInterface { - /** - * @return bool - */ - public function loadClassMetadata(ClassMetadataInterface $classMetadata); + public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool; } diff --git a/Mapping/Loader/XmlFileLoader.php b/Mapping/Loader/XmlFileLoader.php index 5ef21fb37..44ba89df1 100644 --- a/Mapping/Loader/XmlFileLoader.php +++ b/Mapping/Loader/XmlFileLoader.php @@ -12,6 +12,8 @@ namespace Symfony\Component\Serializer\Mapping\Loader; use Symfony\Component\Config\Util\XmlUtils; +use Symfony\Component\PropertyAccess\Exception\InvalidPropertyPathException; +use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; @@ -29,18 +31,11 @@ class XmlFileLoader extends FileLoader * * @var \SimpleXMLElement[]|null */ - private $classes; + private ?array $classes = null; - /** - * {@inheritdoc} - */ - public function loadClassMetadata(ClassMetadataInterface $classMetadata) + public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool { - if (null === $this->classes) { - $this->classes = $this->getClassesFromXml(); - } - - if (!$this->classes) { + if (!$this->classes ??= $this->getClassesFromXml()) { return false; } @@ -71,6 +66,14 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) $attributeMetadata->setSerializedName((string) $attribute['serialized-name']); } + if (isset($attribute['serialized-path'])) { + try { + $attributeMetadata->setSerializedPath(new PropertyPath((string) $attribute['serialized-path'])); + } catch (InvalidPropertyPathException) { + throw new MappingException(\sprintf('The "serialized-path" value must be a valid property path for the attribute "%s" of the class "%s".', $attributeName, $classMetadata->getName())); + } + } + if (isset($attribute['ignore'])) { $attributeMetadata->setIgnore(XmlUtils::phpize($attribute['ignore'])); } @@ -119,13 +122,9 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) * * @return string[] */ - public function getMappedClasses() + public function getMappedClasses(): array { - if (null === $this->classes) { - $this->classes = $this->getClassesFromXml(); - } - - return array_keys($this->classes); + return array_keys($this->classes ??= $this->getClassesFromXml()); } /** diff --git a/Mapping/Loader/YamlFileLoader.php b/Mapping/Loader/YamlFileLoader.php index 8c3792db6..ca71cbcba 100644 --- a/Mapping/Loader/YamlFileLoader.php +++ b/Mapping/Loader/YamlFileLoader.php @@ -11,6 +11,8 @@ namespace Symfony\Component\Serializer\Mapping\Loader; +use Symfony\Component\PropertyAccess\Exception\InvalidPropertyPathException; +use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\Serializer\Exception\MappingException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; @@ -25,25 +27,16 @@ */ class YamlFileLoader extends FileLoader { - private $yamlParser; + private ?Parser $yamlParser = null; /** * An array of YAML class descriptions. - * - * @var array */ - private $classes; + private ?array $classes = null; - /** - * {@inheritdoc} - */ - public function loadClassMetadata(ClassMetadataInterface $classMetadata) + public function loadClassMetadata(ClassMetadataInterface $classMetadata): bool { - if (null === $this->classes) { - $this->classes = $this->getClassesFromYaml(); - } - - if (!$this->classes) { + if (!$this->classes ??= $this->getClassesFromYaml()) { return false; } @@ -66,12 +59,12 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) if (isset($data['groups'])) { if (!\is_array($data['groups'])) { - throw new MappingException(sprintf('The "groups" key must be an array of strings in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + throw new MappingException(\sprintf('The "groups" key must be an array of strings in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); } foreach ($data['groups'] as $group) { if (!\is_string($group)) { - throw new MappingException(sprintf('Group names must be strings in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + throw new MappingException(\sprintf('Group names must be strings in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); } $attributeMetadata->addGroup($group); @@ -80,23 +73,31 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) if (isset($data['max_depth'])) { if (!\is_int($data['max_depth'])) { - throw new MappingException(sprintf('The "max_depth" value must be an integer in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + throw new MappingException(\sprintf('The "max_depth" value must be an integer in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); } $attributeMetadata->setMaxDepth($data['max_depth']); } if (isset($data['serialized_name'])) { - if (!\is_string($data['serialized_name']) || empty($data['serialized_name'])) { - throw new MappingException(sprintf('The "serialized_name" value must be a non-empty string in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + if (!\is_string($data['serialized_name']) || '' === $data['serialized_name']) { + throw new MappingException(\sprintf('The "serialized_name" value must be a non-empty string in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); } $attributeMetadata->setSerializedName($data['serialized_name']); } + if (isset($data['serialized_path'])) { + try { + $attributeMetadata->setSerializedPath(new PropertyPath((string) $data['serialized_path'])); + } catch (InvalidPropertyPathException) { + throw new MappingException(\sprintf('The "serialized_path" value must be a valid property path in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + } + } + if (isset($data['ignore'])) { if (!\is_bool($data['ignore'])) { - throw new MappingException(sprintf('The "ignore" value must be a boolean in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); + throw new MappingException(\sprintf('The "ignore" value must be a boolean in "%s" for the attribute "%s" of the class "%s".', $this->file, $attribute, $classMetadata->getName())); } $attributeMetadata->setIgnore($data['ignore']); @@ -123,11 +124,11 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) if (isset($yaml['discriminator_map'])) { if (!isset($yaml['discriminator_map']['type_property'])) { - throw new MappingException(sprintf('The "type_property" key must be set for the discriminator map of the class "%s" in "%s".', $classMetadata->getName(), $this->file)); + throw new MappingException(\sprintf('The "type_property" key must be set for the discriminator map of the class "%s" in "%s".', $classMetadata->getName(), $this->file)); } if (!isset($yaml['discriminator_map']['mapping'])) { - throw new MappingException(sprintf('The "mapping" key must be set for the discriminator map of the class "%s" in "%s".', $classMetadata->getName(), $this->file)); + throw new MappingException(\sprintf('The "mapping" key must be set for the discriminator map of the class "%s" in "%s".', $classMetadata->getName(), $this->file)); } $classMetadata->setClassDiscriminatorMapping(new ClassDiscriminatorMapping( @@ -144,33 +145,27 @@ public function loadClassMetadata(ClassMetadataInterface $classMetadata) * * @return string[] */ - public function getMappedClasses() + public function getMappedClasses(): array { - if (null === $this->classes) { - $this->classes = $this->getClassesFromYaml(); - } - - return array_keys($this->classes); + return array_keys($this->classes ??= $this->getClassesFromYaml()); } private function getClassesFromYaml(): array { if (!stream_is_local($this->file)) { - throw new MappingException(sprintf('This is not a local file "%s".', $this->file)); + throw new MappingException(\sprintf('This is not a local file "%s".', $this->file)); } - if (null === $this->yamlParser) { - $this->yamlParser = new Parser(); - } + $this->yamlParser ??= new Parser(); $classes = $this->yamlParser->parseFile($this->file, Yaml::PARSE_CONSTANT); - if (empty($classes)) { + if (!$classes) { return []; } if (!\is_array($classes)) { - throw new MappingException(sprintf('The file "%s" must contain a YAML array.', $this->file)); + throw new MappingException(\sprintf('The file "%s" must contain a YAML array.', $this->file)); } return $classes; diff --git a/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd b/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd index 0228e41ce..f5f6cca9f 100644 --- a/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd +++ b/Mapping/Loader/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd @@ -81,6 +81,13 @@ + + + + + + + diff --git a/NameConverter/AdvancedNameConverterInterface.php b/NameConverter/AdvancedNameConverterInterface.php index 9e9ee2a37..975d28fd3 100644 --- a/NameConverter/AdvancedNameConverterInterface.php +++ b/NameConverter/AdvancedNameConverterInterface.php @@ -15,16 +15,12 @@ * Gives access to the class, the format and the context in the property name converters. * * @author Kévin Dunglas + * + * @deprecated since Symfony 7.2, use NameConverterInterface instead */ interface AdvancedNameConverterInterface extends NameConverterInterface { - /** - * {@inheritdoc} - */ - public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []); + public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string; - /** - * {@inheritdoc} - */ - public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []); + public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string; } diff --git a/NameConverter/CamelCaseToSnakeCaseNameConverter.php b/NameConverter/CamelCaseToSnakeCaseNameConverter.php index 8c9953c96..033ec94b7 100644 --- a/NameConverter/CamelCaseToSnakeCaseNameConverter.php +++ b/NameConverter/CamelCaseToSnakeCaseNameConverter.php @@ -11,32 +11,39 @@ namespace Symfony\Component\Serializer\NameConverter; +use Symfony\Component\Serializer\Exception\UnexpectedPropertyException; + /** * CamelCase to Underscore name converter. * * @author Kévin Dunglas + * @author Aurélien Pillevesse */ class CamelCaseToSnakeCaseNameConverter implements NameConverterInterface { - private $attributes; - private $lowerCamelCase; + /** + * Require all properties to be written in snake_case. + */ + public const REQUIRE_SNAKE_CASE_PROPERTIES = 'require_snake_case_properties'; /** - * @param array|null $attributes The list of attributes to rename or null for all attributes - * @param bool $lowerCamelCase Use lowerCamelCase style + * @param string[]|null $attributes The list of attributes to rename or null for all attributes + * @param bool $lowerCamelCase Use lowerCamelCase style */ - public function __construct(?array $attributes = null, bool $lowerCamelCase = true) - { - $this->attributes = $attributes; - $this->lowerCamelCase = $lowerCamelCase; + public function __construct( + private ?array $attributes = null, + private bool $lowerCamelCase = true, + ) { } /** - * {@inheritdoc} + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function normalize(string $propertyName) + public function normalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string { - if (null === $this->attributes || \in_array($propertyName, $this->attributes)) { + if (null === $this->attributes || \in_array($propertyName, $this->attributes, true)) { return strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($propertyName))); } @@ -44,19 +51,27 @@ public function normalize(string $propertyName) } /** - * {@inheritdoc} + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function denormalize(string $propertyName) + public function denormalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string { - $camelCasedName = preg_replace_callback('/(^|_|\.)+(.)/', function ($match) { - return ('.' === $match[1] ? '_' : '').strtoupper($match[2]); - }, $propertyName); + $class = 1 < \func_num_args() ? func_get_arg(1) : null; + $format = 2 < \func_num_args() ? func_get_arg(2) : null; + $context = 3 < \func_num_args() ? func_get_arg(3) : []; + + if (($context[self::REQUIRE_SNAKE_CASE_PROPERTIES] ?? false) && $propertyName !== $this->normalize($propertyName, $class, $format, $context)) { + throw new UnexpectedPropertyException($propertyName); + } + + $camelCasedName = preg_replace_callback('/(^|_|\.)+(.)/', fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), $propertyName); if ($this->lowerCamelCase) { $camelCasedName = lcfirst($camelCasedName); } - if (null === $this->attributes || \in_array($camelCasedName, $this->attributes)) { + if (null === $this->attributes || \in_array($camelCasedName, $this->attributes, true)) { return $camelCasedName; } diff --git a/NameConverter/MetadataAwareNameConverter.php b/NameConverter/MetadataAwareNameConverter.php index a27c82756..c72f148eb 100644 --- a/NameConverter/MetadataAwareNameConverter.php +++ b/NameConverter/MetadataAwareNameConverter.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\NameConverter; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -19,28 +20,27 @@ */ final class MetadataAwareNameConverter implements AdvancedNameConverterInterface { - private $metadataFactory; - /** - * @var NameConverterInterface|AdvancedNameConverterInterface|null + * @var array> */ - private $fallbackNameConverter; - - private static $normalizeCache = []; + private static array $normalizeCache = []; - private static $denormalizeCache = []; + /** + * @var array> + */ + private static array $denormalizeCache = []; - private static $attributesMetadataCache = []; + /** + * @var array> + */ + private static array $attributesMetadataCache = []; - public function __construct(ClassMetadataFactoryInterface $metadataFactory, ?NameConverterInterface $fallbackNameConverter = null) - { - $this->metadataFactory = $metadataFactory; - $this->fallbackNameConverter = $fallbackNameConverter; + public function __construct( + private readonly ClassMetadataFactoryInterface $metadataFactory, + private readonly ?NameConverterInterface $fallbackNameConverter = null, + ) { } - /** - * {@inheritdoc} - */ public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { if (null === $class) { @@ -54,9 +54,6 @@ public function normalize(string $propertyName, ?string $class = null, ?string $ return self::$normalizeCache[$class][$propertyName] ?? $this->normalizeFallback($propertyName, $class, $format, $context); } - /** - * {@inheritdoc} - */ public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { if (null === $class) { @@ -82,6 +79,10 @@ private function getCacheValueForNormalization(string $propertyName, string $cla return null; } + if (null !== $attributesMetadata[$propertyName]->getSerializedName() && null !== $attributesMetadata[$propertyName]->getSerializedPath()) { + throw new LogicException(\sprintf('Found SerializedName and SerializedPath attributes on property "%s" of class "%s".', $propertyName, $class)); + } + return $attributesMetadata[$propertyName]->getSerializedName() ?? null; } @@ -105,6 +106,9 @@ private function denormalizeFallback(string $propertyName, ?string $class = null return $this->fallbackNameConverter ? $this->fallbackNameConverter->denormalize($propertyName, $class, $format, $context) : $propertyName; } + /** + * @return array + */ private function getCacheValueForAttributesMetadata(string $class, array $context): array { if (!$this->metadataFactory->hasMetadataFor($class)) { @@ -119,11 +123,18 @@ private function getCacheValueForAttributesMetadata(string $class, array $contex continue; } - $groups = $metadata->getGroups(); - if (!$groups && ($context[AbstractNormalizer::GROUPS] ?? [])) { + if (null !== $metadata->getSerializedName() && null !== $metadata->getSerializedPath()) { + throw new LogicException(\sprintf('Found SerializedName and SerializedPath attributes on property "%s" of class "%s".', $name, $class)); + } + + $metadataGroups = $metadata->getGroups(); + $contextGroups = (array) ($context[AbstractNormalizer::GROUPS] ?? []); + + if ($contextGroups && !$metadataGroups) { continue; } - if ($groups && !array_intersect($groups, (array) ($context[AbstractNormalizer::GROUPS] ?? []))) { + + if ($metadataGroups && !array_intersect($metadataGroups, $contextGroups) && !\in_array('*', $contextGroups, true)) { continue; } @@ -139,6 +150,6 @@ private function getCacheKey(string $class, array $context): string return $class.'-'.$context['cache_key']; } - return $class.md5(serialize($context[AbstractNormalizer::GROUPS] ?? [])); + return $class.hash('xxh128', serialize($context[AbstractNormalizer::GROUPS] ?? [])); } } diff --git a/NameConverter/NameConverterInterface.php b/NameConverter/NameConverterInterface.php index ae2f4c59c..d6bfeceb4 100644 --- a/NameConverter/NameConverterInterface.php +++ b/NameConverter/NameConverterInterface.php @@ -21,14 +21,18 @@ interface NameConverterInterface /** * Converts a property name to its normalized value. * - * @return string + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function normalize(string $propertyName); + public function normalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string; /** * Converts a property name to its denormalized value. * - * @return string + * @param class-string|null $class + * @param string|null $format + * @param array $context */ - public function denormalize(string $propertyName); + public function denormalize(string $propertyName/* , ?string $class = null, ?string $format = null, array $context = [] */): string; } diff --git a/NameConverter/SnakeCaseToCamelCaseNameConverter.php b/NameConverter/SnakeCaseToCamelCaseNameConverter.php new file mode 100644 index 000000000..cb93d3e98 --- /dev/null +++ b/NameConverter/SnakeCaseToCamelCaseNameConverter.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\NameConverter; + +use Symfony\Component\Serializer\Exception\UnexpectedPropertyException; + +/** + * Underscore to camelCase name converter. + * + * @author Kévin Dunglas + */ +final readonly class SnakeCaseToCamelCaseNameConverter implements NameConverterInterface +{ + /** + * Require all properties to be written in camelCase. + */ + public const REQUIRE_CAMEL_CASE_PROPERTIES = 'require_camel_case_properties'; + + /** + * @param string[]|null $attributes The list of attributes to rename or null for all attributes + * @param bool $lowerCamelCase Use lowerCamelCase style + */ + public function __construct( + private ?array $attributes = null, + private bool $lowerCamelCase = true, + ) { + } + + /** + * @param class-string|null $class + * @param array $context + */ + public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string + { + if (null !== $this->attributes && !\in_array($propertyName, $this->attributes, true)) { + return $propertyName; + } + + $camelCasedName = preg_replace_callback( + '/(^|_|\.)+(.)/', + fn ($match) => ('.' === $match[1] ? '_' : '').strtoupper($match[2]), + $propertyName + ); + + if ($this->lowerCamelCase) { + $camelCasedName = lcfirst($camelCasedName); + } + + return $camelCasedName; + } + + /** + * @param class-string|null $class + * @param array $context + */ + public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string + { + if (($context[self::REQUIRE_CAMEL_CASE_PROPERTIES] ?? false) && $propertyName !== $this->normalize($propertyName, $class, $format, $context)) { + throw new UnexpectedPropertyException($propertyName); + } + + $snakeCased = strtolower(preg_replace('/[A-Z]/', '_\\0', lcfirst($propertyName))); + if (null === $this->attributes || \in_array($snakeCased, $this->attributes, true)) { + return $snakeCased; + } + + return $propertyName; + } +} diff --git a/Normalizer/AbstractNormalizer.php b/Normalizer/AbstractNormalizer.php index 256be49eb..04f378c46 100644 --- a/Normalizer/AbstractNormalizer.php +++ b/Normalizer/AbstractNormalizer.php @@ -28,7 +28,7 @@ * * @author Kévin Dunglas */ -abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { use ObjectToPopulateTrait; use SerializerAwareTrait; @@ -112,60 +112,57 @@ abstract class AbstractNormalizer implements NormalizerInterface, DenormalizerIn */ public const IGNORED_ATTRIBUTES = 'ignored_attributes'; + /** + * Require all properties to be listed in the input instead of falling + * back to null for nullable ones. + */ + public const REQUIRE_ALL_PROPERTIES = 'require_all_properties'; + + /** + * Flag to control whether a non-boolean value should be filtered using the + * filter_var function with the {@see https://www.php.net/manual/fr/filter.filters.validate.php} + * \FILTER_VALIDATE_BOOL filter before casting it to a boolean. + * + * "0", "false", "off", "no" and "" will be cast to false. + * "1", "true", "on" and "yes" will be cast to true. + */ + public const FILTER_BOOL = 'filter_bool'; + /** * @internal */ protected const CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'circular_reference_limit_counters'; - protected $defaultContext = [ + protected array $defaultContext = [ self::ALLOW_EXTRA_ATTRIBUTES => true, self::CIRCULAR_REFERENCE_HANDLER => null, self::CIRCULAR_REFERENCE_LIMIT => 1, self::IGNORED_ATTRIBUTES => [], ]; - /** - * @var ClassMetadataFactoryInterface|null - */ - protected $classMetadataFactory; - - /** - * @var NameConverterInterface|null - */ - protected $nameConverter; - /** * Sets the {@link ClassMetadataFactoryInterface} to use. */ - public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, array $defaultContext = []) - { - $this->classMetadataFactory = $classMetadataFactory; - $this->nameConverter = $nameConverter; + public function __construct( + protected ?ClassMetadataFactoryInterface $classMetadataFactory = null, + protected ?NameConverterInterface $nameConverter = null, + array $defaultContext = [], + ) { $this->defaultContext = array_merge($this->defaultContext, $defaultContext); $this->validateCallbackContext($this->defaultContext, 'default'); 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)); + throw new InvalidArgumentException(\sprintf('Invalid callback found in the "%s" default context option.', self::CIRCULAR_REFERENCE_HANDLER)); } } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return false; - } - /** * Detects if the configured circular reference limit is reached. * - * @return bool - * * @throws CircularReferenceException */ - protected function isCircularReference(object $object, array &$context) + protected function isCircularReference(object $object, array &$context): bool { $objectHash = spl_object_hash($object); @@ -193,36 +190,33 @@ protected function isCircularReference(object $object, array &$context) * * @final * - * @return mixed - * * @throws CircularReferenceException */ - protected function handleCircularReference(object $object, ?string $format = null, array $context = []) + protected function handleCircularReference(object $object, ?string $format = null, array $context = []): mixed { $circularReferenceHandler = $context[self::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_HANDLER]; if ($circularReferenceHandler) { return $circularReferenceHandler($object, $format, $context); } - throw new CircularReferenceException(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[self::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_LIMIT])); + throw new CircularReferenceException(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[self::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[self::CIRCULAR_REFERENCE_LIMIT])); } /** * Gets attributes to normalize using groups. * - * @param string|object $classOrObject - * @param bool $attributesAsString If false, return an array of {@link AttributeMetadataInterface} + * @param bool $attributesAsString If false, return an array of {@link AttributeMetadataInterface} * * @return string[]|AttributeMetadataInterface[]|bool * * @throws LogicException if the 'allow_extra_attributes' context variable is false and no class metadata factory is provided */ - protected function getAllowedAttributes($classOrObject, array $context, bool $attributesAsString = false) + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool { $allowExtraAttributes = $context[self::ALLOW_EXTRA_ATTRIBUTES] ?? $this->defaultContext[self::ALLOW_EXTRA_ATTRIBUTES]; if (!$this->classMetadataFactory) { if (!$allowExtraAttributes) { - throw new LogicException(sprintf('A class metadata factory must be provided in the constructor when setting "%s" to false.', self::ALLOW_EXTRA_ATTRIBUTES)); + throw new LogicException(\sprintf('A class metadata factory must be provided in the constructor when setting "%s" to false.', self::ALLOW_EXTRA_ATTRIBUTES)); } return false; @@ -232,6 +226,7 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at $allowedAttributes = []; $ignoreUsed = false; + foreach ($this->classMetadataFactory->getMetadataFor($classOrObject)->getAttributesMetadata() as $attributeMetadata) { if ($ignore = $attributeMetadata->isIgnored()) { $ignoreUsed = true; @@ -239,9 +234,9 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at // If you update this check, update accordingly the one in Symfony\Component\PropertyInfo\Extractor\SerializerExtractor::getProperties() if ( - !$ignore && - ([] === $groups || array_intersect(array_merge($attributeMetadata->getGroups(), ['*']), $groups)) && - $this->isAllowedAttribute($classOrObject, $name = $attributeMetadata->getName(), null, $context) + !$ignore + && ([] === $groups || \in_array('*', $groups, true) || array_intersect($attributeMetadata->getGroups(), $groups)) + && $this->isAllowedAttribute($classOrObject, $name = $attributeMetadata->getName(), null, $context) ) { $allowedAttributes[] = $attributesAsString ? $name : $attributeMetadata; } @@ -264,15 +259,11 @@ protected function getGroups(array $context): array /** * Is this attribute allowed? - * - * @param object|string $classOrObject - * - * @return bool */ - protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []) + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool { $ignoredAttributes = $context[self::IGNORED_ATTRIBUTES] ?? $this->defaultContext[self::IGNORED_ATTRIBUTES]; - if (\in_array($attribute, $ignoredAttributes)) { + if (\in_array($attribute, $ignoredAttributes, true)) { return false; } @@ -292,12 +283,8 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string /** * Normalizes the given data to an array. It's particularly useful during * the denormalization process. - * - * @param object|array $data - * - * @return array */ - protected function prepareForDenormalization($data) + protected function prepareForDenormalization(mixed $data): array { return (array) $data; } @@ -305,12 +292,8 @@ protected function prepareForDenormalization($data) /** * Returns the method to use to construct an object. This method must be either * the object constructor or static. - * - * @param array|bool $allowedAttributes - * - * @return \ReflectionMethod|null */ - protected function getConstructor(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes) + protected function getConstructor(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes): ?\ReflectionMethod { return $reflectionClass->getConstructor(); } @@ -323,14 +306,10 @@ protected function getConstructor(array &$data, string $class, array &$context, * is removed from the context before being returned to avoid side effects * when recursively normalizing an object graph. * - * @param array|bool $allowedAttributes - * - * @return object - * * @throws RuntimeException * @throws MissingConstructorArgumentsException */ - protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, ?string $format = null) + protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, ?string $format = null): object { if (null !== $object = $this->extractObjectToPopulate($class, $context, self::OBJECT_TO_POPULATE)) { unset($context[self::OBJECT_TO_POPULATE]); @@ -351,25 +330,32 @@ protected function instantiateObject(array &$data, string $class, array &$contex $missingConstructorArguments = []; $params = []; $unsetKeys = []; - $objectDeserializationPath = $context['deserialization_path'] ?? null; foreach ($constructorParameters as $constructorParameter) { $paramName = $constructorParameter->name; + $attributeContext = $this->getAttributeDenormalizationContext($class, $paramName, $context); $key = $this->nameConverter ? $this->nameConverter->normalize($paramName, $class, $format, $context) : $paramName; - $context['deserialization_path'] = $objectDeserializationPath ? $objectDeserializationPath.'.'.$paramName : $paramName; - - $allowed = false === $allowedAttributes || \in_array($paramName, $allowedAttributes); + $allowed = false === $allowedAttributes || \in_array($paramName, $allowedAttributes, true); $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[$key])) { - 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)); + 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[$key] as $parameterKey => $parameterData) { - $variadicParameters[$parameterKey] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + try { + $variadicParameters[$parameterKey] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $attributeContext, $format); + } catch (NotNormalizableValueException $exception) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw $exception; + } + + $context['not_normalizable_value_exceptions'][] = $exception; + $params[$paramName] = $parameterData; + } } $params = array_merge(array_values($params), $variadicParameters); @@ -385,7 +371,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex } try { - $params[$paramName] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $context, $format); + $params[$paramName] = $this->denormalizeParameter($reflectionClass, $constructorParameter, $paramName, $parameterData, $attributeContext, $format); } catch (NotNormalizableValueException $exception) { if (!isset($context['not_normalizable_value_exceptions'])) { throw $exception; @@ -402,7 +388,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex $params[$paramName] = $this->defaultContext[self::DEFAULT_CONSTRUCTOR_ARGUMENTS][$class][$key]; } elseif ($constructorParameter->isDefaultValueAvailable()) { $params[$paramName] = $constructorParameter->getDefaultValue(); - } elseif ($constructorParameter->hasType() && $constructorParameter->getType()->allowsNull()) { + } elseif (!($context[self::REQUIRE_ALL_PROPERTIES] ?? $this->defaultContext[self::REQUIRE_ALL_PROPERTIES] ?? false) && $constructorParameter->hasType() && $constructorParameter->getType()->allowsNull()) { $params[$paramName] = null; } else { if (!isset($context['not_normalizable_value_exceptions'])) { @@ -417,20 +403,18 @@ protected function instantiateObject(array &$data, string $class, array &$contex } $exception = NotNormalizableValueException::createForUnexpectedDataType( - sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), + \sprintf('Failed to create object because the class misses the "%s" property.', $constructorParameter->name), null, [$constructorParameterType], - $context['deserialization_path'], + $attributeContext['deserialization_path'] ?? null, true ); $context['not_normalizable_value_exceptions'][] = $exception; } } - $context['deserialization_path'] = $objectDeserializationPath; - if ($missingConstructorArguments) { - throw new MissingConstructorArgumentsException(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$%s".', $class, implode('", "$', $missingConstructorArguments)), 0, null, $missingConstructorArguments); + throw new MissingConstructorArgumentsException(\sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$%s".', $class, implode('", "$', $missingConstructorArguments)), 0, null, $missingConstructorArguments, $class); } if (!$constructor->isConstructor()) { @@ -465,12 +449,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex unset($context['has_constructor']); if (!$reflectionClass->isInstantiable()) { - throw NotNormalizableValueException::createForUnexpectedDataType( - sprintf('Failed to create object because the class "%s" is not instantiable.', $class), - $data, - ['unknown'], - $context['deserialization_path'] ?? null - ); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Failed to create object because the class "%s" is not instantiable.', $class), $data, ['unknown'], $context['deserialization_path'] ?? null); } return new $class(); @@ -479,7 +458,7 @@ protected function instantiateObject(array &$data, string $class, array &$contex /** * @internal */ - protected function denormalizeParameter(\ReflectionClass $class, \ReflectionParameter $parameter, string $parameterName, $parameterData, array $context, ?string $format = null) + protected function denormalizeParameter(\ReflectionClass $class, \ReflectionParameter $parameter, string $parameterName, mixed $parameterData, array $context, ?string $format = null): mixed { try { if (($parameterType = $parameter->getType()) instanceof \ReflectionNamedType && !$parameterType->isBuiltin()) { @@ -487,13 +466,13 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara new \ReflectionClass($parameterClass); // throws a \ReflectionException if the class doesn't exist if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('Cannot create an instance of "%s" from serialized data because the serializer inject in "%s" is not a denormalizer.', $parameterClass, static::class)); + throw new LogicException(\sprintf('Cannot create an instance of "%s" from serialized data because the serializer inject in "%s" is not a denormalizer.', $parameterClass, static::class)); } $parameterData = $this->serializer->denormalize($parameterData, $parameterClass, $format, $this->createChildContext($context, $parameterName, $format)); } } catch (\ReflectionException $e) { - throw new RuntimeException(sprintf('Could not determine the class of the parameter "%s".', $parameterName), 0, $e); + throw new RuntimeException(\sprintf('Could not determine the class of the parameter "%s".', $parameterName), 0, $e); } catch (MissingConstructorArgumentsException $e) { if (!$parameter->getType()->allowsNull()) { throw $e; @@ -502,7 +481,9 @@ protected function denormalizeParameter(\ReflectionClass $class, \ReflectionPara return null; } - return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + + return $this->applyFilterBool($parameter, $parameterData, $context); } /** @@ -533,25 +514,17 @@ final protected function validateCallbackContext(array $context, string $context } if (!\is_array($context[self::CALLBACKS])) { - throw new InvalidArgumentException(sprintf('The "%s"%s context option must be an array of callables.', self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); + throw new InvalidArgumentException(\sprintf('The "%s"%s context option must be an array of callables.', self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); } foreach ($context[self::CALLBACKS] as $attribute => $callback) { if (!\is_callable($callback)) { - throw new InvalidArgumentException(sprintf('Invalid callback found for attribute "%s" in the "%s"%s context option.', $attribute, self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); + throw new InvalidArgumentException(\sprintf('Invalid callback found for attribute "%s" in the "%s"%s context option.', $attribute, self::CALLBACKS, '' !== $contextType ? " $contextType" : '')); } } } - /** - * Apply callbacks set in context. - * - * @param mixed $value - * @param object|string $object Can be either the object being normalizing or the object's class being denormalized - * - * @return mixed - */ - final protected function applyCallbacks($value, $object, string $attribute, ?string $format, array $context) + final protected function applyCallbacks(mixed $value, object|string $object, string $attribute, ?string $format, array $context): mixed { /** * @var callable|null @@ -560,4 +533,59 @@ final protected function applyCallbacks($value, $object, string $attribute, ?str return $callback ? $callback($value, $object, $attribute, $format, $context) : $value; } + + final protected function applyFilterBool(\ReflectionParameter $parameter, mixed $value, array $context): mixed + { + if (!($context[self::FILTER_BOOL] ?? false)) { + return $value; + } + + if (!($parameterType = $parameter->getType()) instanceof \ReflectionNamedType || 'bool' !== $parameterType->getName()) { + return $value; + } + + return filter_var($value, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE) ?? $value; + } + + /** + * Computes the normalization context merged with current one. Metadata always wins over global context, as more specific. + * + * @internal + */ + protected function getAttributeNormalizationContext(object $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. + * + * @internal + */ + protected function getAttributeDenormalizationContext(string $class, string $attribute, array $context): array + { + $context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute; + + if (null === $metadata = $this->getAttributeMetadata($class, $attribute)) { + return $context; + } + + return array_merge($context, $metadata->getDenormalizationContextForGroups($this->getGroups($context))); + } + + /** + * @internal + */ + protected function getAttributeMetadata(object|string $objectOrClass, string $attribute): ?AttributeMetadataInterface + { + if (!$this->classMetadataFactory) { + return null; + } + + return $this->classMetadataFactory->getMetadataFor($objectOrClass)->getAttributesMetadata()[$attribute] ?? null; + } } diff --git a/Normalizer/AbstractObjectNormalizer.php b/Normalizer/AbstractObjectNormalizer.php index 63b519b70..00d2e3b00 100644 --- a/Normalizer/AbstractObjectNormalizer.php +++ b/Normalizer/AbstractObjectNormalizer.php @@ -12,10 +12,12 @@ namespace Symfony\Component\Serializer\Normalizer; use Symfony\Component\PropertyAccess\Exception\InvalidArgumentException as PropertyAccessInvalidArgumentException; +use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; @@ -27,8 +29,19 @@ use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Exception\LogicException as TypeInfoLogicException; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\CollectionType; +use Symfony\Component\TypeInfo\Type\IntersectionType; +use Symfony\Component\TypeInfo\Type\NullableType; +use Symfony\Component\TypeInfo\Type\ObjectType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Base class for a normalizer dealing with objects. @@ -48,7 +61,7 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer public const DEPTH_KEY_PATTERN = 'depth_%s::%s'; /** - * While denormalizing, we can verify that types match. + * While denormalizing, we can verify that type matches. * * You can disable this by setting this flag to true. */ @@ -104,48 +117,44 @@ abstract class AbstractObjectNormalizer extends AbstractNormalizer */ public const PRESERVE_EMPTY_OBJECTS = 'preserve_empty_objects'; - private $propertyTypeExtractor; - private $typesCache = []; - private $attributesCache = []; - - private $objectClassResolver; + protected ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver; /** - * @var ClassDiscriminatorResolverInterface|null + * @var array|false> */ - protected $classDiscriminatorResolver; - - public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = []) - { + private array $typeCache = []; + private array $attributesCache = []; + private readonly \Closure $objectClassResolver; + + public function __construct( + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + ?NameConverterInterface $nameConverter = null, + private ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, + ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, + ?callable $objectClassResolver = null, + array $defaultContext = [], + ) { parent::__construct($classMetadataFactory, $nameConverter, $defaultContext); 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)); + throw new InvalidArgumentException(\sprintf('The "%s" given in the default context is not callable.', self::MAX_DEPTH_HANDLER)); } $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); + if ($classMetadataFactory) { + $classDiscriminatorResolver ??= new ClassDiscriminatorFromClassMetadata($classMetadataFactory); } $this->classDiscriminatorResolver = $classDiscriminatorResolver; - $this->objectClassResolver = $objectClassResolver; + $this->objectClassResolver = ($objectClassResolver ?? 'get_class')(...); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return \is_object($data) && !$data instanceof \Traversable; } - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $context['_read_attributes'] = true; @@ -162,12 +171,13 @@ public function normalize($object, ?string $format = null, array $context = []) $data = []; $stack = []; $attributes = $this->getAttributes($object, $format, $context); - $class = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); - $attributesMetadata = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null; + $class = ($this->objectClassResolver)($object); + $classMetadata = $this->classMetadataFactory?->getMetadataFor($class); + $attributesMetadata = $this->classMetadataFactory?->getMetadataFor($class)->getAttributesMetadata(); if (isset($context[self::MAX_DEPTH_HANDLER])) { $maxDepthHandler = $context[self::MAX_DEPTH_HANDLER]; if (!\is_callable($maxDepthHandler)) { - throw new InvalidArgumentException(sprintf('The "%s" given in the context is not callable.', self::MAX_DEPTH_HANDLER)); + throw new InvalidArgumentException(\sprintf('The "%s" given in the context is not callable.', self::MAX_DEPTH_HANDLER)); } } else { $maxDepthHandler = null; @@ -181,21 +191,11 @@ public function normalize($object, ?string $format = null, array $context = []) $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); - $discriminatorProperty = null; - if (null !== $this->classDiscriminatorResolver && null !== $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) { - $discriminatorProperty = $mapping->getTypeProperty(); - } - try { - $attributeValue = $attribute === $discriminatorProperty - ? $this->classDiscriminatorResolver->getTypeForMappedObject($object) + $attributeValue = $attribute === $this->classDiscriminatorResolver?->getMappingForMappedObject($object)?->getTypeProperty() + ? $this->classDiscriminatorResolver?->getTypeForMappedObject($object) : $this->getAttributeValue($object, $attribute, $format, $attributeContext); - } catch (UninitializedPropertyException $e) { - if ($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) { - continue; - } - throw $e; - } catch (\Error $e) { + } catch (UninitializedPropertyException|\Error $e) { if (($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) && $this->isUninitializedValueError($e)) { continue; } @@ -206,92 +206,38 @@ public function normalize($object, ?string $format = null, array $context = []) $attributeValue = $maxDepthHandler($attributeValue, $object, $attribute, $format, $attributeContext); } - $attributeValue = $this->applyCallbacks($attributeValue, $object, $attribute, $format, $attributeContext); - - if (null !== $attributeValue && !\is_scalar($attributeValue)) { - $stack[$attribute] = $attributeValue; - } - - $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $attributeContext); + $stack[$attribute] = $this->applyCallbacks($attributeValue, $object, $attribute, $format, $attributeContext); } foreach ($stack as $attribute => $attributeValue) { + $attributeContext = $this->getAttributeNormalizationContext($object, $attribute, $context); + + if (null === $attributeValue || \is_scalar($attributeValue)) { + $data = $this->updateData($data, $attribute, $attributeValue, $class, $format, $attributeContext, $attributesMetadata, $classMetadata); + continue; + } + if (!$this->serializer instanceof NormalizerInterface) { - throw new LogicException(sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer.', $attribute)); + throw new LogicException(\sprintf('Cannot normalize attribute "%s" because the injected serializer is not a normalizer.', $attribute)); } - $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); + $data = $this->updateData($data, $attribute, $this->serializer->normalize($attributeValue, $format, $childContext), $class, $format, $attributeContext, $attributesMetadata, $classMetadata); } - if (isset($context[self::PRESERVE_EMPTY_OBJECTS]) && !\count($data)) { + $preserveEmptyObjects = $context[self::PRESERVE_EMPTY_OBJECTS] ?? $this->defaultContext[self::PRESERVE_EMPTY_OBJECTS] ?? false; + if ($preserveEmptyObjects && !$data) { return new \ArrayObject(); } return $data; } - /** - * Computes the normalization context merged with current one. Metadata always wins over global context, as more specific. - */ - private function getAttributeNormalizationContext(object $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 - { - $context['deserialization_path'] = ($context['deserialization_path'] ?? false) ? $context['deserialization_path'].'.'.$attribute : $attribute; - - 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} - */ - protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes, ?string $format = null) + protected function instantiateObject(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes, ?string $format = null): object { - if (null !== $object = $this->extractObjectToPopulate($class, $context, self::OBJECT_TO_POPULATE)) { - unset($context[self::OBJECT_TO_POPULATE]); - - return $object; - } - - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { - if (!isset($data[$mapping->getTypeProperty()])) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), false); - } - - $type = $data[$mapping->getTypeProperty()]; - if (null === ($mappedClass = $mapping->getClassForType($type))) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true); - } - - if ($mappedClass !== $class) { - return $this->instantiateObject($data, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); - } + if ($class !== $mappedClass = $this->getMappedClass($data, $class, $context)) { + return $this->instantiateObject($data, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); } return parent::instantiateObject($data, $class, $context, $reflectionClass, $allowedAttributes, $format); @@ -302,9 +248,9 @@ protected function instantiateObject(array &$data, string $class, array &$contex * * @return string[] */ - protected function getAttributes(object $object, ?string $format, array $context) + protected function getAttributes(object $object, ?string $format, array $context): array { - $class = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); + $class = ($this->objectClassResolver)($object); $key = $class.'-'.$context['cache_key']; if (isset($this->attributesCache[$key])) { @@ -323,7 +269,7 @@ protected function getAttributes(object $object, ?string $format, array $context $attributes = $this->extractAttributes($object, $format, $context); - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) { + if ($mapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object)) { array_unshift($attributes, $mapping->getTypeProperty()); } @@ -339,27 +285,19 @@ protected function getAttributes(object $object, ?string $format, array $context * * @return string[] */ - abstract protected function extractAttributes(object $object, ?string $format = null, array $context = []); + abstract protected function extractAttributes(object $object, ?string $format = null, array $context = []): array; /** * Gets the attribute value. - * - * @return mixed */ - abstract protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []); + abstract protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed; - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return class_exists($type) || (interface_exists($type, false) && $this->classDiscriminatorResolver && null !== $this->classDiscriminatorResolver->getMappingForClass($type)); + return class_exists($type) || (interface_exists($type, false) && null !== $this->classDiscriminatorResolver?->getMappingForClass($type)); } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { $context['_read_attributes'] = false; @@ -369,7 +307,7 @@ public function denormalize($data, string $type, ?string $format = null, array $ $this->validateCallbackContext($context); - if (null === $data && isset($context['value_type']) && $context['value_type'] instanceof Type && $context['value_type']->isNullable()) { + if (null === $data && isset($context['value_type']) && ($context['value_type'] instanceof Type || $context['value_type'] instanceof LegacyType) && $context['value_type']->isNullable()) { return null; } @@ -381,18 +319,38 @@ public function denormalize($data, string $type, ?string $format = null, array $ $normalizedData = $this->prepareForDenormalization($data); $extraAttributes = []; - $reflectionClass = new \ReflectionClass($type); - $object = $this->instantiateObject($normalizedData, $type, $context, $reflectionClass, $allowedAttributes, $format); - $resolvedClass = $this->objectClassResolver ? ($this->objectClassResolver)($object) : \get_class($object); + $mappedClass = $this->getMappedClass($normalizedData, $type, $context); + + $nestedAttributes = $this->getNestedAttributes($mappedClass); + $nestedData = $originalNestedData = []; + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + foreach ($nestedAttributes as $property => $serializedPath) { + if (null === $value = $propertyAccessor->getValue($normalizedData, $serializedPath)) { + continue; + } + $convertedProperty = $this->nameConverter ? $this->nameConverter->normalize($property, $mappedClass, $format, $context) : $property; + $nestedData[$convertedProperty] = $value; + $originalNestedData[$property] = $value; + $normalizedData = $this->removeNestedValue($serializedPath->getElements(), $normalizedData); + } + + $normalizedData = $nestedData + $normalizedData; + + $object = $this->instantiateObject($normalizedData, $mappedClass, $context, new \ReflectionClass($mappedClass), $allowedAttributes, $format); + $resolvedClass = ($this->objectClassResolver)($object); foreach ($normalizedData as $attribute => $value) { if ($this->nameConverter) { + $notConverted = $attribute; $attribute = $this->nameConverter->denormalize($attribute, $resolvedClass, $format, $context); + if (isset($nestedData[$notConverted]) && !isset($originalNestedData[$attribute])) { + throw new LogicException(\sprintf('Duplicate values for key "%s" found. One value is set via the SerializedPath attribute: "%s", the other one is set via the SerializedName attribute: "%s".', $notConverted, implode('->', $nestedAttributes[$notConverted]->getElements()), $attribute)); + } } $attributeContext = $this->getAttributeDenormalizationContext($resolvedClass, $attribute, $context); - if ((false !== $allowedAttributes && !\in_array($attribute, $allowedAttributes)) || !$this->isAllowedAttribute($resolvedClass, $attribute, $format, $context)) { + if ((false !== $allowedAttributes && !\in_array($attribute, $allowedAttributes, true)) || !$this->isAllowedAttribute($resolvedClass, $attribute, $format, $context)) { if (!($context[self::ALLOW_EXTRA_ATTRIBUTES] ?? $this->defaultContext[self::ALLOW_EXTRA_ATTRIBUTES])) { $extraAttributes[] = $attribute; } @@ -401,32 +359,29 @@ public function denormalize($data, string $type, ?string $format = null, array $ } if ($attributeContext[self::DEEP_OBJECT_TO_POPULATE] ?? $this->defaultContext[self::DEEP_OBJECT_TO_POPULATE] ?? false) { - $discriminatorProperty = null; - if (null !== $this->classDiscriminatorResolver && null !== $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) { - $discriminatorProperty = $mapping->getTypeProperty(); - } + $discriminatorMapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object); try { - $attributeContext[self::OBJECT_TO_POPULATE] = $attribute === $discriminatorProperty - ? $this->classDiscriminatorResolver->getTypeForMappedObject($object) + $attributeContext[self::OBJECT_TO_POPULATE] = $attribute === $discriminatorMapping?->getTypeProperty() + ? $discriminatorMapping : $this->getAttributeValue($object, $attribute, $format, $attributeContext); - } catch (NoSuchPropertyException $e) { - } catch (UninitializedPropertyException $e) { - if (!($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true)) { - throw $e; - } - } catch (\Error $e) { + } catch (NoSuchPropertyException) { + } catch (UninitializedPropertyException|\Error $e) { if (!(($context[self::SKIP_UNINITIALIZED_VALUES] ?? $this->defaultContext[self::SKIP_UNINITIALIZED_VALUES] ?? true) && $this->isUninitializedValueError($e))) { throw $e; } } } - $types = $this->getTypes($resolvedClass, $attribute); - - if (null !== $types) { + if (null !== $type = $this->getType($resolvedClass, $attribute)) { try { - $value = $this->validateAndDenormalize($types, $resolvedClass, $attribute, $value, $format, $attributeContext); + // BC layer for PropertyTypeExtractorInterface::getTypes(). + // Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). + if (\is_array($type)) { + $value = $this->validateAndDenormalizeLegacy($type, $resolvedClass, $attribute, $value, $format, $attributeContext); + } else { + $value = $this->validateAndDenormalize($type, $resolvedClass, $attribute, $value, $format, $attributeContext); + } } catch (NotNormalizableValueException $exception) { if (isset($context['not_normalizable_value_exceptions'])) { $context['not_normalizable_value_exceptions'][] = $exception; @@ -442,9 +397,9 @@ public function denormalize($data, string $type, ?string $format = null, array $ $this->setAttributeValue($object, $attribute, $value, $format, $attributeContext); } catch (PropertyAccessInvalidArgumentException $e) { $exception = NotNormalizableValueException::createForUnexpectedDataType( - sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $type), + \sprintf('Failed to denormalize attribute "%s" value for class "%s": '.$e->getMessage(), $attribute, $resolvedClass), $data, - ['unknown'], + $e instanceof InvalidTypeException ? [$e->expectedType] : ['unknown'], $attributeContext['deserialization_path'] ?? null, false, $e->getCode(), @@ -465,31 +420,28 @@ public function denormalize($data, string $type, ?string $format = null, array $ return $object; } - /** - * Sets attribute value. - */ - abstract protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []); + abstract protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void; /** * Validates the submitted data and denormalizes it. * - * @param Type[] $types - * @param mixed $data + * BC layer for PropertyTypeExtractorInterface::getTypes(). + * Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). * - * @return mixed + * @param LegacyType[] $types * * @throws NotNormalizableValueException * @throws ExtraAttributesException * @throws MissingConstructorArgumentsException * @throws LogicException */ - private function validateAndDenormalize(array $types, string $currentClass, string $attribute, $data, ?string $format, array $context) + private function validateAndDenormalizeLegacy(array $types, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed { $expectedTypes = []; $isUnionType = \count($types) > 1; $e = null; $extraAttributesException = null; - $missingConstructorArgumentException = null; + $missingConstructorArgumentsException = null; $isNullable = false; foreach ($types as $type) { if (null === $data && $type->isNullable()) { @@ -516,11 +468,11 @@ private function validateAndDenormalize(array $types, string $currentClass, stri $builtinType = $type->getBuiltinType(); if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { if ('' === $data) { - if (Type::BUILTIN_TYPE_ARRAY === $builtinType) { + if (LegacyType::BUILTIN_TYPE_ARRAY === $builtinType) { return []; } - if (Type::BUILTIN_TYPE_STRING === $builtinType) { + if (LegacyType::BUILTIN_TYPE_STRING === $builtinType) { return ''; } @@ -529,43 +481,39 @@ private function validateAndDenormalize(array $types, string $currentClass, stri } switch ($builtinType) { - case Type::BUILTIN_TYPE_BOOL: + case LegacyType::BUILTIN_TYPE_BOOL: // according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" if ('false' === $data || '0' === $data) { $data = false; } elseif ('true' === $data || '1' === $data) { $data = true; } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [LegacyType::BUILTIN_TYPE_BOOL], $context['deserialization_path'] ?? null); } break; - case Type::BUILTIN_TYPE_INT: - if (ctype_digit($data) || isset($data[0]) && '-' === $data[0] && ctype_digit(substr($data, 1))) { + case LegacyType::BUILTIN_TYPE_INT: + if (ctype_digit(isset($data[0]) && '-' === $data[0] ? substr($data, 1) : $data)) { $data = (int) $data; } else { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [LegacyType::BUILTIN_TYPE_INT], $context['deserialization_path'] ?? null); } break; - case Type::BUILTIN_TYPE_FLOAT: + case LegacyType::BUILTIN_TYPE_FLOAT: if (is_numeric($data)) { return (float) $data; } - switch ($data) { - case 'NaN': - return \NAN; - case 'INF': - return \INF; - case '-INF': - return -\INF; - default: - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null); - } + return match ($data) { + 'NaN' => \NAN, + 'INF' => \INF, + '-INF' => -\INF, + default => throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [LegacyType::BUILTIN_TYPE_FLOAT], $context['deserialization_path'] ?? null), + }; } } - if (null !== $collectionValueType && Type::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) { - $builtinType = Type::BUILTIN_TYPE_OBJECT; + if (null !== $collectionValueType && LegacyType::BUILTIN_TYPE_OBJECT === $collectionValueType->getBuiltinType()) { + $builtinType = LegacyType::BUILTIN_TYPE_OBJECT; $class = $collectionValueType->getClassName().'[]'; if (\count($collectionKeyType = $type->getCollectionKeyTypes()) > 0) { @@ -573,13 +521,13 @@ private function validateAndDenormalize(array $types, string $currentClass, stri } $context['value_type'] = $collectionValueType; - } elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { + } elseif ($type->isCollection() && \count($collectionValueType = $type->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $collectionValueType[0]->getBuiltinType()) { // get inner type for any nested array [$innerType] = $collectionValueType; // note that it will break for any other builtinType $dimensions = '[]'; - while (\count($innerType->getCollectionValueTypes()) > 0 && Type::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { + while (\count($innerType->getCollectionValueTypes()) > 0 && LegacyType::BUILTIN_TYPE_ARRAY === $innerType->getBuiltinType()) { $dimensions .= '[]'; [$innerType] = $innerType->getCollectionValueTypes(); } @@ -598,11 +546,11 @@ private function validateAndDenormalize(array $types, string $currentClass, stri $class = $type->getClassName(); } - $expectedTypes[Type::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true; + $expectedTypes[LegacyType::BUILTIN_TYPE_OBJECT === $builtinType && $class ? $class : $builtinType] = true; - if (Type::BUILTIN_TYPE_OBJECT === $builtinType && null !== $class) { + if (LegacyType::BUILTIN_TYPE_OBJECT === $builtinType && null !== $class) { if (!$this->serializer instanceof DenormalizerInterface) { - throw new LogicException(sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class)); + throw new LogicException(\sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class)); } $childContext = $this->createChildContext($context, $attribute, $format); @@ -617,40 +565,36 @@ private function validateAndDenormalize(array $types, string $currentClass, stri // PHP's json_decode automatically converts Numbers without a decimal part to integers. // To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when // a float is expected. - if (Type::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) { + if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) { return (float) $data; } - switch ($builtinType) { - case Type::BUILTIN_TYPE_ARRAY: - case Type::BUILTIN_TYPE_BOOL: - case Type::BUILTIN_TYPE_CALLABLE: - case Type::BUILTIN_TYPE_FLOAT: - case Type::BUILTIN_TYPE_INT: - case Type::BUILTIN_TYPE_ITERABLE: - case Type::BUILTIN_TYPE_NULL: - case Type::BUILTIN_TYPE_OBJECT: - case Type::BUILTIN_TYPE_RESOURCE: - case Type::BUILTIN_TYPE_STRING: - if (('is_'.$builtinType)($data)) { - return $data; - } + if (LegacyType::BUILTIN_TYPE_BOOL === $builtinType && (\is_string($data) || \is_int($data)) && ($context[self::FILTER_BOOL] ?? false)) { + return filter_var($data, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + } - break; - case Type::BUILTIN_TYPE_FALSE: - if (false === $data) { - return $data; - } + if ((LegacyType::BUILTIN_TYPE_FALSE === $builtinType && false === $data) || (LegacyType::BUILTIN_TYPE_TRUE === $builtinType && true === $data)) { + return $data; + } - break; - case Type::BUILTIN_TYPE_TRUE: - if (true === $data) { + switch ($builtinType) { + case LegacyType::BUILTIN_TYPE_ARRAY: + case LegacyType::BUILTIN_TYPE_BOOL: + case LegacyType::BUILTIN_TYPE_CALLABLE: + case LegacyType::BUILTIN_TYPE_FLOAT: + case LegacyType::BUILTIN_TYPE_INT: + case LegacyType::BUILTIN_TYPE_ITERABLE: + case LegacyType::BUILTIN_TYPE_NULL: + case LegacyType::BUILTIN_TYPE_OBJECT: + case LegacyType::BUILTIN_TYPE_RESOURCE: + case LegacyType::BUILTIN_TYPE_STRING: + if (('is_'.$builtinType)($data)) { return $data; } break; } - } catch (NotNormalizableValueException $e) { + } catch (NotNormalizableValueException|InvalidArgumentException $e) { if (!$isUnionType && !$isNullable) { throw $e; } @@ -659,17 +603,13 @@ private function validateAndDenormalize(array $types, string $currentClass, stri throw $e; } - if (!$extraAttributesException) { - $extraAttributesException = $e; - } + $extraAttributesException ??= $e; } catch (MissingConstructorArgumentsException $e) { if (!$isUnionType && !$isNullable) { throw $e; } - if (!$missingConstructorArgumentException) { - $missingConstructorArgumentException = $e; - } + $missingConstructorArgumentsException ??= $e; } } @@ -681,8 +621,8 @@ private function validateAndDenormalize(array $types, string $currentClass, stri throw $extraAttributesException; } - if ($missingConstructorArgumentException) { - throw $missingConstructorArgumentException; + if ($missingConstructorArgumentsException) { + throw $missingConstructorArgumentsException; } if (!$isUnionType && $e) { @@ -693,71 +633,385 @@ private function validateAndDenormalize(array $types, string $currentClass, stri return $data; } - 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); + 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); + } + + /** + * Validates the submitted data and denormalizes it. + * + * @throws NotNormalizableValueException + * @throws ExtraAttributesException + * @throws MissingConstructorArgumentsException + * @throws LogicException + */ + private function validateAndDenormalize(Type $type, string $currentClass, string $attribute, mixed $data, ?string $format, array $context): mixed + { + $expectedTypes = []; + + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'asNonNullable')) { + $isUnionType = $type->asNonNullable() instanceof UnionType; + } else { + $isUnionType = $type instanceof UnionType; + } + + $e = null; + $extraAttributesException = null; + $missingConstructorArgumentsException = null; + + $types = match (true) { + $type instanceof IntersectionType => throw new LogicException('Unable to handle intersection type.'), + $type instanceof UnionType => $type->getTypes(), + default => [$type], + }; + + foreach ($types as $t) { + if (null === $data && $type->isNullable()) { + return null; + } + + $collectionKeyType = $collectionValueType = null; + if ($t instanceof CollectionType) { + $collectionKeyType = $t->getCollectionKeyType(); + $collectionValueType = $t->getCollectionValueType(); + } + + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'getBaseType')) { + $t = $t->getBaseType(); + } else { + while ($t instanceof WrappingTypeInterface) { + $t = $t->getWrappedType(); + } + } + + // Fix a collection that contains the only one element + // This is special to xml format only + if ('xml' === $format && $collectionValueType && (!\is_array($data) || !\is_int(key($data)))) { + // BC layer for type-info < 7.2 + $isMixedType = method_exists(Type::class, 'isA') ? $collectionValueType->isA(TypeIdentifier::MIXED) : $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); + if (!$isMixedType) { + $data = [$data]; + } + } + + // This try-catch should cover all NotNormalizableValueException (and all return branches after the first + // exception) so we could try denormalizing all types of an union type. If the target type is not an union + // type, we will just re-throw the catched exception. + // In the case of no denormalization succeeds with an union type, it will fall back to the default exception + // with the acceptable types list. + try { + // In XML and CSV all basic datatypes are represented as strings, it is e.g. not possible to determine, + // if a value is meant to be a string, float, int or a boolean value from the serialized representation. + // That's why we have to transform the values, if one of these non-string basic datatypes is expected. + $typeIdentifier = $t->getTypeIdentifier(); + if (\is_string($data) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { + if ('' === $data) { + if (TypeIdentifier::ARRAY === $typeIdentifier) { + return []; + } + + if (TypeIdentifier::STRING === $typeIdentifier) { + return ''; + } + } + + switch ($typeIdentifier) { + case TypeIdentifier::BOOL: + // according to https://www.w3.org/TR/xmlschema-2/#boolean, valid representations are "false", "true", "0" and "1" + if ('false' === $data || '0' === $data) { + $data = false; + } elseif ('true' === $data || '1' === $data) { + $data = true; + } else { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be bool ("%s" given).', $attribute, $currentClass, $data), $data, [Type::bool()], $context['deserialization_path'] ?? null); + } + break; + case TypeIdentifier::INT: + if (ctype_digit(isset($data[0]) && '-' === $data[0] ? substr($data, 1) : $data)) { + $data = (int) $data; + } else { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be int ("%s" given).', $attribute, $currentClass, $data), $data, [Type::int()], $context['deserialization_path'] ?? null); + } + break; + case TypeIdentifier::FLOAT: + if (is_numeric($data)) { + return (float) $data; + } + + return match ($data) { + 'NaN' => \NAN, + 'INF' => \INF, + '-INF' => -\INF, + default => throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute for class "%s" must be float ("%s" given).', $attribute, $currentClass, $data), $data, [Type::float()], $context['deserialization_path'] ?? null), + }; + } + } + + if ($collectionValueType) { + try { + $collectionValueBaseType = $collectionValueType; + + // BC layer for type-info < 7.2 + if (!interface_exists(WrappingTypeInterface::class)) { + $collectionValueBaseType = $collectionValueType->getBaseType(); + } else { + while ($collectionValueBaseType instanceof WrappingTypeInterface) { + $collectionValueBaseType = $collectionValueBaseType->getWrappedType(); + } + } + } catch (TypeInfoLogicException) { + $collectionValueBaseType = Type::mixed(); + } + + if ($collectionValueBaseType instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $class = $collectionValueBaseType->getClassName().'[]'; + $context['key_type'] = $collectionKeyType; + $context['value_type'] = $collectionValueType; + } elseif ( + // BC layer for type-info < 7.2 + !class_exists(NullableType::class) && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier() + || $collectionValueBaseType instanceof BuiltinType && TypeIdentifier::ARRAY === $collectionValueBaseType->getTypeIdentifier() + ) { + // get inner type for any nested array + $innerType = $collectionValueType; + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } + + // note that it will break for any other builtinType + $dimensions = '[]'; + while ($innerType instanceof CollectionType) { + $dimensions .= '[]'; + $innerType = $innerType->getCollectionValueType(); + if ($innerType instanceof NullableType) { + $innerType = $innerType->getWrappedType(); + } + } + + while ($innerType instanceof WrappingTypeInterface) { + $innerType = $innerType->getWrappedType(); + } + + if ($innerType instanceof ObjectType) { + // the builtinType is the inner one and the class is the class followed by []...[] + $typeIdentifier = TypeIdentifier::OBJECT; + $class = $innerType->getClassName().$dimensions; + } else { + // default fallback (keep it as array) + if ($t instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $class = $t->getClassName(); + } else { + $typeIdentifier = $t->getTypeIdentifier(); + $class = null; + } + } + } elseif ($t instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $class = $t->getClassName(); + } else { + $typeIdentifier = $t->getTypeIdentifier(); + $class = null; + } + } else { + if ($t instanceof ObjectType) { + $typeIdentifier = TypeIdentifier::OBJECT; + $class = $t->getClassName(); + } else { + $typeIdentifier = $t->getTypeIdentifier(); + $class = null; + } + } + + $expectedTypes[TypeIdentifier::OBJECT === $typeIdentifier && $class ? $class : $typeIdentifier->value] = true; + + if (TypeIdentifier::OBJECT === $typeIdentifier && null !== $class) { + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException(\sprintf('Cannot denormalize attribute "%s" for class "%s" because injected serializer is not a denormalizer.', $attribute, $class)); + } + + $childContext = $this->createChildContext($context, $attribute, $format); + if ($this->serializer->supportsDenormalization($data, $class, $format, $childContext)) { + return $this->serializer->denormalize($data, $class, $format, $childContext); + } + } + + // JSON only has a Number type corresponding to both int and float PHP types. + // PHP's json_encode, JavaScript's JSON.stringify, Go's json.Marshal as well as most other JSON encoders convert + // floating-point numbers like 12.0 to 12 (the decimal part is dropped when possible). + // PHP's json_decode automatically converts Numbers without a decimal part to integers. + // To circumvent this behavior, integers are converted to floats when denormalizing JSON based formats and when + // a float is expected. + if (TypeIdentifier::FLOAT === $typeIdentifier && \is_int($data) && null !== $format && str_contains($format, JsonEncoder::FORMAT)) { + return (float) $data; + } + + if (TypeIdentifier::BOOL === $typeIdentifier && (\is_string($data) || \is_int($data)) && ($context[self::FILTER_BOOL] ?? false)) { + return filter_var($data, \FILTER_VALIDATE_BOOL, \FILTER_NULL_ON_FAILURE); + } + + $dataMatchesExpectedType = match ($typeIdentifier) { + TypeIdentifier::ARRAY => \is_array($data), + TypeIdentifier::BOOL => \is_bool($data), + TypeIdentifier::CALLABLE => \is_callable($data), + TypeIdentifier::FALSE => false === $data, + TypeIdentifier::FLOAT => \is_float($data), + TypeIdentifier::INT => \is_int($data), + TypeIdentifier::ITERABLE => is_iterable($data), + TypeIdentifier::MIXED => true, + TypeIdentifier::NULL => null === $data, + TypeIdentifier::OBJECT => \is_object($data), + TypeIdentifier::RESOURCE => \is_resource($data), + TypeIdentifier::STRING => \is_string($data), + TypeIdentifier::TRUE => true === $data, + default => false, + }; + + if ($dataMatchesExpectedType) { + return $data; + } + } catch (NotNormalizableValueException|InvalidArgumentException $e) { + if (!$type instanceof UnionType) { + throw $e; + } + } catch (ExtraAttributesException $e) { + if (!$type instanceof UnionType) { + throw $e; + } + + $extraAttributesException ??= $e; + } catch (MissingConstructorArgumentsException $e) { + if (!$type instanceof UnionType) { + throw $e; + } + + $missingConstructorArgumentsException ??= $e; + } + } + + if ('' === $data && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format) && $type->isNullable()) { + return null; + } + + if ($extraAttributesException) { + throw $extraAttributesException; + } + + if ($missingConstructorArgumentsException) { + throw $missingConstructorArgumentsException; + } + + // BC layer for type-info < 7.2 + if (!class_exists(NullableType::class)) { + if (!$isUnionType && $e) { + throw $e; + } + } else { + if ($e && !($type instanceof UnionType && !$type instanceof NullableType)) { + throw $e; + } + } + + if ($context[self::DISABLE_TYPE_ENFORCEMENT] ?? $this->defaultContext[self::DISABLE_TYPE_ENFORCEMENT] ?? false) { + return $data; + } + + 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); } /** * @internal */ - protected function denormalizeParameter(\ReflectionClass $class, \ReflectionParameter $parameter, string $parameterName, $parameterData, array $context, ?string $format = null) + protected function denormalizeParameter(\ReflectionClass $class, \ReflectionParameter $parameter, string $parameterName, mixed $parameterData, array $context, ?string $format = null): mixed { - if ($parameter->isVariadic() || null === $this->propertyTypeExtractor || null === $types = $this->getTypes($class->getName(), $parameterName)) { + if ($parameter->isVariadic() || null === $this->propertyTypeExtractor || null === $type = $this->getType($class->getName(), $parameterName)) { return parent::denormalizeParameter($class, $parameter, $parameterName, $parameterData, $context, $format); } - $parameterData = $this->validateAndDenormalize($types, $class->getName(), $parameterName, $parameterData, $format, $context); + // BC layer for PropertyTypeExtractorInterface::getTypes(). + // Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). + if (\is_array($type)) { + $parameterData = $this->validateAndDenormalizeLegacy($type, $class->getName(), $parameterName, $parameterData, $format, $context); + } else { + $parameterData = $this->validateAndDenormalize($type, $class->getName(), $parameterName, $parameterData, $format, $context); + } - return $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + $parameterData = $this->applyCallbacks($parameterData, $class->getName(), $parameterName, $format, $context); + + return $this->applyFilterBool($parameter, $parameterData, $context); } /** - * @return Type[]|null + * @return Type|list|null */ - private function getTypes(string $currentClass, string $attribute): ?array + private function getType(string $currentClass, string $attribute): Type|array|null { if (null === $this->propertyTypeExtractor) { return null; } $key = $currentClass.'::'.$attribute; - if (isset($this->typesCache[$key])) { - return false === $this->typesCache[$key] ? null : $this->typesCache[$key]; + if (isset($this->typeCache[$key])) { + return false === $this->typeCache[$key] ? null : $this->typeCache[$key]; } - if (null !== $types = $this->propertyTypeExtractor->getTypes($currentClass, $attribute)) { - return $this->typesCache[$key] = $types; + if (null !== $type = $this->getPropertyType($currentClass, $attribute)) { + return $this->typeCache[$key] = $type; } - if (null !== $this->classDiscriminatorResolver && null !== $discriminatorMapping = $this->classDiscriminatorResolver->getMappingForClass($currentClass)) { + if ($discriminatorMapping = $this->classDiscriminatorResolver?->getMappingForClass($currentClass)) { if ($discriminatorMapping->getTypeProperty() === $attribute) { - return $this->typesCache[$key] = [ - new Type(Type::BUILTIN_TYPE_STRING), - ]; + return $this->typeCache[$key] = Type::string(); } foreach ($discriminatorMapping->getTypesMapping() as $mappedClass) { - if (null !== $types = $this->propertyTypeExtractor->getTypes($mappedClass, $attribute)) { - return $this->typesCache[$key] = $types; + if (null !== $type = $this->getPropertyType($mappedClass, $attribute)) { + return $this->typeCache[$key] = $type; } } } - $this->typesCache[$key] = false; + $this->typeCache[$key] = false; return null; } /** - * Sets an attribute and apply the name converter if necessary. + * BC layer for PropertyTypeExtractorInterface::getTypes(). + * Can be removed as soon as PropertyTypeExtractorInterface::getTypes() is removed (8.0). * - * @param mixed $attributeValue + * @return Type|list|null + */ + private function getPropertyType(string $className, string $property): Type|array|null + { + if (class_exists(Type::class) && method_exists($this->propertyTypeExtractor, 'getType')) { + return $this->propertyTypeExtractor->getType($className, $property); + } + + return $this->propertyTypeExtractor->getTypes($className, $property); + } + + /** + * Sets an attribute and apply the name converter if necessary. */ - private function updateData(array $data, string $attribute, $attributeValue, string $class, ?string $format, array $context): array + private function updateData(array $data, string $attribute, mixed $attributeValue, string $class, ?string $format, array $context, ?array $attributesMetadata, ?ClassMetadataInterface $classMetadata): array { if (null === $attributeValue && ($context[self::SKIP_NULL_VALUES] ?? $this->defaultContext[self::SKIP_NULL_VALUES] ?? false)) { return $data; } + if (null !== $classMetadata && null !== $serializedPath = ($attributesMetadata[$attribute] ?? null)?->getSerializedPath()) { + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + if ($propertyAccessor->isReadable($data, $serializedPath) && null !== $propertyAccessor->getValue($data, $serializedPath)) { + throw new LogicException(\sprintf('The element you are trying to set is already populated: "%s".', (string) $serializedPath)); + } + $propertyAccessor->setValue($data, $serializedPath, $attributeValue); + + return $data; + } + if ($this->nameConverter) { $attribute = $this->nameConverter->normalize($attribute, $class, $format, $context); } @@ -774,16 +1028,13 @@ private function updateData(array $data, string $attribute, $attributeValue, str */ private function isMaxDepthReached(array $attributesMetadata, string $class, string $attribute, array &$context): bool { - $enableMaxDepth = $context[self::ENABLE_MAX_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_DEPTH] ?? false; - if ( - !$enableMaxDepth || - !isset($attributesMetadata[$attribute]) || - null === $maxDepth = $attributesMetadata[$attribute]->getMaxDepth() + if (!($enableMaxDepth = $context[self::ENABLE_MAX_DEPTH] ?? $this->defaultContext[self::ENABLE_MAX_DEPTH] ?? false) + || !isset($attributesMetadata[$attribute]) || null === $maxDepth = $attributesMetadata[$attribute]?->getMaxDepth() ) { return false; } - $key = sprintf(self::DEPTH_KEY_PATTERN, $class, $attribute); + $key = \sprintf(self::DEPTH_KEY_PATTERN, $class, $attribute); if (!isset($context[$key])) { $context[$key] = 1; @@ -804,8 +1055,6 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str * * We must not mix up the attribute cache between parent and children. * - * {@inheritdoc} - * * @internal */ protected function createChildContext(array $parentContext, string $attribute, ?string $format): array @@ -824,10 +1073,8 @@ protected function createChildContext(array $parentContext, string $attribute, ? * 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 getCacheKey(?string $format, array $context) + private function getCacheKey(?string $format, array $context): bool|string { foreach ($context[self::EXCLUDE_FROM_CACHE_KEY] ?? $this->defaultContext[self::EXCLUDE_FROM_CACHE_KEY] as $key) { unset($context[$key]); @@ -837,11 +1084,11 @@ private function getCacheKey(?string $format, array $context) unset($context['cache_key']); // avoid artificially different keys try { - return md5($format.serialize([ + return hash('xxh128', $format.serialize([ 'context' => $context, 'ignored' => $context[self::IGNORED_ATTRIBUTES] ?? $this->defaultContext[self::IGNORED_ATTRIBUTES], ])); - } catch (\Exception $e) { + } catch (\Exception) { // The context cannot be serialized, skip the cache return false; } @@ -851,10 +1098,71 @@ private function getCacheKey(?string $format, array $context) * This error may occur when specific object normalizer implementation gets attribute value * by accessing a public uninitialized property or by calling a method accessing such property. */ - private function isUninitializedValueError(\Error $e): bool + private function isUninitializedValueError(\Error|UninitializedPropertyException $e): bool { - return \PHP_VERSION_ID >= 70400 - && str_starts_with($e->getMessage(), 'Typed property') + return $e instanceof UninitializedPropertyException + || str_starts_with($e->getMessage(), 'Typed property') && str_ends_with($e->getMessage(), 'must not be accessed before initialization'); } + + /** + * Returns all attributes with a SerializedPath attribute and the respective path. + */ + private function getNestedAttributes(string $class): array + { + if (!$this->classMetadataFactory?->hasMetadataFor($class)) { + return []; + } + + $properties = []; + $serializedPaths = []; + $classMetadata = $this->classMetadataFactory->getMetadataFor($class); + foreach ($classMetadata->getAttributesMetadata() as $name => $metadata) { + if (!$serializedPath = $metadata->getSerializedPath()) { + continue; + } + $pathIdentifier = implode(',', $serializedPath->getElements()); + if (isset($serializedPaths[$pathIdentifier])) { + throw new LogicException(\sprintf('Duplicate serialized path: "%s" used for properties "%s" and "%s".', $pathIdentifier, $serializedPaths[$pathIdentifier], $name)); + } + $serializedPaths[$pathIdentifier] = $name; + $properties[$name] = $serializedPath; + } + + return $properties; + } + + private function removeNestedValue(array $path, array $data): array + { + $element = array_shift($path); + if (!$path || !$data[$element] = $this->removeNestedValue($path, $data[$element])) { + unset($data[$element]); + } + + return $data; + } + + /** + * @return class-string + */ + private function getMappedClass(array $data, string $class, array $context): string + { + if (null !== $object = $this->extractObjectToPopulate($class, $context, self::OBJECT_TO_POPULATE)) { + return $object::class; + } + + if (!$mapping = $this->classDiscriminatorResolver?->getMappingForClass($class)) { + return $class; + } + + if (null === $type = $data[$mapping->getTypeProperty()] ?? null) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Type property "%s" not found for the abstract object "%s".', $mapping->getTypeProperty(), $class), null, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), false); + } + + if (null === $mappedClass = $mapping->getClassForType($type)) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type "%s" is not a valid value.', $type), $type, ['string'], isset($context['deserialization_path']) ? $context['deserialization_path'].'.'.$mapping->getTypeProperty() : $mapping->getTypeProperty(), true); + } + + return $mappedClass; + } } diff --git a/Normalizer/ArrayDenormalizer.php b/Normalizer/ArrayDenormalizer.php index 248a8bb66..96c4d259c 100644 --- a/Normalizer/ArrayDenormalizer.php +++ b/Normalizer/ArrayDenormalizer.php @@ -11,13 +11,14 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\BadMethodCallException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerAwareInterface; -use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\TypeInfo\Type; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\Type\UnionType; +use Symfony\Component\TypeInfo\TypeIdentifier; /** * Denormalizes arrays of objects. @@ -26,22 +27,25 @@ * * @final */ -class ArrayDenormalizer implements ContextAwareDenormalizerInterface, DenormalizerAwareInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +class ArrayDenormalizer implements DenormalizerInterface, DenormalizerAwareInterface { use DenormalizerAwareTrait; + public function getSupportedTypes(?string $format): array + { + return ['object' => null, '*' => false]; + } + /** - * {@inheritdoc} - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []): array + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): array { - if (null === $this->denormalizer) { + if (!isset($this->denormalizer)) { throw new BadMethodCallException('Please set a denormalizer before calling denormalize()!'); } if (!\is_array($data)) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be "%s", "%s" given.', $type, get_debug_type($data)), $data, [Type::BUILTIN_TYPE_ARRAY], $context['deserialization_path'] ?? null); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Data expected to be "%s", "%s" given.', $type, get_debug_type($data)), $data, ['array'], $context['deserialization_path'] ?? null); } if (!str_ends_with($type, '[]')) { throw new InvalidArgumentException('Unsupported class: '.$type); @@ -49,15 +53,28 @@ public function denormalize($data, string $type, ?string $format = null, array $ $type = substr($type, 0, -2); - $builtinTypes = array_map(static function (Type $keyType) { - return $keyType->getBuiltinType(); - }, \is_array($keyType = $context['key_type'] ?? []) ? $keyType : [$keyType]); + $typeIdentifiers = []; + if (null !== $keyType = ($context['key_type'] ?? null)) { + if ($keyType instanceof Type) { + // BC layer for type-info < 7.2 + if (method_exists(Type::class, 'getBaseType')) { + $typeIdentifiers = array_map(fn (Type $t): string => $t->getBaseType()->getTypeIdentifier()->value, $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]); + } else { + /** @var list|BuiltinType> */ + $keyTypes = $keyType instanceof UnionType ? $keyType->getTypes() : [$keyType]; + + $typeIdentifiers = array_map(fn (BuiltinType $t): string => $t->getTypeIdentifier()->value, $keyTypes); + } + } else { + $typeIdentifiers = array_map(fn (LegacyType $t): string => $t->getBuiltinType(), \is_array($keyType) ? $keyType : [$keyType]); + } + } foreach ($data as $key => $value) { $subContext = $context; - $subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]"; + $subContext['deserialization_path'] = ($context['deserialization_path'] ?? false) ? \sprintf('%s[%s]', $context['deserialization_path'], $key) : "[$key]"; - $this->validateKeyType($builtinTypes, $key, $subContext['deserialization_path']); + $this->validateKeyType($typeIdentifiers, $key, $subContext['deserialization_path']); $data[$key] = $this->denormalizer->denormalize($value, $type, $format, $subContext); } @@ -65,13 +82,10 @@ public function denormalize($data, string $type, ?string $format = null, array $ return $data; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - if (null === $this->denormalizer) { - throw new BadMethodCallException(sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__)); + if (!isset($this->denormalizer)) { + throw new BadMethodCallException(\sprintf('The nested denormalizer needs to be set to allow "%s()" to be used.', __METHOD__)); } return str_ends_with($type, '[]') @@ -79,46 +93,20 @@ public function supportsDenormalization($data, string $type, ?string $format = n } /** - * {@inheritdoc} - * - * @deprecated call setDenormalizer() instead - */ - public function setSerializer(SerializerInterface $serializer) - { - if (!$serializer instanceof DenormalizerInterface) { - throw new InvalidArgumentException('Expected a serializer that also implements DenormalizerInterface.'); - } - - if (Serializer::class !== debug_backtrace()[1]['class'] ?? null) { - trigger_deprecation('symfony/serializer', '5.3', 'Calling "%s()" is deprecated. Please call setDenormalizer() instead.', __METHOD__); - } - - $this->setDenormalizer($serializer); - } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return $this->denormalizer instanceof CacheableSupportsMethodInterface && $this->denormalizer->hasCacheableSupportsMethod(); - } - - /** - * @param mixed $key + * @param list $typeIdentifiers */ - private function validateKeyType(array $builtinTypes, $key, string $path): void + private function validateKeyType(array $typeIdentifiers, mixed $key, string $path): void { - if (!$builtinTypes) { + if (!$typeIdentifiers) { return; } - foreach ($builtinTypes as $builtinType) { - if (('is_'.$builtinType)($key)) { + foreach ($typeIdentifiers as $typeIdentifier) { + if (('is_'.$typeIdentifier)($key)) { return; } } - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, implode('", "', $builtinTypes), get_debug_type($key)), $key, $builtinTypes, $path, true); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the key "%s" must be "%s" ("%s" given).', $key, implode('", "', $typeIdentifiers), get_debug_type($key)), $key, $typeIdentifiers, $path, true); } } diff --git a/Normalizer/BackedEnumNormalizer.php b/Normalizer/BackedEnumNormalizer.php index 8f3e55a1f..3d8e7e7c5 100644 --- a/Normalizer/BackedEnumNormalizer.php +++ b/Normalizer/BackedEnumNormalizer.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -20,16 +19,21 @@ * * @author Alexandre Daubois */ -final class BackedEnumNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class BackedEnumNormalizer implements NormalizerInterface, DenormalizerInterface { /** - * {@inheritdoc} - * - * @return int|string - * - * @throws InvalidArgumentException + * If true, will denormalize any invalid value into null. */ - public function normalize($object, ?string $format = null, array $context = []) + public const ALLOW_INVALID_VALUES = 'allow_invalid_values'; + + public function getSupportedTypes(?string $format): array + { + return [ + \BackedEnum::class => true, + ]; + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): int|string { if (!$object instanceof \BackedEnum) { throw new InvalidArgumentException('The data must belong to a backed enumeration.'); @@ -38,27 +42,34 @@ public function normalize($object, ?string $format = null, array $context = []) return $object->value; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \BackedEnum; } /** - * {@inheritdoc} - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { if (!is_subclass_of($type, \BackedEnum::class)) { throw new InvalidArgumentException('The data must belong to a backed enumeration.'); } + if ($context[self::ALLOW_INVALID_VALUES] ?? false) { + if (null === $data || (!\is_int($data) && !\is_string($data))) { + return null; + } + + try { + return $type::tryFrom($data); + } catch (\TypeError) { + return null; + } + } + if (!\is_int($data) && !\is_string($data)) { - throw NotNormalizableValueException::createForUnexpectedDataType('The data is neither an integer nor a string, you should pass an integer or a string that can be parsed as an enumeration case of type '.$type.'.', $data, [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType('The data is neither an integer nor a string, you should pass an integer or a string that can be parsed as an enumeration case of type '.$type.'.', $data, ['int', 'string'], $context['deserialization_path'] ?? null, true); } try { @@ -72,19 +83,8 @@ public function denormalize($data, string $type, ?string $format = null, array $ } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return is_subclass_of($type, \BackedEnum::class); } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return true; - } } diff --git a/Normalizer/CacheableSupportsMethodInterface.php b/Normalizer/CacheableSupportsMethodInterface.php deleted file mode 100644 index 3a55f653b..000000000 --- a/Normalizer/CacheableSupportsMethodInterface.php +++ /dev/null @@ -1,26 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Normalizer; - -/** - * Marker interface for normalizers and denormalizers that use - * only the type and the format in their supports*() methods. - * - * By implementing this interface, the return value of the - * supports*() methods will be cached by type and format. - * - * @author Kévin Dunglas - */ -interface CacheableSupportsMethodInterface -{ - public function hasCacheableSupportsMethod(): bool; -} diff --git a/Normalizer/ConstraintViolationListNormalizer.php b/Normalizer/ConstraintViolationListNormalizer.php index dd9f859c2..eda3b758e 100644 --- a/Normalizer/ConstraintViolationListNormalizer.php +++ b/Normalizer/ConstraintViolationListNormalizer.php @@ -22,7 +22,7 @@ * @author Grégoire Pineau * @author Kévin Dunglas */ -class ConstraintViolationListNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class ConstraintViolationListNormalizer implements NormalizerInterface { public const INSTANCE = 'instance'; public const STATUS = 'status'; @@ -30,21 +30,20 @@ class ConstraintViolationListNormalizer implements NormalizerInterface, Cacheabl public const TYPE = 'type'; public const PAYLOAD_FIELDS = 'payload_fields'; - private $defaultContext; - private $nameConverter; + public function __construct( + private readonly array $defaultContext = [], + private readonly ?NameConverterInterface $nameConverter = null, + ) { + } - public function __construct(array $defaultContext = [], ?NameConverterInterface $nameConverter = null) + public function getSupportedTypes(?string $format): array { - $this->defaultContext = $defaultContext; - $this->nameConverter = $nameConverter; + return [ + ConstraintViolationListInterface::class => true, + ]; } - /** - * {@inheritdoc} - * - * @return array - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array { if (\array_key_exists(self::PAYLOAD_FIELDS, $context)) { $payloadFieldsToSerialize = $context[self::PAYLOAD_FIELDS]; @@ -66,26 +65,27 @@ public function normalize($object, ?string $format = null, array $context = []) $violationEntry = [ 'propertyPath' => $propertyPath, 'title' => $violation->getMessage(), + 'template' => $violation->getMessageTemplate(), 'parameters' => $violation->getParameters(), ]; if (null !== $code = $violation->getCode()) { - $violationEntry['type'] = sprintf('urn:uuid:%s', $code); + $violationEntry['type'] = \sprintf('urn:uuid:%s', $code); } $constraint = $violation->getConstraint(); if ( - [] !== $payloadFieldsToSerialize && - $constraint && - $constraint->payload && + [] !== $payloadFieldsToSerialize + && $constraint + && $constraint->payload // If some or all payload fields are whitelisted, add them - $payloadFields = null === $payloadFieldsToSerialize || true === $payloadFieldsToSerialize ? $constraint->payload : array_intersect_key($constraint->payload, $payloadFieldsToSerialize) + && $payloadFields = null === $payloadFieldsToSerialize || true === $payloadFieldsToSerialize ? $constraint->payload : array_intersect_key($constraint->payload, $payloadFieldsToSerialize) ) { $violationEntry['payload'] = $payloadFields; } $violations[] = $violationEntry; - $prefix = $propertyPath ? sprintf('%s: ', $propertyPath) : ''; + $prefix = $propertyPath ? \sprintf('%s: ', $propertyPath) : ''; $messages[] = $prefix.$violation->getMessage(); } @@ -106,19 +106,8 @@ public function normalize($object, ?string $format = null, array $context = []) return $result + ['violations' => $violations]; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof ConstraintViolationListInterface; } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } } diff --git a/Normalizer/ContextAwareDenormalizerInterface.php b/Normalizer/ContextAwareDenormalizerInterface.php deleted file mode 100644 index 9682cf5a4..000000000 --- a/Normalizer/ContextAwareDenormalizerInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Normalizer; - -/** - * Adds the support of an extra $context parameter for the supportsDenormalization method. - * - * @author Kévin Dunglas - */ -interface ContextAwareDenormalizerInterface extends DenormalizerInterface -{ - /** - * {@inheritdoc} - * - * @param array $context options that denormalizers have access to - */ - public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []); -} diff --git a/Normalizer/ContextAwareNormalizerInterface.php b/Normalizer/ContextAwareNormalizerInterface.php deleted file mode 100644 index f20d5c991..000000000 --- a/Normalizer/ContextAwareNormalizerInterface.php +++ /dev/null @@ -1,27 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\Serializer\Normalizer; - -/** - * Adds the support of an extra $context parameter for the supportsNormalization method. - * - * @author Kévin Dunglas - */ -interface ContextAwareNormalizerInterface extends NormalizerInterface -{ - /** - * {@inheritdoc} - * - * @param array $context options that normalizers have access to - */ - public function supportsNormalization($data, ?string $format = null, array $context = []); -} diff --git a/Normalizer/CustomNormalizer.php b/Normalizer/CustomNormalizer.php index ae37783c9..d97108312 100644 --- a/Normalizer/CustomNormalizer.php +++ b/Normalizer/CustomNormalizer.php @@ -17,23 +17,25 @@ /** * @author Jordi Boggiano */ -class CustomNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +final class CustomNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { use ObjectToPopulateTrait; use SerializerAwareTrait; - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function getSupportedTypes(?string $format): array + { + return [ + NormalizableInterface::class => true, + DenormalizableInterface::class => true, + ]; + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { return $object->normalize($this->serializer, $format, $context); } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { $object = $this->extractObjectToPopulate($type, $context) ?? new $type(); $object->denormalize($this->serializer, $data, $format, $context); @@ -46,10 +48,8 @@ public function denormalize($data, string $type, ?string $format = null, array $ * * @param mixed $data Data to normalize * @param string|null $format The format being (de-)serialized from or into - * - * @return bool */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof NormalizableInterface; } @@ -60,19 +60,9 @@ public function supportsNormalization($data, ?string $format = null) * @param mixed $data Data to denormalize from * @param string $type The class to which the data should be denormalized * @param string|null $format The format being deserialized from - * - * @return bool */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return is_subclass_of($type, DenormalizableInterface::class); } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } } diff --git a/Normalizer/DataUriNormalizer.php b/Normalizer/DataUriNormalizer.php index 79042c292..5ee076be6 100644 --- a/Normalizer/DataUriNormalizer.php +++ b/Normalizer/DataUriNormalizer.php @@ -23,7 +23,7 @@ * * @author Kévin Dunglas */ -class DataUriNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class DataUriNormalizer implements NormalizerInterface, DenormalizerInterface { private const SUPPORTED_TYPES = [ \SplFileInfo::class => true, @@ -31,10 +31,7 @@ class DataUriNormalizer implements NormalizerInterface, DenormalizerInterface, C File::class => true, ]; - /** - * @var MimeTypeGuesserInterface|null - */ - private $mimeTypeGuesser; + private readonly ?MimeTypeGuesserInterface $mimeTypeGuesser; public function __construct(?MimeTypeGuesserInterface $mimeTypeGuesser = null) { @@ -45,12 +42,12 @@ public function __construct(?MimeTypeGuesserInterface $mimeTypeGuesser = null) $this->mimeTypeGuesser = $mimeTypeGuesser; } - /** - * {@inheritdoc} - * - * @return string - */ - public function normalize($object, ?string $format = null, array $context = []) + public function getSupportedTypes(?string $format): array + { + return self::SUPPORTED_TYPES; + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): string { if (!$object instanceof \SplFileInfo) { throw new InvalidArgumentException('The object must be an instance of "\SplFileInfo".'); @@ -67,35 +64,28 @@ public function normalize($object, ?string $format = null, array $context = []) } if ('text' === explode('/', $mimeType, 2)[0]) { - return sprintf('data:%s,%s', $mimeType, rawurlencode($data)); + return \sprintf('data:%s,%s', $mimeType, rawurlencode($data)); } - return sprintf('data:%s;base64,%s', $mimeType, base64_encode($data)); + return \sprintf('data:%s;base64,%s', $mimeType, base64_encode($data)); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \SplFileInfo; } /** - * {@inheritdoc} - * * Regex adapted from Brian Grinstead code. * * @see https://gist.github.com/bgrins/6194623 * - * @return \SplFileInfo - * * @throws InvalidArgumentException * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \SplFileInfo { - if (null === $data || !preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\,\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) { + if (null === $data || !preg_match('/^data:([a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}\/[a-z0-9][a-z0-9\!\#\$\&\-\^\_\+\.]{0,126}(;[a-z0-9\-]+\=[a-z0-9\-]+)?)?(;base64)?,[a-z0-9\!\$\&\\\'\,\(\)\*\+\;\=\-\.\_\~\:\@\/\?\%\s]*\s*$/i', $data)) { throw NotNormalizableValueException::createForUnexpectedDataType('The provided "data:" URI is not valid.', $data, ['string'], $context['deserialization_path'] ?? null, true); } @@ -103,7 +93,7 @@ public function denormalize($data, string $type, ?string $format = null, array $ switch ($type) { case File::class: if (!class_exists(File::class)) { - throw new InvalidArgumentException(sprintf('Cannot denormalize to a "%s" without the HttpFoundation component installed. Try running "composer require symfony/http-foundation".', File::class)); + throw new InvalidArgumentException(\sprintf('Cannot denormalize to a "%s" without the HttpFoundation component installed. Try running "composer require symfony/http-foundation".', File::class)); } return new File($data, false); @@ -116,25 +106,14 @@ public function denormalize($data, string $type, ?string $format = null, array $ throw NotNormalizableValueException::createForUnexpectedDataType($exception->getMessage(), $data, ['string'], $context['deserialization_path'] ?? null, false, $exception->getCode(), $exception); } - throw new InvalidArgumentException(sprintf('The class parameter "%s" is not supported. It must be one of "SplFileInfo", "SplFileObject" or "Symfony\Component\HttpFoundation\File\File".', $type)); + throw new InvalidArgumentException(\sprintf('The class parameter "%s" is not supported. It must be one of "SplFileInfo", "SplFileObject" or "Symfony\Component\HttpFoundation\File\File".', $type)); } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return isset(self::SUPPORTED_TYPES[$type]); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } - /** * Gets the mime type of the object. Defaults to application/octet-stream. */ @@ -144,11 +123,7 @@ private function getMimeType(\SplFileInfo $object): string return $object->getMimeType(); } - if ($this->mimeTypeGuesser && $mimeType = $this->mimeTypeGuesser->guessMimeType($object->getPathname())) { - return $mimeType; - } - - return 'application/octet-stream'; + return $this->mimeTypeGuesser?->guessMimeType($object->getPathname()) ?: 'application/octet-stream'; } /** diff --git a/Normalizer/DateIntervalNormalizer.php b/Normalizer/DateIntervalNormalizer.php index 4a09f2e8b..05d1a8529 100644 --- a/Normalizer/DateIntervalNormalizer.php +++ b/Normalizer/DateIntervalNormalizer.php @@ -20,11 +20,11 @@ * * @author Jérôme Parmentier */ -class DateIntervalNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class DateIntervalNormalizer implements NormalizerInterface, DenormalizerInterface { public const FORMAT_KEY = 'dateinterval_format'; - private $defaultContext = [ + private array $defaultContext = [ self::FORMAT_KEY => '%rP%yY%mM%dDT%hH%iM%sS', ]; @@ -33,14 +33,17 @@ public function __construct(array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } + public function getSupportedTypes(?string $format): array + { + return [ + \DateInterval::class => true, + ]; + } + /** - * {@inheritdoc} - * - * @return string - * * @throws InvalidArgumentException */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): string { if (!$object instanceof \DateInterval) { throw new InvalidArgumentException('The object must be an instance of "\DateInterval".'); @@ -49,30 +52,15 @@ public function normalize($object, ?string $format = null, array $context = []) return $object->format($context[self::FORMAT_KEY] ?? $this->defaultContext[self::FORMAT_KEY]); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \DateInterval; } /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } - - /** - * {@inheritdoc} - * - * @return \DateInterval - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \DateInterval { if (!\is_string($data)) { throw NotNormalizableValueException::createForUnexpectedDataType('Data expected to be a string.', $data, ['string'], $context['deserialization_path'] ?? null, true); @@ -97,7 +85,7 @@ public function denormalize($data, string $type, ?string $format = null, array $ } $valuePattern = '/^'.$signPattern.preg_replace('/%([yYmMdDhHiIsSwW])(\w)/', '(?:(?P<$1>\d+)$2)?', preg_replace('/(T.*)$/', '($1)?', $dateIntervalFormat)).'$/'; if (!preg_match($valuePattern, $data)) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Value "%s" contains intervals not accepted by format "%s".', $data, $dateIntervalFormat), $data, ['string'], $context['deserialization_path'] ?? null, false); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Value "%s" contains intervals not accepted by format "%s".', $data, $dateIntervalFormat), $data, ['string'], $context['deserialization_path'] ?? null, false); } try { @@ -118,20 +106,13 @@ public function denormalize($data, string $type, ?string $format = null, array $ } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return \DateInterval::class === $type; } private function isISO8601(string $string): bool { - if (\PHP_VERSION_ID >= 80000) { - return preg_match('/^[\-+]?P(?=\w*(?:\d|%\w))(?:\d+Y|%[yY]Y)?(?:\d+M|%[mM]M)?(?:\d+W|%[wW]W)?(?:\d+D|%[dD]D)?(?:T(?:\d+H|[hH]H)?(?:\d+M|[iI]M)?(?:\d+S|[sS]S)?)?$/', $string); - } - - return preg_match('/^[\-+]?P(?=\w*(?:\d|%\w))(?:\d+Y|%[yY]Y)?(?:\d+M|%[mM]M)?(?:(?:\d+D|%[dD]D)|(?:\d+W|%[wW]W))?(?:T(?:\d+H|[hH]H)?(?:\d+M|[iI]M)?(?:\d+S|[sS]S)?)?$/', $string); + return preg_match('/^[\-+]?P(?=\w*(?:\d|%\w))(?:\d+Y|%[yY]Y)?(?:\d+M|%[mM]M)?(?:\d+W|%[wW]W)?(?:\d+D|%[dD]D)?(?:T(?:\d+H|[hH]H)?(?:\d+M|[iI]M)?(?:\d+S|[sS]S)?)?$/', $string); } } diff --git a/Normalizer/DateTimeNormalizer.php b/Normalizer/DateTimeNormalizer.php index b4357b566..dfc498c19 100644 --- a/Normalizer/DateTimeNormalizer.php +++ b/Normalizer/DateTimeNormalizer.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -21,14 +20,16 @@ * * @author Kévin Dunglas */ -class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class DateTimeNormalizer implements NormalizerInterface, DenormalizerInterface { public const FORMAT_KEY = 'datetime_format'; public const TIMEZONE_KEY = 'datetime_timezone'; + public const CAST_KEY = 'datetime_cast'; - private $defaultContext = [ - self::FORMAT_KEY => \DateTime::RFC3339, + private array $defaultContext = [ + self::FORMAT_KEY => \DateTimeInterface::RFC3339, self::TIMEZONE_KEY => null, + self::CAST_KEY => null, ]; private const SUPPORTED_TYPES = [ @@ -47,14 +48,15 @@ public function setDefaultContext(array $defaultContext): void $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } + public function getSupportedTypes(?string $format): array + { + return self::SUPPORTED_TYPES; + } + /** - * {@inheritdoc} - * - * @return string - * * @throws InvalidArgumentException */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): int|float|string { if (!$object instanceof \DateTimeInterface) { throw new InvalidArgumentException('The object must implement the "\DateTimeInterface".'); @@ -68,85 +70,75 @@ public function normalize($object, ?string $format = null, array $context = []) $object = $object->setTimezone($timezone); } - return $object->format($dateTimeFormat); + return match ($context[self::CAST_KEY] ?? $this->defaultContext[self::CAST_KEY] ?? false) { + 'int' => (int) $object->format($dateTimeFormat), + 'float' => (float) $object->format($dateTimeFormat), + default => $object->format($dateTimeFormat), + }; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \DateTimeInterface; } /** - * {@inheritdoc} - * - * @return \DateTimeInterface - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \DateTimeInterface { if (\is_int($data) || \is_float($data)) { switch ($context[self::FORMAT_KEY] ?? $this->defaultContext[self::FORMAT_KEY] ?? null) { - case 'U': $data = sprintf('%d', $data); break; - case 'U.u': $data = sprintf('%.6F', $data); break; + case 'U': + $data = \sprintf('%d', $data); + break; + case 'U.u': + $data = \sprintf('%.6F', $data); + break; } } if (!\is_string($data) || '' === trim($data)) { - throw NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', $data, ['string'], $context['deserialization_path'] ?? null, true); } try { + if (\DateTimeInterface::class === $type) { + $type = \DateTimeImmutable::class; + } + $timezone = $this->getTimezone($context); $dateTimeFormat = $context[self::FORMAT_KEY] ?? null; if (null !== $dateTimeFormat) { - $object = \DateTime::class === $type ? \DateTime::createFromFormat($dateTimeFormat, $data, $timezone) : \DateTimeImmutable::createFromFormat($dateTimeFormat, $data, $timezone); - - if (false !== $object) { + if (false !== $object = $type::createFromFormat($dateTimeFormat, $data, $timezone)) { return $object; } - $dateTimeErrors = \DateTime::class === $type ? \DateTime::getLastErrors() : \DateTimeImmutable::getLastErrors(); + $dateTimeErrors = $type::getLastErrors(); - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Parsing datetime string "%s" using format "%s" resulted in %d errors: ', $data, $dateTimeFormat, $dateTimeErrors['error_count'])."\n".implode("\n", $this->formatDateTimeErrors($dateTimeErrors['errors'])), $data, ['string'], $context['deserialization_path'] ?? null, true); } $defaultDateTimeFormat = $this->defaultContext[self::FORMAT_KEY] ?? null; if (null !== $defaultDateTimeFormat) { - $object = \DateTime::class === $type ? \DateTime::createFromFormat($defaultDateTimeFormat, $data, $timezone) : \DateTimeImmutable::createFromFormat($defaultDateTimeFormat, $data, $timezone); - - if (false !== $object) { + if (false !== $object = $type::createFromFormat($defaultDateTimeFormat, $data, $timezone)) { return $object; } } - return \DateTime::class === $type ? new \DateTime($data, $timezone) : new \DateTimeImmutable($data, $timezone); + return new $type($data, $timezone); } catch (NotNormalizableValueException $e) { throw $e; } catch (\Exception $e) { - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, ['string'], $context['deserialization_path'] ?? null, false, $e->getCode(), $e); } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) - { - return isset(self::SUPPORTED_TYPES[$type]); - } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return __CLASS__ === static::class; + return is_a($type, \DateTimeInterface::class, true); } /** @@ -159,7 +151,7 @@ private function formatDateTimeErrors(array $errors): array $formattedErrors = []; foreach ($errors as $pos => $message) { - $formattedErrors[] = sprintf('at position %d: %s', $pos, $message); + $formattedErrors[] = \sprintf('at position %d: %s', $pos, $message); } return $formattedErrors; diff --git a/Normalizer/DateTimeZoneNormalizer.php b/Normalizer/DateTimeZoneNormalizer.php index 497460369..f4528a03d 100644 --- a/Normalizer/DateTimeZoneNormalizer.php +++ b/Normalizer/DateTimeZoneNormalizer.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -20,16 +19,19 @@ * * @author Jérôme Desjardins */ -class DateTimeZoneNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class DateTimeZoneNormalizer implements NormalizerInterface, DenormalizerInterface { + public function getSupportedTypes(?string $format): array + { + return [ + \DateTimeZone::class => true, + ]; + } + /** - * {@inheritdoc} - * - * @return string - * * @throws InvalidArgumentException */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): string { if (!$object instanceof \DateTimeZone) { throw new InvalidArgumentException('The object must be an instance of "\DateTimeZone".'); @@ -38,47 +40,29 @@ public function normalize($object, ?string $format = null, array $context = []) return $object->getName(); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof \DateTimeZone; } /** - * {@inheritdoc} - * - * @return \DateTimeZone - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): \DateTimeZone { if ('' === $data || null === $data) { - throw NotNormalizableValueException::createForUnexpectedDataType('The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.', $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType('The data is either an empty string or null, you should pass a string that can be parsed as a DateTimeZone.', $data, ['string'], $context['deserialization_path'] ?? null, true); } try { return new \DateTimeZone($data); } catch (\Exception $e) { - throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); + throw NotNormalizableValueException::createForUnexpectedDataType($e->getMessage(), $data, ['string'], $context['deserialization_path'] ?? null, true, $e->getCode(), $e); } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return \DateTimeZone::class === $type; } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } } diff --git a/Normalizer/DenormalizableInterface.php b/Normalizer/DenormalizableInterface.php index 3cf07de92..48df97624 100644 --- a/Normalizer/DenormalizableInterface.php +++ b/Normalizer/DenormalizableInterface.php @@ -34,5 +34,5 @@ interface DenormalizableInterface * differently based on different input formats * @param array $context Options for denormalizing */ - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []); + public function denormalize(DenormalizerInterface $denormalizer, array|string|int|float|bool $data, ?string $format = null, array $context = []): void; } diff --git a/Normalizer/DenormalizerAwareInterface.php b/Normalizer/DenormalizerAwareInterface.php index f88df1bec..84bf8d89d 100644 --- a/Normalizer/DenormalizerAwareInterface.php +++ b/Normalizer/DenormalizerAwareInterface.php @@ -19,5 +19,5 @@ interface DenormalizerAwareInterface /** * Sets the owning Denormalizer object. */ - public function setDenormalizer(DenormalizerInterface $denormalizer); + public function setDenormalizer(DenormalizerInterface $denormalizer): void; } diff --git a/Normalizer/DenormalizerAwareTrait.php b/Normalizer/DenormalizerAwareTrait.php index 82dba8f18..f4620e1c0 100644 --- a/Normalizer/DenormalizerAwareTrait.php +++ b/Normalizer/DenormalizerAwareTrait.php @@ -16,12 +16,9 @@ */ trait DenormalizerAwareTrait { - /** - * @var DenormalizerInterface - */ - protected $denormalizer; + protected DenormalizerInterface $denormalizer; - public function setDenormalizer(DenormalizerInterface $denormalizer) + public function setDenormalizer(DenormalizerInterface $denormalizer): void { $this->denormalizer = $denormalizer; } diff --git a/Normalizer/DenormalizerInterface.php b/Normalizer/DenormalizerInterface.php index 4f8f49f7c..23ee3928a 100644 --- a/Normalizer/DenormalizerInterface.php +++ b/Normalizer/DenormalizerInterface.php @@ -34,8 +34,6 @@ interface DenormalizerInterface * @param string|null $format Format the given data was extracted from * @param array $context Options available to the denormalizer * - * @return mixed - * * @throws BadMethodCallException Occurs when the normalizer is not called in an expected context * @throws InvalidArgumentException Occurs when the arguments are not coherent or not supported * @throws UnexpectedValueException Occurs when the item cannot be hydrated with the given data @@ -44,7 +42,7 @@ interface DenormalizerInterface * @throws RuntimeException Occurs if the class cannot be instantiated * @throws ExceptionInterface Occurs for all the other cases of errors */ - public function denormalize($data, string $type, ?string $format = null, array $context = []); + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed; /** * Checks whether the given class is supported for denormalization by this normalizer. @@ -52,8 +50,23 @@ public function denormalize($data, string $type, ?string $format = null, array $ * @param mixed $data Data to denormalize from * @param string $type The class to which the data should be denormalized * @param string|null $format The format being deserialized from + */ + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool; + + /** + * Returns the types potentially supported by this denormalizer. + * + * For each supported formats (if applicable), the supported types should be + * returned as keys, and each type should be mapped to a boolean indicating + * if the result of supportsDenormalization() can be cached or not + * (a result cannot be cached when it depends on the context or on the data.) + * A null value means that the denormalizer does not support the corresponding + * type. + * + * Use type "object" to match any classes or interfaces, + * and type "*" to match any types. * - * @return bool + * @return array */ - public function supportsDenormalization($data, string $type, ?string $format = null); + public function getSupportedTypes(?string $format): array; } diff --git a/Normalizer/FormErrorNormalizer.php b/Normalizer/FormErrorNormalizer.php index 81ce6de3a..9ef13a669 100644 --- a/Normalizer/FormErrorNormalizer.php +++ b/Normalizer/FormErrorNormalizer.php @@ -16,16 +16,13 @@ /** * Normalizes invalid Form instances. */ -final class FormErrorNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +final class FormErrorNormalizer implements NormalizerInterface { public const TITLE = 'title'; public const TYPE = 'type'; public const CODE = 'status_code'; - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []): array + public function normalize(mixed $object, ?string $format = null, array $context = []): array { $data = [ 'title' => $context[self::TITLE] ?? 'Validation Failed', @@ -41,10 +38,14 @@ public function normalize($object, ?string $format = null, array $context = []): return $data; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function getSupportedTypes(?string $format): array + { + return [ + FormInterface::class => false, + ]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof FormInterface && $data->isSubmitted() && !$data->isValid(); } @@ -72,7 +73,7 @@ private function convertFormChildrenToArray(FormInterface $data): array 'errors' => $this->convertFormErrorsToArray($child), ]; - if (!empty($child->all())) { + if ($child->all()) { $childData['children'] = $this->convertFormChildrenToArray($child); } @@ -81,12 +82,4 @@ private function convertFormChildrenToArray(FormInterface $data): array return $children; } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } } diff --git a/Normalizer/GetSetMethodNormalizer.php b/Normalizer/GetSetMethodNormalizer.php index 619500828..4d2d7829a 100644 --- a/Normalizer/GetSetMethodNormalizer.php +++ b/Normalizer/GetSetMethodNormalizer.php @@ -11,7 +11,8 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Annotation\Ignore as LegacyIgnore; +use Symfony\Component\Serializer\Attribute\Ignore; /** * Converts between objects with getter and setter methods and arrays. @@ -34,33 +35,24 @@ * @author Nils Adermann * @author Kévin Dunglas */ -class GetSetMethodNormalizer extends AbstractObjectNormalizer +final class GetSetMethodNormalizer extends AbstractObjectNormalizer { private static $reflectionCache = []; - private static $setterAccessibleCache = []; + private static array $setterAccessibleCache = []; - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function getSupportedTypes(?string $format): array { - return parent::supportsNormalization($data, $format) && $this->supports(\get_class($data), true); + return ['object' => true]; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return parent::supportsDenormalization($data, $type, $format) && $this->supports($type, false); + return parent::supportsNormalization($data, $format) && $this->supports($data::class, true); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return __CLASS__ === static::class; + return parent::supportsDenormalization($data, $type, $format) && $this->supports($type, false); } /** @@ -68,7 +60,7 @@ public function hasCacheableSupportsMethod(): bool */ private function supports(string $class, bool $readAttributes): bool { - if (null !== $this->classDiscriminatorResolver && $this->classDiscriminatorResolver->getMappingForClass($class)) { + if ($this->classDiscriminatorResolver?->getMappingForClass($class)) { return true; } @@ -93,10 +85,10 @@ private function supports(string $class, bool $readAttributes): bool private function isGetMethod(\ReflectionMethod $method): bool { return !$method->isStatic() - && (\PHP_VERSION_ID < 80000 || !$method->getAttributes(Ignore::class)) + && !($method->getAttributes(Ignore::class) || $method->getAttributes(LegacyIgnore::class)) && !$method->getNumberOfRequiredParameters() - && ((2 < ($methodLength = \strlen($method->name)) && str_starts_with($method->name, 'is')) - || (3 < $methodLength && (str_starts_with($method->name, 'has') || str_starts_with($method->name, 'get'))) + && ((2 < ($methodLength = \strlen($method->name)) && str_starts_with($method->name, 'is') && !ctype_lower($method->name[2])) + || (3 < $methodLength && (str_starts_with($method->name, 'has') || str_starts_with($method->name, 'get')) && !ctype_lower($method->name[3])) ); } @@ -106,15 +98,15 @@ private function isGetMethod(\ReflectionMethod $method): bool private function isSetMethod(\ReflectionMethod $method): bool { return !$method->isStatic() - && (\PHP_VERSION_ID < 80000 || !$method->getAttributes(Ignore::class)) + && !$method->getAttributes(Ignore::class) && 0 < $method->getNumberOfParameters() - && str_starts_with($method->name, 'set'); + && 3 < \strlen($method->name) + && str_starts_with($method->name, 'set') + && !ctype_lower($method->name[3]) + ; } - /** - * {@inheritdoc} - */ - protected function extractAttributes(object $object, ?string $format = null, array $context = []) + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { $reflectionObject = new \ReflectionObject($object); $reflectionMethods = $reflectionObject->getMethods(\ReflectionMethod::IS_PUBLIC); @@ -135,10 +127,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr return $attributes; } - /** - * {@inheritdoc} - */ - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { $getter = 'get'.$attribute; if (method_exists($object, $getter) && \is_callable([$object, $getter])) { @@ -158,13 +147,10 @@ protected function getAttributeValue(object $object, string $attribute, ?string return null; } - /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void { $setter = 'set'.$attribute; - $key = \get_class($object).':'.$setter; + $key = $object::class.':'.$setter; if (!isset(self::$setterAccessibleCache[$key])) { self::$setterAccessibleCache[$key] = method_exists($object, $setter) && \is_callable([$object, $setter]) && !(new \ReflectionMethod($object, $setter))->isStatic(); @@ -175,13 +161,13 @@ protected function setAttributeValue(object $object, string $attribute, $value, } } - protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []) + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool { if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { return false; } - $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; + $class = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; if (!isset(self::$reflectionCache[$class])) { self::$reflectionCache[$class] = new \ReflectionClass($class); diff --git a/Normalizer/JsonSerializableNormalizer.php b/Normalizer/JsonSerializableNormalizer.php index 74f23fde9..31c224175 100644 --- a/Normalizer/JsonSerializableNormalizer.php +++ b/Normalizer/JsonSerializableNormalizer.php @@ -19,19 +19,16 @@ * * @author Fred Cox */ -class JsonSerializableNormalizer extends AbstractNormalizer +final class JsonSerializableNormalizer extends AbstractNormalizer { - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if ($this->isCircularReference($object, $context)) { return $this->handleCircularReference($object, $format, $context); } if (!$object instanceof \JsonSerializable) { - throw new InvalidArgumentException(sprintf('The object must implement "%s".', \JsonSerializable::class)); + throw new InvalidArgumentException(\sprintf('The object must implement "%s".', \JsonSerializable::class)); } if (!$this->serializer instanceof NormalizerInterface) { @@ -41,35 +38,25 @@ public function normalize($object, ?string $format = null, array $context = []) return $this->serializer->normalize($object->jsonSerialize(), $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null) + public function getSupportedTypes(?string $format): array { - return $data instanceof \JsonSerializable; + return [ + \JsonSerializable::class => true, + ]; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return false; + return $data instanceof \JsonSerializable; } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - throw new LogicException(sprintf('Cannot denormalize with "%s".', \JsonSerializable::class)); + return false; } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - return __CLASS__ === static::class; + throw new LogicException(\sprintf('Cannot denormalize with "%s".', \JsonSerializable::class)); } } diff --git a/Normalizer/MimeMessageNormalizer.php b/Normalizer/MimeMessageNormalizer.php index a1e131835..633edf369 100644 --- a/Normalizer/MimeMessageNormalizer.php +++ b/Normalizer/MimeMessageNormalizer.php @@ -18,6 +18,7 @@ use Symfony\Component\Mime\Message; use Symfony\Component\Mime\Part\AbstractPart; use Symfony\Component\Mime\RawMessage; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -29,31 +30,39 @@ * * Emails using resources for any parts are not serializable. */ -final class MimeMessageNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +final class MimeMessageNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { - private $serializer; - private $normalizer; - private $headerClassMap; - private $headersProperty; + private NormalizerInterface&DenormalizerInterface $serializer; + private array $headerClassMap; + private \ReflectionProperty $headersProperty; - public function __construct(PropertyNormalizer $normalizer) + public function __construct(private readonly PropertyNormalizer $normalizer) { - $this->normalizer = $normalizer; $this->headerClassMap = (new \ReflectionClassConstant(Headers::class, 'HEADER_CLASS_MAP'))->getValue(); $this->headersProperty = new \ReflectionProperty(Headers::class, 'headers'); - $this->headersProperty->setAccessible(true); } - public function setSerializer(SerializerInterface $serializer) + public function getSupportedTypes(?string $format): array { + return [ + Message::class => true, + Headers::class => true, + HeaderInterface::class => true, + Address::class => true, + AbstractPart::class => true, + ]; + } + + public function setSerializer(SerializerInterface $serializer): void + { + if (!$serializer instanceof NormalizerInterface || !$serializer instanceof DenormalizerInterface) { + throw new LogicException(\sprintf('The passed serializer should implement both NormalizerInterface and DenormalizerInterface, "%s" given.', get_debug_type($serializer))); + } $this->serializer = $serializer; $this->normalizer->setSerializer($serializer); } - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { if ($object instanceof Headers) { $ret = []; @@ -67,7 +76,7 @@ public function normalize($object, ?string $format = null, array $context = []) $ret = $this->normalizer->normalize($object, $format, $context); if ($object instanceof AbstractPart) { - $ret['class'] = \get_class($object); + $ret['class'] = $object::class; unset($ret['seekable'], $ret['cid'], $ret['handle']); } @@ -78,10 +87,7 @@ public function normalize($object, ?string $format = null, array $context = []) return $ret; } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { if (Headers::class === $type) { $ret = []; @@ -103,27 +109,13 @@ public function denormalize($data, string $type, ?string $format = null, array $ return $this->normalizer->denormalize($data, $type, $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof Message || $data instanceof Headers || $data instanceof HeaderInterface || $data instanceof Address || $data instanceof AbstractPart; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return is_a($type, Message::class, true) || Headers::class === $type || AbstractPart::class === $type; } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return __CLASS__ === static::class; - } } diff --git a/Normalizer/NormalizableInterface.php b/Normalizer/NormalizableInterface.php index 5a8dc2897..7be598668 100644 --- a/Normalizer/NormalizableInterface.php +++ b/Normalizer/NormalizableInterface.php @@ -32,8 +32,6 @@ interface NormalizableInterface * @param string|null $format The format is optionally given to be able to normalize differently * based on different output formats * @param array $context Options for normalizing this object - * - * @return array|string|int|float|bool */ - public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []); + public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null; } diff --git a/Normalizer/NormalizerAwareInterface.php b/Normalizer/NormalizerAwareInterface.php index 08865a3f1..1079ab6dd 100644 --- a/Normalizer/NormalizerAwareInterface.php +++ b/Normalizer/NormalizerAwareInterface.php @@ -19,5 +19,5 @@ interface NormalizerAwareInterface /** * Sets the owning Normalizer object. */ - public function setNormalizer(NormalizerInterface $normalizer); + public function setNormalizer(NormalizerInterface $normalizer): void; } diff --git a/Normalizer/NormalizerAwareTrait.php b/Normalizer/NormalizerAwareTrait.php index a8835c93d..e21c6c175 100644 --- a/Normalizer/NormalizerAwareTrait.php +++ b/Normalizer/NormalizerAwareTrait.php @@ -16,12 +16,9 @@ */ trait NormalizerAwareTrait { - /** - * @var NormalizerInterface - */ - protected $normalizer; + protected NormalizerInterface $normalizer; - public function setNormalizer(NormalizerInterface $normalizer) + public function setNormalizer(NormalizerInterface $normalizer): void { $this->normalizer = $normalizer; } diff --git a/Normalizer/NormalizerInterface.php b/Normalizer/NormalizerInterface.php index 87f8f1d8b..bbc8a94e7 100644 --- a/Normalizer/NormalizerInterface.php +++ b/Normalizer/NormalizerInterface.php @@ -22,9 +22,9 @@ interface NormalizerInterface { /** - * Normalizes an object into a set of arrays/scalars. + * Normalizes data into a set of arrays/scalars. * - * @param mixed $object Object to normalize + * @param mixed $data Data to normalize * @param string|null $format Format the normalization result will be encoded as * @param array $context Context options for the normalizer * @@ -36,15 +36,30 @@ interface NormalizerInterface * @throws LogicException Occurs when the normalizer is not called in an expected context * @throws ExceptionInterface Occurs for all the other cases of errors */ - public function normalize($object, ?string $format = null, array $context = []); + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null; /** * Checks whether the given class is supported for normalization by this normalizer. * * @param mixed $data Data to normalize * @param string|null $format The format being (de-)serialized from or into + */ + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool; + + /** + * Returns the types potentially supported by this normalizer. + * + * For each supported formats (if applicable), the supported types should be + * returned as keys, and each type should be mapped to a boolean indicating + * if the result of supportsNormalization() can be cached or not + * (a result cannot be cached when it depends on the context or on the data.) + * A null value means that the normalizer does not support the corresponding + * type. + * + * Use type "object" to match any classes or interfaces, + * and type "*" to match any types. * - * @return bool + * @return array */ - public function supportsNormalization($data, ?string $format = null); + public function getSupportedTypes(?string $format): array; } diff --git a/Normalizer/ObjectNormalizer.php b/Normalizer/ObjectNormalizer.php index f4a234981..1d60cba50 100644 --- a/Normalizer/ObjectNormalizer.php +++ b/Normalizer/ObjectNormalizer.php @@ -30,48 +30,41 @@ * * @author Kévin Dunglas */ -class ObjectNormalizer extends AbstractObjectNormalizer +final class ObjectNormalizer extends AbstractObjectNormalizer { private static $reflectionCache = []; + private static $isReadableCache = []; + private static $isWritableCache = []; - protected $propertyAccessor; + protected PropertyAccessorInterface $propertyAccessor; protected $propertyInfoExtractor; private $writeInfoExtractor; - private $objectClassResolver; + private readonly \Closure $objectClassResolver; public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, ?PropertyAccessorInterface $propertyAccessor = null, ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = [], ?PropertyInfoExtractorInterface $propertyInfoExtractor = null) { if (!class_exists(PropertyAccess::class)) { - throw new LogicException('The ObjectNormalizer class requires the "PropertyAccess" component. Install "symfony/property-access" to use it.'); + throw new LogicException('The ObjectNormalizer class requires the "PropertyAccess" component. Try running "composer require symfony/property-access".'); } parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); - $this->objectClassResolver = $objectClassResolver ?? function ($class) { - return \is_object($class) ? \get_class($class) : $class; - }; - + $this->objectClassResolver = ($objectClassResolver ?? static fn ($class) => \is_object($class) ? $class::class : $class)(...); $this->propertyInfoExtractor = $propertyInfoExtractor ?: new ReflectionExtractor(); $this->writeInfoExtractor = new ReflectionExtractor(); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function getSupportedTypes(?string $format): array { - return __CLASS__ === static::class; + return ['object' => true]; } - /** - * {@inheritdoc} - */ - protected function extractAttributes(object $object, ?string $format = null, array $context = []) + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { - if (\stdClass::class === \get_class($object)) { + if (\stdClass::class === $object::class) { return array_keys((array) $object); } @@ -84,10 +77,10 @@ protected function extractAttributes(object $object, ?string $format = null, arr foreach ($reflClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $reflMethod) { if ( - 0 !== $reflMethod->getNumberOfRequiredParameters() || - $reflMethod->isStatic() || - $reflMethod->isConstructor() || - $reflMethod->isDestructor() + 0 !== $reflMethod->getNumberOfRequiredParameters() + || $reflMethod->isStatic() + || $reflMethod->isConstructor() + || $reflMethod->isDestructor() ) { continue; } @@ -95,14 +88,20 @@ protected function extractAttributes(object $object, ?string $format = null, arr $name = $reflMethod->name; $attributeName = null; - if (str_starts_with($name, 'get') || str_starts_with($name, 'has')) { - // getters and hassers + // ctype_lower check to find out if method looks like accessor but actually is not, e.g. hash, cancel + if (3 < \strlen($name) && !ctype_lower($name[3]) && match ($name[0]) { + 'g' => str_starts_with($name, 'get'), + 'h' => str_starts_with($name, 'has'), + 'c' => str_starts_with($name, 'can'), + default => false, + }) { + // getters, hassers and canners $attributeName = substr($name, 3); if (!$reflClass->hasProperty($attributeName)) { $attributeName = lcfirst($attributeName); } - } elseif (str_starts_with($name, 'is')) { + } elseif ('is' !== $name && str_starts_with($name, 'is') && !ctype_lower($name[2])) { // issers $attributeName = substr($name, 2); @@ -132,44 +131,32 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_keys($attributes); } - /** - * {@inheritdoc} - */ - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { - $discriminatorProperty = null; - if (null !== $this->classDiscriminatorResolver && null !== $mapping = $this->classDiscriminatorResolver->getMappingForMappedObject($object)) { - $discriminatorProperty = $mapping->getTypeProperty(); - } + $mapping = $this->classDiscriminatorResolver?->getMappingForMappedObject($object); - return $attribute === $discriminatorProperty - ? $this->classDiscriminatorResolver->getTypeForMappedObject($object) + return $attribute === $mapping?->getTypeProperty() + ? $mapping : $this->propertyAccessor->getValue($object, $attribute); } - /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void { try { $this->propertyAccessor->setValue($object, $attribute, $value); - } catch (NoSuchPropertyException $exception) { + } catch (NoSuchPropertyException) { // Properties not found are ignored } } - /** - * {@inheritdoc} - */ - protected function getAllowedAttributes($classOrObject, array $context, bool $attributesAsString = false) + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool { if (false === $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString)) { return false; } if (null !== $this->classDiscriminatorResolver) { - $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; + $class = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; if (null !== $discriminatorMapping = $this->classDiscriminatorResolver->getMappingForMappedObject($classOrObject)) { $allowedAttributes[] = $attributesAsString ? $discriminatorMapping->getTypeProperty() : new AttributeMetadata($discriminatorMapping->getTypeProperty()); } @@ -186,30 +173,32 @@ protected function getAllowedAttributes($classOrObject, array $context, bool $at return $allowedAttributes; } - protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []) + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool { if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { return false; } - $class = \is_object($classOrObject) ? \get_class($classOrObject) : $classOrObject; - if ($context['_read_attributes'] ?? true) { - return (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $attribute)) || $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute); - } + $class = \is_object($classOrObject) ? $classOrObject::class : $classOrObject; - if (str_contains($attribute, '.')) { - return true; - } + if ($context['_read_attributes'] ?? true) { + if (!isset(self::$isReadableCache[$class.$attribute])) { + self::$isReadableCache[$class.$attribute] = $this->propertyInfoExtractor->isReadable($class, $attribute) || $this->hasAttributeAccessorMethod($class, $attribute) || (\is_object($classOrObject) && $this->propertyAccessor->isReadable($classOrObject, $attribute)); + } - if ($this->propertyInfoExtractor->isWritable($class, $attribute)) { - return true; + return self::$isReadableCache[$class.$attribute]; } - if (($writeInfo = $this->writeInfoExtractor->getWriteInfo($class, $attribute)) && PropertyWriteInfo::TYPE_NONE !== $writeInfo->getType()) { - return true; + if (!isset(self::$isWritableCache[$class.$attribute])) { + if (str_contains($attribute, '.')) { + self::$isWritableCache[$class.$attribute] = true; + } else { + self::$isWritableCache[$class.$attribute] = $this->propertyInfoExtractor->isWritable($class, $attribute) + || (($writeInfo = $this->writeInfoExtractor->getWriteInfo($class, $attribute)) && PropertyWriteInfo::TYPE_NONE !== $writeInfo->getType()); + } } - return false; + return self::$isWritableCache[$class.$attribute]; } private function hasAttributeAccessorMethod(string $class, string $attribute): bool @@ -227,7 +216,7 @@ private function hasAttributeAccessorMethod(string $class, string $attribute): b $method = $reflection->getMethod($attribute); return !$method->isStatic() - && (\PHP_VERSION_ID < 80000 || !$method->getAttributes(Ignore::class)) + && !$method->getAttributes(Ignore::class) && !$method->getNumberOfRequiredParameters(); } } diff --git a/Normalizer/ObjectToPopulateTrait.php b/Normalizer/ObjectToPopulateTrait.php index adb519f45..21ad5f7ad 100644 --- a/Normalizer/ObjectToPopulateTrait.php +++ b/Normalizer/ObjectToPopulateTrait.php @@ -23,7 +23,7 @@ trait ObjectToPopulateTrait */ protected function extractObjectToPopulate(string $class, array $context, ?string $key = null): ?object { - $key = $key ?? AbstractNormalizer::OBJECT_TO_POPULATE; + $key ??= AbstractNormalizer::OBJECT_TO_POPULATE; if (isset($context[$key]) && \is_object($context[$key]) && $context[$key] instanceof $class) { return $context[$key]; diff --git a/Normalizer/ProblemNormalizer.php b/Normalizer/ProblemNormalizer.php index 0cc47cdf8..08aca6796 100644 --- a/Normalizer/ProblemNormalizer.php +++ b/Normalizer/ProblemNormalizer.php @@ -12,7 +12,14 @@ namespace Symfony\Component\Serializer\Normalizer; use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\Messenger\Exception\ValidationFailedException as MessageValidationFailedException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\SerializerAwareInterface; +use Symfony\Component\Serializer\SerializerAwareTrait; +use Symfony\Component\Validator\Exception\ValidationFailedException; +use Symfony\Contracts\Translation\TranslatorInterface; /** * Normalizes errors according to the API Problem spec (RFC 7807). @@ -22,40 +29,76 @@ * @author Kévin Dunglas * @author Yonel Ceruto */ -class ProblemNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +class ProblemNormalizer implements NormalizerInterface, SerializerAwareInterface { - private $debug; - private $defaultContext = [ - 'type' => 'https://tools.ietf.org/html/rfc2616#section-10', - 'title' => 'An error occurred', - ]; + use SerializerAwareTrait; - public function __construct(bool $debug = false, array $defaultContext = []) + public const TITLE = 'title'; + public const TYPE = 'type'; + public const STATUS = 'status'; + + public function __construct( + private bool $debug = false, + private array $defaultContext = [], + private ?TranslatorInterface $translator = null, + ) { + } + + public function getSupportedTypes(?string $format): array { - $this->debug = $debug; - $this->defaultContext = $defaultContext + $this->defaultContext; + return [ + FlattenException::class => __CLASS__ === self::class, + ]; } - /** - * {@inheritdoc} - * - * @return array - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array { if (!$object instanceof FlattenException) { - throw new InvalidArgumentException(sprintf('The object must implement "%s".', FlattenException::class)); + throw new InvalidArgumentException(\sprintf('The object must implement "%s".', FlattenException::class)); } + $data = []; $context += $this->defaultContext; $debug = $this->debug && ($context['debug'] ?? true); + $exception = $context['exception'] ?? null; + if ($exception instanceof HttpExceptionInterface) { + $exception = $exception->getPrevious(); + + if ($exception instanceof PartialDenormalizationException) { + $trans = $this->translator ? $this->translator->trans(...) : fn ($m, $p) => strtr($m, $p); + $template = 'This value should be of type {{ type }}.'; + $data = [ + self::TYPE => 'https://symfony.com/errors/validation', + self::TITLE => 'Validation Failed', + 'violations' => array_map( + fn ($e) => [ + 'propertyPath' => $e->getPath(), + 'title' => $trans($template, [ + '{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']), + ], 'validators'), + 'template' => $template, + 'parameters' => [ + '{{ type }}' => implode('|', $e->getExpectedTypes() ?? ['?']), + ], + ] + ($debug || $e->canUseMessageForUser() ? ['hint' => $e->getMessage()] : []), + $exception->getErrors() + ), + ]; + $data['detail'] = implode("\n", array_map(fn ($e) => $e['propertyPath'].': '.$e['title'], $data['violations'])); + } elseif (($exception instanceof ValidationFailedException || $exception instanceof MessageValidationFailedException) + && $this->serializer instanceof NormalizerInterface + && $this->serializer->supportsNormalization($exception->getViolations(), $format, $context) + ) { + $data = $this->serializer->normalize($exception->getViolations(), $format, $context); + } + } $data = [ - 'type' => $context['type'], - 'title' => $context['title'], - 'status' => $context['status'] ?? $object->getStatusCode(), - 'detail' => $debug ? $object->getMessage() : $object->getStatusText(), - ]; + self::TYPE => $data[self::TYPE] ?? $context[self::TYPE] ?? 'https://tools.ietf.org/html/rfc2616#section-10', + self::TITLE => $data[self::TITLE] ?? $context[self::TITLE] ?? 'An error occurred', + self::STATUS => $context[self::STATUS] ?? $object->getStatusCode(), + 'detail' => $data['detail'] ?? ($debug ? $object->getMessage() : $object->getStatusText()), + ] + $data; if ($debug) { $data['class'] = $object->getClass(); $data['trace'] = $object->getTrace(); @@ -64,19 +107,8 @@ public function normalize($object, ?string $format = null, array $context = []) return $data; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof FlattenException; } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool - { - return true; - } } diff --git a/Normalizer/PropertyNormalizer.php b/Normalizer/PropertyNormalizer.php index 15e60a968..1619f35bf 100644 --- a/Normalizer/PropertyNormalizer.php +++ b/Normalizer/PropertyNormalizer.php @@ -12,6 +12,10 @@ namespace Symfony\Component\Serializer\Normalizer; use Symfony\Component\PropertyAccess\Exception\UninitializedPropertyException; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\Serializer\Mapping\ClassDiscriminatorResolverInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * Converts between objects and arrays by mapping properties. @@ -30,30 +34,39 @@ * @author Matthieu Napoli * @author Kévin Dunglas */ -class PropertyNormalizer extends AbstractObjectNormalizer +final class PropertyNormalizer extends AbstractObjectNormalizer { + public const NORMALIZE_PUBLIC = 1; + public const NORMALIZE_PROTECTED = 2; + public const NORMALIZE_PRIVATE = 4; + /** - * {@inheritdoc} + * Flag to control whether fields should be output based on visibility. */ - public function supportsNormalization($data, ?string $format = null) + public const NORMALIZE_VISIBILITY = 'normalize_visibility'; + + public function __construct(?ClassMetadataFactoryInterface $classMetadataFactory = null, ?NameConverterInterface $nameConverter = null, ?PropertyTypeExtractorInterface $propertyTypeExtractor = null, ?ClassDiscriminatorResolverInterface $classDiscriminatorResolver = null, ?callable $objectClassResolver = null, array $defaultContext = []) { - return parent::supportsNormalization($data, $format) && $this->supports(\get_class($data)); + parent::__construct($classMetadataFactory, $nameConverter, $propertyTypeExtractor, $classDiscriminatorResolver, $objectClassResolver, $defaultContext); + + if (!isset($this->defaultContext[self::NORMALIZE_VISIBILITY])) { + $this->defaultContext[self::NORMALIZE_VISIBILITY] = self::NORMALIZE_PUBLIC | self::NORMALIZE_PROTECTED | self::NORMALIZE_PRIVATE; + } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null) + public function getSupportedTypes(?string $format): array { - return parent::supportsDenormalization($data, $type, $format) && $this->supports($type); + return ['object' => true]; } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { - return __CLASS__ === static::class; + return parent::supportsNormalization($data, $format) && $this->supports($data::class); + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return parent::supportsDenormalization($data, $type, $format) && $this->supports($type); } /** @@ -61,7 +74,7 @@ public function hasCacheableSupportsMethod(): bool */ private function supports(string $class): bool { - if (null !== $this->classDiscriminatorResolver && $this->classDiscriminatorResolver->getMappingForClass($class)) { + if ($this->classDiscriminatorResolver?->getMappingForClass($class)) { return true; } @@ -79,10 +92,7 @@ private function supports(string $class): bool return false; } - /** - * {@inheritdoc} - */ - protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []) + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool { if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { return false; @@ -90,20 +100,32 @@ protected function isAllowedAttribute($classOrObject, string $attribute, ?string try { $reflectionProperty = $this->getReflectionProperty($classOrObject, $attribute); - if ($reflectionProperty->isStatic()) { - return false; - } - } catch (\ReflectionException $reflectionException) { + } catch (\ReflectionException) { return false; } - return true; + if ($reflectionProperty->isStatic()) { + return false; + } + + $normalizeVisibility = $context[self::NORMALIZE_VISIBILITY] ?? $this->defaultContext[self::NORMALIZE_VISIBILITY]; + + if ((self::NORMALIZE_PUBLIC & $normalizeVisibility) && $reflectionProperty->isPublic()) { + return true; + } + + if ((self::NORMALIZE_PROTECTED & $normalizeVisibility) && $reflectionProperty->isProtected()) { + return true; + } + + if ((self::NORMALIZE_PRIVATE & $normalizeVisibility) && $reflectionProperty->isPrivate()) { + return true; + } + + return false; } - /** - * {@inheritdoc} - */ - protected function extractAttributes(object $object, ?string $format = null, array $context = []) + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { $reflectionObject = new \ReflectionObject($object); $attributes = []; @@ -121,23 +143,15 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_unique($attributes); } - /** - * {@inheritdoc} - */ - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { try { $reflectionProperty = $this->getReflectionProperty($object, $attribute); - } catch (\ReflectionException $reflectionException) { + } catch (\ReflectionException) { return null; } - // Override visibility - if (!$reflectionProperty->isPublic()) { - $reflectionProperty->setAccessible(true); - } - - if (\PHP_VERSION_ID >= 70400 && $reflectionProperty->hasType()) { + if ($reflectionProperty->hasType()) { return $reflectionProperty->getValue($object); } @@ -148,21 +162,18 @@ protected function getAttributeValue(object $object, string $attribute, ?string || ($reflectionProperty->isProtected() && !\array_key_exists("\0*\0{$reflectionProperty->name}", $propertyValues)) || ($reflectionProperty->isPrivate() && !\array_key_exists("\0{$reflectionProperty->class}\0{$reflectionProperty->name}", $propertyValues)) ) { - throw new UninitializedPropertyException(sprintf('The property "%s::$%s" is not initialized.', \get_class($object), $reflectionProperty->name)); + throw new UninitializedPropertyException(\sprintf('The property "%s::$%s" is not initialized.', $object::class, $reflectionProperty->name)); } } return $reflectionProperty->getValue($object); } - /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void { try { $reflectionProperty = $this->getReflectionProperty($object, $attribute); - } catch (\ReflectionException $reflectionException) { + } catch (\ReflectionException) { return; } @@ -170,20 +181,13 @@ protected function setAttributeValue(object $object, string $attribute, $value, return; } - // Override visibility - if (!$reflectionProperty->isPublic()) { - $reflectionProperty->setAccessible(true); - } - $reflectionProperty->setValue($object, $value); } /** - * @param string|object $classOrObject - * * @throws \ReflectionException */ - private function getReflectionProperty($classOrObject, string $attribute): \ReflectionProperty + private function getReflectionProperty(string|object $classOrObject, string $attribute): \ReflectionProperty { $reflectionClass = new \ReflectionClass($classOrObject); while (true) { diff --git a/Normalizer/TranslatableNormalizer.php b/Normalizer/TranslatableNormalizer.php new file mode 100644 index 000000000..463616e72 --- /dev/null +++ b/Normalizer/TranslatableNormalizer.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Normalizer; + +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +final class TranslatableNormalizer implements NormalizerInterface +{ + public const NORMALIZATION_LOCALE_KEY = 'translatable_normalization_locale'; + + private array $defaultContext = [ + self::NORMALIZATION_LOCALE_KEY => null, + ]; + + public function __construct( + private readonly TranslatorInterface $translator, + array $defaultContext = [], + ) { + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); + } + + /** + * @throws InvalidArgumentException + */ + public function normalize(mixed $object, ?string $format = null, array $context = []): string + { + if (!$object instanceof TranslatableInterface) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The object must implement the "%s".', TranslatableInterface::class), $object, [TranslatableInterface::class]); + } + + return $object->trans($this->translator, $context[self::NORMALIZATION_LOCALE_KEY] ?? $this->defaultContext[self::NORMALIZATION_LOCALE_KEY]); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof TranslatableInterface; + } + + public function getSupportedTypes(?string $format): array + { + return [TranslatableInterface::class => true]; + } +} diff --git a/Normalizer/UidNormalizer.php b/Normalizer/UidNormalizer.php index aa2a8b4fe..0bee1f704 100644 --- a/Normalizer/UidNormalizer.php +++ b/Normalizer/UidNormalizer.php @@ -11,22 +11,28 @@ namespace Symfony\Component\Serializer\Normalizer; -use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Uid\AbstractUid; -use Symfony\Component\Uid\Uuid; -final class UidNormalizer implements NormalizerInterface, DenormalizerInterface, CacheableSupportsMethodInterface +final class UidNormalizer implements NormalizerInterface, DenormalizerInterface { public const NORMALIZATION_FORMAT_KEY = 'uid_normalization_format'; public const NORMALIZATION_FORMAT_CANONICAL = 'canonical'; public const NORMALIZATION_FORMAT_BASE58 = 'base58'; public const NORMALIZATION_FORMAT_BASE32 = 'base32'; - public const NORMALIZATION_FORMAT_RFC4122 = 'rfc4122'; // RFC 9562 obsoleted RFC 4122 but the format is the same + public const NORMALIZATION_FORMAT_RFC4122 = 'rfc4122'; + public const NORMALIZATION_FORMAT_RFC9562 = self::NORMALIZATION_FORMAT_RFC4122; // RFC 9562 obsoleted RFC 4122 but the format is the same - private $defaultContext = [ + public const NORMALIZATION_FORMATS = [ + self::NORMALIZATION_FORMAT_CANONICAL, + self::NORMALIZATION_FORMAT_BASE58, + self::NORMALIZATION_FORMAT_BASE32, + self::NORMALIZATION_FORMAT_RFC4122, + ]; + + private array $defaultContext = [ self::NORMALIZATION_FORMAT_KEY => self::NORMALIZATION_FORMAT_CANONICAL, ]; @@ -35,66 +41,43 @@ public function __construct(array $defaultContext = []) $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } + public function getSupportedTypes(?string $format): array + { + return [ + AbstractUid::class => true, + ]; + } + /** - * {@inheritdoc} - * * @param AbstractUid $object */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - switch ($context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY]) { - case self::NORMALIZATION_FORMAT_CANONICAL: - return (string) $object; - case self::NORMALIZATION_FORMAT_BASE58: - return $object->toBase58(); - case self::NORMALIZATION_FORMAT_BASE32: - return $object->toBase32(); - case self::NORMALIZATION_FORMAT_RFC4122: - return $object->toRfc4122(); - } - - throw new LogicException(sprintf('The "%s" format is not valid.', $context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY])); + return match ($context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY]) { + self::NORMALIZATION_FORMAT_CANONICAL => (string) $object, + self::NORMALIZATION_FORMAT_BASE58 => $object->toBase58(), + self::NORMALIZATION_FORMAT_BASE32 => $object->toBase32(), + self::NORMALIZATION_FORMAT_RFC4122 => $object->toRfc4122(), + default => throw new LogicException(\sprintf('The "%s" format is not valid.', $context[self::NORMALIZATION_FORMAT_KEY] ?? $this->defaultContext[self::NORMALIZATION_FORMAT_KEY])), + }; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return $data instanceof AbstractUid; } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { try { - return AbstractUid::class !== $type ? $type::fromString($data) : Uuid::fromString($data); - } catch (\InvalidArgumentException|\TypeError $exception) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('The data is not a valid "%s" string representation.', $type), $data, [Type::BUILTIN_TYPE_STRING], $context['deserialization_path'] ?? null, true); - } catch (\Error $e) { - if (str_starts_with($e->getMessage(), 'Cannot instantiate abstract class')) { - return $this->denormalize($data, AbstractUid::class, $format, $context); - } - - throw $e; + return $type::fromString($data); + } catch (\InvalidArgumentException|\TypeError) { + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The data is not a valid "%s" string representation.', $type), $data, ['string'], $context['deserialization_path'] ?? null, true); } } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null): bool - { - return is_a($type, AbstractUid::class, true); - } - - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return __CLASS__ === static::class; + return is_subclass_of($type, AbstractUid::class, true); } } diff --git a/Normalizer/UnwrappingDenormalizer.php b/Normalizer/UnwrappingDenormalizer.php index 8a38538f0..9f01d2a71 100644 --- a/Normalizer/UnwrappingDenormalizer.php +++ b/Normalizer/UnwrappingDenormalizer.php @@ -13,29 +13,32 @@ use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerAwareTrait; /** * @author Eduard Bulava */ -final class UnwrappingDenormalizer implements DenormalizerInterface, SerializerAwareInterface, CacheableSupportsMethodInterface +final class UnwrappingDenormalizer implements DenormalizerInterface, SerializerAwareInterface { use SerializerAwareTrait; public const UNWRAP_PATH = 'unwrap_path'; - private $propertyAccessor; + private readonly PropertyAccessorInterface $propertyAccessor; public function __construct(?PropertyAccessorInterface $propertyAccessor = null) { $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); } - /** - * {@inheritdoc} - */ - public function denormalize($data, $class, ?string $format = null, array $context = []) + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { $propertyPath = $context[self::UNWRAP_PATH]; $context['unwrapped'] = true; @@ -48,22 +51,15 @@ public function denormalize($data, $class, ?string $format = null, array $contex $data = $this->propertyAccessor->getValue($data, $propertyPath); } - return $this->serializer->denormalize($data, $class, $format, $context); - } + if (!$this->serializer instanceof DenormalizerInterface) { + throw new LogicException('Cannot unwrap path because the injected serializer is not a denormalizer.'); + } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, $type, ?string $format = null, array $context = []): bool - { - return \array_key_exists(self::UNWRAP_PATH, $context) && !isset($context['unwrapped']); + return $this->serializer->denormalize($data, $type, $format, $context); } - /** - * {@inheritdoc} - */ - public function hasCacheableSupportsMethod(): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { - return $this->serializer instanceof CacheableSupportsMethodInterface && $this->serializer->hasCacheableSupportsMethod(); + return \array_key_exists(self::UNWRAP_PATH, $context) && !isset($context['unwrapped']); } } diff --git a/Serializer.php b/Serializer.php index d814a8aef..f95f2d72e 100644 --- a/Serializer.php +++ b/Serializer.php @@ -19,13 +19,10 @@ use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Exception\UnsupportedFormatException; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; -use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; -use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; -use Symfony\Component\Serializer\Normalizer\ContextAwareNormalizerInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; @@ -46,7 +43,7 @@ * @author Lukas Kahwe Smith * @author Kévin Dunglas */ -class Serializer implements SerializerInterface, ContextAwareNormalizerInterface, ContextAwareDenormalizerInterface, ContextAwareEncoderInterface, ContextAwareDecoderInterface +class Serializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, ContextAwareEncoderInterface, ContextAwareDecoderInterface { /** * Flag to control whether an empty array should be transformed to an @@ -61,26 +58,30 @@ class Serializer implements SerializerInterface, ContextAwareNormalizerInterface 'string' => true, ]; + protected ChainEncoder $encoder; + + protected ChainDecoder $decoder; + /** - * @var Encoder\ChainEncoder + * @var array>> */ - protected $encoder; + private array $denormalizerCache = []; /** - * @var Encoder\ChainDecoder + * @var array>> */ - protected $decoder; - - private $normalizers = []; - private $denormalizerCache = []; - private $normalizerCache = []; + private array $normalizerCache = []; /** * @param array $normalizers * @param array $encoders + * @param array $defaultContext */ - public function __construct(array $normalizers = [], array $encoders = []) - { + public function __construct( + private array $normalizers = [], + array $encoders = [], + private array $defaultContext = [], + ) { foreach ($normalizers as $normalizer) { if ($normalizer instanceof SerializerAwareInterface) { $normalizer->setSerializer($this); @@ -95,10 +96,9 @@ public function __construct(array $normalizers = [], array $encoders = []) } if (!($normalizer instanceof NormalizerInterface || $normalizer instanceof DenormalizerInterface)) { - throw new InvalidArgumentException(sprintf('The class "%s" neither implements "%s" nor "%s".', get_debug_type($normalizer), NormalizerInterface::class, DenormalizerInterface::class)); + throw new InvalidArgumentException(\sprintf('The class "%s" neither implements "%s" nor "%s".', get_debug_type($normalizer), NormalizerInterface::class, DenormalizerInterface::class)); } } - $this->normalizers = $normalizers; $decoders = []; $realEncoders = []; @@ -114,20 +114,17 @@ public function __construct(array $normalizers = [], array $encoders = []) } if (!($encoder instanceof EncoderInterface || $encoder instanceof DecoderInterface)) { - throw new InvalidArgumentException(sprintf('The class "%s" neither implements "%s" nor "%s".', get_debug_type($encoder), EncoderInterface::class, DecoderInterface::class)); + throw new InvalidArgumentException(\sprintf('The class "%s" neither implements "%s" nor "%s".', get_debug_type($encoder), EncoderInterface::class, DecoderInterface::class)); } } $this->encoder = new ChainEncoder($realEncoders); $this->decoder = new ChainDecoder($decoders); } - /** - * {@inheritdoc} - */ - final public function serialize($data, string $format, array $context = []): string + final public function serialize(mixed $data, string $format, array $context = []): string { if (!$this->supportsEncoding($format, $context)) { - throw new NotEncodableValueException(sprintf('Serialization for the format "%s" is not supported.', $format)); + throw new UnsupportedFormatException(\sprintf('Serialization for the format "%s" is not supported.', $format)); } if ($this->encoder->needsNormalization($format, $context)) { @@ -137,13 +134,10 @@ final public function serialize($data, string $format, array $context = []): str return $this->encode($data, $format, $context); } - /** - * {@inheritdoc} - */ - final public function deserialize($data, string $type, string $format, array $context = []) + final public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed { if (!$this->supportsDecoding($format, $context)) { - throw new NotEncodableValueException(sprintf('Deserialization for the format "%s" is not supported.', $format)); + throw new UnsupportedFormatException(\sprintf('Deserialization for the format "%s" is not supported.', $format)); } $data = $this->decode($data, $format, $context); @@ -151,10 +145,7 @@ final public function deserialize($data, string $type, string $format, array $co return $this->denormalize($data, $type, $format, $context); } - /** - * {@inheritdoc} - */ - public function normalize($data, ?string $format = null, array $context = []) + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { // If a normalizer supports the given data, use it if ($normalizer = $this->getNormalizer($data, $format, $context)) { @@ -165,12 +156,12 @@ public function normalize($data, ?string $format = null, array $context = []) return $data; } - if (\is_array($data) && !$data && ($context[self::EMPTY_ARRAY_AS_OBJECT] ?? false)) { + if (\is_array($data) && !$data && ($context[self::EMPTY_ARRAY_AS_OBJECT] ?? $this->defaultContext[self::EMPTY_ARRAY_AS_OBJECT] ?? false)) { return new \ArrayObject(); } if (is_iterable($data)) { - if ($data instanceof \Countable && ($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false) && !\count($data)) { + if ($data instanceof \Countable && ($context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? $this->defaultContext[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] ?? false) && !\count($data)) { return new \ArrayObject(); } @@ -187,19 +178,17 @@ public function normalize($data, ?string $format = null, array $context = []) throw new LogicException('You must register at least one normalizer to be able to normalize objects.'); } - throw new NotNormalizableValueException(sprintf('Could not normalize object of type "%s", no supporting normalizer found.', get_debug_type($data))); + throw new NotNormalizableValueException(\sprintf('Could not normalize object of type "%s", no supporting normalizer found.', get_debug_type($data))); } - throw new NotNormalizableValueException('An unexpected value could not be normalized: '.(!\is_resource($data) ? var_export($data, true) : sprintf('"%s" resource', get_resource_type($data)))); + throw new NotNormalizableValueException('An unexpected value could not be normalized: '.(!\is_resource($data) ? var_export($data, true) : \sprintf('"%s" resource', get_resource_type($data)))); } /** - * {@inheritdoc} - * * @throws NotNormalizableValueException * @throws PartialDenormalizationException Occurs when one or more properties of $type fails to denormalize */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS], $context['not_normalizable_value_exceptions'])) { throw new LogicException('Passing a value for "not_normalizable_value_exceptions" context key is not allowed.'); @@ -210,7 +199,7 @@ public function denormalize($data, string $type, ?string $format = null, array $ // Check for a denormalizer first, e.g. the data is wrapped if (!$normalizer && isset(self::SCALAR_TYPES[$type])) { if (!('is_'.$type)($data)) { - throw NotNormalizableValueException::createForUnexpectedDataType(sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)), $data, [$type], $context['deserialization_path'] ?? null, true); + throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('Data expected to be of type "%s" ("%s" given).', $type, get_debug_type($data)), $data, [$type], $context['deserialization_path'] ?? null, true); } return $data; @@ -221,10 +210,10 @@ public function denormalize($data, string $type, ?string $format = null, array $ } if (!$normalizer) { - throw new NotNormalizableValueException(sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type)); + throw new NotNormalizableValueException(\sprintf('Could not denormalize object of type "%s", no supporting normalizer found.', $type)); } - if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])) { + if (isset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]) || isset($this->defaultContext[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS])) { unset($context[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]); $context['not_normalizable_value_exceptions'] = []; $errors = &$context['not_normalizable_value_exceptions']; @@ -251,18 +240,17 @@ public function denormalize($data, string $type, ?string $format = null, array $ return $normalizer->denormalize($data, $type, $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null, array $context = []) + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return null !== $this->getNormalizer($data, $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []) + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return isset(self::SCALAR_TYPES[$type]) || null !== $this->getDenormalizer($data, $type, $format, $context); } @@ -274,9 +262,15 @@ public function supportsDenormalization($data, string $type, ?string $format = n * @param string|null $format Format name, present to give the option to normalizers to act differently based on formats * @param array $context Options available to the normalizer */ - private function getNormalizer($data, ?string $format, array $context): ?NormalizerInterface + private function getNormalizer(mixed $data, ?string $format, array $context): ?NormalizerInterface { - $type = \is_object($data) ? \get_class($data) : 'native-'.\gettype($data); + if (\is_object($data)) { + $type = $data::class; + $genericType = 'object'; + } else { + $type = 'native-'.\gettype($data); + $genericType = '*'; + } if (!isset($this->normalizerCache[$format][$type])) { $this->normalizerCache[$format][$type] = []; @@ -286,10 +280,29 @@ private function getNormalizer($data, ?string $format, array $context): ?Normali continue; } - if (!$normalizer instanceof CacheableSupportsMethodInterface || !$normalizer->hasCacheableSupportsMethod()) { - $this->normalizerCache[$format][$type][$k] = false; - } elseif ($normalizer->supportsNormalization($data, $format, $context)) { - $this->normalizerCache[$format][$type][$k] = true; + $supportedTypes = $normalizer->getSupportedTypes($format); + + foreach ($supportedTypes as $supportedType => $isCacheable) { + if (\in_array($supportedType, ['*', 'object'], true) + || $type !== $supportedType && ('object' !== $genericType || !is_subclass_of($type, $supportedType)) + ) { + continue; + } + + if (null === $isCacheable) { + unset($supportedTypes['*'], $supportedTypes['object']); + } elseif ($this->normalizerCache[$format][$type][$k] = $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { + break 2; + } + + break; + } + + if (null === $isCacheable = $supportedTypes[\array_key_exists($genericType, $supportedTypes) ? $genericType : '*'] ?? null) { + continue; + } + + if ($this->normalizerCache[$format][$type][$k] ??= $isCacheable && $normalizer->supportsNormalization($data, $format, $context)) { break; } } @@ -313,20 +326,43 @@ private function getNormalizer($data, ?string $format, array $context): ?Normali * @param string|null $format Format name, present to give the option to normalizers to act differently based on formats * @param array $context Options available to the denormalizer */ - private function getDenormalizer($data, string $class, ?string $format, array $context): ?DenormalizerInterface + private function getDenormalizer(mixed $data, string $class, ?string $format, array $context): ?DenormalizerInterface { if (!isset($this->denormalizerCache[$format][$class])) { $this->denormalizerCache[$format][$class] = []; + $genericType = class_exists($class) || interface_exists($class, false) ? 'object' : '*'; foreach ($this->normalizers as $k => $normalizer) { if (!$normalizer instanceof DenormalizerInterface) { continue; } - if (!$normalizer instanceof CacheableSupportsMethodInterface || !$normalizer->hasCacheableSupportsMethod()) { - $this->denormalizerCache[$format][$class][$k] = false; - } elseif ($normalizer->supportsDenormalization(null, $class, $format, $context)) { - $this->denormalizerCache[$format][$class][$k] = true; + $supportedTypes = $normalizer->getSupportedTypes($format); + + $doesClassRepresentCollection = str_ends_with($class, '[]'); + + foreach ($supportedTypes as $supportedType => $isCacheable) { + if (\in_array($supportedType, ['*', 'object'], true) + || $class !== $supportedType && ('object' !== $genericType || !is_subclass_of($class, $supportedType)) + && !($doesClassRepresentCollection && str_ends_with($supportedType, '[]') && is_subclass_of(strstr($class, '[]', true), strstr($supportedType, '[]', true))) + ) { + continue; + } + + if (null === $isCacheable) { + unset($supportedTypes['*'], $supportedTypes['object']); + } elseif ($this->denormalizerCache[$format][$class][$k] = $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { + break 2; + } + + break; + } + + if (null === $isCacheable = $supportedTypes[\array_key_exists($genericType, $supportedTypes) ? $genericType : '*'] ?? null) { + continue; + } + + if ($this->denormalizerCache[$format][$class][$k] ??= $isCacheable && $normalizer->supportsDenormalization(null, $class, $format, $context)) { break; } } @@ -342,34 +378,22 @@ private function getDenormalizer($data, string $class, ?string $format, array $c return null; } - /** - * {@inheritdoc} - */ - final public function encode($data, string $format, array $context = []): string + final public function encode(mixed $data, string $format, array $context = []): string { return $this->encoder->encode($data, $format, $context); } - /** - * {@inheritdoc} - */ - final public function decode(string $data, string $format, array $context = []) + final public function decode(string $data, string $format, array $context = []): mixed { return $this->decoder->decode($data, $format, $context); } - /** - * {@inheritdoc} - */ - public function supportsEncoding(string $format, array $context = []) + public function supportsEncoding(string $format, array $context = []): bool { return $this->encoder->supportsEncoding($format, $context); } - /** - * {@inheritdoc} - */ - public function supportsDecoding(string $format, array $context = []) + public function supportsDecoding(string $format, array $context = []): bool { return $this->decoder->supportsDecoding($format, $context); } diff --git a/SerializerAwareInterface.php b/SerializerAwareInterface.php index 4811fc966..0b4db4c77 100644 --- a/SerializerAwareInterface.php +++ b/SerializerAwareInterface.php @@ -19,5 +19,5 @@ interface SerializerAwareInterface /** * Sets the owning Serializer object. */ - public function setSerializer(SerializerInterface $serializer); + public function setSerializer(SerializerInterface $serializer): void; } diff --git a/SerializerAwareTrait.php b/SerializerAwareTrait.php index 99f4d0f3e..495e5889c 100644 --- a/SerializerAwareTrait.php +++ b/SerializerAwareTrait.php @@ -16,12 +16,9 @@ */ trait SerializerAwareTrait { - /** - * @var SerializerInterface - */ - protected $serializer; + protected ?SerializerInterface $serializer = null; - public function setSerializer(SerializerInterface $serializer) + public function setSerializer(SerializerInterface $serializer): void { $this->serializer = $serializer; } diff --git a/SerializerInterface.php b/SerializerInterface.php index 96e144e90..b883dbea5 100644 --- a/SerializerInterface.php +++ b/SerializerInterface.php @@ -19,20 +19,22 @@ interface SerializerInterface /** * Serializes data in the appropriate format. * - * @param mixed $data Any data - * @param string $format Format name - * @param array $context Options normalizers/encoders have access to - * - * @return string + * @param array $context Options normalizers/encoders have access to */ - public function serialize($data, string $format, array $context = []); + public function serialize(mixed $data, string $format, array $context = []): string; /** * Deserializes data into the given type. * - * @param mixed $data + * @template TObject of object + * @template TType of string|class-string + * + * @param TType $type + * @param array $context + * + * @psalm-return (TType is class-string ? TObject : mixed) * - * @return mixed + * @phpstan-return ($type is class-string ? TObject : mixed) */ - public function deserialize($data, string $type, string $format, array $context = []); + public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed; } diff --git a/Tests/Annotation/ContextTest.php b/Tests/Annotation/ContextTest.php deleted file mode 100644 index afa445893..000000000 --- a/Tests/Annotation/ContextTest.php +++ /dev/null @@ -1,308 +0,0 @@ - - * - * 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\Bridge\PhpUnit\ExpectDeprecationTrait; -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 ExpectDeprecationTrait; - use VarDumperTestTrait; - - protected function setUp(): void - { - $this->setUpVarDumper([], CliDumper::DUMP_LIGHT_ARRAY | CliDumper::DUMP_TRAILING_COMMA); - } - - public function testThrowsOnEmptyContext() - { - $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.'); - - new Context(); - } - - /** - * @group legacy - * - * @dataProvider provideTestThrowsOnEmptyContextLegacyData - */ - public function testThrowsOnEmptyContextLegacy(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 static function provideTestThrowsOnEmptyContextLegacyData(): iterable - { - 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']]); }]; - } - - /** - * @group legacy - * - * @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 static 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']]; - } - - /** - * @requires PHP 8 - */ - 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()]]); - } - - /** - * @group legacy - */ - public function testInvalidGroupOptionLegacy() - { - $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()); - } - - /** - * @requires PHP 8 - * - * @dataProvider provideValidInputs - */ - public function testValidInputs(callable $factory, string $expectedDump) - { - $this->assertDumpEquals($expectedDump, $factory()); - } - - public static function provideValidInputs(): iterable - { - yield 'named arguments: with context option' => [ - function () { return new Context(...['context' => ['foo' => 'bar']]); }, - << "bar", - ] - -normalizationContext: [] - -denormalizationContext: [] - -groups: [] -} -DUMP - ]; - - yield 'named arguments: with normalization context option' => [ - function () { return new Context(...['normalizationContext' => ['foo' => 'bar']]); }, - << "bar", - ] - -denormalizationContext: [] - -groups: [] -} -DUMP - ]; - - yield 'named arguments: with denormalization context option' => [ - function () { return new Context(...['denormalizationContext' => ['foo' => 'bar']]); }, - << "bar", - ] - -groups: [] -} -DUMP - ]; - - yield 'named arguments: with groups option as string' => [ - function () { return new Context(...['context' => ['foo' => 'bar'], 'groups' => 'a']); }, - << "bar", - ] - -normalizationContext: [] - -denormalizationContext: [] - -groups: [ - "a", - ] -} -DUMP - ]; - - yield 'named arguments: with groups option as array' => [ - function () { return new Context(...['context' => ['foo' => 'bar'], 'groups' => ['a', 'b']]); }, - << "bar", - ] - -normalizationContext: [] - -denormalizationContext: [] - -groups: [ - "a", - "b", - ] -} -DUMP - ]; - } - - /** - * @group legacy - * - * @dataProvider provideValidLegacyInputs - */ - public function testValidLegacyInputs(callable $factory, string $expectedDump) - { - $this->expectDeprecation('Since symfony/serializer 5.3: Passing an array of properties as first argument to "Symfony\Component\Serializer\Annotation\Context::__construct" is deprecated. Use named arguments instead.'); - $this->assertDumpEquals($expectedDump, $factory()); - } - - public static function provideValidLegacyInputs(): iterable - { - yield 'doctrine-style: with context option' => [ - function () { return new Context(['context' => ['foo' => 'bar']]); }, - << "bar", - ] - -normalizationContext: [] - -denormalizationContext: [] - -groups: [] -} -DUMP - ]; - - yield 'doctrine-style: with normalization context option' => [ - function () { return new Context(['normalizationContext' => ['foo' => 'bar']]); }, - << "bar", - ] - -denormalizationContext: [] - -groups: [] -} -DUMP - ]; - - yield 'doctrine-style: with denormalization context option' => [ - function () { return new Context(['denormalizationContext' => ['foo' => 'bar']]); }, - << "bar", - ] - -groups: [] -} -DUMP - ]; - - 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']]); }, - << "bar", - ] - -normalizationContext: [] - -denormalizationContext: [] - -groups: [ - "a", - "b", - ] -} -DUMP - ]; - } -} diff --git a/Tests/Annotation/DiscriminatorMapTest.php b/Tests/Annotation/DiscriminatorMapTest.php deleted file mode 100644 index fd9cf68a2..000000000 --- a/Tests/Annotation/DiscriminatorMapTest.php +++ /dev/null @@ -1,123 +0,0 @@ - - * - * 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\Bridge\PhpUnit\ExpectDeprecationTrait; -use Symfony\Component\Serializer\Annotation\DiscriminatorMap; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; - -/** - * @author Samuel Roze - */ -class DiscriminatorMapTest extends TestCase -{ - use ExpectDeprecationTrait; - - /** - * @requires PHP 8 - */ - public function testGetTypePropertyAndMapping() - { - $annotation = new DiscriminatorMap(...['typeProperty' => 'type', 'mapping' => [ - 'foo' => 'FooClass', - 'bar' => 'BarClass', - ]]); - - $this->assertEquals('type', $annotation->getTypeProperty()); - $this->assertEquals([ - 'foo' => 'FooClass', - 'bar' => 'BarClass', - ], $annotation->getMapping()); - } - - /** - * @group legacy - */ - public function testGetTypePropertyAndMappingLegacy() - { - $this->expectDeprecation('Since symfony/serializer 5.3: Passing an array as first argument to "Symfony\Component\Serializer\Annotation\DiscriminatorMap::__construct" is deprecated. Use named arguments instead.'); - $annotation = new DiscriminatorMap(['typeProperty' => 'type', 'mapping' => [ - 'foo' => 'FooClass', - 'bar' => 'BarClass', - ]]); - - $this->assertEquals('type', $annotation->getTypeProperty()); - $this->assertEquals([ - 'foo' => 'FooClass', - 'bar' => 'BarClass', - ], $annotation->getMapping()); - } - - /** - * @group legacy - */ - public function testExceptionWithoutTypeProperty() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(['mapping' => ['foo' => 'FooClass']]); - } - - /** - * @requires PHP 8 - */ - public function testExceptionWithEmptyTypeProperty() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(...['typeProperty' => '', 'mapping' => ['foo' => 'FooClass']]); - } - - /** - * @group legacy - */ - public function testExceptionWithEmptyTypePropertyLegacy() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(['typeProperty' => '', 'mapping' => ['foo' => 'FooClass']]); - } - - /** - * @requires PHP 8 - */ - public function testExceptionWithoutMappingProperty() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(...['typeProperty' => 'type']); - } - - /** - * @group legacy - */ - public function testExceptionWithoutMappingPropertyLegacy() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(['typeProperty' => 'type']); - } - - /** - * @requires PHP 8 - */ - public function testExceptionWitEmptyMappingProperty() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(...['typeProperty' => 'type', 'mapping' => []]); - } - - /** - * @group legacy - */ - public function testExceptionWitEmptyMappingPropertyLegacy() - { - $this->expectException(InvalidArgumentException::class); - new DiscriminatorMap(['typeProperty' => 'type', 'mapping' => []]); - } -} diff --git a/Tests/Annotation/MaxDepthTest.php b/Tests/Annotation/MaxDepthTest.php deleted file mode 100644 index e1a7c1ca3..000000000 --- a/Tests/Annotation/MaxDepthTest.php +++ /dev/null @@ -1,72 +0,0 @@ - - * - * 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\Bridge\PhpUnit\ExpectDeprecationTrait; -use Symfony\Component\Serializer\Annotation\MaxDepth; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; - -/** - * @author Kévin Dunglas - */ -class MaxDepthTest extends TestCase -{ - use ExpectDeprecationTrait; - - /** - * @group legacy - */ - public function testNotSetMaxDepthParameter() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameter of annotation "Symfony\Component\Serializer\Annotation\MaxDepth" should be set.'); - new MaxDepth([]); - } - - public static function provideInvalidValues() - { - return [ - [''], - ['foo'], - ['1'], - [0], - ]; - } - - /** - * @dataProvider provideInvalidValues - */ - public function testNotAnIntMaxDepthParameter($value) - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameter of annotation "Symfony\Component\Serializer\Annotation\MaxDepth" must be a positive integer.'); - new MaxDepth($value); - } - - public function testMaxDepthParameters() - { - $maxDepth = new MaxDepth(3); - $this->assertEquals(3, $maxDepth->getMaxDepth()); - } - - /** - * @group legacy - */ - public function testMaxDepthParametersLegacy() - { - $this->expectDeprecation('Since symfony/serializer 5.3: Passing an array as first argument to "Symfony\Component\Serializer\Annotation\MaxDepth::__construct" is deprecated. Use named arguments instead.'); - - $maxDepth = new MaxDepth(['value' => 3]); - $this->assertEquals(3, $maxDepth->getMaxDepth()); - } -} diff --git a/Tests/Annotation/SerializedNameTest.php b/Tests/Annotation/SerializedNameTest.php deleted file mode 100644 index 2a2ca2bbf..000000000 --- a/Tests/Annotation/SerializedNameTest.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * 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\Bridge\PhpUnit\ExpectDeprecationTrait; -use Symfony\Component\Serializer\Annotation\SerializedName; -use Symfony\Component\Serializer\Exception\InvalidArgumentException; - -/** - * @author Fabien Bourigault - */ -class SerializedNameTest extends TestCase -{ - use ExpectDeprecationTrait; - - /** - * @group legacy - */ - public function testNotSetSerializedNameParameter() - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameter of annotation "Symfony\Component\Serializer\Annotation\SerializedName" should be set.'); - new SerializedName([]); - } - - public static function provideInvalidValues(): array - { - return [ - [''], - [0], - ]; - } - - /** - * @dataProvider provideInvalidValues - */ - public function testNotAStringSerializedNameParameter($value) - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Parameter of annotation "Symfony\Component\Serializer\Annotation\SerializedName" must be a non-empty string.'); - - new SerializedName($value); - } - - public function testSerializedNameParameters() - { - $maxDepth = new SerializedName('foo'); - $this->assertEquals('foo', $maxDepth->getSerializedName()); - } - - /** - * @group legacy - */ - public function testSerializedNameParametersLegacy() - { - $this->expectDeprecation('Since symfony/serializer 5.3: Passing an array as first argument to "Symfony\Component\Serializer\Annotation\SerializedName::__construct" is deprecated. Use named arguments instead.'); - - $maxDepth = new SerializedName(['value' => 'foo']); - $this->assertEquals('foo', $maxDepth->getSerializedName()); - } -} diff --git a/Tests/Attribute/ContextTest.php b/Tests/Attribute/ContextTest.php new file mode 100644 index 000000000..cfe175050 --- /dev/null +++ b/Tests/Attribute/ContextTest.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Attribute\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); + } + + public function testThrowsOnEmptyContext() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one of the "context", "normalizationContext", or "denormalizationContext" options must be provided as a non-empty array to "Symfony\Component\Serializer\Attribute\Context".'); + + new Context(); + } + + public function testInvalidGroupOption() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('Parameter "groups" given to "%s" must be a string or an array of strings, "stdClass" given', Context::class)); + + new Context(context: ['foo' => 'bar'], groups: ['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(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) + { + $this->assertDumpEquals($expectedDump, $factory()); + } + + public static function provideValidInputs(): iterable + { + yield 'named arguments: with context option' => [ + fn () => new Context(context: ['foo' => 'bar']), + << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] +} +DUMP + ]; + + yield 'named arguments: with normalization context option' => [ + fn () => new Context(normalizationContext: ['foo' => 'bar']), + << "bar", + ] + -denormalizationContext: [] +} +DUMP + ]; + + yield 'named arguments: with denormalization context option' => [ + fn () => new Context(denormalizationContext: ['foo' => 'bar']), + << "bar", + ] +} +DUMP + ]; + + yield 'named arguments: with groups option as string' => [ + fn () => new Context(context: ['foo' => 'bar'], groups: 'a'), + << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] +} +DUMP + ]; + + yield 'named arguments: with groups option as array' => [ + fn () => new Context(context: ['foo' => 'bar'], groups: ['a', 'b']), + << "bar", + ] + -normalizationContext: [] + -denormalizationContext: [] +} +DUMP + ]; + } +} diff --git a/Tests/Attribute/DiscriminatorMapTest.php b/Tests/Attribute/DiscriminatorMapTest.php new file mode 100644 index 000000000..497bc6201 --- /dev/null +++ b/Tests/Attribute/DiscriminatorMapTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Samuel Roze + */ +class DiscriminatorMapTest extends TestCase +{ + public function testGetTypePropertyAndMapping() + { + $attribute = new DiscriminatorMap(typeProperty: 'type', mapping: [ + 'foo' => 'FooClass', + 'bar' => 'BarClass', + ]); + + $this->assertEquals('type', $attribute->getTypeProperty()); + $this->assertEquals([ + 'foo' => 'FooClass', + 'bar' => 'BarClass', + ], $attribute->getMapping()); + } + + public function testExceptionWithEmptyTypeProperty() + { + $this->expectException(InvalidArgumentException::class); + new DiscriminatorMap(typeProperty: '', mapping: ['foo' => 'FooClass']); + } + + public function testExceptionWitEmptyMappingProperty() + { + $this->expectException(InvalidArgumentException::class); + new DiscriminatorMap(typeProperty: 'type', mapping: []); + } +} diff --git a/Tests/Annotation/GroupsTest.php b/Tests/Attribute/GroupsTest.php similarity index 53% rename from Tests/Annotation/GroupsTest.php rename to Tests/Attribute/GroupsTest.php index 890ab86c4..266cbc4f4 100644 --- a/Tests/Annotation/GroupsTest.php +++ b/Tests/Attribute/GroupsTest.php @@ -9,10 +9,10 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Serializer\Tests\Annotation; +namespace Symfony\Component\Serializer\Tests\Attribute; use PHPUnit\Framework\TestCase; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Exception\InvalidArgumentException; /** @@ -26,24 +26,6 @@ public function testEmptyGroupsParameter() new Groups([]); } - /** - * @group legacy - */ - public function testEmptyGroupsParameterLegacy() - { - $this->expectException(InvalidArgumentException::class); - new Groups(['value' => []]); - } - - /** - * @group legacy - */ - public function testNotAnArrayGroupsParameter() - { - $this->expectException(InvalidArgumentException::class); - new Groups(['value' => 12]); - } - public function testInvalidGroupsParameter() { $this->expectException(InvalidArgumentException::class); @@ -58,29 +40,9 @@ public function testGroupsParameters() $this->assertEquals($validData, $groups->getGroups()); } - /** - * @group legacy - */ - public function testGroupsParametersLegacy() - { - $validData = ['a', 'b']; - - $groups = new Groups(['value' => $validData]); - $this->assertEquals($validData, $groups->getGroups()); - } - public function testSingleGroup() { $groups = new Groups('a'); $this->assertEquals(['a'], $groups->getGroups()); } - - /** - * @group legacy - */ - public function testSingleGroupLegacy() - { - $groups = new Groups(['value' => 'a']); - $this->assertEquals(['a'], $groups->getGroups()); - } } diff --git a/Tests/Attribute/MaxDepthTest.php b/Tests/Attribute/MaxDepthTest.php new file mode 100644 index 000000000..e611bfbc4 --- /dev/null +++ b/Tests/Attribute/MaxDepthTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Kévin Dunglas + */ +class MaxDepthTest extends TestCase +{ + /** + * @testWith [-4] + * [0] + */ + public function testNotAnIntMaxDepthParameter(int $value) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter given to "Symfony\Component\Serializer\Attribute\MaxDepth" must be a positive integer.'); + new MaxDepth($value); + } + + public function testMaxDepthParameters() + { + $maxDepth = new MaxDepth(3); + $this->assertEquals(3, $maxDepth->getMaxDepth()); + } +} diff --git a/Tests/Attribute/SerializedNameTest.php b/Tests/Attribute/SerializedNameTest.php new file mode 100644 index 000000000..c645e7e85 --- /dev/null +++ b/Tests/Attribute/SerializedNameTest.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\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Fabien Bourigault + */ +class SerializedNameTest extends TestCase +{ + public function testNotAStringSerializedNameParameter() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter given to "Symfony\Component\Serializer\Attribute\SerializedName" must be a non-empty string.'); + + new SerializedName(''); + } + + public function testSerializedNameParameters() + { + $foo = new SerializedName('foo'); + $this->assertEquals('foo', $foo->getSerializedName()); + } +} diff --git a/Tests/Attribute/SerializedPathTest.php b/Tests/Attribute/SerializedPathTest.php new file mode 100644 index 000000000..7ba31fc22 --- /dev/null +++ b/Tests/Attribute/SerializedPathTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Attribute; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyPath; +use Symfony\Component\Serializer\Attribute\SerializedPath; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Tobias Bönner + */ +class SerializedPathTest extends TestCase +{ + public function testEmptyStringSerializedPathParameter() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter given to "Symfony\Component\Serializer\Attribute\SerializedPath" must be a valid property path.'); + + new SerializedPath(''); + } + + public function testSerializedPath() + { + $path = '[one][two]'; + $serializedPath = new SerializedPath($path); + $propertyPath = new PropertyPath($path); + $this->assertEquals($propertyPath, $serializedPath->getSerializedPath()); + } +} diff --git a/Tests/Command/DebugCommandTest.php b/Tests/Command/DebugCommandTest.php new file mode 100644 index 000000000..7bfdf93dd --- /dev/null +++ b/Tests/Command/DebugCommandTest.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Serializer\Command\DebugCommand; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Tests\Dummy\DummyClassOne; + +/** + * @author Loïc Frémont + */ +class DebugCommandTest extends TestCase +{ + public function testOutputWithClassArgument() + { + $command = new DebugCommand(new ClassMetadataFactory(new AttributeLoader())); + + $tester = new CommandTester($command); + $tester->execute(['class' => DummyClassOne::class], ['decorated' => false]); + + $this->assertSame(<< [ | + | | "book:read", | + | | "book:write" | + | | ], | + | | "maxDepth" => 1, | + | | "serializedName" => "identifier", | + | | "serializedPath" => null, | + | | "ignore" => true, | + | | "normalizationContexts" => [ | + | | "*" => [ | + | | "groups" => [ | + | | "book:read" | + | | ] | + | | ] | + | | ], | + | | "denormalizationContexts" => [ | + | | "*" => [ | + | | "groups" => [ | + | | "book:write" | + | | ] | + | | ] | + | | ] | + | | ] | + | name | [ | + | | "groups" => [], | + | | "maxDepth" => null, | + | | "serializedName" => null, | + | | "serializedPath" => "[data][name]", | + | | "ignore" => false, | + | | "normalizationContexts" => [], | + | | "denormalizationContexts" => [] | + | | ] | + +----------+---------------------------------------+ + + TXT, + $tester->getDisplay(true), + ); + } + + public function testOutputWithInvalidClassArgument() + { + $serializer = $this->createMock(ClassMetadataFactoryInterface::class); + + $command = new DebugCommand($serializer); + + $tester = new CommandTester($command); + $tester->execute(['class' => 'App\\NotFoundResource'], ['decorated' => false]); + + $this->assertStringContainsString('[ERROR] Class "App\NotFoundResource" was not found.', $tester->getDisplay(true) + ); + } +} diff --git a/Tests/Context/ContextBuilderTraitTest.php b/Tests/Context/ContextBuilderTraitTest.php new file mode 100644 index 000000000..edd3937e2 --- /dev/null +++ b/Tests/Context/ContextBuilderTraitTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\ContextBuilderInterface; +use Symfony\Component\Serializer\Context\ContextBuilderTrait; + +/** + * @author Mathias Arlaud + */ +class ContextBuilderTraitTest extends TestCase +{ + public function testWithContext() + { + $contextBuilder = new class implements ContextBuilderInterface { + use ContextBuilderTrait; + }; + + $context = $contextBuilder->withContext(['foo' => 'bar'])->toArray(); + + $this->assertSame(['foo' => 'bar'], $context); + + $withContextBuilderObject = $contextBuilder->withContext($contextBuilder->withContext(['foo' => 'bar']))->toArray(); + + $this->assertSame(['foo' => 'bar'], $withContextBuilderObject); + } + + public function testWith() + { + $contextBuilder = new class { + use ContextBuilderTrait; + + public function withFoo(string $value): static + { + return $this->with('foo', $value); + } + }; + + $context = $contextBuilder->withFoo('bar')->toArray(); + + $this->assertSame(['foo' => 'bar'], $context); + } +} diff --git a/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php b/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php new file mode 100644 index 000000000..fe39feb81 --- /dev/null +++ b/Tests/Context/Encoder/CsvEncoderContextBuilderTest.php @@ -0,0 +1,146 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Encoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; +use Symfony\Component\Serializer\Context\Encoder\CsvEncoderContextBuilder; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; + +/** + * @author Mathias Arlaud + */ +class CsvEncoderContextBuilderTest extends TestCase +{ + use ExpectUserDeprecationMessageTrait; + + private CsvEncoderContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new CsvEncoderContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withDelimiter($values[CsvEncoder::DELIMITER_KEY]) + ->withEnclosure($values[CsvEncoder::ENCLOSURE_KEY]) + ->withKeySeparator($values[CsvEncoder::KEY_SEPARATOR_KEY]) + ->withHeaders($values[CsvEncoder::HEADERS_KEY]) + ->withEscapedFormulas($values[CsvEncoder::ESCAPE_FORMULAS_KEY]) + ->withAsCollection($values[CsvEncoder::AS_COLLECTION_KEY]) + ->withNoHeaders($values[CsvEncoder::NO_HEADERS_KEY]) + ->withEndOfLine($values[CsvEncoder::END_OF_LINE]) + ->withOutputUtf8Bom($values[CsvEncoder::OUTPUT_UTF8_BOM_KEY]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable|}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + CsvEncoder::DELIMITER_KEY => ';', + CsvEncoder::ENCLOSURE_KEY => '"', + CsvEncoder::KEY_SEPARATOR_KEY => '_', + CsvEncoder::HEADERS_KEY => ['h1', 'h2'], + CsvEncoder::ESCAPE_FORMULAS_KEY => true, + CsvEncoder::AS_COLLECTION_KEY => true, + CsvEncoder::NO_HEADERS_KEY => false, + CsvEncoder::END_OF_LINE => 'EOL', + CsvEncoder::OUTPUT_UTF8_BOM_KEY => false, + ]]; + + yield 'With null values' => [[ + CsvEncoder::DELIMITER_KEY => null, + CsvEncoder::ENCLOSURE_KEY => null, + CsvEncoder::KEY_SEPARATOR_KEY => null, + CsvEncoder::HEADERS_KEY => null, + CsvEncoder::ESCAPE_FORMULAS_KEY => null, + CsvEncoder::AS_COLLECTION_KEY => null, + CsvEncoder::NO_HEADERS_KEY => null, + CsvEncoder::END_OF_LINE => null, + CsvEncoder::OUTPUT_UTF8_BOM_KEY => null, + ]]; + } + + public function testWithersWithoutValue() + { + $context = $this->contextBuilder + ->withDelimiter(null) + ->withEnclosure(null) + ->withKeySeparator(null) + ->withHeaders(null) + ->withEscapedFormulas(null) + ->withAsCollection(null) + ->withNoHeaders(null) + ->withEndOfLine(null) + ->withOutputUtf8Bom(null) + ->toArray(); + + $this->assertSame([ + CsvEncoder::DELIMITER_KEY => null, + CsvEncoder::ENCLOSURE_KEY => null, + CsvEncoder::KEY_SEPARATOR_KEY => null, + CsvEncoder::HEADERS_KEY => null, + CsvEncoder::ESCAPE_FORMULAS_KEY => null, + CsvEncoder::AS_COLLECTION_KEY => null, + CsvEncoder::NO_HEADERS_KEY => null, + CsvEncoder::END_OF_LINE => null, + CsvEncoder::OUTPUT_UTF8_BOM_KEY => null, + ], $context); + } + + public function testCannotSetMultipleBytesAsDelimiter() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withDelimiter('ọ'); + } + + public function testCannotSetMultipleBytesAsEnclosure() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withEnclosure('ọ'); + } + + /** + * @group legacy + */ + public function testCannotSetMultipleBytesAsEscapeChar() + { + $this->expectUserDeprecationMessage('Since symfony/serializer 7.2: The "Symfony\Component\Serializer\Context\Encoder\CsvEncoderContextBuilder::withEscapeChar" method is deprecated. It will be removed in 8.0.'); + + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withEscapeChar('ọ'); + } + + /** + * @group legacy + */ + public function testWithEscapeCharIsDeprecated() + { + $this->expectUserDeprecationMessage('Since symfony/serializer 7.2: The "Symfony\Component\Serializer\Context\Encoder\CsvEncoderContextBuilder::withEscapeChar" method is deprecated. It will be removed in 8.0.'); + $context = $this->contextBuilder->withEscapeChar('\\'); + + $this->assertSame(['csv_escape_char' => '\\'], $context->toArray()); + } +} diff --git a/Tests/Context/Encoder/JsonEncoderContextBuilderTest.php b/Tests/Context/Encoder/JsonEncoderContextBuilderTest.php new file mode 100644 index 000000000..9cabbf17a --- /dev/null +++ b/Tests/Context/Encoder/JsonEncoderContextBuilderTest.php @@ -0,0 +1,67 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Encoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Encoder\JsonEncoderContextBuilder; +use Symfony\Component\Serializer\Encoder\JsonDecode; +use Symfony\Component\Serializer\Encoder\JsonEncode; + +/** + * @author Mathias Arlaud + */ +class JsonEncoderContextBuilderTest extends TestCase +{ + private JsonEncoderContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new JsonEncoderContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withEncodeOptions($values[JsonEncode::OPTIONS]) + ->withDecodeOptions($values[JsonDecode::OPTIONS]) + ->withAssociative($values[JsonDecode::ASSOCIATIVE]) + ->withRecursionDepth($values[JsonDecode::RECURSION_DEPTH]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable|}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + JsonEncode::OPTIONS => \JSON_PRETTY_PRINT, + JsonDecode::OPTIONS => \JSON_BIGINT_AS_STRING, + JsonDecode::ASSOCIATIVE => true, + JsonDecode::RECURSION_DEPTH => 1024, + ]]; + + yield 'With null values' => [[ + JsonEncode::OPTIONS => null, + JsonDecode::OPTIONS => null, + JsonDecode::ASSOCIATIVE => null, + JsonDecode::RECURSION_DEPTH => null, + ]]; + } +} diff --git a/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php b/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php new file mode 100644 index 000000000..2f71c6012 --- /dev/null +++ b/Tests/Context/Encoder/XmlEncoderContextBuilderTest.php @@ -0,0 +1,91 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Encoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Encoder\XmlEncoderContextBuilder; +use Symfony\Component\Serializer\Encoder\XmlEncoder; + +/** + * @author Mathias Arlaud + */ +class XmlEncoderContextBuilderTest extends TestCase +{ + private XmlEncoderContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new XmlEncoderContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withAsCollection($values[XmlEncoder::AS_COLLECTION]) + ->withDecoderIgnoredNodeTypes($values[XmlEncoder::DECODER_IGNORED_NODE_TYPES]) + ->withEncoderIgnoredNodeTypes($values[XmlEncoder::ENCODER_IGNORED_NODE_TYPES]) + ->withEncoding($values[XmlEncoder::ENCODING]) + ->withFormatOutput($values[XmlEncoder::FORMAT_OUTPUT]) + ->withLoadOptions($values[XmlEncoder::LOAD_OPTIONS]) + ->withSaveOptions($values[XmlEncoder::SAVE_OPTIONS]) + ->withRemoveEmptyTags($values[XmlEncoder::REMOVE_EMPTY_TAGS]) + ->withRootNodeName($values[XmlEncoder::ROOT_NODE_NAME]) + ->withStandalone($values[XmlEncoder::STANDALONE]) + ->withTypeCastAttributes($values[XmlEncoder::TYPE_CAST_ATTRIBUTES]) + ->withVersion($values[XmlEncoder::VERSION]) + ->withCdataWrapping($values[XmlEncoder::CDATA_WRAPPING]) + ->withCdataWrappingPattern($values[XmlEncoder::CDATA_WRAPPING_PATTERN]) + ->toArray(); + + $this->assertSame($values, $context); + } + + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + XmlEncoder::AS_COLLECTION => true, + XmlEncoder::DECODER_IGNORED_NODE_TYPES => [\XML_PI_NODE, \XML_COMMENT_NODE], + XmlEncoder::ENCODER_IGNORED_NODE_TYPES => [\XML_TEXT_NODE], + XmlEncoder::ENCODING => 'UTF-8', + XmlEncoder::FORMAT_OUTPUT => false, + XmlEncoder::LOAD_OPTIONS => \LIBXML_COMPACT, + XmlEncoder::SAVE_OPTIONS => \LIBXML_NOERROR, + XmlEncoder::REMOVE_EMPTY_TAGS => true, + XmlEncoder::ROOT_NODE_NAME => 'root', + XmlEncoder::STANDALONE => false, + XmlEncoder::TYPE_CAST_ATTRIBUTES => true, + XmlEncoder::VERSION => '1.0', + XmlEncoder::CDATA_WRAPPING => false, + XmlEncoder::CDATA_WRAPPING_PATTERN => '/[<>&"\']/', + ]]; + + yield 'With null values' => [[ + XmlEncoder::AS_COLLECTION => null, + XmlEncoder::DECODER_IGNORED_NODE_TYPES => null, + XmlEncoder::ENCODER_IGNORED_NODE_TYPES => null, + XmlEncoder::ENCODING => null, + XmlEncoder::FORMAT_OUTPUT => null, + XmlEncoder::LOAD_OPTIONS => null, + XmlEncoder::SAVE_OPTIONS => null, + XmlEncoder::REMOVE_EMPTY_TAGS => null, + XmlEncoder::ROOT_NODE_NAME => null, + XmlEncoder::STANDALONE => null, + XmlEncoder::TYPE_CAST_ATTRIBUTES => null, + XmlEncoder::VERSION => null, + XmlEncoder::CDATA_WRAPPING => null, + XmlEncoder::CDATA_WRAPPING_PATTERN => null, + ]]; + } +} diff --git a/Tests/Context/Encoder/YamlEncoderContextBuilderTest.php b/Tests/Context/Encoder/YamlEncoderContextBuilderTest.php new file mode 100644 index 000000000..86371bf4d --- /dev/null +++ b/Tests/Context/Encoder/YamlEncoderContextBuilderTest.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Encoder; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Encoder\YamlEncoderContextBuilder; +use Symfony\Component\Serializer\Encoder\YamlEncoder; + +/** + * @author Mathias Arlaud + */ +class YamlEncoderContextBuilderTest extends TestCase +{ + private YamlEncoderContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new YamlEncoderContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withIndentLevel($values[YamlEncoder::YAML_INDENT]) + ->withInlineThreshold($values[YamlEncoder::YAML_INLINE]) + ->withFlags($values[YamlEncoder::YAML_FLAGS]) + ->withPreservedEmptyObjects($values[YamlEncoder::PRESERVE_EMPTY_OBJECTS]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable|}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + YamlEncoder::YAML_INDENT => 4, + YamlEncoder::YAML_INLINE => 16, + YamlEncoder::YAML_FLAGS => 128, + YamlEncoder::PRESERVE_EMPTY_OBJECTS => false, + ]]; + + yield 'With null values' => [[ + YamlEncoder::YAML_INDENT => null, + YamlEncoder::YAML_INLINE => null, + YamlEncoder::YAML_FLAGS => null, + YamlEncoder::PRESERVE_EMPTY_OBJECTS => null, + ]]; + } +} diff --git a/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php new file mode 100644 index 000000000..4e92c54d8 --- /dev/null +++ b/Tests/Context/Normalizer/AbstractNormalizerContextBuilderTest.php @@ -0,0 +1,96 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\AbstractNormalizerContextBuilder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; + +/** + * @author Mathias Arlaud + */ +class AbstractNormalizerContextBuilderTest extends TestCase +{ + private AbstractNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new class extends AbstractNormalizerContextBuilder {}; + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withCircularReferenceLimit($values[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]) + ->withObjectToPopulate($values[AbstractNormalizer::OBJECT_TO_POPULATE]) + ->withGroups($values[AbstractNormalizer::GROUPS]) + ->withAttributes($values[AbstractNormalizer::ATTRIBUTES]) + ->withAllowExtraAttributes($values[AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES]) + ->withDefaultConstructorArguments($values[AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS]) + ->withCallbacks($values[AbstractNormalizer::CALLBACKS]) + ->withCircularReferenceHandler($values[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]) + ->withIgnoredAttributes($values[AbstractNormalizer::IGNORED_ATTRIBUTES]) + ->withRequireAllProperties($values[AbstractNormalizer::REQUIRE_ALL_PROPERTIES]) + ->toArray(); + + $this->assertEquals($values, $context); + } + + /** + * @return iterable|}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT => 12, + AbstractNormalizer::OBJECT_TO_POPULATE => new \stdClass(), + AbstractNormalizer::GROUPS => ['group'], + AbstractNormalizer::ATTRIBUTES => ['attribute1', 'attribute2'], + AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true, + AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS => [self::class => ['foo' => 'bar']], + AbstractNormalizer::CALLBACKS => [static function (): void {}], + AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => static function (): void {}, + AbstractNormalizer::IGNORED_ATTRIBUTES => ['attribute3'], + AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true, + ]]; + + yield 'With null values' => [[ + AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT => null, + AbstractNormalizer::OBJECT_TO_POPULATE => null, + AbstractNormalizer::GROUPS => null, + AbstractNormalizer::ATTRIBUTES => null, + AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => null, + AbstractNormalizer::DEFAULT_CONSTRUCTOR_ARGUMENTS => null, + AbstractNormalizer::CALLBACKS => null, + AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER => null, + AbstractNormalizer::IGNORED_ATTRIBUTES => null, + AbstractNormalizer::REQUIRE_ALL_PROPERTIES => null, + ]]; + } + + public function testCastSingleGroupToArray() + { + $this->assertSame([AbstractNormalizer::GROUPS => ['group']], $this->contextBuilder->withGroups('group')->toArray()); + } + + public function testCannotSetNonStringAttributes() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withAttributes(['attribute', ['nested attribute', 1]]); + } +} diff --git a/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php new file mode 100644 index 000000000..c13760118 --- /dev/null +++ b/Tests/Context/Normalizer/AbstractObjectNormalizerContextBuilderTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\AbstractObjectNormalizerContextBuilder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; + +/** + * @author Mathias Arlaud + */ +class AbstractObjectNormalizerContextBuilderTest extends TestCase +{ + private AbstractObjectNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new class extends AbstractObjectNormalizerContextBuilder {}; + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withEnableMaxDepth($values[AbstractObjectNormalizer::ENABLE_MAX_DEPTH]) + ->withDepthKeyPattern($values[AbstractObjectNormalizer::DEPTH_KEY_PATTERN]) + ->withDisableTypeEnforcement($values[AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT]) + ->withSkipNullValues($values[AbstractObjectNormalizer::SKIP_NULL_VALUES]) + ->withSkipUninitializedValues($values[AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES]) + ->withMaxDepthHandler($values[AbstractObjectNormalizer::MAX_DEPTH_HANDLER]) + ->withExcludeFromCacheKeys($values[AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY]) + ->withDeepObjectToPopulate($values[AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE]) + ->withPreserveEmptyObjects($values[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable|}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true, + AbstractObjectNormalizer::DEPTH_KEY_PATTERN => '%s_%s', + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => false, + AbstractObjectNormalizer::SKIP_NULL_VALUES => true, + AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => false, + AbstractObjectNormalizer::MAX_DEPTH_HANDLER => static function (): void {}, + AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY => ['key'], + AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE => true, + AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => false, + ]]; + + yield 'With null values' => [[ + AbstractObjectNormalizer::ENABLE_MAX_DEPTH => null, + AbstractObjectNormalizer::DEPTH_KEY_PATTERN => null, + AbstractObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => null, + AbstractObjectNormalizer::SKIP_NULL_VALUES => null, + AbstractObjectNormalizer::SKIP_UNINITIALIZED_VALUES => null, + AbstractObjectNormalizer::MAX_DEPTH_HANDLER => null, + AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY => null, + AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE => null, + AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => null, + ]]; + } + + /** + * @dataProvider validateDepthKeyPatternDataProvider + */ + public function testValidateDepthKeyPattern(string $pattern, bool $expectException) + { + $exception = null; + + try { + $this->contextBuilder->withDepthKeyPattern($pattern); + } catch (InvalidArgumentException $e) { + $exception = $e; + } + + $this->assertSame($expectException, null !== $exception); + } + + /** + * @return iterable + */ + public static function validateDepthKeyPatternDataProvider(): iterable + { + yield ['depth_%s::%s', false]; + yield ['%%%s %%s %%%%%s', false]; + yield ['%s%%%s', false]; + yield ['', true]; + yield ['depth_%d::%s', true]; + yield ['%s_%s::%s', true]; + } +} diff --git a/Tests/Context/Normalizer/BackedEnumNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/BackedEnumNormalizerContextBuilderTest.php new file mode 100644 index 000000000..94d98776f --- /dev/null +++ b/Tests/Context/Normalizer/BackedEnumNormalizerContextBuilderTest.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\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\BackedEnumNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; + +class BackedEnumNormalizerContextBuilderTest extends TestCase +{ + private BackedEnumNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new BackedEnumNormalizerContextBuilder(); + } + + public function testWithers() + { + $context = $this->contextBuilder->withAllowInvalidValues(true)->toArray(); + self::assertSame([BackedEnumNormalizer::ALLOW_INVALID_VALUES => true], $context); + + $context = $this->contextBuilder->withAllowInvalidValues(false)->toArray(); + self::assertSame([BackedEnumNormalizer::ALLOW_INVALID_VALUES => false], $context); + } +} diff --git a/Tests/Context/Normalizer/ConstraintViolationListNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/ConstraintViolationListNormalizerContextBuilderTest.php new file mode 100644 index 000000000..df1a0ced7 --- /dev/null +++ b/Tests/Context/Normalizer/ConstraintViolationListNormalizerContextBuilderTest.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\ConstraintViolationListNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer; + +/** + * @author Mathias Arlaud + */ +class ConstraintViolationListNormalizerContextBuilderTest extends TestCase +{ + private ConstraintViolationListNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new ConstraintViolationListNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withInstance($values[ConstraintViolationListNormalizer::INSTANCE]) + ->withStatus($values[ConstraintViolationListNormalizer::STATUS]) + ->withTitle($values[ConstraintViolationListNormalizer::TITLE]) + ->withType($values[ConstraintViolationListNormalizer::TYPE]) + ->withPayloadFields($values[ConstraintViolationListNormalizer::PAYLOAD_FIELDS]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + ConstraintViolationListNormalizer::INSTANCE => new \stdClass(), + ConstraintViolationListNormalizer::STATUS => 418, + ConstraintViolationListNormalizer::TITLE => 'title', + ConstraintViolationListNormalizer::TYPE => 'type', + ConstraintViolationListNormalizer::PAYLOAD_FIELDS => ['field'], + ]]; + + yield 'With null values' => [[ + ConstraintViolationListNormalizer::INSTANCE => null, + ConstraintViolationListNormalizer::STATUS => null, + ConstraintViolationListNormalizer::TITLE => null, + ConstraintViolationListNormalizer::TYPE => null, + ConstraintViolationListNormalizer::PAYLOAD_FIELDS => null, + ]]; + } +} diff --git a/Tests/Context/Normalizer/DateIntervalNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/DateIntervalNormalizerContextBuilderTest.php new file mode 100644 index 000000000..018d34abf --- /dev/null +++ b/Tests/Context/Normalizer/DateIntervalNormalizerContextBuilderTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\DateIntervalNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer; + +/** + * @author Mathias Arlaud + */ +class DateIntervalNormalizerContextBuilderTest extends TestCase +{ + private DateIntervalNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new DateIntervalNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withFormat($values[DateIntervalNormalizer::FORMAT_KEY]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + DateIntervalNormalizer::FORMAT_KEY => 'format', + ]]; + + yield 'With null values' => [[ + DateIntervalNormalizer::FORMAT_KEY => null, + ]]; + } +} diff --git a/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php new file mode 100644 index 000000000..ac4badc19 --- /dev/null +++ b/Tests/Context/Normalizer/DateTimeNormalizerContextBuilderTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\DateTimeNormalizerContextBuilder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; + +/** + * @author Mathias Arlaud + */ +class DateTimeNormalizerContextBuilderTest extends TestCase +{ + private DateTimeNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new DateTimeNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withFormat($values[DateTimeNormalizer::FORMAT_KEY]) + ->withTimezone($values[DateTimeNormalizer::TIMEZONE_KEY]) + ->withCast($values[DateTimeNormalizer::CAST_KEY]) + ->toArray(); + + $this->assertEquals($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + DateTimeNormalizer::FORMAT_KEY => 'format', + DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('GMT'), + DateTimeNormalizer::CAST_KEY => 'int', + ]]; + + yield 'With null values' => [[ + DateTimeNormalizer::FORMAT_KEY => null, + DateTimeNormalizer::TIMEZONE_KEY => null, + DateTimeNormalizer::CAST_KEY => null, + ]]; + } + + public function testCastTimezoneStringToTimezone() + { + $this->assertEquals([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('GMT')], $this->contextBuilder->withTimezone('GMT')->toArray()); + } + + public function testCannotSetInvalidTimezone() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withTimezone('not a timezone'); + } +} diff --git a/Tests/Context/Normalizer/FormErrorNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/FormErrorNormalizerContextBuilderTest.php new file mode 100644 index 000000000..0557c2548 --- /dev/null +++ b/Tests/Context/Normalizer/FormErrorNormalizerContextBuilderTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\FormErrorNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\FormErrorNormalizer; + +/** + * @author Mathias Arlaud + */ +class FormErrorNormalizerContextBuilderTest extends TestCase +{ + private FormErrorNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new FormErrorNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withTitle($values[FormErrorNormalizer::TITLE]) + ->withType($values[FormErrorNormalizer::TYPE]) + ->withStatusCode($values[FormErrorNormalizer::CODE]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + FormErrorNormalizer::TITLE => 'title', + FormErrorNormalizer::TYPE => 'type', + FormErrorNormalizer::CODE => 418, + ]]; + + yield 'With null values' => [[ + FormErrorNormalizer::TITLE => null, + FormErrorNormalizer::TYPE => null, + FormErrorNormalizer::CODE => null, + ]]; + } +} diff --git a/Tests/Context/Normalizer/ProblemNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/ProblemNormalizerContextBuilderTest.php new file mode 100644 index 000000000..3e9821d17 --- /dev/null +++ b/Tests/Context/Normalizer/ProblemNormalizerContextBuilderTest.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\ProblemNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; + +/** + * @author Mathias Arlaud + */ +class ProblemNormalizerContextBuilderTest extends TestCase +{ + private ProblemNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new ProblemNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withTitle($values[ProblemNormalizer::TITLE]) + ->withType($values[ProblemNormalizer::TYPE]) + ->withStatusCode($values[ProblemNormalizer::STATUS]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + ProblemNormalizer::TITLE => 'title', + ProblemNormalizer::TYPE => 'type', + ProblemNormalizer::STATUS => 418, + ]]; + + yield 'With null values' => [[ + ProblemNormalizer::TITLE => null, + ProblemNormalizer::TYPE => null, + ProblemNormalizer::STATUS => null, + ]]; + } +} diff --git a/Tests/Context/Normalizer/PropertyNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/PropertyNormalizerContextBuilderTest.php new file mode 100644 index 000000000..41f0b8990 --- /dev/null +++ b/Tests/Context/Normalizer/PropertyNormalizerContextBuilderTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\PropertyNormalizerContextBuilder; +use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; + +/** + * @author Antoine Lamirault + */ +class PropertyNormalizerContextBuilderTest extends TestCase +{ + private PropertyNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new PropertyNormalizerContextBuilder(); + } + + public function testWithNormalizeVisibility() + { + $context = $this->contextBuilder + ->withNormalizeVisibility(PropertyNormalizer::NORMALIZE_PUBLIC | PropertyNormalizer::NORMALIZE_PROTECTED) + ->toArray(); + + $this->assertSame([ + PropertyNormalizer::NORMALIZE_VISIBILITY => PropertyNormalizer::NORMALIZE_PUBLIC | PropertyNormalizer::NORMALIZE_PROTECTED, + ], $context); + } +} diff --git a/Tests/Context/Normalizer/UidNormalizerContextBuilderTest.php b/Tests/Context/Normalizer/UidNormalizerContextBuilderTest.php new file mode 100644 index 000000000..6a3855704 --- /dev/null +++ b/Tests/Context/Normalizer/UidNormalizerContextBuilderTest.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\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\UidNormalizerContextBuilder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\UidNormalizer; + +/** + * @author Mathias Arlaud + */ +class UidNormalizerContextBuilderTest extends TestCase +{ + private UidNormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new UidNormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withNormalizationFormat($values[UidNormalizer::NORMALIZATION_FORMAT_KEY]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + UidNormalizer::NORMALIZATION_FORMAT_KEY => UidNormalizer::NORMALIZATION_FORMAT_BASE32, + ]]; + + yield 'With null values' => [[ + UidNormalizer::NORMALIZATION_FORMAT_KEY => null, + ]]; + } + + public function testCannotSetInvalidUidNormalizationFormat() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withNormalizationFormat('invalid format'); + } +} diff --git a/Tests/Context/Normalizer/UnwrappingDenormalizerContextBuilderTest.php b/Tests/Context/Normalizer/UnwrappingDenormalizerContextBuilderTest.php new file mode 100644 index 000000000..5307618fe --- /dev/null +++ b/Tests/Context/Normalizer/UnwrappingDenormalizerContextBuilderTest.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\Tests\Context\Normalizer; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\Normalizer\UnwrappingDenormalizerContextBuilder; +use Symfony\Component\Serializer\Exception\InvalidArgumentException; +use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; + +/** + * @author Mathias Arlaud + */ +class UnwrappingDenormalizerContextBuilderTest extends TestCase +{ + private UnwrappingDenormalizerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new UnwrappingDenormalizerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withUnwrapPath($values[UnwrappingDenormalizer::UNWRAP_PATH]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + UnwrappingDenormalizer::UNWRAP_PATH => 'foo', + ]]; + + yield 'With null values' => [[ + UnwrappingDenormalizer::UNWRAP_PATH => null, + ]]; + } + + public function testCannotSetInvalidPropertyPath() + { + $this->expectException(InvalidArgumentException::class); + $this->contextBuilder->withUnwrapPath('invalid path...'); + } +} diff --git a/Tests/Context/SerializerContextBuilderTest.php b/Tests/Context/SerializerContextBuilderTest.php new file mode 100644 index 000000000..a417869f1 --- /dev/null +++ b/Tests/Context/SerializerContextBuilderTest.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Context; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Context\SerializerContextBuilder; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Serializer; + +/** + * @author Mathias Arlaud + */ +class SerializerContextBuilderTest extends TestCase +{ + private SerializerContextBuilder $contextBuilder; + + protected function setUp(): void + { + $this->contextBuilder = new SerializerContextBuilder(); + } + + /** + * @dataProvider withersDataProvider + * + * @param array $values + */ + public function testWithers(array $values) + { + $context = $this->contextBuilder + ->withEmptyArrayAsObject($values[Serializer::EMPTY_ARRAY_AS_OBJECT]) + ->withCollectDenormalizationErrors($values[DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS]) + ->toArray(); + + $this->assertSame($values, $context); + } + + /** + * @return iterable}> + */ + public static function withersDataProvider(): iterable + { + yield 'With values' => [[ + Serializer::EMPTY_ARRAY_AS_OBJECT => true, + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => false, + ]]; + + yield 'With null values' => [[ + Serializer::EMPTY_ARRAY_AS_OBJECT => null, + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => null, + ]]; + } +} diff --git a/Tests/DataCollector/SerializerDataCollectorTest.php b/Tests/DataCollector/SerializerDataCollectorTest.php new file mode 100644 index 000000000..6a26565a8 --- /dev/null +++ b/Tests/DataCollector/SerializerDataCollectorTest.php @@ -0,0 +1,413 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\DataCollector; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Encoder\CsvEncoder; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; + +class SerializerDataCollectorTest extends TestCase +{ + public function testCollectSerialize() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['serialize']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => 'type', + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['deserialize']); + } + + public function testCollectNormalize() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectNormalize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['normalize']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => 'type', + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['denormalize']); + } + + public function testCollectEncode() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectEncode('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + + $dataCollector->lateCollect(); + $collectedData = $this->castCollectedData($dataCollector->getData()); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['encode']); + + $this->assertSame([[ + 'data' => 'data', + 'dataType' => 'string', + 'type' => null, + 'format' => 'format', + 'time' => 1.0, + 'context' => ['foo' => 'bar'], + 'normalization' => [], + 'encoding' => [], + 'caller' => $caller, + 'name' => 'default', + ]], $collectedData['decode']); + } + + public function testCollectNormalization() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectNormalize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectDenormalize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 1.0, 'default'); + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 2.0, 'default'); + $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 5.0, 'default'); + $dataCollector->collectNormalization('traceIdOne', ObjectNormalizer::class, 10.0, 'default'); + + $dataCollector->collectNormalization('traceIdTwo', DateTimeNormalizer::class, 1.0, 'default'); + $dataCollector->collectNormalization('traceIdTwo', DateTimeNormalizer::class, 2.0, 'default'); + $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 5.0, 'default'); + $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 10.0, 'default'); + + $dataCollector->lateCollect(); + $collectedData = $dataCollector->getData(); + + $this->assertSame(10.0, $collectedData['normalize'][0]['normalizer']['time']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalizer']['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalizer']); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalizer']); + + $this->assertSame(3.0, $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['time']); + $this->assertSame(2, $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['calls']); + $this->assertSame('DateTimeNormalizer', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalization'][DateTimeNormalizer::class]); + + $this->assertSame(5.0, $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['time']); + $this->assertSame(1, $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['calls']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['normalize'][0]['normalization'][ObjectNormalizer::class]); + + $this->assertSame(10.0, $collectedData['denormalize'][0]['normalizer']['time']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalizer']['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalizer']); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalizer']); + + $this->assertSame(3.0, $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['time']); + $this->assertSame(2, $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['calls']); + $this->assertSame('DateTimeNormalizer', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalization'][DateTimeNormalizer::class]); + + $this->assertSame(5.0, $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['time']); + $this->assertSame(1, $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['calls']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]['class']); + $this->assertArrayHasKey('file', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]); + $this->assertArrayHasKey('line', $collectedData['denormalize'][0]['normalization'][ObjectNormalizer::class]); + } + + public function testCollectEncoding() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectEncode('traceIdOne', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectDecode('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + + $dataCollector->collectEncoding('traceIdOne', JsonEncoder::class, 1.0, 'default'); + $dataCollector->collectEncoding('traceIdOne', JsonEncoder::class, 2.0, 'default'); + $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 5.0, 'default'); + $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 10.0, 'default'); + + $dataCollector->collectDecoding('traceIdTwo', JsonEncoder::class, 1.0, 'default'); + $dataCollector->collectDecoding('traceIdTwo', JsonEncoder::class, 2.0, 'default'); + $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 5.0, 'default'); + $dataCollector->collectDecoding('traceIdTwo', CsvEncoder::class, 10.0, 'default'); + + $dataCollector->lateCollect(); + $collectedData = $dataCollector->getData(); + + $this->assertSame(10.0, $collectedData['encode'][0]['encoder']['time']); + $this->assertSame('CsvEncoder', $collectedData['encode'][0]['encoder']['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoder']); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoder']); + + $this->assertSame(3.0, $collectedData['encode'][0]['encoding'][JsonEncoder::class]['time']); + $this->assertSame(2, $collectedData['encode'][0]['encoding'][JsonEncoder::class]['calls']); + $this->assertSame('JsonEncoder', $collectedData['encode'][0]['encoding'][JsonEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoding'][JsonEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoding'][JsonEncoder::class]); + + $this->assertSame(5.0, $collectedData['encode'][0]['encoding'][CsvEncoder::class]['time']); + $this->assertSame(1, $collectedData['encode'][0]['encoding'][CsvEncoder::class]['calls']); + $this->assertSame('CsvEncoder', $collectedData['encode'][0]['encoding'][CsvEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['encode'][0]['encoding'][CsvEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['encode'][0]['encoding'][CsvEncoder::class]); + + $this->assertSame(10.0, $collectedData['decode'][0]['encoder']['time']); + $this->assertSame('CsvEncoder', $collectedData['decode'][0]['encoder']['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoder']); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoder']); + + $this->assertSame(3.0, $collectedData['decode'][0]['encoding'][JsonEncoder::class]['time']); + $this->assertSame(2, $collectedData['decode'][0]['encoding'][JsonEncoder::class]['calls']); + $this->assertSame('JsonEncoder', $collectedData['decode'][0]['encoding'][JsonEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoding'][JsonEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoding'][JsonEncoder::class]); + + $this->assertSame(5.0, $collectedData['decode'][0]['encoding'][CsvEncoder::class]['time']); + $this->assertSame(1, $collectedData['decode'][0]['encoding'][CsvEncoder::class]['calls']); + $this->assertSame('CsvEncoder', $collectedData['decode'][0]['encoding'][CsvEncoder::class]['class']); + $this->assertArrayHasKey('file', $collectedData['decode'][0]['encoding'][CsvEncoder::class]); + $this->assertArrayHasKey('line', $collectedData['decode'][0]['encoding'][CsvEncoder::class]); + } + + public function testCountHandled() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 20.0, $caller, 'default'); + $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + + $dataCollector->lateCollect(); + + $this->assertSame(7, $dataCollector->getHandledCount()); + } + + public function testGetTotalTime() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->collectDeserialize('traceIdTwo', 'data', 'type', 'format', ['foo' => 'bar'], 2.0, $caller, 'default'); + $dataCollector->collectNormalize('traceIdThree', 'data', 'format', ['foo' => 'bar'], 3.0, $caller, 'default'); + $dataCollector->collectDenormalize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 4.0, $caller, 'default'); + $dataCollector->collectEncode('traceIdFive', 'data', 'format', ['foo' => 'bar'], 5.0, $caller, 'default'); + $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 6.0, $caller, 'default'); + $dataCollector->collectSerialize('traceIdSeven', 'data', 'format', ['foo' => 'bar'], 7.0, $caller, 'default'); + + $dataCollector->lateCollect(); + + $this->assertSame(28.0, $dataCollector->getTotalTime()); + } + + public function testReset() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 1.0, $caller, 'default'); + $dataCollector->lateCollect(); + + $this->assertNotSame([], $dataCollector->getData()); + + $dataCollector->reset(); + $this->assertSame([], $dataCollector->getData()); + } + + public function testDoNotCollectPartialTraces() + { + $dataCollector = new SerializerDataCollector(); + + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 1.0, 'default'); + $dataCollector->collectDenormalization('traceIdTwo', DateTimeNormalizer::class, 1.0, 'default'); + $dataCollector->collectEncoding('traceIdThree', CsvEncoder::class, 10.0, 'default'); + $dataCollector->collectDecoding('traceIdFour', JsonEncoder::class, 1.0, 'default'); + + $dataCollector->lateCollect(); + + $data = $dataCollector->getData(); + + $this->assertSame([], $data['serialize']); + $this->assertSame([], $data['deserialize']); + $this->assertSame([], $data['normalize']); + $this->assertSame([], $data['denormalize']); + $this->assertSame([], $data['encode']); + $this->assertSame([], $data['decode']); + } + + public function testNamedSerializers() + { + $dataCollector = new SerializerDataCollector(); + + $caller = ['name' => 'Foo.php', 'file' => 'src/Foo.php', 'line' => 123]; + $dataCollector->collectNormalization('traceIdOne', DateTimeNormalizer::class, 3.0, 'default'); + $dataCollector->collectEncoding('traceIdOne', CsvEncoder::class, 4.0, 'default'); + $dataCollector->collectSerialize('traceIdOne', 'data', 'format', ['foo' => 'bar'], 7.0, $caller, 'default'); + $dataCollector->collectNormalization('traceIdTwo', ObjectNormalizer::class, 3.0, 'default'); + $dataCollector->collectNormalize('traceIdTwo', 'data', 'format', ['foo' => 'bar'], 5.0, $caller, 'default'); + + $dataCollector->collectEncoding('traceIdThree', JsonEncoder::class, 4.0, 'api'); + $dataCollector->collectEncode('traceIdThree', 'data', 'format', ['foo' => 'bar'], 5.0, $caller, 'api'); + $dataCollector->collectDenormalization('traceIdFour', DateTimeNormalizer::class, 3.0, 'api'); + $dataCollector->collectDecoding('traceIdFour', CsvEncoder::class, 4.0, 'api'); + $dataCollector->collectDeserialize('traceIdFour', 'data', 'type', 'format', ['foo' => 'bar'], 7.0, $caller, 'api'); + $dataCollector->collectDenormalization('traceIdFive', ObjectNormalizer::class, 3.0, 'api'); + $dataCollector->collectDenormalize('traceIdFive', 'data', 'type', 'format', ['foo' => 'bar'], 5.0, $caller, 'api'); + $dataCollector->collectDecoding('traceIdSix', JsonEncoder::class, 4.0, 'api'); + $dataCollector->collectDecode('traceIdSix', 'data', 'format', ['foo' => 'bar'], 5.0, $caller, 'api'); + + $dataCollector->lateCollect(); + + $this->assertSame(6, $dataCollector->getHandledCount()); + + $collectedData = $dataCollector->getData(); + + $this->assertSame('default', $collectedData['serialize'][0]['name']); + $this->assertSame('DateTimeNormalizer', $collectedData['serialize'][0]['normalizer']['class']); + $this->assertSame('CsvEncoder', $collectedData['serialize'][0]['encoder']['class']); + $this->assertSame('default', $collectedData['normalize'][0]['name']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalizer']['class']); + + $this->assertSame('api', $collectedData['encode'][0]['name']); + $this->assertSame('JsonEncoder', $collectedData['encode'][0]['encoder']['class']); + $this->assertSame('api', $collectedData['deserialize'][0]['name']); + $this->assertSame('DateTimeNormalizer', $collectedData['deserialize'][0]['normalizer']['class']); + $this->assertSame('CsvEncoder', $collectedData['deserialize'][0]['encoder']['class']); + $this->assertSame('api', $collectedData['denormalize'][0]['name']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalizer']['class']); + $this->assertSame('api', $collectedData['decode'][0]['name']); + $this->assertSame('JsonEncoder', $collectedData['decode'][0]['encoder']['class']); + + $this->assertSame(['default', 'api'], $dataCollector->getSerializerNames()); + + $this->assertSame(2, $dataCollector->getHandledCount('default')); + + $collectedData = $dataCollector->getData('default'); + + $this->assertSame('default', $collectedData['serialize'][0]['name']); + $this->assertSame('DateTimeNormalizer', $collectedData['serialize'][0]['normalizer']['class']); + $this->assertSame('CsvEncoder', $collectedData['serialize'][0]['encoder']['class']); + $this->assertSame('default', $collectedData['normalize'][0]['name']); + $this->assertSame('ObjectNormalizer', $collectedData['normalize'][0]['normalizer']['class']); + + $this->assertEmpty($collectedData['encode']); + $this->assertEmpty($collectedData['deserialize']); + $this->assertEmpty($collectedData['denormalize']); + $this->assertEmpty($collectedData['decode']); + + $this->assertSame(4, $dataCollector->getHandledCount('api')); + + $collectedData = $dataCollector->getData('api'); + + $this->assertEmpty($collectedData['serialize']); + $this->assertEmpty($collectedData['normalize']); + + $this->assertSame('api', $collectedData['encode'][0]['name']); + $this->assertSame('JsonEncoder', $collectedData['encode'][0]['encoder']['class']); + $this->assertSame('api', $collectedData['deserialize'][0]['name']); + $this->assertSame('DateTimeNormalizer', $collectedData['deserialize'][0]['normalizer']['class']); + $this->assertSame('CsvEncoder', $collectedData['deserialize'][0]['encoder']['class']); + $this->assertSame('api', $collectedData['denormalize'][0]['name']); + $this->assertSame('ObjectNormalizer', $collectedData['denormalize'][0]['normalizer']['class']); + $this->assertSame('api', $collectedData['decode'][0]['name']); + $this->assertSame('JsonEncoder', $collectedData['decode'][0]['encoder']['class']); + } + + /** + * Cast cloned vars to be able to test nested values. + */ + private function castCollectedData(array $collectedData): array + { + foreach ($collectedData as $method => $collectedMethodData) { + foreach ($collectedMethodData as $i => $collected) { + $collectedData[$method][$i]['data'] = $collected['data']->getValue(); + $collectedData[$method][$i]['context'] = $collected['context']->getValue(true); + } + } + + return $collectedData; + } +} diff --git a/Tests/Debug/TraceableEncoderTest.php b/Tests/Debug/TraceableEncoderTest.php new file mode 100644 index 000000000..2ac0b8f1f --- /dev/null +++ b/Tests/Debug/TraceableEncoderTest.php @@ -0,0 +1,107 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; + +class TraceableEncoderTest extends TestCase +{ + public function testForwardsToEncoder() + { + $encoder = $this->createMock(EncoderInterface::class); + $encoder + ->expects($this->once()) + ->method('encode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('encoded'); + + $decoder = $this->createMock(DecoderInterface::class); + $decoder + ->expects($this->once()) + ->method('decode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('decoded'); + + $this->assertSame('encoded', (new TraceableEncoder($encoder, new SerializerDataCollector(), 'default'))->encode('data', 'format')); + $this->assertSame('decoded', (new TraceableEncoder($decoder, new SerializerDataCollector(), 'default'))->decode('data', 'format')); + } + + public function testCollectEncodingData() + { + $serializerName = uniqid('name', true); + + $encoder = $this->createMock(EncoderInterface::class); + $decoder = $this->createMock(DecoderInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectEncoding') + ->with($this->isType('string'), $encoder::class, $this->isType('float'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectDecoding') + ->with($this->isType('string'), $decoder::class, $this->isType('float'), $serializerName); + + (new TraceableEncoder($encoder, $dataCollector, $serializerName))->encode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (new TraceableEncoder($decoder, $dataCollector, $serializerName))->decode('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + } + + public function testNotCollectEncodingDataIfNoDebugTraceId() + { + $encoder = $this->createMock(EncoderInterface::class); + $decoder = $this->createMock(DecoderInterface::class); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector->expects($this->never())->method('collectEncoding'); + $dataCollector->expects($this->never())->method('collectDecoding'); + + (new TraceableEncoder($encoder, $dataCollector, 'default'))->encode('data', 'format'); + (new TraceableEncoder($decoder, $dataCollector, 'default'))->decode('data', 'format'); + } + + public function testCannotEncodeIfNotEncoder() + { + $this->expectException(\BadMethodCallException::class); + + (new TraceableEncoder($this->createMock(DecoderInterface::class), new SerializerDataCollector(), 'default'))->encode('data', 'format'); + } + + public function testCannotDecodeIfNotDecoder() + { + $this->expectException(\BadMethodCallException::class); + + (new TraceableEncoder($this->createMock(EncoderInterface::class), new SerializerDataCollector(), 'default'))->decode('data', 'format'); + } + + public function testSupports() + { + $encoder = $this->createMock(EncoderInterface::class); + $encoder->method('supportsEncoding')->willReturn(true); + + $decoder = $this->createMock(DecoderInterface::class); + $decoder->method('supportsDecoding')->willReturn(true); + + $traceableEncoder = new TraceableEncoder($encoder, new SerializerDataCollector(), 'default'); + $traceableDecoder = new TraceableEncoder($decoder, new SerializerDataCollector(), 'default'); + + $this->assertTrue($traceableEncoder->supportsEncoding('data')); + $this->assertTrue($traceableDecoder->supportsDecoding('data')); + $this->assertFalse($traceableEncoder->supportsDecoding('data')); + $this->assertFalse($traceableDecoder->supportsEncoding('data')); + } +} diff --git a/Tests/Debug/TraceableNormalizerTest.php b/Tests/Debug/TraceableNormalizerTest.php new file mode 100644 index 000000000..56c161392 --- /dev/null +++ b/Tests/Debug/TraceableNormalizerTest.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +class TraceableNormalizerTest extends TestCase +{ + public function testForwardsToNormalizer() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $normalizer + ->expects($this->once()) + ->method('normalize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('normalized'); + + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $denormalizer + ->expects($this->once()) + ->method('denormalize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('denormalized'); + + $this->assertSame('normalized', (new TraceableNormalizer($normalizer, new SerializerDataCollector(), 'default'))->normalize('data', 'format')); + $this->assertSame('denormalized', (new TraceableNormalizer($denormalizer, new SerializerDataCollector(), 'default'))->denormalize('data', 'type', 'format')); + } + + public function testCollectNormalizationData() + { + $serializerName = uniqid('name', true); + + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectNormalization') + ->with($this->isType('string'), $normalizer::class, $this->isType('float'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectDenormalization') + ->with($this->isType('string'), $denormalizer::class, $this->isType('float'), $serializerName); + + (new TraceableNormalizer($normalizer, $dataCollector, $serializerName))->normalize('data', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + (new TraceableNormalizer($denormalizer, $dataCollector, $serializerName))->denormalize('data', 'type', 'format', [TraceableSerializer::DEBUG_TRACE_ID => 'debug']); + } + + public function testNotCollectNormalizationDataIfNoDebugTraceId() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector->expects($this->never())->method('collectNormalization'); + $dataCollector->expects($this->never())->method('collectDenormalization'); + + (new TraceableNormalizer($normalizer, $dataCollector, 'default'))->normalize('data', 'format'); + (new TraceableNormalizer($denormalizer, $dataCollector, 'default'))->denormalize('data', 'type', 'format'); + } + + public function testCannotNormalizeIfNotNormalizer() + { + $this->expectException(\BadMethodCallException::class); + + (new TraceableNormalizer($this->createMock(DenormalizerInterface::class), new SerializerDataCollector(), 'default'))->normalize('data'); + } + + public function testCannotDenormalizeIfNotDenormalizer() + { + $this->expectException(\BadMethodCallException::class); + + (new TraceableNormalizer($this->createMock(NormalizerInterface::class), new SerializerDataCollector(), 'default'))->denormalize('data', 'type'); + } + + public function testSupports() + { + $normalizer = $this->createMock(NormalizerInterface::class); + $normalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $normalizer->method('supportsNormalization')->willReturn(true); + + $denormalizer = $this->createMock(DenormalizerInterface::class); + $denormalizer->method('getSupportedTypes')->willReturn(['*' => false]); + $denormalizer->method('supportsDenormalization')->willReturn(true); + + $traceableNormalizer = new TraceableNormalizer($normalizer, new SerializerDataCollector(), 'default'); + $traceableDenormalizer = new TraceableNormalizer($denormalizer, new SerializerDataCollector(), 'default'); + + $this->assertTrue($traceableNormalizer->supportsNormalization('data')); + $this->assertTrue($traceableDenormalizer->supportsDenormalization('data', 'type')); + $this->assertFalse($traceableNormalizer->supportsDenormalization('data', 'type')); + $this->assertFalse($traceableDenormalizer->supportsNormalization('data')); + } +} diff --git a/Tests/Debug/TraceableSerializerTest.php b/Tests/Debug/TraceableSerializerTest.php new file mode 100644 index 000000000..d697b270f --- /dev/null +++ b/Tests/Debug/TraceableSerializerTest.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Serializer\Tests\Debug; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\DataCollector\SerializerDataCollector; +use Symfony\Component\Serializer\Debug\TraceableSerializer; +use Symfony\Component\Serializer\Encoder\DecoderInterface; +use Symfony\Component\Serializer\Encoder\EncoderInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class TraceableSerializerTest extends TestCase +{ + public function testForwardsToSerializer() + { + $serializer = $this->createMock(Serializer::class); + $serializer + ->expects($this->once()) + ->method('serialize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('serialized'); + $serializer + ->expects($this->once()) + ->method('deserialize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('deserialized'); + $serializer + ->expects($this->once()) + ->method('normalize') + ->with('data', 'format', $this->isType('array')) + ->willReturn('normalized'); + $serializer + ->expects($this->once()) + ->method('denormalize') + ->with('data', 'type', 'format', $this->isType('array')) + ->willReturn('denormalized'); + $serializer + ->expects($this->once()) + ->method('encode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('encoded'); + $serializer + ->expects($this->once()) + ->method('decode') + ->with('data', 'format', $this->isType('array')) + ->willReturn('decoded'); + + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector(), 'default'); + + $this->assertSame('serialized', $traceableSerializer->serialize('data', 'format')); + $this->assertSame('deserialized', $traceableSerializer->deserialize('data', 'type', 'format')); + $this->assertSame('normalized', $traceableSerializer->normalize('data', 'format')); + $this->assertSame('denormalized', $traceableSerializer->denormalize('data', 'type', 'format')); + $this->assertSame('encoded', $traceableSerializer->encode('data', 'format')); + $this->assertSame('decoded', $traceableSerializer->decode('data', 'format')); + } + + public function testCollectData() + { + $serializerName = uniqid('name', true); + + $dataCollector = $this->createMock(SerializerDataCollector::class); + $dataCollector + ->expects($this->once()) + ->method('collectSerialize') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectDeserialize') + ->with($this->isType('string'), 'data', 'type', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectNormalize') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectDenormalize') + ->with($this->isType('string'), 'data', 'type', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectEncode') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + $dataCollector + ->expects($this->once()) + ->method('collectDecode') + ->with($this->isType('string'), 'data', 'format', $this->isType('array'), $this->isType('float'), $this->isType('array'), $serializerName); + + $traceableSerializer = new TraceableSerializer(new Serializer(), $dataCollector, $serializerName); + + $traceableSerializer->serialize('data', 'format'); + $traceableSerializer->deserialize('data', 'type', 'format'); + $traceableSerializer->normalize('data', 'format'); + $traceableSerializer->denormalize('data', 'type', 'format'); + $traceableSerializer->encode('data', 'format'); + $traceableSerializer->decode('data', 'format'); + } + + public function testAddDebugTraceIdInContext() + { + $serializer = $this->createMock(Serializer::class); + + foreach (['serialize', 'deserialize', 'normalize', 'denormalize', 'encode', 'decode'] as $method) { + $serializer->method($method)->willReturnCallback(function (): string { + $context = func_get_arg(\func_num_args() - 1); + $this->assertIsString($context[TraceableSerializer::DEBUG_TRACE_ID]); + + return ''; + }); + } + + $traceableSerializer = new TraceableSerializer($serializer, new SerializerDataCollector(), 'default'); + + $traceableSerializer->serialize('data', 'format'); + $traceableSerializer->deserialize('data', 'format', 'type'); + $traceableSerializer->normalize('data', 'format'); + $traceableSerializer->denormalize('data', 'format'); + $traceableSerializer->encode('data', 'format'); + $traceableSerializer->decode('data', 'format'); + } +} + +class Serializer implements SerializerInterface, NormalizerInterface, DenormalizerInterface, EncoderInterface, DecoderInterface +{ + public function serialize(mixed $data, string $format, array $context = []): string + { + return 'serialized'; + } + + public function deserialize(mixed $data, string $type, string $format, array $context = []): mixed + { + return 'deserialized'; + } + + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null + { + return 'normalized'; + } + + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return true; + } + + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + return 'denormalized'; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return true; + } + + public function encode(mixed $data, string $format, array $context = []): string + { + return 'encoded'; + } + + public function supportsEncoding(string $format, array $context = []): bool + { + return true; + } + + public function decode(string $data, string $format, array $context = []): mixed + { + return 'decoded'; + } + + public function supportsDecoding(string $format, array $context = []): bool + { + return true; + } +} diff --git a/Tests/DependencyInjection/SerializerPassTest.php b/Tests/DependencyInjection/SerializerPassTest.php index e9b1efd22..88ec02b87 100644 --- a/Tests/DependencyInjection/SerializerPassTest.php +++ b/Tests/DependencyInjection/SerializerPassTest.php @@ -15,7 +15,12 @@ use Symfony\Component\DependencyInjection\Argument\BoundArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Reference; +use Symfony\Component\Serializer\Debug\TraceableEncoder; +use Symfony\Component\Serializer\Debug\TraceableNormalizer; +use Symfony\Component\Serializer\Debug\TraceableSerializer; use Symfony\Component\Serializer\DependencyInjection\SerializerPass; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\SerializerInterface; /** * Tests for the SerializerPass class. @@ -26,32 +31,39 @@ class SerializerPassTest extends TestCase { public function testThrowExceptionWhenNoNormalizers() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('You must tag at least one service as "serializer.normalizer" to use the "serializer" service'); $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $container->register('serializer'); $serializerPass = new SerializerPass(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('You must tag at least one service as "serializer.normalizer" to use the "serializer" service'); + $serializerPass->process($container); } public function testThrowExceptionWhenNoEncoders() { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('You must tag at least one service as "serializer.encoder" to use the "serializer" service'); $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $container->register('serializer') ->addArgument([]) ->addArgument([]); $container->register('normalizer')->addTag('serializer.normalizer'); $serializerPass = new SerializerPass(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('You must tag at least one service as "serializer.encoder" to use the "serializer" service'); + $serializerPass->process($container); } public function testServicesAreOrderedAccordingToPriority() { $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); $definition = $container->register('serializer')->setArguments([null, null]); $container->register('n2')->addTag('serializer.normalizer', ['priority' => 100])->addTag('serializer.encoder', ['priority' => 100]); @@ -72,8 +84,11 @@ public function testServicesAreOrderedAccordingToPriority() public function testBindSerializerDefaultContext() { + $context = ['enable_max_depth' => true]; + $container = new ContainerBuilder(); - $container->register('serializer')->setArguments([null, null]); + $container->setParameter('kernel.debug', false); + $container->register('serializer')->setArguments([null, null, []]); $container->setParameter('serializer.default_context', ['enable_max_depth' => true]); $definition = $container->register('n1')->addTag('serializer.normalizer')->addTag('serializer.encoder'); @@ -81,6 +96,610 @@ public function testBindSerializerDefaultContext() $serializerPass->process($container); $bindings = $definition->getBindings(); - $this->assertEquals($bindings['array $defaultContext'], new BoundArgument(['enable_max_depth' => true], false)); + $this->assertEquals($bindings['array $defaultContext'], new BoundArgument($context, false)); + $this->assertEquals($context, $container->getDefinition('serializer')->getArgument('$defaultContext')); + } + + /** + * @testWith [{}, {}] + * [{"serializer.default_context": {"enable_max_depth": true}}, {"enable_max_depth": true}] + * [{".serializer.circular_reference_handler": "foo"}, {"circular_reference_handler": "foo"}] + * [{".serializer.max_depth_handler": "bar"}, {"max_depth_handler": "bar"}] + * [{"serializer.default_context": {"enable_max_depth": true}, ".serializer.circular_reference_handler": "foo", ".serializer.max_depth_handler": "bar"}, {"enable_max_depth": true, "circular_reference_handler": "foo", "max_depth_handler": "bar"}] + */ + public function testBindObjectNormalizerDefaultContext(array $parameters, array $context) + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->register('serializer')->setArguments([null, null, []]); + $container->getParameterBag()->add($parameters); + $definition = $container->register('serializer.normalizer.object') + ->setClass(ObjectNormalizer::class) + ->addTag('serializer.normalizer') + ->addTag('serializer.encoder') + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $bindings = $definition->getBindings(); + $this->assertEquals($bindings['array $defaultContext'], new BoundArgument($context, false)); + } + + public function testNormalizersAndEncodersAreDecoratedAndOrderedWhenCollectingData() + { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', true); + $container->register('serializer.data_collector'); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer'); + $container->register('e')->addTag('serializer.encoder'); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $traceableNormalizerDefinition = $container->getDefinition('.debug.serializer.normalizer.n'); + $traceableEncoderDefinition = $container->getDefinition('.debug.serializer.encoder.e'); + + $this->assertEquals(TraceableNormalizer::class, $traceableNormalizerDefinition->getClass()); + $this->assertEquals(new Reference('n'), $traceableNormalizerDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableNormalizerDefinition->getArgument(1)); + $this->assertSame('default', $traceableNormalizerDefinition->getArgument(2)); + + $this->assertEquals(TraceableEncoder::class, $traceableEncoderDefinition->getClass()); + $this->assertEquals(new Reference('e'), $traceableEncoderDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableEncoderDefinition->getArgument(1)); + $this->assertSame('default', $traceableEncoderDefinition->getArgument(2)); + } + + /** + * @dataProvider provideDefaultSerializerTagsData + */ + public function testDefaultSerializerTagsAreResolvedCorrectly( + array $normalizerTagAttributes, + array $encoderTagAttributes, + array $expectedNormalizerTags, + array $expectedEncoderTags, + ) { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', []); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n0')->addTag('serializer.normalizer', ['serializer' => 'default']); + $container->register('e0')->addTag('serializer.encoder', ['serializer' => 'default']); + + $normalizerDefinition = $container->register('n1')->addTag('serializer.normalizer', $normalizerTagAttributes); + $encoderDefinition = $container->register('e1')->addTag('serializer.encoder', $encoderTagAttributes); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertSame($expectedNormalizerTags, $normalizerDefinition->getTag('serializer.normalizer.default')); + $this->assertSame($expectedEncoderTags, $encoderDefinition->getTag('serializer.encoder.default')); + } + + public static function provideDefaultSerializerTagsData(): iterable + { + yield 'include no name' => [ + [], + [], + [[]], + [[]], + ]; + + yield 'include name' => [ + ['serializer' => 'default'], + ['serializer' => 'default'], + [[]], + [[]], + ]; + + yield 'include built-in with different name' => [ + ['built_in' => true, 'serializer' => 'api'], + ['built_in' => true, 'serializer' => 'api'], + [[]], + [[]], + ]; + + yield 'include no name with priority' => [ + ['priority' => 200], + ['priority' => 100], + [['priority' => 200]], + [['priority' => 100]], + ]; + + yield 'include name with priority' => [ + ['serializer' => 'default', 'priority' => 200], + ['serializer' => 'default', 'priority' => 100], + [['priority' => 200]], + [['priority' => 100]], + ]; + + yield 'include wildcard' => [ + ['serializer' => '*'], + ['serializer' => '*'], + [[]], + [[]], + ]; + + yield 'is unique when built-in with name' => [ + ['built_in' => true, 'serializer' => 'default'], + ['built_in' => true, 'serializer' => 'default'], + [[]], + [[]], + ]; + + yield 'do not include different name' => [ + ['serializer' => 'api'], + ['serializer' => 'api'], + [], + [], + ]; + } + + /** + * @dataProvider provideNamedSerializerTagsData + */ + public function testNamedSerializerTagsAreResolvedCorrectly( + array $config, + array $normalizerTagAttributes, + array $encoderTagAttributes, + array $expectedNormalizerTags, + array $expectedEncoderTags, + ) { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', ['api' => $config]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n0')->addTag('serializer.normalizer', ['serializer' => ['default', 'api']]); + $container->register('e0')->addTag('serializer.encoder', ['serializer' => ['default', 'api']]); + + $normalizerDefinition = $container->register('n1')->addTag('serializer.normalizer', $normalizerTagAttributes); + $encoderDefinition = $container->register('e1')->addTag('serializer.encoder', $encoderTagAttributes); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertSame($expectedNormalizerTags, $normalizerDefinition->getTag('serializer.normalizer.api')); + $this->assertSame($expectedEncoderTags, $encoderDefinition->getTag('serializer.encoder.api')); + } + + public static function provideNamedSerializerTagsData(): iterable + { + yield 'include built-in' => [ + ['include_built_in_normalizers' => true, 'include_built_in_encoders' => true], + ['built_in' => true], + ['built_in' => true], + [[]], + [[]], + ]; + + yield 'include built-in normalizers only' => [ + ['include_built_in_normalizers' => true, 'include_built_in_encoders' => false], + ['built_in' => true], + ['built_in' => true], + [[]], + [], + ]; + + yield 'include built-in encoders only' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => true], + ['built_in' => true], + ['built_in' => true], + [], + [[]], + ]; + + yield 'include name' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => false], + ['serializer' => 'api'], + ['serializer' => 'api'], + [[]], + [[]], + ]; + + yield 'include name with priority' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => false], + ['serializer' => 'api', 'priority' => 200], + ['serializer' => 'api', 'priority' => 100], + [['priority' => 200]], + [['priority' => 100]], + ]; + + yield 'include wildcard' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => false], + ['serializer' => '*'], + ['serializer' => '*'], + [[]], + [[]], + ]; + + yield 'do not include when include built-in not set' => [ + [], + ['built_in' => true], + ['built_in' => true], + [], + [], + ]; + + yield 'do not include not built-in and no name' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => false], + [], + [], + [], + [], + ]; + + yield 'do not include different name' => [ + ['include_built_in_normalizers' => false, 'include_built_in_encoders' => false], + ['serializer' => 'api2'], + ['serializer' => 'api2'], + [], + [], + ]; + } + + public function testMultipleNamedSerializerTagsAreResolvedCorrectly() + { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => [], + 'api2' => [], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n0')->addTag('serializer.normalizer', ['serializer' => 'default']); + $container->register('e0')->addTag('serializer.encoder', ['serializer' => 'default']); + + $normalizerDefinition = $container->register('n1')->addTag('serializer.normalizer', ['serializer' => ['api', 'api2']]); + $encoderDefinition = $container->register('e1') + ->addTag('serializer.encoder', ['serializer' => ['api', 'api2']]) + ->addTag('serializer.encoder', ['serializer' => ['api', 'api2'], 'priority' => 100]) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertTrue($normalizerDefinition->hasTag('serializer.normalizer.api')); + $this->assertCount(1, $normalizerDefinition->getTag('serializer.normalizer.api')); + $this->assertTrue($normalizerDefinition->hasTag('serializer.normalizer.api2')); + $this->assertCount(1, $normalizerDefinition->getTag('serializer.normalizer.api2')); + + $this->assertTrue($encoderDefinition->hasTag('serializer.encoder.api')); + $this->assertCount(2, $encoderDefinition->getTag('serializer.encoder.api')); + $this->assertTrue($encoderDefinition->hasTag('serializer.encoder.api2')); + $this->assertCount(2, $encoderDefinition->getTag('serializer.encoder.api2')); + } + + public function testThrowExceptionWhenNoNormalizersForNamedSerializers() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => [], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n0')->addTag('serializer.normalizer'); + $container->register('e0')->addTag('serializer.encoder', ['serializer' => '*']); + + $serializerPass = new SerializerPass(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The named serializer "api" requires at least one registered normalizer. Tag the normalizers as "serializer.normalizer" with the "serializer" attribute set to "api".'); + + $serializerPass->process($container); + } + + public function testThrowExceptionWhenNoEncodersForNamedSerializers() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => [], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n0')->addTag('serializer.normalizer', ['serializer' => '*']); + $container->register('e0')->addTag('serializer.encoder'); + + $serializerPass = new SerializerPass(); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The named serializer "api" requires at least one registered encoder. Tag the encoders as "serializer.encoder" with the "serializer" attribute set to "api".'); + + $serializerPass->process($container); + } + + /** + * @testWith [null] + * ["some.converter"] + */ + public function testChildNameConverterIsNotBuiltWhenExpected(?string $nameConverter) + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.name_converter', $nameConverter); + $container->setParameter('.serializer.named_serializers', [ + 'api' => ['name_converter' => $nameConverter], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer', ['serializer' => '*']); + $container->register('e')->addTag('serializer.encoder', ['serializer' => '*']); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertFalse($container->hasDefinition('serializer.name_converter.metadata_aware.'.ContainerBuilder::hash($nameConverter))); + } + + /** + * @dataProvider provideChildNameConverterCases + */ + public function testChildNameConverterIsBuiltWhenExpected( + ?string $defaultSerializerNameConverter, + ?string $namedSerializerNameConverter, + string $nameConverterIdExists, + string $nameConverterIdDoesNotExist, + array $nameConverterArguments, + ) { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.name_converter', $defaultSerializerNameConverter); + $container->setParameter('.serializer.named_serializers', [ + 'api' => ['name_converter' => $namedSerializerNameConverter], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer', ['serializer' => '*']); + $container->register('e')->addTag('serializer.encoder', ['serializer' => '*']); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertFalse($container->hasDefinition($nameConverterIdExists)); + $this->assertTrue($container->hasDefinition($nameConverterIdDoesNotExist)); + $this->assertEquals($nameConverterArguments, $container->getDefinition($nameConverterIdDoesNotExist)->getArguments()); + } + + public static function provideChildNameConverterCases(): iterable + { + $withNull = 'serializer.name_converter.metadata_aware.'.ContainerBuilder::hash(null); + $withConverter = 'serializer.name_converter.metadata_aware.'.ContainerBuilder::hash('some.converter'); + + yield [null, 'some.converter', $withNull, $withConverter, [new Reference('some.converter')]]; + yield ['some.converter', null, $withConverter, $withNull, []]; + } + + /** + * @dataProvider provideDifferentNamedSerializerConfigsCases + */ + public function testNamedSerializersCreateNewServices( + array $defaultSerializerDefaultContext, + ?string $defaultSerializerNameConverter, + array $namedSerializerConfig, + string $nameConverterId, + ) { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('serializer.default_context', $defaultSerializerDefaultContext); + $container->setParameter('.serializer.name_converter', $defaultSerializerNameConverter); + $container->setParameter('.serializer.named_serializers', [ + 'api' => $namedSerializerConfig, + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n') + ->addArgument(new Reference('serializer.name_converter.metadata_aware')) + ->addTag('serializer.normalizer', ['serializer' => '*']) + ; + $container->register('e') + ->addArgument(new Reference('serializer.name_converter.metadata_aware')) + ->addTag('serializer.encoder', ['serializer' => '*']) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertEquals([new Reference('n.api')], $container->getDefinition('serializer.api')->getArgument(0)); + $this->assertEquals(new Reference($nameConverterId), $container->getDefinition('n.api')->getArgument(0)); + $this->assertEquals([new Reference('e.api')], $container->getDefinition('serializer.api')->getArgument(1)); + $this->assertEquals(new Reference($nameConverterId), $container->getDefinition('e.api')->getArgument(0)); + } + + public static function provideDifferentNamedSerializerConfigsCases(): iterable + { + yield [ + ['a' => true, 'b' => 3], + null, + ['default_context' => ['c' => 3, 'a' => true]], + 'serializer.name_converter.metadata_aware', + ]; + yield [ + [], + 'some.converter', + ['name_converter' => null], + 'serializer.name_converter.metadata_aware.'.ContainerBuilder::hash(null), + ]; + yield [ + ['a' => true, 'b' => 3], + null, + ['default_context' => ['c' => 3, 'a' => true], 'name_converter' => 'some.converter'], + 'serializer.name_converter.metadata_aware.'.ContainerBuilder::hash('some.converter'), + ]; + } + + public function testServicesAreOrderedAccordingToPriorityForNamedSerializers() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => [], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n2') + ->addTag('serializer.normalizer', ['serializer' => '*', 'priority' => 100]) + ->addTag('serializer.encoder', ['serializer' => '*', 'priority' => 100]) + ; + $container->register('n1') + ->addTag('serializer.normalizer', ['serializer' => 'api', 'priority' => 200]) + ->addTag('serializer.encoder', ['serializer' => 'api', 'priority' => 200]) + ; + $container->register('n3') + ->addTag('serializer.normalizer', ['serializer' => 'api']) + ->addTag('serializer.encoder', ['serializer' => 'api']) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertTrue($container->hasDefinition('serializer.api')); + $definition = $container->getDefinition('serializer.api'); + + $expected = [ + new Reference('n1.api'), + new Reference('n2.api'), + new Reference('n3.api'), + ]; + $this->assertEquals($expected, $definition->getArgument(0)); + $this->assertEquals($expected, $definition->getArgument(1)); + } + + public function testBindSerializerDefaultContextToNamedSerializers() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => ['default_context' => $defaultContext = ['enable_max_depth' => true]], + ]); + + $container->register('serializer')->setArguments([null, null, []]); + $definition = $container->register('n1') + ->addTag('serializer.normalizer', ['serializer' => '*']) + ->addTag('serializer.encoder', ['serializer' => '*']) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $bindings = $definition->getBindings(); + $this->assertArrayHasKey('array $defaultContext', $bindings); + $this->assertEquals($bindings['array $defaultContext'], new BoundArgument([], false)); + + $bindings = $container->getDefinition('n1.api')->getBindings(); + $this->assertArrayHasKey('array $defaultContext', $bindings); + $this->assertEquals($bindings['array $defaultContext'], new BoundArgument($defaultContext, false)); + $this->assertArrayNotHasKey('$defaultContext', $container->getDefinition('serializer')->getArguments()); + $this->assertEquals($defaultContext, $container->getDefinition('serializer.api')->getArgument('$defaultContext')); + } + + /** + * @testWith [{}, {}, {}] + * [{"enable_max_depth": true}, {}, {"enable_max_depth": true}] + * [{}, {".serializer.circular_reference_handler": "foo"}, {"circular_reference_handler": "foo"}] + * [{}, {".serializer.max_depth_handler": "bar"}, {"max_depth_handler": "bar"}] + * [{"enable_max_depth": true}, {".serializer.circular_reference_handler": "foo", ".serializer.max_depth_handler": "bar"}, {"enable_max_depth": true, "circular_reference_handler": "foo", "max_depth_handler": "bar"}] + */ + public function testBindNamedSerializerObjectNormalizerDefaultContext(array $defaultContext, array $parameters, array $context) + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => ['default_context' => $defaultContext], + ]); + + $container->register('serializer')->setArguments([null, null, []]); + $container->getParameterBag()->add($parameters); + $container->register('serializer.normalizer.object') + ->setClass(ObjectNormalizer::class) + ->addTag('serializer.normalizer', ['serializer' => '*']) + ->addTag('serializer.encoder', ['serializer' => '*']) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $bindings = $container->getDefinition('serializer.normalizer.object.api')->getBindings(); + $this->assertArrayHasKey('array $defaultContext', $bindings); + $this->assertEquals($bindings['array $defaultContext'], new BoundArgument($context, false)); + } + + public function testNamedSerializersAreRegistered() + { + $container = new ContainerBuilder(); + $container->setParameter('kernel.debug', false); + $container->setParameter('.serializer.named_serializers', [ + 'api' => [], + 'api2' => [], + ]); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer', ['serializer' => '*']); + $container->register('e')->addTag('serializer.encoder', ['serializer' => '*']); + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $this->assertFalse($container->hasAlias(\sprintf('%s $defaultSerializer', SerializerInterface::class))); + + $this->assertTrue($container->hasDefinition('serializer.api')); + $this->assertTrue($container->hasAlias(\sprintf('%s $apiSerializer', SerializerInterface::class))); + $this->assertTrue($container->hasDefinition('serializer.api2')); + $this->assertTrue($container->hasAlias(\sprintf('%s $api2Serializer', SerializerInterface::class))); + } + + public function testNormalizersAndEncodersAreDecoratedAndOrderedWhenCollectingDataForNamedSerializers() + { + $container = new ContainerBuilder(); + + $container->setParameter('kernel.debug', true); + $container->setParameter('.serializer.named_serializers', [ + 'api' => ['default_context' => ['enable_max_depth' => true]], + ]); + $container->register('serializer.data_collector'); + + $container->register('serializer')->setArguments([null, null]); + $container->register('n')->addTag('serializer.normalizer', ['serializer' => '*']); + $container->register('e')->addTag('serializer.encoder', ['serializer' => '*']); + + $container->register('debug.serializer', TraceableSerializer::class) + ->setDecoratedService('serializer') + ->setArguments([ + new Reference('debug.serializer.inner'), + new Reference('serializer.data_collector'), + 'default', + ]) + ; + + $serializerPass = new SerializerPass(); + $serializerPass->process($container); + + $traceableNormalizerDefinition = $container->getDefinition('.debug.serializer.normalizer.n.api'); + $traceableEncoderDefinition = $container->getDefinition('.debug.serializer.encoder.e.api'); + + $traceableSerializerDefinition = $container->getDefinition('debug.serializer.api'); + $this->assertSame('serializer.api', $traceableSerializerDefinition->getDecoratedService()[0]); + $this->assertEquals(new Reference('debug.serializer.api.inner'), $traceableSerializerDefinition->getArgument(0)); + $this->assertSame('api', $traceableSerializerDefinition->getArgument(2)); + + $this->assertEquals(TraceableNormalizer::class, $traceableNormalizerDefinition->getClass()); + $this->assertEquals(new Reference('n.api'), $traceableNormalizerDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableNormalizerDefinition->getArgument(1)); + $this->assertSame('api', $traceableNormalizerDefinition->getArgument(2)); + + $this->assertEquals(TraceableEncoder::class, $traceableEncoderDefinition->getClass()); + $this->assertEquals(new Reference('e.api'), $traceableEncoderDefinition->getArgument(0)); + $this->assertEquals(new Reference('serializer.data_collector'), $traceableEncoderDefinition->getArgument(1)); + $this->assertSame('api', $traceableEncoderDefinition->getArgument(2)); } } diff --git a/Tests/Dummy/DummyClassOne.php b/Tests/Dummy/DummyClassOne.php new file mode 100644 index 000000000..fc78db51c --- /dev/null +++ b/Tests/Dummy/DummyClassOne.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\Dummy; + +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\Ignore; +use Symfony\Component\Serializer\Attribute\MaxDepth; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; + +class DummyClassOne +{ + #[MaxDepth(1)] + #[Groups(['book:read', 'book:write'])] + #[SerializedName('identifier')] + #[Ignore] + #[Context( + normalizationContext: ['groups' => ['book:read']], + denormalizationContext: ['groups' => ['book:write']], + )] + public string $code; + + #[SerializedPath('[data][name]')] + public string $name; +} diff --git a/Tests/Encoder/ChainDecoderTest.php b/Tests/Encoder/ChainDecoderTest.php index 8f433ce0f..c3bbad2ab 100644 --- a/Tests/Encoder/ChainDecoderTest.php +++ b/Tests/Encoder/ChainDecoderTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Encoder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Encoder\ChainDecoder; use Symfony\Component\Serializer\Encoder\ContextAwareDecoderInterface; @@ -23,9 +24,9 @@ class ChainDecoderTest extends TestCase private const FORMAT_2 = 'format2'; private const FORMAT_3 = 'format3'; - private $chainDecoder; - private $decoder1; - private $decoder2; + private ChainDecoder $chainDecoder; + private MockObject&ContextAwareDecoderInterface $decoder1; + private MockObject&DecoderInterface $decoder2; protected function setUp(): void { diff --git a/Tests/Encoder/ChainEncoderTest.php b/Tests/Encoder/ChainEncoderTest.php index 08656c2ba..375d08054 100644 --- a/Tests/Encoder/ChainEncoderTest.php +++ b/Tests/Encoder/ChainEncoderTest.php @@ -11,7 +11,9 @@ namespace Symfony\Component\Serializer\Tests\Encoder; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Debug\TraceableEncoder; use Symfony\Component\Serializer\Encoder\ChainEncoder; use Symfony\Component\Serializer\Encoder\ContextAwareEncoderInterface; use Symfony\Component\Serializer\Encoder\EncoderInterface; @@ -24,9 +26,9 @@ class ChainEncoderTest extends TestCase private const FORMAT_2 = 'format2'; private const FORMAT_3 = 'format3'; - private $chainEncoder; - private $encoder1; - private $encoder2; + private ChainEncoder $chainEncoder; + private MockObject&ContextAwareEncoderInterface $encoder1; + private MockObject&EncoderInterface $encoder2; protected function setUp(): void { @@ -106,6 +108,21 @@ public function testNeedsNormalizationNormalizationAware() $this->assertFalse($sut->needsNormalization(self::FORMAT_1)); } + + public function testNeedsNormalizationTraceableEncoder() + { + $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder->method('needsNormalization')->willReturn(true); + $traceableEncoder->method('supportsEncoding')->willReturn(true); + + $this->assertTrue((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); + + $traceableEncoder = $this->createMock(TraceableEncoder::class); + $traceableEncoder->method('needsNormalization')->willReturn(false); + $traceableEncoder->method('supportsEncoding')->willReturn(true); + + $this->assertFalse((new ChainEncoder([$traceableEncoder]))->needsNormalization('format')); + } } class NormalizationAwareEncoder implements EncoderInterface, NormalizationAwareInterface diff --git a/Tests/Encoder/CsvEncoderTest.php b/Tests/Encoder/CsvEncoderTest.php index ae6fb7a2a..048d790b0 100644 --- a/Tests/Encoder/CsvEncoderTest.php +++ b/Tests/Encoder/CsvEncoderTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Encoder; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -20,10 +21,9 @@ */ class CsvEncoderTest extends TestCase { - /** - * @var CsvEncoder - */ - private $encoder; + use ExpectUserDeprecationMessageTrait; + + private CsvEncoder $encoder; protected function setUp(): void { @@ -59,9 +59,6 @@ public function testTrueFalseValues() ], $this->encoder->decode($csv, 'csv', [CsvEncoder::AS_COLLECTION_KEY => false])); } - /** - * @requires PHP 7.4 - */ public function testDoubleQuotesAndSlashes() { $this->assertSame($csv = <<<'CSV' @@ -74,9 +71,6 @@ public function testDoubleQuotesAndSlashes() $this->assertSame($data, $this->encoder->decode($csv, 'csv', [CsvEncoder::AS_COLLECTION_KEY => false])); } - /** - * @requires PHP 7.4 - */ public function testSingleSlash() { $this->assertSame($csv = "0\n\\\n", $this->encoder->encode($data = ['\\'], 'csv')); @@ -158,7 +152,6 @@ public function testEncodeCustomSettings() $this->encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); @@ -184,7 +177,6 @@ public function testEncodeCustomSettingsPassedInContext() , $this->encoder->encode($value, 'csv', [ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', ])); } @@ -194,7 +186,6 @@ public function testEncodeCustomSettingsPassedInConstructor() $encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); $value = ['a' => 'he\'llo', 'c' => ['d' => 'foo']]; @@ -583,7 +574,6 @@ public function testDecodeCustomSettings() $this->encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', ]); @@ -605,7 +595,6 @@ public function testDecodeCustomSettingsPassedInContext() , 'csv', [ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', ])); } @@ -615,7 +604,6 @@ public function testDecodeCustomSettingsPassedInConstructor() $encoder = new CsvEncoder([ CsvEncoder::DELIMITER_KEY => ';', CsvEncoder::ENCLOSURE_KEY => "'", - CsvEncoder::ESCAPE_CHAR_KEY => \PHP_VERSION_ID < 70400 ? '|' : '', CsvEncoder::KEY_SEPARATOR_KEY => '-', CsvEncoder::AS_COLLECTION_KEY => true, // Can be removed in 5.0 ]); @@ -719,4 +707,26 @@ public function testEndOfLinePassedInConstructor() $encoder = new CsvEncoder([CsvEncoder::END_OF_LINE => "\r\n"]); $this->assertSame("foo,bar\r\nhello,test\r\n", $encoder->encode($value, 'csv')); } + + /** + * @group legacy + */ + public function testPassingNonEmptyEscapeCharIsDeprecated() + { + $this->expectUserDeprecationMessage('Since symfony/serializer 7.2: Setting the "csv_escape_char" option is deprecated. The option will be removed in 8.0.'); + $encoder = new CsvEncoder(['csv_escape_char' => '@']); + + $this->assertSame( + [[ + 'A, B@"' => 'D', + 'C' => 'E', + ]], + $encoder->decode(<<<'CSV' + "A, B@"", "C" + "D", "E" + CSV, + 'csv' + ) + ); + } } diff --git a/Tests/Encoder/JsonDecodeTest.php b/Tests/Encoder/JsonDecodeTest.php index 62045972d..f336bcd42 100644 --- a/Tests/Encoder/JsonDecodeTest.php +++ b/Tests/Encoder/JsonDecodeTest.php @@ -18,8 +18,7 @@ class JsonDecodeTest extends TestCase { - /** @var \Symfony\Component\Serializer\Encoder\JsonDecode */ - private $decode; + private JsonDecode $decode; protected function setUp(): void { @@ -48,28 +47,29 @@ public static function decodeProvider() $stdClass = new \stdClass(); $stdClass->foo = 'bar'; - $assoc = ['foo' => 'bar']; - return [ ['{"foo": "bar"}', $stdClass, []], - ['{"foo": "bar"}', $assoc, ['json_decode_associative' => true]], + ['{"foo": "bar"}', ['foo' => 'bar'], ['json_decode_associative' => true]], ]; } /** * @dataProvider decodeProviderException */ - public function testDecodeWithException($value) + public function testDecodeWithException(string $value, string $expectedExceptionMessage, array $context) { $this->expectException(UnexpectedValueException::class); - $this->decode->decode($value, JsonEncoder::FORMAT); + $this->expectExceptionMessage($expectedExceptionMessage); + $this->decode->decode($value, JsonEncoder::FORMAT, $context); } public static function decodeProviderException() { return [ - ["{'foo': 'bar'}"], - ['kaboom!'], + ["{'foo': 'bar'}", 'Syntax error', []], + ["{'foo': 'bar'}", 'single quotes instead of double quotes', ['json_decode_detailed_errors' => true]], + ['kaboom!', 'Syntax error', ['json_decode_detailed_errors' => false]], + ['kaboom!', "Expected one of: 'STRING', 'NUMBER', 'NULL',", ['json_decode_detailed_errors' => true]], ]; } } diff --git a/Tests/Encoder/JsonEncodeTest.php b/Tests/Encoder/JsonEncodeTest.php index 779cad6e3..e5e653403 100644 --- a/Tests/Encoder/JsonEncodeTest.php +++ b/Tests/Encoder/JsonEncodeTest.php @@ -18,7 +18,7 @@ class JsonEncodeTest extends TestCase { - private $encode; + private JsonEncode $encode; protected function setUp(): void { diff --git a/Tests/Encoder/JsonEncoderTest.php b/Tests/Encoder/JsonEncoderTest.php index 6cd1f82b1..a34e82c6b 100644 --- a/Tests/Encoder/JsonEncoderTest.php +++ b/Tests/Encoder/JsonEncoderTest.php @@ -19,8 +19,8 @@ class JsonEncoderTest extends TestCase { - private $encoder; - private $serializer; + private JsonEncoder $encoder; + private Serializer $serializer; protected function setUp(): void { @@ -84,12 +84,13 @@ public function testWithDefaultContext() public function testEncodeNotUtf8WithoutPartialOnError() { - $this->expectException(UnexpectedValueException::class); $arr = [ 'utf8' => 'Hello World!', 'notUtf8' => "\xb0\xd0\xb5\xd0", ]; + $this->expectException(UnexpectedValueException::class); + $this->encoder->encode($arr, 'json'); } diff --git a/Tests/Encoder/XmlEncoderTest.php b/Tests/Encoder/XmlEncoderTest.php index ce25e9598..0eb332e80 100644 --- a/Tests/Encoder/XmlEncoderTest.php +++ b/Tests/Encoder/XmlEncoderTest.php @@ -29,12 +29,8 @@ class XmlEncoderTest extends TestCase { - /** - * @var XmlEncoder - */ - private $encoder; - - private $exampleDateTimeString = '2017-02-19T15:16:08+0300'; + private XmlEncoder $encoder; + private string $exampleDateTimeString = '2017-02-19T15:16:08+0300'; protected function setUp(): void { @@ -43,46 +39,39 @@ protected function setUp(): void $this->encoder->setSerializer($serializer); } - public function testEncodeScalar() - { - $obj = new ScalarDummy(); - $obj->xmlFoo = 'foo'; - - $expected = ''."\n". - 'foo'."\n"; - - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); - } - - public function testEncodeArrayObject() + /** + * @dataProvider validEncodeProvider + */ + public function testEncode(string $expected, mixed $data, array $context = []) { - $obj = new \ArrayObject(['foo' => 'bar']); - - $expected = ''."\n". - 'bar'."\n"; - - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); + $this->assertSame($expected, $this->encoder->encode($data, 'xml', $context)); } - public function testEncodeEmptyArrayObject() + /** + * @return iterable + */ + public static function validEncodeProvider(): iterable { - $obj = new \ArrayObject(); + $obj = new ScalarDummy(); + $obj->xmlFoo = 'foo'; - $expected = ''."\n". - ''."\n"; + yield 'encode scalar' => [ + ''."\n" + .'foo'."\n", + $obj, + ]; - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); - } + yield 'encode array object' => [ + ''."\n" + .'bar'."\n", + new \ArrayObject(['foo' => 'bar']), + ]; - public function testDocTypeIsNotAllowed() - { - $this->expectException(UnexpectedValueException::class); - $this->expectExceptionMessage('Document types are not allowed.'); - $this->encoder->decode('', 'foo'); - } + yield 'encode empty array object' => [ + ''."\n".''."\n", + new \ArrayObject(), + ]; - public function testAttributes() - { $obj = new ScalarDummy(); $obj->xmlFoo = [ 'foo-bar' => [ @@ -104,7 +93,9 @@ public function testAttributes() '@sring' => 'a', ], ]; - $expected = ''."\n". + + yield 'attributes' => [ + ''."\n". ''. ''. 'Test'. @@ -114,12 +105,10 @@ public function testAttributes() '3'. 'b'. ''. - ''."\n"; - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); - } + ''."\n", + $obj, + ]; - public function testElementNameValid() - { $obj = new ScalarDummy(); $obj->xmlFoo = [ 'foo-bar' => 'a', @@ -127,134 +116,195 @@ public function testElementNameValid() 'föo_bär' => 'a', ]; - $expected = ''."\n". + yield 'element name valid' => [ + ''."\n". ''. 'a'. 'a'. 'a'. - ''."\n"; - - $this->assertEquals($expected, $this->encoder->encode($obj, 'xml')); - } + ''."\n", + $obj, + ]; - public function testEncodeSimpleXML() - { $xml = simplexml_load_string('Peter'); $array = ['person' => $xml]; - $expected = ''."\n". - 'Peter'."\n"; - - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode SimpleXML' => [ + ''."\n". + 'Peter'."\n", + $array, + ]; - public function testEncodeXmlAttributes() - { $xml = simplexml_load_string('Peter'); $array = ['person' => $xml]; - $expected = ''."\n". - 'Peter'."\n"; - - $context = [ - 'xml_version' => '1.1', - 'xml_encoding' => 'utf-8', - 'xml_standalone' => true, + yield 'encode XML attributes' => [ + ''."\n". + 'Peter'."\n", + $array, + [ + 'xml_version' => '1.1', + 'xml_encoding' => 'utf-8', + 'xml_standalone' => true, + ], ]; - $this->assertSame($expected, $this->encoder->encode($array, 'xml', $context)); - } - - public function testEncodeRemovingEmptyTags() - { - $array = ['person' => ['firstname' => 'Peter', 'lastname' => null]]; - - $expected = ''."\n". - 'Peter'."\n"; - - $context = ['remove_empty_tags' => true]; - - $this->assertSame($expected, $this->encoder->encode($array, 'xml', $context)); - } - - public function testEncodeNotRemovingEmptyTags() - { - $array = ['person' => ['firstname' => 'Peter', 'lastname' => null]]; - - $expected = ''."\n". - 'Peter'."\n"; + yield 'encode removing empty tags' => [ + ''."\n". + 'Peter'."\n", + ['person' => ['firstname' => 'Peter', 'lastname' => null]], + ['remove_empty_tags' => true], + ]; - $this->assertSame($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode not removing empty tags' => [ + ''."\n". + 'Peter'."\n", + ['person' => ['firstname' => 'Peter', 'lastname' => null]], + ]; - public function testContext() - { - $array = ['person' => ['name' => 'George Abitbol']]; - $expected = <<<'XML' + yield 'encode with context' => [ + <<<'XML' George Abitbol + -XML; +XML, + ['person' => ['name' => 'George Abitbol', 'age' => null]], + [ + 'xml_format_output' => true, + 'save_options' => \LIBXML_NOEMPTYTAG, + ], + ]; - $context = [ - 'xml_format_output' => true, + yield 'encode scalar root attributes' => [ + ''."\n". + 'Paul'."\n", + [ + '#' => 'Paul', + '@eye-color' => 'brown', + ], ]; - $this->assertSame($expected, $this->encoder->encode($array, 'xml', $context)); - } + yield 'encode root attributes' => [ + ''."\n". + 'Paul'."\n", + [ + 'firstname' => 'Paul', + '@eye-color' => 'brown', + ], + ]; - public function testEncodeScalarRootAttributes() - { - $array = [ - '#' => 'Paul', - '@eye-color' => 'brown', + yield 'encode with CDATA wrapping with default pattern #1' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], ]; - $expected = ''."\n". - 'Paul'."\n"; + yield 'encode with CDATA wrapping with default pattern #2' => [ + ''."\n". + 'O\'Donnel'."\n", + ['lastname' => 'O\'Donnel'], + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode with CDATA wrapping with default pattern #3' => [ + ''."\n". + ''."\n", + ['firstname' => 'Paul & Martha'], + ]; - public function testEncodeRootAttributes() - { - $array = [ - 'firstname' => 'Paul', - '@eye-color' => 'brown', + yield 'encode with CDATA wrapping with custom pattern #1' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], ]; - $expected = ''."\n". - 'Paul'."\n"; + yield 'encode with CDATA wrapping with custom pattern #2' => [ + ''."\n". + ''."\n", + ['lastname' => 'O\'Donnel'], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); - } + yield 'encode with CDATA wrapping with custom pattern #3' => [ + ''."\n". + 'Paul and Martha'."\n", + ['firstname' => 'Paul and Martha'], + ['cdata_wrapping_pattern' => '/[<>&"\']/'], + ]; - public function testEncodeCdataWrapping() - { - $array = [ - 'firstname' => 'Paul ', + yield 'enable CDATA wrapping' => [ + ''."\n". + ']]>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping' => true], ]; - $expected = ''."\n". - ']]>'."\n"; + yield 'disable CDATA wrapping' => [ + ''."\n". + 'Paul & Martha <or Me>'."\n", + ['firstname' => 'Paul & Martha '], + ['cdata_wrapping' => false], + ]; + + yield 'encode scalar with attribute' => [ + ''."\n". + 'Peter'."\n", + ['person' => ['@eye-color' => 'brown', '#' => 'Peter']], + ]; + + yield 'encode' => [ + self::getXmlSource(), + self::getObject(), + ]; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); + yield 'encode with namespace' => [ + self::getNamespacedXmlSource(), + self::getNamespacedArray(), + ]; } - public function testEncodeScalarWithAttribute() + public function testEncodeSerializerXmlRootNodeNameOption() { + $options = ['xml_root_node_name' => 'test']; + $this->encoder = new XmlEncoder(); + $serializer = new Serializer([], ['xml' => new XmlEncoder()]); + $this->encoder->setSerializer($serializer); + $array = [ 'person' => ['@eye-color' => 'brown', '#' => 'Peter'], ]; $expected = ''."\n". - 'Peter'."\n"; + 'Peter'."\n"; + + $this->assertSame($expected, $serializer->serialize($array, 'xml', $options)); + } + + public function testEncodeTraversableWhenNormalizable() + { + $this->encoder = new XmlEncoder(); + $serializer = new Serializer([new CustomNormalizer()], ['xml' => new XmlEncoder()]); + $this->encoder->setSerializer($serializer); + + $expected = <<<'XML' + +normalizedFoonormalizedBar + +XML; - $this->assertEquals($expected, $this->encoder->encode($array, 'xml')); + $this->assertSame($expected, $serializer->serialize(new NormalizableTraversableDummy(), 'xml')); + } + + public function testDocTypeIsNotAllowed() + { + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Document types are not allowed.'); + $this->encoder->decode('', 'foo'); } public function testDecodeScalar() @@ -262,7 +312,7 @@ public function testDecodeScalar() $source = ''."\n". 'foo'."\n"; - $this->assertEquals('foo', $this->encoder->decode($source, 'xml')); + $this->assertSame('foo', $this->encoder->decode($source, 'xml')); } public function testDecodeBigDigitAttributes() @@ -363,52 +413,10 @@ public function testDoesNotTypeCastStringsStartingWith0() $this->assertSame('018', $data['@a']); } - public function testEncode() + public function testEncodeException() { - $source = $this->getXmlSource(); - $obj = $this->getObject(); - - $this->assertEquals($source, $this->encoder->encode($obj, 'xml')); - } - - public function testEncodeWithNamespace() - { - $source = $this->getNamespacedXmlSource(); - $array = $this->getNamespacedArray(); - - $this->assertEquals($source, $this->encoder->encode($array, 'xml')); - } - - public function testEncodeSerializerXmlRootNodeNameOption() - { - $options = ['xml_root_node_name' => 'test']; - $this->encoder = new XmlEncoder(); - $serializer = new Serializer([], ['xml' => new XmlEncoder()]); - $this->encoder->setSerializer($serializer); - - $array = [ - 'person' => ['@eye-color' => 'brown', '#' => 'Peter'], - ]; - - $expected = ''."\n". - 'Peter'."\n"; - - $this->assertEquals($expected, $serializer->serialize($array, 'xml', $options)); - } - - public function testEncodeTraversableWhenNormalizable() - { - $this->encoder = new XmlEncoder(); - $serializer = new Serializer([new CustomNormalizer()], ['xml' => new XmlEncoder()]); - $this->encoder->setSerializer($serializer); - - $expected = <<<'XML' - -normalizedFoonormalizedBar - -XML; - - $this->assertEquals($expected, $serializer->serialize(new NormalizableTraversableDummy(), 'xml')); + $this->expectException(NotEncodableValueException::class); + $this->encoder->encode('Invalid character: '.\chr(7), 'xml'); } public function testDecode() @@ -603,8 +611,8 @@ public function testDecodeIgnoreComments() XML; $expected = ['person' => [ - ['firstname' => 'Benjamin', 'lastname' => 'Alexandre'], - ['firstname' => 'Damien', 'lastname' => 'Clay'], + ['firstname' => 'Benjamin', 'lastname' => 'Alexandre'], + ['firstname' => 'Damien', 'lastname' => 'Clay'], ]]; $this->assertEquals($expected, $this->encoder->decode($source, 'xml')); @@ -627,8 +635,8 @@ public function testDecodeIgnoreDocumentType() XML; $expected = ['person' => [ - ['firstname' => 'Benjamin', 'lastname' => 'Alexandre'], - ['firstname' => 'Damien', 'lastname' => 'Clay'], + ['firstname' => 'Benjamin', 'lastname' => 'Alexandre'], + ['firstname' => 'Damien', 'lastname' => 'Clay'], ]]; $this->assertEquals($expected, $this->encoder->decode( $source, @@ -662,8 +670,8 @@ public function testDecodePreserveComments() $this->encoder->setSerializer($serializer); $expected = ['person' => [ - ['firstname' => 'Benjamin', 'lastname' => 'Alexandre', '#comment' => ' This comment should be decoded. '], - ['firstname' => 'Damien', 'lastname' => 'Clay'], + ['firstname' => 'Benjamin', 'lastname' => 'Alexandre', '#comment' => ' This comment should be decoded. '], + ['firstname' => 'Damien', 'lastname' => 'Clay'], ]]; $this->assertEquals($expected, $this->encoder->decode($source, 'xml')); @@ -748,7 +756,7 @@ public function testDecodeEmptyXml() $this->encoder->decode(' ', 'xml'); } - protected function getXmlSource() + protected static function getXmlSource(): string { return ''."\n". ''. @@ -761,7 +769,7 @@ protected function getXmlSource() ''."\n"; } - protected function getNamespacedXmlSource() + protected static function getNamespacedXmlSource(): string { return ''."\n". ''. @@ -774,7 +782,7 @@ protected function getNamespacedXmlSource() ''."\n"; } - protected function getNamespacedArray() + protected static function getNamespacedArray(): array { return [ '@xmlns' => 'http://www.w3.org/2005/Atom', @@ -808,7 +816,10 @@ protected function getNamespacedArray() ]; } - protected function getObject() + /** + * @return Dummy + */ + protected static function getObject(): object { $obj = new Dummy(); $obj->foo = 'foo'; @@ -850,7 +861,7 @@ public function testEncodeXmlWithDateTimeObjectValue() { $xmlEncoder = $this->createXmlEncoderWithDateTimeNormalizer(); - $actualXml = $xmlEncoder->encode(['dateTime' => new \DateTime($this->exampleDateTimeString)], 'xml'); + $actualXml = $xmlEncoder->encode(['dateTime' => new \DateTimeImmutable($this->exampleDateTimeString)], 'xml'); $this->assertEquals($this->createXmlWithDateTime(), $actualXml); } @@ -859,7 +870,7 @@ public function testEncodeXmlWithDateTimeObjectField() { $xmlEncoder = $this->createXmlEncoderWithDateTimeNormalizer(); - $actualXml = $xmlEncoder->encode(['foo' => ['@dateTime' => new \DateTime($this->exampleDateTimeString)]], 'xml'); + $actualXml = $xmlEncoder->encode(['foo' => ['@dateTime' => new \DateTimeImmutable($this->exampleDateTimeString)]], 'xml'); $this->assertEquals($this->createXmlWithDateTimeField(), $actualXml); } @@ -956,23 +967,25 @@ private function createXmlEncoderWithDateTimeNormalizer(): XmlEncoder return $encoder; } - /** - * @return MockObject&NormalizerInterface - */ - private function createMockDateTimeNormalizer(): NormalizerInterface + private function createMockDateTimeNormalizer(): MockObject&NormalizerInterface { - $mock = $this->createMock(CustomNormalizer::class); + $mock = $this->createMock(NormalizerInterface::class); $mock ->expects($this->once()) ->method('normalize') - ->with(new \DateTime($this->exampleDateTimeString), 'xml', []) + ->with(new \DateTimeImmutable($this->exampleDateTimeString), 'xml', []) ->willReturn($this->exampleDateTimeString); + $mock + ->expects($this->once()) + ->method('getSupportedTypes') + ->willReturn([\DateTimeImmutable::class => true]); + $mock ->expects($this->once()) ->method('supportsNormalization') - ->with(new \DateTime($this->exampleDateTimeString), 'xml') + ->with(new \DateTimeImmutable($this->exampleDateTimeString), 'xml') ->willReturn(true); return $mock; @@ -980,14 +993,14 @@ private function createMockDateTimeNormalizer(): NormalizerInterface private function createXmlWithDateTime(): string { - return sprintf(' + return \sprintf(' %s ', $this->exampleDateTimeString); } private function createXmlWithDateTimeField(): string { - return sprintf(' + return \sprintf(' ', $this->exampleDateTimeString); } diff --git a/Tests/Encoder/YamlEncoderTest.php b/Tests/Encoder/YamlEncoderTest.php index 708d3dc62..f647fe423 100644 --- a/Tests/Encoder/YamlEncoderTest.php +++ b/Tests/Encoder/YamlEncoderTest.php @@ -13,8 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Encoder\YamlEncoder; -use Symfony\Component\Yaml\Dumper; -use Symfony\Component\Yaml\Parser; +use Symfony\Component\Serializer\Exception\NotEncodableValueException; use Symfony\Component\Yaml\Yaml; /** @@ -58,9 +57,20 @@ public function testSupportsDecoding() $this->assertFalse($encoder->supportsDecoding('json')); } + public function testIndentation() + { + $encoder = new YamlEncoder(null, null, [YamlEncoder::YAML_INLINE => 100, YamlEncoder::YAML_INDENTATION => 7]); + + $expected = <<<'END' +foo: + bar: baz +END; + $this->assertSame($expected."\n", $encoder->encode(['foo' => ['bar' => 'baz']], 'yaml')); + } + public function testContext() { - $encoder = new YamlEncoder(new Dumper(), new Parser(), [YamlEncoder::YAML_INLINE => 1, YamlEncoder::YAML_INDENT => 4, YamlEncoder::YAML_FLAGS => Yaml::DUMP_OBJECT | Yaml::PARSE_OBJECT]); + $encoder = new YamlEncoder(null, null, [YamlEncoder::YAML_INLINE => 1, YamlEncoder::YAML_INDENT => 4, YamlEncoder::YAML_FLAGS => Yaml::DUMP_OBJECT | Yaml::PARSE_OBJECT]); $obj = new \stdClass(); $obj->bar = 2; @@ -72,4 +82,12 @@ public function testContext() $this->assertEquals(['foo' => $obj], $encoder->decode("foo: !php/object 'O:8:\"stdClass\":1:{s:3:\"bar\";i:2;}'", 'yaml')); $this->assertEquals(['foo' => null], $encoder->decode("foo: !php/object 'O:8:\"stdClass\":1:{s:3:\"bar\";i:2;}'", 'yaml', [YamlEncoder::YAML_FLAGS => 0])); } + + public function testInvalidYaml() + { + $encoder = new YamlEncoder(); + + $this->expectException(NotEncodableValueException::class); + $encoder->decode("\t", 'yaml'); + } } diff --git a/Tests/Extractor/ObjectPropertyListExtractorTest.php b/Tests/Extractor/ObjectPropertyListExtractorTest.php index 65e776684..d639bf733 100644 --- a/Tests/Extractor/ObjectPropertyListExtractorTest.php +++ b/Tests/Extractor/ObjectPropertyListExtractorTest.php @@ -26,7 +26,7 @@ public function testGetPropertiesWithoutObjectClassResolver() $propertyListExtractor = $this->createMock(PropertyListExtractorInterface::class); $propertyListExtractor->expects($this->once()) ->method('getProperties') - ->with(\get_class($object), $context) + ->with($object::class, $context) ->willReturn($properties); $this->assertSame( diff --git a/Tests/Fixtures/AbstractNormalizerDummy.php b/Tests/Fixtures/AbstractNormalizerDummy.php index a3d28c6ca..f5bf565a1 100644 --- a/Tests/Fixtures/AbstractNormalizerDummy.php +++ b/Tests/Fixtures/AbstractNormalizerDummy.php @@ -20,40 +20,30 @@ */ class AbstractNormalizerDummy extends AbstractNormalizer { - /** - * {@inheritdoc} - */ - public function getAllowedAttributes($classOrObject, array $context, bool $attributesAsString = false) + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool { return parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); } - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize(mixed $object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return true; } - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null): bool + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool { return true; } diff --git a/Tests/Fixtures/Annotations/AbstractDummy.php b/Tests/Fixtures/Annotations/AbstractDummy.php deleted file mode 100644 index fad842b0d..000000000 --- a/Tests/Fixtures/Annotations/AbstractDummy.php +++ /dev/null @@ -1,31 +0,0 @@ - - * - * 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\DiscriminatorMap; - -/** - * @DiscriminatorMap(typeProperty="type", mapping={ - * "first"="Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild", - * "second"="Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild", - * "third"="Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyThirdChild", - * }) - */ -abstract class AbstractDummy -{ - public $foo; - - public function __construct($foo = null) - { - $this->foo = $foo; - } -} diff --git a/Tests/Fixtures/Annotations/AbstractDummyFirstChild.php b/Tests/Fixtures/Annotations/AbstractDummyFirstChild.php deleted file mode 100644 index 81f15aaf0..000000000 --- a/Tests/Fixtures/Annotations/AbstractDummyFirstChild.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * 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\Tests\Fixtures\DummyFirstChildQuux; - -class AbstractDummyFirstChild extends AbstractDummy -{ - public $bar; - - /** @var DummyFirstChildQuux|null */ - public $quux; - - public function __construct($foo = null, $bar = null) - { - parent::__construct($foo); - - $this->bar = $bar; - } - - public function getQuux(): ?DummyFirstChildQuux - { - return $this->quux; - } - - public function setQuux(DummyFirstChildQuux $quux): void - { - $this->quux = $quux; - } -} diff --git a/Tests/Fixtures/Annotations/AbstractDummySecondChild.php b/Tests/Fixtures/Annotations/AbstractDummySecondChild.php deleted file mode 100644 index 4d113066b..000000000 --- a/Tests/Fixtures/Annotations/AbstractDummySecondChild.php +++ /dev/null @@ -1,39 +0,0 @@ - - * - * 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\Tests\Fixtures\DummySecondChildQuux; - -class AbstractDummySecondChild extends AbstractDummy -{ - public $baz; - - /** @var DummySecondChildQuux|null */ - public $quux; - - public function __construct($foo = null, $baz = null) - { - parent::__construct($foo); - - $this->baz = $baz; - } - - public function getQuux(): ?DummySecondChildQuux - { - return $this->quux; - } - - public function setQuux(DummySecondChildQuux $quux): void - { - $this->quux = $quux; - } -} diff --git a/Tests/Fixtures/Annotations/BadMethodContextDummy.php b/Tests/Fixtures/Annotations/BadMethodContextDummy.php deleted file mode 100644 index 77b3884de..000000000 --- a/Tests/Fixtures/Annotations/BadMethodContextDummy.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * 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/Tests/Fixtures/Annotations/ContextDummy.php b/Tests/Fixtures/Annotations/ContextDummy.php deleted file mode 100644 index 804df290f..000000000 --- a/Tests/Fixtures/Annotations/ContextDummy.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * 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/Tests/Fixtures/Annotations/ContextDummyParent.php b/Tests/Fixtures/Annotations/ContextDummyParent.php deleted file mode 100644 index b7b286c37..000000000 --- a/Tests/Fixtures/Annotations/ContextDummyParent.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * 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/Tests/Fixtures/Annotations/Entity45016.php b/Tests/Fixtures/Annotations/Entity45016.php deleted file mode 100644 index a896d9b76..000000000 --- a/Tests/Fixtures/Annotations/Entity45016.php +++ /dev/null @@ -1,26 +0,0 @@ -id; - } - - /** - * @Ignore() - */ - public function badIgnore(): bool - { - return true; - } -} diff --git a/Tests/Fixtures/Annotations/GroupDummy.php b/Tests/Fixtures/Annotations/GroupDummy.php deleted file mode 100644 index 1d502c60c..000000000 --- a/Tests/Fixtures/Annotations/GroupDummy.php +++ /dev/null @@ -1,97 +0,0 @@ - - * - * 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\Groups; -use Symfony\Component\Serializer\Tests\Fixtures\GroupDummyInterface; -use Symfony\Component\Serializer\Tests\Fixtures\ChildOfGroupsAnnotationDummy; - -/** - * @author Kévin Dunglas - */ -class GroupDummy extends GroupDummyParent implements GroupDummyInterface -{ - /** - * @Groups({"a"}) - */ - private $foo; - /** - * @Groups({"b", "c", "name_converter"}) - */ - protected $bar; - /** - * @ChildOfGroupsAnnotationDummy - */ - protected $quux; - - private $fooBar; - private $symfony; - - /** - * @Groups({"b"}) - */ - public function setBar($bar) - { - $this->bar = $bar; - } - - /** - * @Groups({"c"}) - */ - public function getBar() - { - return $this->bar; - } - - public function setFoo($foo) - { - $this->foo = $foo; - } - - public function getFoo() - { - return $this->foo; - } - - public function setFooBar($fooBar) - { - $this->fooBar = $fooBar; - } - - /** - * @Groups({"a", "b", "name_converter"}) - */ - public function isFooBar() - { - return $this->fooBar; - } - - public function setSymfony($symfony) - { - $this->symfony = $symfony; - } - - public function getSymfony() - { - return $this->symfony; - } - - public function getQuux() - { - return $this->quux; - } - - public function setQuux($quux): void - { - $this->quux = $quux; - } -} diff --git a/Tests/Fixtures/Annotations/GroupDummyChild.php b/Tests/Fixtures/Annotations/GroupDummyChild.php deleted file mode 100644 index c8845736a..000000000 --- a/Tests/Fixtures/Annotations/GroupDummyChild.php +++ /dev/null @@ -1,33 +0,0 @@ - - * - * 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; - -class GroupDummyChild extends GroupDummy -{ - private $baz; - - /** - * @return mixed - */ - public function getBaz() - { - return $this->baz; - } - - /** - * @param mixed $baz - */ - public function setBaz($baz) - { - $this->baz = $baz; - } -} diff --git a/Tests/Fixtures/Annotations/GroupDummyParent.php b/Tests/Fixtures/Annotations/GroupDummyParent.php deleted file mode 100644 index 77d539b94..000000000 --- a/Tests/Fixtures/Annotations/GroupDummyParent.php +++ /dev/null @@ -1,49 +0,0 @@ - - * - * 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\Groups; - -/** - * @author Kévin Dunglas - */ -class GroupDummyParent -{ - /** - * @Groups({"a"}) - */ - private $kevin; - private $coopTilleuls; - - public function setKevin($kevin) - { - $this->kevin = $kevin; - } - - public function getKevin() - { - return $this->kevin; - } - - public function setCoopTilleuls($coopTilleuls) - { - $this->coopTilleuls = $coopTilleuls; - } - - /** - * @Groups({"a", "b"}) - */ - public function getCoopTilleuls() - { - return $this->coopTilleuls; - } -} diff --git a/Tests/Fixtures/Annotations/IgnoreDummy.php b/Tests/Fixtures/Annotations/IgnoreDummy.php deleted file mode 100644 index 900447c58..000000000 --- a/Tests/Fixtures/Annotations/IgnoreDummy.php +++ /dev/null @@ -1,35 +0,0 @@ - - * - * 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\Ignore; - -/** - * @author Kévin Dunglas - */ -class IgnoreDummy -{ - public $notIgnored; - /** - * @Ignore() - */ - public $ignored1; - private $ignored2; - - /** - * @Ignore() - */ - public function getIgnored2() - { - return $this->ignored2; - } -} diff --git a/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php b/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php deleted file mode 100644 index 326a9cd07..000000000 --- a/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetter.php +++ /dev/null @@ -1,27 +0,0 @@ -myValue; - } - - public function getExtraValue(string $parameter) - { - return $parameter; - } - - public function setExtraValue2(string $parameter) - { - } -} diff --git a/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php b/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php deleted file mode 100644 index 2b717c93a..000000000 --- a/Tests/Fixtures/Annotations/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php +++ /dev/null @@ -1,22 +0,0 @@ -myValue; - } - - public function getExtraValue(string $parameter) - { - return $parameter; - } - - public function setExtraValue2(string $parameter) - { - } -} diff --git a/Tests/Fixtures/Annotations/MaxDepthDummy.php b/Tests/Fixtures/Annotations/MaxDepthDummy.php deleted file mode 100644 index 12be2db03..000000000 --- a/Tests/Fixtures/Annotations/MaxDepthDummy.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * 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\MaxDepth; - -/** - * @author Kévin Dunglas - */ -class MaxDepthDummy -{ - /** - * @MaxDepth(2) - */ - public $foo; - - public $bar; - - /** - * @var self - */ - public $child; - - /** - * @MaxDepth(3) - */ - public function getBar() - { - return $this->bar; - } - - public function getChild() - { - return $this->child; - } - - public function getFoo() - { - return $this->foo; - } -} diff --git a/Tests/Fixtures/Annotations/SerializedNameDummy.php b/Tests/Fixtures/Annotations/SerializedNameDummy.php deleted file mode 100644 index 1eaa579b4..000000000 --- a/Tests/Fixtures/Annotations/SerializedNameDummy.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * 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\SerializedName; - -/** - * @author Fabien Bourigault - */ -class SerializedNameDummy -{ - /** - * @SerializedName("baz") - */ - public $foo; - - public $bar; - - public $quux; - - /** - * @var self - */ - public $child; - - /** - * @SerializedName("qux") - */ - public function getBar() - { - return $this->bar; - } - - public function getChild() - { - return $this->child; - } -} diff --git a/Tests/Fixtures/Attributes/AbstractDummy.php b/Tests/Fixtures/Attributes/AbstractDummy.php index 2e66f465b..a8c15fccb 100644 --- a/Tests/Fixtures/Attributes/AbstractDummy.php +++ b/Tests/Fixtures/Attributes/AbstractDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; #[DiscriminatorMap(typeProperty: 'type', mapping: [ 'first' => AbstractDummyFirstChild::class, diff --git a/Tests/Fixtures/Attributes/AccessorishGetters.php b/Tests/Fixtures/Attributes/AccessorishGetters.php new file mode 100644 index 000000000..f434e84f3 --- /dev/null +++ b/Tests/Fixtures/Attributes/AccessorishGetters.php @@ -0,0 +1,39 @@ + + * + * 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; + +class AccessorishGetters +{ + public function hash(): void + { + } + + public function cancel() + { + } + + public function getField1() + { + } + + public function isField2() + { + } + + public function hasField3() + { + } + + public function setField4() + { + } +} diff --git a/Tests/Fixtures/Attributes/BadAttributeDummy.php b/Tests/Fixtures/Attributes/BadAttributeDummy.php new file mode 100644 index 000000000..36c3b945b --- /dev/null +++ b/Tests/Fixtures/Attributes/BadAttributeDummy.php @@ -0,0 +1,23 @@ + + * + * 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\Attribute\Groups; + +class BadAttributeDummy extends ContextDummyParent +{ + #[Groups(['bar'])] + #[Groups(['foo'])] + public function myMethod() + { + } +} diff --git a/Tests/Fixtures/Attributes/BadMethodContextDummy.php b/Tests/Fixtures/Attributes/BadMethodContextDummy.php index 090911af2..7ae9441d8 100644 --- a/Tests/Fixtures/Attributes/BadMethodContextDummy.php +++ b/Tests/Fixtures/Attributes/BadMethodContextDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Attribute\Context; /** * @author Maxime Steinhausser diff --git a/Tests/Fixtures/Attributes/ClassWithIgnoreAnnotation.php b/Tests/Fixtures/Attributes/ClassWithIgnoreAnnotation.php new file mode 100644 index 000000000..22df4f84a --- /dev/null +++ b/Tests/Fixtures/Attributes/ClassWithIgnoreAnnotation.php @@ -0,0 +1,25 @@ + + * + * 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\Ignore; + +class ClassWithIgnoreAnnotation +{ + public string $foo; + + #[Ignore] + public function isSomeIgnoredMethod(): bool + { + return true; + } +} diff --git a/Tests/Fixtures/Attributes/ClassWithIgnoreAttribute.php b/Tests/Fixtures/Attributes/ClassWithIgnoreAttribute.php index 14d5e9472..fed0614b0 100644 --- a/Tests/Fixtures/Attributes/ClassWithIgnoreAttribute.php +++ b/Tests/Fixtures/Attributes/ClassWithIgnoreAttribute.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Attribute\Ignore; class ClassWithIgnoreAttribute { diff --git a/Tests/Fixtures/Attributes/ContextDummy.php b/Tests/Fixtures/Attributes/ContextDummy.php index 464b9cab6..3d518950b 100644 --- a/Tests/Fixtures/Attributes/ContextDummy.php +++ b/Tests/Fixtures/Attributes/ContextDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Attribute\Context; /** * @author Maxime Steinhausser diff --git a/Tests/Fixtures/Attributes/ContextDummyParent.php b/Tests/Fixtures/Attributes/ContextDummyParent.php index 9480c953e..1ac1928fb 100644 --- a/Tests/Fixtures/Attributes/ContextDummyParent.php +++ b/Tests/Fixtures/Attributes/ContextDummyParent.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Context; +use Symfony\Component\Serializer\Attribute\Context; /** * @author Maxime Steinhausser diff --git a/Tests/Fixtures/Attributes/ContextDummyPromotedProperties.php b/Tests/Fixtures/Attributes/ContextDummyPromotedProperties.php new file mode 100644 index 000000000..5d9fb5eff --- /dev/null +++ b/Tests/Fixtures/Attributes/ContextDummyPromotedProperties.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\Attributes; + +use Symfony\Component\Serializer\Attribute\Context; + +/** + * @author Maxime Steinhausser + */ +class ContextDummyPromotedProperties extends ContextDummyParent +{ + public function __construct( + #[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/Tests/Fixtures/Attributes/Entity45016.php b/Tests/Fixtures/Attributes/Entity45016.php index 5a7ace0fd..e9d219a5f 100644 --- a/Tests/Fixtures/Attributes/Entity45016.php +++ b/Tests/Fixtures/Attributes/Entity45016.php @@ -2,7 +2,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Attribute\Ignore; class Entity45016 { diff --git a/Tests/Fixtures/Attributes/GroupClassDummy.php b/Tests/Fixtures/Attributes/GroupClassDummy.php new file mode 100644 index 000000000..abd7d0b03 --- /dev/null +++ b/Tests/Fixtures/Attributes/GroupClassDummy.php @@ -0,0 +1,56 @@ + + * + * 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\Attribute\Groups; + +#[Groups('a')] +class GroupClassDummy +{ + #[Groups('b')] + private $foo; + + #[Groups(['c', 'd'])] + private $bar; + + private $baz; + + public function getFoo() + { + return $this->foo; + } + + public function setFoo($foo): void + { + $this->foo = $foo; + } + + public function getBar() + { + return $this->bar; + } + + public function setBar($bar): void + { + $this->bar = $bar; + } + + public function getBaz() + { + return $this->baz; + } + + public function setBaz($baz): void + { + $this->baz = $baz; + } +} diff --git a/Tests/Fixtures/Attributes/GroupDummy.php b/Tests/Fixtures/Attributes/GroupDummy.php index 1cb2026d9..41457d796 100644 --- a/Tests/Fixtures/Attributes/GroupDummy.php +++ b/Tests/Fixtures/Attributes/GroupDummy.php @@ -11,9 +11,8 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Groups; -use Symfony\Component\Serializer\Tests\Fixtures\ChildOfGroupsAnnotationDummy; -use Symfony\Component\Serializer\Tests\Fixtures\GroupDummyInterface; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Tests\Fixtures\ChildOfGroupsAttributeDummy; /** * @author Kévin Dunglas @@ -24,7 +23,7 @@ class GroupDummy extends GroupDummyParent implements GroupDummyInterface private $foo; #[Groups(['b', 'c', 'name_converter'])] protected $bar; - #[ChildOfGroupsAnnotationDummy] + #[ChildOfGroupsAttributeDummy] protected $quux; private $fooBar; private $symfony; diff --git a/Tests/Fixtures/Attributes/GroupDummyChild.php b/Tests/Fixtures/Attributes/GroupDummyChild.php index 5a7423914..7167a0b18 100644 --- a/Tests/Fixtures/Attributes/GroupDummyChild.php +++ b/Tests/Fixtures/Attributes/GroupDummyChild.php @@ -15,18 +15,12 @@ class GroupDummyChild extends GroupDummy { private $baz; - /** - * @return mixed - */ - public function getBaz() + public function getBaz(): mixed { return $this->baz; } - /** - * @param mixed $baz - */ - public function setBaz($baz) + public function setBaz(mixed $baz) { $this->baz = $baz; } diff --git a/Tests/Fixtures/GroupDummyInterface.php b/Tests/Fixtures/Attributes/GroupDummyInterface.php similarity index 68% rename from Tests/Fixtures/GroupDummyInterface.php rename to Tests/Fixtures/Attributes/GroupDummyInterface.php index a60629e6d..3f9ed159a 100644 --- a/Tests/Fixtures/GroupDummyInterface.php +++ b/Tests/Fixtures/Attributes/GroupDummyInterface.php @@ -9,17 +9,15 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Serializer\Tests\Fixtures; +namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * @author Kévin Dunglas */ interface GroupDummyInterface { - /** - * @Groups({"a", "name_converter"}) - */ + #[Groups(['a', 'name_converter'])] public function getSymfony(); } diff --git a/Tests/Fixtures/Attributes/GroupDummyParent.php b/Tests/Fixtures/Attributes/GroupDummyParent.php index 39c73160f..de758be64 100644 --- a/Tests/Fixtures/Attributes/GroupDummyParent.php +++ b/Tests/Fixtures/Attributes/GroupDummyParent.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * @author Kévin Dunglas diff --git a/Tests/Fixtures/Attributes/IgnoreDummy.php b/Tests/Fixtures/Attributes/IgnoreDummy.php index 85d7a9ca4..6e12f7c00 100644 --- a/Tests/Fixtures/Attributes/IgnoreDummy.php +++ b/Tests/Fixtures/Attributes/IgnoreDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Attribute\Ignore; /** * @author Kévin Dunglas diff --git a/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php b/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php index 274479e63..aa6439b48 100644 --- a/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php +++ b/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetter.php @@ -2,7 +2,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Attribute\Ignore; class IgnoreDummyAdditionalGetter { diff --git a/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php b/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAttributes.php similarity index 85% rename from Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php rename to Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAttributes.php index 21abb870b..5d3e8f47f 100644 --- a/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations.php +++ b/Tests/Fixtures/Attributes/IgnoreDummyAdditionalGetterWithoutIgnoreAttributes.php @@ -2,7 +2,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -class IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations +class IgnoreDummyAdditionalGetterWithoutIgnoreAttributes { private $myValue; diff --git a/Tests/Fixtures/Attributes/MaxDepthDummy.php b/Tests/Fixtures/Attributes/MaxDepthDummy.php index 7a1dc42c2..8f45cbd78 100644 --- a/Tests/Fixtures/Attributes/MaxDepthDummy.php +++ b/Tests/Fixtures/Attributes/MaxDepthDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\MaxDepth; +use Symfony\Component\Serializer\Attribute\MaxDepth; /** * @author Kévin Dunglas diff --git a/Tests/Fixtures/Attributes/SerializedNameDummy.php b/Tests/Fixtures/Attributes/SerializedNameDummy.php index fe0a67e83..27679b009 100644 --- a/Tests/Fixtures/Attributes/SerializedNameDummy.php +++ b/Tests/Fixtures/Attributes/SerializedNameDummy.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures\Attributes; -use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedName; /** * @author Fabien Bourigault diff --git a/Tests/Fixtures/Attributes/SerializedPathDummy.php b/Tests/Fixtures/Attributes/SerializedPathDummy.php new file mode 100644 index 000000000..8b627b792 --- /dev/null +++ b/Tests/Fixtures/Attributes/SerializedPathDummy.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\Tests\Fixtures\Attributes; + +use Symfony\Component\Serializer\Attribute\SerializedPath; + +/** + * @author Tobias Bönner + */ +class SerializedPathDummy +{ + #[SerializedPath('[one][two]')] + public $three; + + public $seven; + + #[SerializedPath('[three][four]')] + public function getSeven() + { + return $this->seven; + } +} diff --git a/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php b/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php new file mode 100644 index 000000000..e463429c0 --- /dev/null +++ b/Tests/Fixtures/Attributes/SerializedPathInConstructorDummy.php @@ -0,0 +1,22 @@ + + * + * 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\Attribute\SerializedPath; + +class SerializedPathInConstructorDummy +{ + public function __construct( + #[SerializedPath('[one][two]')] public $three, + ) { + } +} diff --git a/Tests/Fixtures/ChildOfGroupsAnnotationDummy.php b/Tests/Fixtures/ChildOfGroupsAttributeDummy.php similarity index 57% rename from Tests/Fixtures/ChildOfGroupsAnnotationDummy.php rename to Tests/Fixtures/ChildOfGroupsAttributeDummy.php index 653758dca..4fc81fa9d 100644 --- a/Tests/Fixtures/ChildOfGroupsAnnotationDummy.php +++ b/Tests/Fixtures/ChildOfGroupsAttributeDummy.php @@ -2,14 +2,10 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; -/** - * @Annotation - * @Target({"PROPERTY", "METHOD"}) - */ #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] -final class ChildOfGroupsAnnotationDummy extends Groups +final class ChildOfGroupsAttributeDummy extends Groups { public function __construct() { diff --git a/Tests/Fixtures/DenormalizableDummy.php b/Tests/Fixtures/DenormalizableDummy.php index 3170e3618..157adeffa 100644 --- a/Tests/Fixtures/DenormalizableDummy.php +++ b/Tests/Fixtures/DenormalizableDummy.php @@ -16,7 +16,7 @@ class DenormalizableDummy implements DenormalizableInterface { - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, array|string|int|float|bool $data, ?string $format = null, array $context = []): void { } } diff --git a/Tests/Fixtures/Dummy.php b/Tests/Fixtures/Dummy.php index db09a40b5..e4fb01f94 100644 --- a/Tests/Fixtures/Dummy.php +++ b/Tests/Fixtures/Dummy.php @@ -23,7 +23,7 @@ class Dummy implements NormalizableInterface, DenormalizableInterface public $baz; public $qux; - public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []) + public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array|string|int|float|bool { return [ 'foo' => $this->foo, @@ -33,7 +33,7 @@ public function normalize(NormalizerInterface $normalizer, ?string $format = nul ]; } - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, array|string|int|float|bool $data, ?string $format = null, array $context = []): void { $this->foo = $data['foo']; $this->bar = $data['bar']; diff --git a/Tests/Fixtures/DummyMessageInterface.php b/Tests/Fixtures/DummyMessageInterface.php index 78beb8374..31206ea67 100644 --- a/Tests/Fixtures/DummyMessageInterface.php +++ b/Tests/Fixtures/DummyMessageInterface.php @@ -11,17 +11,16 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; /** - * @DiscriminatorMap(typeProperty="type", mapping={ - * "one"="Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne", - * "two"="Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberTwo", - * "three"="Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberThree" - * }) - * * @author Samuel Roze */ +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'one' => DummyMessageNumberOne::class, + 'two' => DummyMessageNumberTwo::class, + 'three' => DummyMessageNumberThree::class, +])] interface DummyMessageInterface { } diff --git a/Tests/Fixtures/DummyMessageNumberOne.php b/Tests/Fixtures/DummyMessageNumberOne.php index 200476b54..45e682fbc 100644 --- a/Tests/Fixtures/DummyMessageNumberOne.php +++ b/Tests/Fixtures/DummyMessageNumberOne.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * @author Samuel Roze @@ -20,8 +20,6 @@ class DummyMessageNumberOne implements DummyMessageInterface { public $one; - /** - * @Groups({"two"}) - */ + #[Groups(['two'])] public $two; } diff --git a/Tests/Fixtures/DummyMessageNumberTwo.php b/Tests/Fixtures/DummyMessageNumberTwo.php index 5a24e7c9f..ec2be9a81 100644 --- a/Tests/Fixtures/DummyMessageNumberTwo.php +++ b/Tests/Fixtures/DummyMessageNumberTwo.php @@ -11,7 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; /** * @author Samuel Roze diff --git a/Tests/Fixtures/DummyObjectWithEnumConstructor.php b/Tests/Fixtures/DummyObjectWithEnumConstructor.php index be5ea3cff..022b57cd9 100644 --- a/Tests/Fixtures/DummyObjectWithEnumConstructor.php +++ b/Tests/Fixtures/DummyObjectWithEnumConstructor.php @@ -1,8 +1,15 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ -use Symfony\Component\Serializer\Tests\Fixtures\StringBackedEnumDummy; +namespace Symfony\Component\Serializer\Tests\Fixtures; class DummyObjectWithEnumConstructor { diff --git a/Tests/Fixtures/DummyObjectWithEnumProperty.php b/Tests/Fixtures/DummyObjectWithEnumProperty.php index f2677195f..70c6cff7b 100644 --- a/Tests/Fixtures/DummyObjectWithEnumProperty.php +++ b/Tests/Fixtures/DummyObjectWithEnumProperty.php @@ -2,8 +2,6 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Tests\Fixtures\StringBackedEnumDummy; - class DummyObjectWithEnumProperty { public StringBackedEnumDummy $get; diff --git a/Tests/Fixtures/DummyString.php b/Tests/Fixtures/DummyString.php index 4a4bef8a1..98e0fa06f 100644 --- a/Tests/Fixtures/DummyString.php +++ b/Tests/Fixtures/DummyString.php @@ -22,7 +22,7 @@ class DummyString implements DenormalizableInterface /** @var string $value */ public $value; - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []): void { $this->value = $data; } diff --git a/Tests/Fixtures/DummyWithVariadicParameter.php b/Tests/Fixtures/DummyWithVariadicParameter.php new file mode 100644 index 000000000..827111921 --- /dev/null +++ b/Tests/Fixtures/DummyWithVariadicParameter.php @@ -0,0 +1,24 @@ + + * + * 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; + +use Symfony\Component\Uid\Uuid; + +class DummyWithVariadicParameter +{ + public array $variadic; + + public function __construct(Uuid ...$variadic) + { + $this->variadic = $variadic; + } +} diff --git a/Tests/Fixtures/EnvelopeNormalizer.php b/Tests/Fixtures/EnvelopeNormalizer.php index f321d55af..4acfdf830 100644 --- a/Tests/Fixtures/EnvelopeNormalizer.php +++ b/Tests/Fixtures/EnvelopeNormalizer.php @@ -31,6 +31,13 @@ public function normalize($envelope, ?string $format = null, array $context = [] ]; } + public function getSupportedTypes(?string $format): array + { + return [ + EnvelopeObject::class => true, + ]; + } + public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof EnvelopeObject; diff --git a/Tests/Fixtures/EnvelopedMessageNormalizer.php b/Tests/Fixtures/EnvelopedMessageNormalizer.php index 68a5b9470..812dbf015 100644 --- a/Tests/Fixtures/EnvelopedMessageNormalizer.php +++ b/Tests/Fixtures/EnvelopedMessageNormalizer.php @@ -25,6 +25,13 @@ public function normalize($message, ?string $format = null, array $context = []) ]; } + public function getSupportedTypes(?string $format): array + { + return [ + EnvelopedMessage::class => true, + ]; + } + public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return $data instanceof EnvelopedMessage; diff --git a/Tests/Fixtures/Annotations/AbstractDummyThirdChild.php b/Tests/Fixtures/FooDummyInterface.php similarity index 64% rename from Tests/Fixtures/Annotations/AbstractDummyThirdChild.php rename to Tests/Fixtures/FooDummyInterface.php index 2df26c332..da206e039 100644 --- a/Tests/Fixtures/Annotations/AbstractDummyThirdChild.php +++ b/Tests/Fixtures/FooDummyInterface.php @@ -9,8 +9,8 @@ * file that was distributed with this source code. */ -namespace Symfony\Component\Serializer\Tests\Fixtures\Annotations; +namespace Symfony\Component\Serializer\Tests\Fixtures; -final class AbstractDummyThirdChild extends AbstractDummyFirstChild +interface FooDummyInterface { } diff --git a/Tests/Fixtures/FooImplementationDummy.php b/Tests/Fixtures/FooImplementationDummy.php new file mode 100644 index 000000000..b7f7194a5 --- /dev/null +++ b/Tests/Fixtures/FooImplementationDummy.php @@ -0,0 +1,20 @@ + + * + * 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; + +final class FooImplementationDummy implements FooDummyInterface +{ + /** + * @var string + */ + public $name; +} diff --git a/Tests/Fixtures/FooInterfaceDummyDenormalizer.php b/Tests/Fixtures/FooInterfaceDummyDenormalizer.php new file mode 100644 index 000000000..0fa3c8202 --- /dev/null +++ b/Tests/Fixtures/FooInterfaceDummyDenormalizer.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; + +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +final class FooInterfaceDummyDenormalizer implements DenormalizerInterface +{ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): array + { + $result = []; + foreach ($data as $foo) { + $fooDummy = new FooImplementationDummy(); + $fooDummy->name = $foo['name']; + $result[] = $fooDummy; + } + + return $result; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + if (str_ends_with($type, '[]')) { + $className = substr($type, 0, -2); + $classImplements = class_implements($className); + \assert(\is_array($classImplements)); + + return class_exists($className) && \in_array(FooDummyInterface::class, $classImplements, true); + } + + return false; + } + + /** + * @return array + */ + public function getSupportedTypes(?string $format): array + { + return [FooDummyInterface::class.'[]' => false]; + } +} diff --git a/Tests/Fixtures/NormalizableTraversableDummy.php b/Tests/Fixtures/NormalizableTraversableDummy.php index 26bbae37e..5165120c3 100644 --- a/Tests/Fixtures/NormalizableTraversableDummy.php +++ b/Tests/Fixtures/NormalizableTraversableDummy.php @@ -18,7 +18,7 @@ class NormalizableTraversableDummy extends TraversableDummy implements NormalizableInterface, DenormalizableInterface { - public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []) + public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array|string|int|float|bool { return [ 'foo' => 'normalizedFoo', @@ -26,11 +26,7 @@ public function normalize(NormalizerInterface $normalizer, ?string $format = nul ]; } - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, array|string|int|float|bool $data, ?string $format = null, array $context = []): void { - return [ - 'foo' => 'denormalizedFoo', - 'bar' => 'denormalizedBar', - ]; } } diff --git a/Tests/Fixtures/NotNormalizableDummy.php b/Tests/Fixtures/NotNormalizableDummy.php index d146c1a8b..ef7a8c906 100644 --- a/Tests/Fixtures/NotNormalizableDummy.php +++ b/Tests/Fixtures/NotNormalizableDummy.php @@ -24,7 +24,7 @@ public function __construct() { } - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []): void { throw new NotNormalizableValueException('Custom exception message'); } diff --git a/Tests/Fixtures/ObjectCollectionPropertyDummy.php b/Tests/Fixtures/ObjectCollectionPropertyDummy.php new file mode 100644 index 000000000..cbb77987b --- /dev/null +++ b/Tests/Fixtures/ObjectCollectionPropertyDummy.php @@ -0,0 +1,25 @@ + + * + * 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; + +final class ObjectCollectionPropertyDummy +{ + /** + * @var FooImplementationDummy[] + */ + public $foo; + + public function getFoo(): array + { + return $this->foo; + } +} diff --git a/Tests/Fixtures/OtherSerializedNameDummy.php b/Tests/Fixtures/OtherSerializedNameDummy.php index 1ac543ca6..86fc6ead1 100644 --- a/Tests/Fixtures/OtherSerializedNameDummy.php +++ b/Tests/Fixtures/OtherSerializedNameDummy.php @@ -11,17 +11,15 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Annotation\Groups; -use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\SerializedName; /** * @author Anthony GRASSIOT */ class OtherSerializedNameDummy { - /** - * @Groups({"a"}) - */ + #[Groups(['a'])] private $buz; public function setBuz($buz) @@ -34,10 +32,7 @@ public function getBuz() return $this->buz; } - /** - * @Groups({"b"}) - * @SerializedName("buz") - */ + #[Groups(['b']), SerializedName('buz')] public function getBuzForExport() { return $this->buz.' Rocks'; diff --git a/Tests/Fixtures/Php74Full.php b/Tests/Fixtures/Php74Full.php index 0fe8ffd15..ed3c49577 100644 --- a/Tests/Fixtures/Php74Full.php +++ b/Tests/Fixtures/Php74Full.php @@ -36,7 +36,6 @@ final class Php74Full public $anotherCollection; } - final class Php74FullWithConstructor { public function __construct($constructorArgument) diff --git a/Tests/Fixtures/ScalarDummy.php b/Tests/Fixtures/ScalarDummy.php index 0704db31d..50617f33e 100644 --- a/Tests/Fixtures/ScalarDummy.php +++ b/Tests/Fixtures/ScalarDummy.php @@ -21,12 +21,12 @@ class ScalarDummy implements NormalizableInterface, DenormalizableInterface public $foo; public $xmlFoo; - public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []) + public function normalize(NormalizerInterface $normalizer, ?string $format = null, array $context = []): array|string|int|float|bool { return 'xml' === $format ? $this->xmlFoo : $this->foo; } - public function denormalize(DenormalizerInterface $denormalizer, $data, ?string $format = null, array $context = []) + public function denormalize(DenormalizerInterface $denormalizer, array|string|int|float|bool $data, ?string $format = null, array $context = []): void { if ('xml' === $format) { $this->xmlFoo = $data; diff --git a/Tests/Fixtures/StaticConstructorNormalizer.php b/Tests/Fixtures/StaticConstructorNormalizer.php index 10398f4fc..1ba6884bd 100644 --- a/Tests/Fixtures/StaticConstructorNormalizer.php +++ b/Tests/Fixtures/StaticConstructorNormalizer.php @@ -11,17 +11,34 @@ namespace Symfony\Component\Serializer\Tests\Fixtures; -use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; /** * @author Guilhem N. */ -class StaticConstructorNormalizer extends ObjectNormalizer +class StaticConstructorNormalizer extends AbstractObjectNormalizer { - /** - * {@inheritdoc} - */ - protected function getConstructor(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, $allowedAttributes): ?\ReflectionMethod + public function getSupportedTypes(?string $format): array + { + return [StaticConstructorDummy::class]; + } + + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array + { + return get_object_vars($object); + } + + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed + { + return $object->$attribute; + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + $object->$attribute = $value; + } + + protected function getConstructor(array &$data, string $class, array &$context, \ReflectionClass $reflectionClass, array|bool $allowedAttributes): ?\ReflectionMethod { if (is_a($class, StaticConstructorDummy::class, true)) { return new \ReflectionMethod($class, 'create'); diff --git a/Tests/Fixtures/TrueBuiltInDummy.php b/Tests/Fixtures/TrueBuiltInDummy.php new file mode 100644 index 000000000..aa7991484 --- /dev/null +++ b/Tests/Fixtures/TrueBuiltInDummy.php @@ -0,0 +1,17 @@ + + * + * 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; + +class TrueBuiltInDummy +{ + public true $true = true; +} diff --git a/Tests/Fixtures/VariadicConstructorTypedArgsDummy.php b/Tests/Fixtures/VariadicConstructorTypedArgsDummy.php index 020fd0d22..1a39064d6 100644 --- a/Tests/Fixtures/VariadicConstructorTypedArgsDummy.php +++ b/Tests/Fixtures/VariadicConstructorTypedArgsDummy.php @@ -21,7 +21,7 @@ public function __construct(Dummy ...$foo) } /** @return Dummy[] */ - public function getFoo() + public function getFoo(): array { return $this->foo; } diff --git a/Tests/Fixtures/invalid-ignore.yml b/Tests/Fixtures/invalid-ignore.yml index 00aa0fa96..f81636cc5 100644 --- a/Tests/Fixtures/invalid-ignore.yml +++ b/Tests/Fixtures/invalid-ignore.yml @@ -1,4 +1,4 @@ -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy': attributes: ignored1: ignore: foo diff --git a/Tests/Fixtures/serialization.xml b/Tests/Fixtures/serialization.xml index 69243cfdd..512736db4 100644 --- a/Tests/Fixtures/serialization.xml +++ b/Tests/Fixtures/serialization.xml @@ -4,7 +4,7 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/serializer-mapping https://symfony.com/schema/dic/serializer-mapping/serializer-mapping-1.0.xsd"> - + group1 group2 @@ -15,32 +15,41 @@ - + - + - + + + + + + + + + + - - + + - + - + dummy_parent_value @@ -53,7 +62,7 @@ - + value diff --git a/Tests/Fixtures/serialization.yml b/Tests/Fixtures/serialization.yml index 80100e826..4371016e3 100644 --- a/Tests/Fixtures/serialization.yml +++ b/Tests/Fixtures/serialization.yml @@ -1,37 +1,47 @@ -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy': attributes: foo: groups: ['group1', 'group2'] bar: groups: ['group2'] -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy': attributes: foo: max_depth: 2 bar: max_depth: 3 -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy': attributes: foo: serialized_name: 'baz' bar: serialized_name: 'qux' -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy': + attributes: + three: + serialized_path: '[one][two]' + seven: + serialized_path: '[three][four]' +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathInConstructorDummy': + attributes: + three: + serialized_path: '[one][two]' +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummy': discriminator_map: type_property: type mapping: - first: 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild' - second: 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild' + first: 'Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild' + second: 'Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild' attributes: foo: ~ -'Symfony\Component\Serializer\Tests\Fixtures\Annotations\IgnoreDummy': +'Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy': attributes: ignored1: ignore: true ignored2: ignore: true -Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent: +Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummyParent: attributes: parentProperty: contexts: @@ -40,7 +50,7 @@ Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummyParent: contexts: - { normalization_context: { prop: dummy_parent_value } } -Symfony\Component\Serializer\Tests\Fixtures\Annotations\ContextDummy: +Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummy: attributes: foo: contexts: diff --git a/Tests/Mapping/AttributeMetadataTest.php b/Tests/Mapping/AttributeMetadataTest.php index 8fc4b8b49..1f1b291be 100644 --- a/Tests/Mapping/AttributeMetadataTest.php +++ b/Tests/Mapping/AttributeMetadataTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Mapping; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyPath; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; @@ -58,6 +59,15 @@ public function testSerializedName() $this->assertEquals('serialized_name', $attributeMetadata->getSerializedName()); } + public function testSerializedPath() + { + $attributeMetadata = new AttributeMetadata('path'); + $serializedPath = new PropertyPath('[serialized][path]'); + $attributeMetadata->setSerializedPath($serializedPath); + + $this->assertEquals($serializedPath, $attributeMetadata->getSerializedPath()); + } + public function testIgnore() { $attributeMetadata = new AttributeMetadata('ignored'); @@ -119,6 +129,7 @@ public function testGetContextsForGroups() public function testMerge() { + $serializedPath = new PropertyPath('[a4][a5]'); $attributeMetadata1 = new AttributeMetadata('a1'); $attributeMetadata1->addGroup('a'); $attributeMetadata1->addGroup('b'); @@ -128,6 +139,7 @@ public function testMerge() $attributeMetadata2->addGroup('c'); $attributeMetadata2->setMaxDepth(2); $attributeMetadata2->setSerializedName('a3'); + $attributeMetadata2->setSerializedPath($serializedPath); $attributeMetadata2->setNormalizationContextForGroups(['foo' => 'bar'], ['a']); $attributeMetadata2->setDenormalizationContextForGroups(['baz' => 'qux'], ['c']); @@ -138,6 +150,7 @@ public function testMerge() $this->assertEquals(['a', 'b', 'c'], $attributeMetadata1->getGroups()); $this->assertEquals(2, $attributeMetadata1->getMaxDepth()); $this->assertEquals('a3', $attributeMetadata1->getSerializedName()); + $this->assertEquals($serializedPath, $attributeMetadata1->getSerializedPath()); $this->assertSame(['a' => ['foo' => 'bar']], $attributeMetadata1->getNormalizationContexts()); $this->assertSame(['c' => ['baz' => 'qux']], $attributeMetadata1->getDenormalizationContexts()); $this->assertTrue($attributeMetadata1->isIgnored()); @@ -166,6 +179,8 @@ public function testSerialize() $attributeMetadata->addGroup('b'); $attributeMetadata->setMaxDepth(3); $attributeMetadata->setSerializedName('serialized_name'); + $serializedPath = new PropertyPath('[serialized][path]'); + $attributeMetadata->setSerializedPath($serializedPath); $serialized = serialize($attributeMetadata); $this->assertEquals($attributeMetadata, unserialize($serialized)); diff --git a/Tests/Mapping/ClassDiscriminatorMappingTest.php b/Tests/Mapping/ClassDiscriminatorMappingTest.php index 4dcd64eca..a8c736b8c 100644 --- a/Tests/Mapping/ClassDiscriminatorMappingTest.php +++ b/Tests/Mapping/ClassDiscriminatorMappingTest.php @@ -13,9 +13,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorMapping; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyFirstChild; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummySecondChild; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\AbstractDummyThirdChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyThirdChild; /** * @author Samuel Roze diff --git a/Tests/Mapping/Factory/CacheMetadataFactoryTest.php b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php index 74fc10069..e18dd707f 100644 --- a/Tests/Mapping/Factory/CacheMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CacheMetadataFactoryTest.php @@ -58,19 +58,20 @@ public function testHasMetadataFor() public function testInvalidClassThrowsException() { - $this->expectException(InvalidArgumentException::class); $decorated = $this->createMock(ClassMetadataFactoryInterface::class); $factory = new CacheClassMetadataFactory($decorated, new ArrayAdapter()); + $this->expectException(InvalidArgumentException::class); + $factory->getMetadataFor('Not\Exist'); } public function testAnonymousClass() { - $anonymousObject = new class() { + $anonymousObject = new class { }; - $metadata = new ClassMetadata(\get_class($anonymousObject)); + $metadata = new ClassMetadata($anonymousObject::class); $decorated = $this->createMock(ClassMetadataFactoryInterface::class); $decorated ->expects($this->once()) diff --git a/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php b/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php index 43e764abe..40dcb5015 100644 --- a/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php +++ b/Tests/Mapping/Factory/ClassMetadataFactoryCompilerTest.php @@ -11,25 +11,23 @@ namespace Symfony\Component\Serializer\Tests\Mapping\Factory; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryCompiler; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathInConstructorDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; final class ClassMetadataFactoryCompilerTest extends TestCase { - /** - * @var string - */ - private $dumpPath; + private string $dumpPath; protected function setUp(): void { - $this->dumpPath = sys_get_temp_dir().\DIRECTORY_SEPARATOR.'php_serializer_metadata.'.uniqid('CompiledClassMetadataFactory', true).'.php'; + $this->dumpPath = tempnam(sys_get_temp_dir(), 'sf_serializer_metadata_'); } protected function tearDown(): void @@ -39,30 +37,34 @@ protected function tearDown(): void public function testItDumpMetadata() { - $classMetatadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetatadataFactory = new ClassMetadataFactory(new AttributeLoader()); $dummyMetadata = $classMetatadataFactory->getMetadataFor(Dummy::class); $maxDepthDummyMetadata = $classMetatadataFactory->getMetadataFor(MaxDepthDummy::class); $serializedNameDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedNameDummy::class); + $serializedPathDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedPathDummy::class); + $serializedPathInConstructorDummyMetadata = $classMetatadataFactory->getMetadataFor(SerializedPathInConstructorDummy::class); $code = (new ClassMetadataFactoryCompiler())->compile([ $dummyMetadata, $maxDepthDummyMetadata, $serializedNameDummyMetadata, + $serializedPathDummyMetadata, + $serializedPathInConstructorDummyMetadata, ]); file_put_contents($this->dumpPath, $code); $compiledMetadata = require $this->dumpPath; - $this->assertCount(3, $compiledMetadata); + $this->assertCount(5, $compiledMetadata); $this->assertArrayHasKey(Dummy::class, $compiledMetadata); $this->assertEquals([ [ - 'foo' => [[], null, null], - 'bar' => [[], null, null], - 'baz' => [[], null, null], - 'qux' => [[], null, null], + 'foo' => [[], null, null, null], + 'bar' => [[], null, null, null], + 'baz' => [[], null, null, null], + 'qux' => [[], null, null, null], ], null, ], $compiledMetadata[Dummy::class]); @@ -70,9 +72,9 @@ public function testItDumpMetadata() $this->assertArrayHasKey(MaxDepthDummy::class, $compiledMetadata); $this->assertEquals([ [ - 'foo' => [[], 2, null], - 'bar' => [[], 3, null], - 'child' => [[], null, null], + 'foo' => [[], 2, null, null], + 'bar' => [[], 3, null, null], + 'child' => [[], null, null, null], ], null, ], $compiledMetadata[MaxDepthDummy::class]); @@ -80,12 +82,29 @@ public function testItDumpMetadata() $this->assertArrayHasKey(SerializedNameDummy::class, $compiledMetadata); $this->assertEquals([ [ - 'foo' => [[], null, 'baz'], - 'bar' => [[], null, 'qux'], - 'quux' => [[], null, null], - 'child' => [[], null, null], + 'foo' => [[], null, 'baz', null], + 'bar' => [[], null, 'qux', null], + 'quux' => [[], null, null, null], + 'child' => [[], null, null, null], ], null, ], $compiledMetadata[SerializedNameDummy::class]); + + $this->assertArrayHasKey(SerializedPathDummy::class, $compiledMetadata); + $this->assertEquals([ + [ + 'three' => [[], null, null, '[one][two]'], + 'seven' => [[], null, null, '[three][four]'], + ], + null, + ], $compiledMetadata[SerializedPathDummy::class]); + + $this->assertArrayHasKey(SerializedPathInConstructorDummy::class, $compiledMetadata); + $this->assertEquals([ + [ + 'three' => [[], null, null, '[one][two]'], + ], + null, + ], $compiledMetadata[SerializedPathInConstructorDummy::class]); } } diff --git a/Tests/Mapping/Factory/ClassMetadataFactoryTest.php b/Tests/Mapping/Factory/ClassMetadataFactoryTest.php index bef034a8f..140623ab0 100644 --- a/Tests/Mapping/Factory/ClassMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/ClassMetadataFactoryTest.php @@ -11,12 +11,14 @@ namespace Symfony\Component\Serializer\Tests\Mapping\Factory; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Mapping\Loader\LoaderChain; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummyInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummyParent; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; /** @@ -32,18 +34,18 @@ public function testInterface() public function testGetMetadataFor() { - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); - $classMetadata = $factory->getMetadataFor('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy'); + $factory = new ClassMetadataFactory(new AttributeLoader()); + $classMetadata = $factory->getMetadataFor(GroupDummy::class); - $this->assertEquals(TestClassMetadataFactory::createClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations', true, true), $classMetadata); + $this->assertEquals(TestClassMetadataFactory::createClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Attributes', true, true), $classMetadata); } public function testHasMetadataFor() { - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); - $this->assertTrue($factory->hasMetadataFor('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy')); - $this->assertTrue($factory->hasMetadataFor('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummyParent')); - $this->assertTrue($factory->hasMetadataFor('Symfony\Component\Serializer\Tests\Fixtures\GroupDummyInterface')); + $factory = new ClassMetadataFactory(new AttributeLoader()); + $this->assertTrue($factory->hasMetadataFor(GroupDummy::class)); + $this->assertTrue($factory->hasMetadataFor(GroupDummyParent::class)); + $this->assertTrue($factory->hasMetadataFor(GroupDummyInterface::class)); $this->assertFalse($factory->hasMetadataFor('Dunglas\Entity')); } } diff --git a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php index d82431a8a..ff54fb96b 100644 --- a/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php +++ b/Tests/Mapping/Factory/CompiledClassMetadataFactoryTest.php @@ -16,7 +16,7 @@ use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\Mapping\Factory\CompiledClassMetadataFactory; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; /** @@ -34,19 +34,21 @@ public function testItImplementsClassMetadataFactoryInterface() public function testItThrowAnExceptionWhenCacheFileIsNotFound() { + $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); + $this->expectException(\RuntimeException::class); $this->expectExceptionMessageMatches('#File ".*/Fixtures/not-found-serializer.class.metadata.php" could not be found.#'); - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/not-found-serializer.class.metadata.php', $classMetadataFactory); } public function testItThrowAnExceptionWhenMetadataIsNotOfTypeArray() { + $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); + $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Compiled metadata must be of the type array, object given.'); - $classMetadataFactory = $this->createMock(ClassMetadataFactoryInterface::class); new CompiledClassMetadataFactory(__DIR__.'/../../Fixtures/object-metadata.php', $classMetadataFactory); } diff --git a/Tests/Mapping/Loader/AnnotationLoaderTestCase.php b/Tests/Mapping/Loader/AnnotationLoaderTestCase.php deleted file mode 100644 index b60981f7a..000000000 --- a/Tests/Mapping/Loader/AnnotationLoaderTestCase.php +++ /dev/null @@ -1,180 +0,0 @@ - - * - * 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; - -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; - -/** - * @author Kévin Dunglas - */ -abstract class AnnotationLoaderTestCase extends TestCase -{ - use ContextMappingTestTrait; - - /** - * @var AnnotationLoader - */ - private $loader; - - protected function setUp(): void - { - $this->loader = $this->createLoader(); - } - - public function testInterface() - { - $this->assertInstanceOf(LoaderInterface::class, $this->loader); - } - - public function testLoadClassMetadataReturnsTrueIfSuccessful() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\GroupDummy'); - - $this->assertTrue($this->loader->loadClassMetadata($classMetadata)); - } - - public function testLoadGroups() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\GroupDummy'); - $this->loader->loadClassMetadata($classMetadata); - - $this->assertEquals(TestClassMetadataFactory::createClassMetadata($this->getNamespace()), $classMetadata); - } - - public function testLoadDiscriminatorMap() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\AbstractDummy'); - $this->loader->loadClassMetadata($classMetadata); - - $expected = new ClassMetadata($this->getNamespace().'\AbstractDummy', new ClassDiscriminatorMapping('type', [ - 'first' => $this->getNamespace().'\AbstractDummyFirstChild', - 'second' => $this->getNamespace().'\AbstractDummySecondChild', - 'third' => $this->getNamespace().'\AbstractDummyThirdChild', - ])); - - $expected->addAttributeMetadata(new AttributeMetadata('foo')); - $expected->getReflectionClass(); - - $this->assertEquals($expected, $classMetadata); - } - - public function testLoadMaxDepth() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\MaxDepthDummy'); - $this->loader->loadClassMetadata($classMetadata); - - $attributesMetadata = $classMetadata->getAttributesMetadata(); - $this->assertEquals(2, $attributesMetadata['foo']->getMaxDepth()); - $this->assertEquals(3, $attributesMetadata['bar']->getMaxDepth()); - } - - public function testLoadSerializedName() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\SerializedNameDummy'); - $this->loader->loadClassMetadata($classMetadata); - - $attributesMetadata = $classMetadata->getAttributesMetadata(); - $this->assertEquals('baz', $attributesMetadata['foo']->getSerializedName()); - $this->assertEquals('qux', $attributesMetadata['bar']->getSerializedName()); - } - - public function testLoadClassMetadataAndMerge() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\GroupDummy'); - $parentClassMetadata = new ClassMetadata($this->getNamespace().'\GroupDummyParent'); - - $this->loader->loadClassMetadata($parentClassMetadata); - $classMetadata->merge($parentClassMetadata); - - $this->loader->loadClassMetadata($classMetadata); - - $this->assertEquals(TestClassMetadataFactory::createClassMetadata($this->getNamespace(), true), $classMetadata); - } - - public function testLoadIgnore() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\IgnoreDummy'); - $this->loader->loadClassMetadata($classMetadata); - - $attributesMetadata = $classMetadata->getAttributesMetadata(); - $this->assertTrue($attributesMetadata['ignored1']->isIgnored()); - $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); - } - - public function testCanHandleUnrelatedIgnoredMethods() - { - $class = $this->getNamespace().'\Entity45016'; - - $metadata = new ClassMetadata($class); - $loader = $this->getLoaderForContextMapping(); - - $loader->loadClassMetadata($metadata); - - $this->assertSame(['id'], array_keys($metadata->getAttributesMetadata())); - } - - public function testIgnoreGetterWirhRequiredParameterIfIgnoreAnnotationIsUsed() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\IgnoreDummyAdditionalGetter'); - $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); - - $attributes = $classMetadata->getAttributesMetadata(); - self::assertArrayNotHasKey('extraValue', $attributes); - self::assertArrayHasKey('extraValue2', $attributes); - } - - public function testIgnoreGetterWirhRequiredParameterIfIgnoreAnnotationIsNotUsed() - { - $classMetadata = new ClassMetadata($this->getNamespace().'\IgnoreDummyAdditionalGetterWithoutIgnoreAnnotations'); - $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); - - $attributes = $classMetadata->getAttributesMetadata(); - self::assertArrayNotHasKey('extraValue', $attributes); - self::assertArrayHasKey('extraValue2', $attributes); - } - - abstract protected function createLoader(): AnnotationLoader; - - abstract protected function getNamespace(): string; - - protected function getLoaderForContextMapping(): LoaderInterface - { - return $this->loader; - } -} diff --git a/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php b/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php deleted file mode 100644 index cb1b9d649..000000000 --- a/Tests/Mapping/Loader/AnnotationLoaderWithAttributesTest.php +++ /dev/null @@ -1,30 +0,0 @@ - - * - * 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; - -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; - -/** - * @requires PHP 8 - */ -class AnnotationLoaderWithAttributesTest extends AnnotationLoaderTestCase -{ - protected function createLoader(): AnnotationLoader - { - return new AnnotationLoader(); - } - - protected function getNamespace(): string - { - return 'Symfony\Component\Serializer\Tests\Fixtures\Attributes'; - } -} diff --git a/Tests/Mapping/Loader/AnnotationLoaderWithDoctrineAnnotationsTest.php b/Tests/Mapping/Loader/AnnotationLoaderWithDoctrineAnnotationsTest.php deleted file mode 100644 index cfb1547c5..000000000 --- a/Tests/Mapping/Loader/AnnotationLoaderWithDoctrineAnnotationsTest.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * 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; - -use Doctrine\Common\Annotations\AnnotationReader; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; - -class AnnotationLoaderWithDoctrineAnnotationsTest extends AnnotationLoaderTestCase -{ - protected function createLoader(): AnnotationLoader - { - return new AnnotationLoader(new AnnotationReader()); - } - - protected function getNamespace(): string - { - return 'Symfony\Component\Serializer\Tests\Fixtures\Annotations'; - } -} diff --git a/Tests/Mapping/Loader/AttributeLoaderTest.php b/Tests/Mapping/Loader/AttributeLoaderTest.php new file mode 100644 index 000000000..2af244a6f --- /dev/null +++ b/Tests/Mapping/Loader/AttributeLoaderTest.php @@ -0,0 +1,253 @@ + + * + * 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; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyPath; +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\AttributeLoader; +use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyThirdChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AccessorishGetters; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\BadAttributeDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\BadMethodContextDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummyParent; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummyPromotedProperties; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\Entity45016; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupClassDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummyParent; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummyAdditionalGetter; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummyAdditionalGetterWithoutIgnoreAttributes; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathInConstructorDummy; +use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; +use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; + +/** + * @author Kévin Dunglas + */ +class AttributeLoaderTest extends TestCase +{ + use ContextMappingTestTrait; + + protected AttributeLoader $loader; + + protected function setUp(): void + { + $this->loader = new AttributeLoader(); + } + + public function testInterface() + { + $this->assertInstanceOf(LoaderInterface::class, $this->loader); + } + + public function testLoadClassMetadataReturnsTrueIfSuccessful() + { + $classMetadata = new ClassMetadata(GroupDummy::class); + + $this->assertTrue($this->loader->loadClassMetadata($classMetadata)); + } + + public function testLoadGroups() + { + $classMetadata = new ClassMetadata(GroupDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $this->assertEquals(TestClassMetadataFactory::createClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Attributes'), $classMetadata); + } + + public function testLoadDiscriminatorMap() + { + $classMetadata = new ClassMetadata(AbstractDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $expected = new ClassMetadata(AbstractDummy::class, new ClassDiscriminatorMapping('type', [ + 'first' => AbstractDummyFirstChild::class, + 'second' => AbstractDummySecondChild::class, + 'third' => AbstractDummyThirdChild::class, + ])); + + $expected->addAttributeMetadata(new AttributeMetadata('foo')); + $expected->getReflectionClass(); + + $this->assertEquals($expected, $classMetadata); + } + + public function testLoadMaxDepth() + { + $classMetadata = new ClassMetadata(MaxDepthDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(2, $attributesMetadata['foo']->getMaxDepth()); + $this->assertEquals(3, $attributesMetadata['bar']->getMaxDepth()); + } + + public function testLoadSerializedName() + { + $classMetadata = new ClassMetadata(SerializedNameDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals('baz', $attributesMetadata['foo']->getSerializedName()); + $this->assertEquals('qux', $attributesMetadata['bar']->getSerializedName()); + } + + public function testLoadSerializedPath() + { + $classMetadata = new ClassMetadata(SerializedPathDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + $this->assertEquals(new PropertyPath('[three][four]'), $attributesMetadata['seven']->getSerializedPath()); + } + + public function testLoadSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata(SerializedPathInConstructorDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + } + + public function testLoadClassMetadataAndMerge() + { + $classMetadata = new ClassMetadata(GroupDummy::class); + $parentClassMetadata = new ClassMetadata(GroupDummyParent::class); + + $this->loader->loadClassMetadata($parentClassMetadata); + $classMetadata->merge($parentClassMetadata); + + $this->loader->loadClassMetadata($classMetadata); + + $this->assertEquals(TestClassMetadataFactory::createClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Attributes', true), $classMetadata); + } + + public function testLoadIgnore() + { + $classMetadata = new ClassMetadata(IgnoreDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertTrue($attributesMetadata['ignored1']->isIgnored()); + $this->assertTrue($attributesMetadata['ignored2']->isIgnored()); + } + + public function testLoadContextsPropertiesPromoted() + { + $this->assertLoadedContexts(ContextDummyPromotedProperties::class, ContextDummyParent::class); + } + + public function testThrowsOnContextOnInvalidMethod() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage(\sprintf('Context on "%s::badMethod()" cannot be added', BadMethodContextDummy::class)); + + $loader = $this->getLoaderForContextMapping(); + + $classMetadata = new ClassMetadata(BadMethodContextDummy::class); + + $loader->loadClassMetadata($classMetadata); + } + + public function testCanHandleUnrelatedIgnoredMethods() + { + $metadata = new ClassMetadata(Entity45016::class); + $loader = $this->getLoaderForContextMapping(); + + $loader->loadClassMetadata($metadata); + + $this->assertSame(['id'], array_keys($metadata->getAttributesMetadata())); + } + + public function testIgnoreGetterWithRequiredParameterIfIgnoreAttributeIsUsed() + { + $classMetadata = new ClassMetadata(IgnoreDummyAdditionalGetter::class); + $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); + + $attributes = $classMetadata->getAttributesMetadata(); + self::assertArrayNotHasKey('extraValue', $attributes); + self::assertArrayHasKey('extraValue2', $attributes); + } + + public function testIgnoreGetterWithRequiredParameterIfIgnoreAttributeIsNotUsed() + { + $classMetadata = new ClassMetadata(IgnoreDummyAdditionalGetterWithoutIgnoreAttributes::class); + $this->getLoaderForContextMapping()->loadClassMetadata($classMetadata); + + $attributes = $classMetadata->getAttributesMetadata(); + self::assertArrayNotHasKey('extraValue', $attributes); + self::assertArrayHasKey('extraValue2', $attributes); + } + + public function testLoadGroupsOnClass() + { + $classMetadata = new ClassMetadata(GroupClassDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + self::assertCount(3, $classMetadata->getAttributesMetadata()); + + self::assertArrayHasKey('foo', $attributesMetadata); + self::assertArrayHasKey('bar', $attributesMetadata); + self::assertArrayHasKey('baz', $attributesMetadata); + + self::assertSame(['a', 'b'], $attributesMetadata['foo']->getGroups()); + self::assertSame(['a', 'c', 'd'], $attributesMetadata['bar']->getGroups()); + self::assertSame(['a'], $attributesMetadata['baz']->getGroups()); + } + + public function testLoadWithInvalidAttribute() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Could not instantiate attribute "Symfony\Component\Serializer\Attribute\Groups" on "Symfony\Component\Serializer\Tests\Fixtures\Attributes\BadAttributeDummy::myMethod()".'); + + $classMetadata = new ClassMetadata(BadAttributeDummy::class); + + $this->loader->loadClassMetadata($classMetadata); + } + + public function testIgnoresAccessorishGetters() + { + $classMetadata = new ClassMetadata(AccessorishGetters::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + + self::assertCount(4, $classMetadata->getAttributesMetadata()); + + self::assertArrayHasKey('field1', $attributesMetadata); + self::assertArrayHasKey('field2', $attributesMetadata); + self::assertArrayHasKey('field3', $attributesMetadata); + self::assertArrayHasKey('field4', $attributesMetadata); + self::assertArrayNotHasKey('h', $attributesMetadata); + } + + protected function getLoaderForContextMapping(): AttributeLoader + { + return $this->loader; + } +} diff --git a/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php b/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php index 97c70c149..c33d8a904 100644 --- a/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php +++ b/Tests/Mapping/Loader/Features/ContextMappingTestTrait.php @@ -14,8 +14,8 @@ 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; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ContextDummyParent; /** * @author Maxime Steinhausser @@ -29,7 +29,7 @@ public function testLoadContexts() $this->assertLoadedContexts(); } - public function assertLoadedContexts(string $dummyClass = ContextDummy::class, string $parentClass = ContextDummyParent::class) + public function assertLoadedContexts(string $dummyClass = ContextDummy::class, string $parentClass = ContextDummyParent::class): void { $loader = $this->getLoaderForContextMapping(); diff --git a/Tests/Mapping/Loader/XmlFileLoaderTest.php b/Tests/Mapping/Loader/XmlFileLoaderTest.php index 47d6305a8..c0298129e 100644 --- a/Tests/Mapping/Loader/XmlFileLoaderTest.php +++ b/Tests/Mapping/Loader/XmlFileLoaderTest.php @@ -17,10 +17,15 @@ use Symfony\Component\Serializer\Mapping\ClassMetadata; use Symfony\Component\Serializer\Mapping\Loader\LoaderInterface; use Symfony\Component\Serializer\Mapping\Loader\XmlFileLoader; -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\Fixtures\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathInConstructorDummy; use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; @@ -31,20 +36,13 @@ class XmlFileLoaderTest extends TestCase { use ContextMappingTestTrait; - /** - * @var XmlFileLoader - */ - private $loader; - - /** - * @var ClassMetadata - */ - private $metadata; + private XmlFileLoader $loader; + private ClassMetadata $metadata; protected function setUp(): void { $this->loader = new XmlFileLoader(__DIR__.'/../../Fixtures/serialization.xml'); - $this->metadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy'); + $this->metadata = new ClassMetadata(GroupDummy::class); } public function testInterface() @@ -66,7 +64,7 @@ public function testLoadClassMetadata() public function testMaxDepth() { - $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy'); + $classMetadata = new ClassMetadata(MaxDepthDummy::class); $this->loader->loadClassMetadata($classMetadata); $attributesMetadata = $classMetadata->getAttributesMetadata(); @@ -76,7 +74,7 @@ public function testMaxDepth() public function testSerializedName() { - $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy'); + $classMetadata = new ClassMetadata(SerializedNameDummy::class); $this->loader->loadClassMetadata($classMetadata); $attributesMetadata = $classMetadata->getAttributesMetadata(); @@ -84,6 +82,25 @@ public function testSerializedName() $this->assertEquals('qux', $attributesMetadata['bar']->getSerializedName()); } + public function testSerializedPath() + { + $classMetadata = new ClassMetadata(SerializedPathDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals('[one][two]', $attributesMetadata['three']->getSerializedPath()); + $this->assertEquals('[three][four]', $attributesMetadata['seven']->getSerializedPath()); + } + + public function testSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata(SerializedPathInConstructorDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals('[one][two]', $attributesMetadata['three']->getSerializedPath()); + } + public function testLoadDiscriminatorMap() { $classMetadata = new ClassMetadata(AbstractDummy::class); diff --git a/Tests/Mapping/Loader/YamlFileLoaderTest.php b/Tests/Mapping/Loader/YamlFileLoaderTest.php index aa235762b..48e95aecd 100644 --- a/Tests/Mapping/Loader/YamlFileLoaderTest.php +++ b/Tests/Mapping/Loader/YamlFileLoaderTest.php @@ -12,16 +12,22 @@ namespace Symfony\Component\Serializer\Tests\Mapping\Loader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\PropertyPath; 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\LoaderInterface; use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; -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\Fixtures\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\IgnoreDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedPathInConstructorDummy; use Symfony\Component\Serializer\Tests\Mapping\Loader\Features\ContextMappingTestTrait; use Symfony\Component\Serializer\Tests\Mapping\TestClassMetadataFactory; @@ -32,19 +38,13 @@ class YamlFileLoaderTest extends TestCase { use ContextMappingTestTrait; - /** - * @var YamlFileLoader - */ - private $loader; - /** - * @var ClassMetadata - */ - private $metadata; + private YamlFileLoader $loader; + private ClassMetadata $metadata; protected function setUp(): void { $this->loader = new YamlFileLoader(__DIR__.'/../../Fixtures/serialization.yml'); - $this->metadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy'); + $this->metadata = new ClassMetadata(GroupDummy::class); } public function testInterface() @@ -65,8 +65,10 @@ public function testLoadClassMetadataReturnsFalseWhenEmpty() public function testLoadClassMetadataReturnsThrowsInvalidMapping() { - $this->expectException(MappingException::class); $loader = new YamlFileLoader(__DIR__.'/../../Fixtures/invalid-mapping.yml'); + + $this->expectException(MappingException::class); + $loader->loadClassMetadata($this->metadata); } @@ -79,7 +81,7 @@ public function testLoadClassMetadata() public function testMaxDepth() { - $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy'); + $classMetadata = new ClassMetadata(MaxDepthDummy::class); $this->loader->loadClassMetadata($classMetadata); $attributesMetadata = $classMetadata->getAttributesMetadata(); @@ -89,7 +91,7 @@ public function testMaxDepth() public function testSerializedName() { - $classMetadata = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy'); + $classMetadata = new ClassMetadata(SerializedNameDummy::class); $this->loader->loadClassMetadata($classMetadata); $attributesMetadata = $classMetadata->getAttributesMetadata(); @@ -97,6 +99,25 @@ public function testSerializedName() $this->assertEquals('qux', $attributesMetadata['bar']->getSerializedName()); } + public function testSerializedPath() + { + $classMetadata = new ClassMetadata(SerializedPathDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + $this->assertEquals(new PropertyPath('[three][four]'), $attributesMetadata['seven']->getSerializedPath()); + } + + public function testSerializedPathInConstructor() + { + $classMetadata = new ClassMetadata(SerializedPathInConstructorDummy::class); + $this->loader->loadClassMetadata($classMetadata); + + $attributesMetadata = $classMetadata->getAttributesMetadata(); + $this->assertEquals(new PropertyPath('[one][two]'), $attributesMetadata['three']->getSerializedPath()); + } + public function testLoadDiscriminatorMap() { $classMetadata = new ClassMetadata(AbstractDummy::class); diff --git a/Tests/Mapping/TestClassMetadataFactory.php b/Tests/Mapping/TestClassMetadataFactory.php index a33ded3d8..61147316a 100644 --- a/Tests/Mapping/TestClassMetadataFactory.php +++ b/Tests/Mapping/TestClassMetadataFactory.php @@ -13,6 +13,7 @@ use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; /** * @author Kévin Dunglas @@ -70,7 +71,7 @@ public static function createClassMetadata(string $namespace, bool $withParent = public static function createXmlCLassMetadata(): ClassMetadata { - $expected = new ClassMetadata('Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy'); + $expected = new ClassMetadata(GroupDummy::class); $foo = new AttributeMetadata('foo'); $foo->addGroup('group1'); diff --git a/Tests/NameConverter/CamelCaseToSnakeCaseNameConverterTest.php b/Tests/NameConverter/CamelCaseToSnakeCaseNameConverterTest.php index e4d419e45..d1edc2325 100644 --- a/Tests/NameConverter/CamelCaseToSnakeCaseNameConverterTest.php +++ b/Tests/NameConverter/CamelCaseToSnakeCaseNameConverterTest.php @@ -12,11 +12,13 @@ namespace Symfony\Component\Serializer\Tests\NameConverter; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\UnexpectedPropertyException; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * @author Kévin Dunglas + * @author Aurélien Pillevesse */ class CamelCaseToSnakeCaseNameConverterTest extends TestCase { @@ -55,4 +57,20 @@ public static function attributeProvider() ['this_is_a_test', 'ThisIsATest', false], ]; } + + public function testDenormalizeWithContext() + { + $nameConverter = new CamelCaseToSnakeCaseNameConverter(null, true); + $denormalizedValue = $nameConverter->denormalize('last_name', null, null, [CamelCaseToSnakeCaseNameConverter::REQUIRE_SNAKE_CASE_PROPERTIES => true]); + + $this->assertSame('lastName', $denormalizedValue); + } + + public function testErrorDenormalizeWithContext() + { + $nameConverter = new CamelCaseToSnakeCaseNameConverter(null, true); + + $this->expectException(UnexpectedPropertyException::class); + $nameConverter->denormalize('lastName', null, null, [CamelCaseToSnakeCaseNameConverter::REQUIRE_SNAKE_CASE_PROPERTIES => true]); + } } diff --git a/Tests/NameConverter/MetadataAwareNameConverterTest.php b/Tests/NameConverter/MetadataAwareNameConverterTest.php index e0c2d4faf..c6ccd2601 100644 --- a/Tests/NameConverter/MetadataAwareNameConverterTest.php +++ b/Tests/NameConverter/MetadataAwareNameConverterTest.php @@ -11,14 +11,16 @@ namespace Symfony\Component\Serializer\Tests\NameConverter; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; +use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\SerializedNameDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\SerializedNameDummy; use Symfony\Component\Serializer\Tests\Fixtures\OtherSerializedNameDummy; /** @@ -36,9 +38,9 @@ public function testInterface() /** * @dataProvider attributeProvider */ - public function testNormalize($propertyName, $expected) + public function testNormalize(string|int $propertyName, string|int $expected) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); @@ -48,16 +50,14 @@ public function testNormalize($propertyName, $expected) /** * @dataProvider fallbackAttributeProvider */ - public function testNormalizeWithFallback($propertyName, $expected) + public function testNormalizeWithFallback(string|int $propertyName, string|int $expected) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $fallback = $this->createMock(NameConverterInterface::class); $fallback ->method('normalize') - ->willReturnCallback(function ($propertyName) { - return strtoupper($propertyName); - }) + ->willReturnCallback(static fn ($propertyName) => strtoupper($propertyName)) ; $nameConverter = new MetadataAwareNameConverter($classMetadataFactory, $fallback); @@ -68,9 +68,9 @@ public function testNormalizeWithFallback($propertyName, $expected) /** * @dataProvider attributeProvider */ - public function testDenormalize($expected, $propertyName) + public function testDenormalize(string|int $expected, string|int $propertyName) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); @@ -80,16 +80,14 @@ public function testDenormalize($expected, $propertyName) /** * @dataProvider fallbackAttributeProvider */ - public function testDenormalizeWithFallback($expected, $propertyName) + public function testDenormalizeWithFallback(string|int $expected, string|int $propertyName) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $fallback = $this->createMock(NameConverterInterface::class); $fallback ->method('denormalize') - ->willReturnCallback(function ($propertyName) { - return strtolower($propertyName); - }) + ->willReturnCallback(static fn ($propertyName) => strtolower($propertyName)) ; $nameConverter = new MetadataAwareNameConverter($classMetadataFactory, $fallback); @@ -120,9 +118,9 @@ public static function fallbackAttributeProvider(): array /** * @dataProvider attributeAndContextProvider */ - public function testNormalizeWithGroups($propertyName, $expected, $context = []) + public function testNormalizeWithGroups(string $propertyName, string $expected, array $context = []) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); @@ -132,16 +130,16 @@ public function testNormalizeWithGroups($propertyName, $expected, $context = []) /** * @dataProvider attributeAndContextProvider */ - public function testDenormalizeWithGroups($expected, $propertyName, $context = []) + public function testDenormalizeWithGroups(string $expected, string $propertyName, array $context = []) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); $this->assertEquals($expected, $nameConverter->denormalize($propertyName, OtherSerializedNameDummy::class, null, $context)); } - public static function attributeAndContextProvider() + public static function attributeAndContextProvider(): array { return [ ['buz', 'buz', ['groups' => ['a']]], @@ -150,12 +148,13 @@ public static function attributeAndContextProvider() ['buzForExport', 'buz', ['groups' => 'b']], ['buz', 'buz', ['groups' => ['c']]], ['buz', 'buz', []], + ['buzForExport', 'buz', ['groups' => ['*']]], ]; } public function testDenormalizeWithCacheContext() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); @@ -163,4 +162,28 @@ public function testDenormalizeWithCacheContext() $this->assertEquals('buzForExport', $nameConverter->denormalize('buz', OtherSerializedNameDummy::class, null, ['groups' => ['b']])); $this->assertEquals('buz', $nameConverter->denormalize('buz', OtherSerializedNameDummy::class)); } + + public function testDenormalizeWithNestedPathAndName() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Found SerializedName and SerializedPath attributes on property "foo" of class "Symfony\Component\Serializer\Tests\NameConverter\NestedPathAndName".'); + $nameConverter->denormalize('foo', NestedPathAndName::class); + } + + public function testNormalizeWithNestedPathAndName() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $nameConverter = new MetadataAwareNameConverter($classMetadataFactory); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Found SerializedName and SerializedPath attributes on property "foo" of class "Symfony\Component\Serializer\Tests\NameConverter\NestedPathAndName".'); + $nameConverter->normalize('foo', NestedPathAndName::class); + } +} + +class NestedPathAndName +{ + #[SerializedName('five'), SerializedPath('[one][two][three]')] + public $foo; } diff --git a/Tests/NameConverter/SnakeCaseToCamelCaseNameConverterTest.php b/Tests/NameConverter/SnakeCaseToCamelCaseNameConverterTest.php new file mode 100644 index 000000000..2d2799e2c --- /dev/null +++ b/Tests/NameConverter/SnakeCaseToCamelCaseNameConverterTest.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\Tests\NameConverter; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\UnexpectedPropertyException; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter; + +/** + * @author Kévin Dunglas + * @author Aurélien Pillevesse + */ +class SnakeCaseToCamelCaseNameConverterTest extends TestCase +{ + public function testInterface() + { + $attributeMetadata = new SnakeCaseToCamelCaseNameConverter(); + $this->assertInstanceOf(NameConverterInterface::class, $attributeMetadata); + } + + /** + * @dataProvider Symfony\Component\Serializer\Tests\NameConverter\CamelCaseToSnakeCaseNameConverterTest::attributeProvider + */ + public function testNormalize($underscored, $camelCased, $useLowerCamelCase) + { + $nameConverter = new SnakeCaseToCamelCaseNameConverter(null, $useLowerCamelCase); + $this->assertEquals($camelCased, $nameConverter->normalize($underscored)); + } + + /** + * @dataProvider Symfony\Component\Serializer\Tests\NameConverter\CamelCaseToSnakeCaseNameConverterTest::attributeProvider + */ + public function testDenormalize($underscored, $camelCased, $useLowerCamelCase) + { + $nameConverter = new SnakeCaseToCamelCaseNameConverter(null, $useLowerCamelCase); + $this->assertEquals($underscored, $nameConverter->denormalize($camelCased)); + } + + public function testDenormalizeWithContext() + { + $nameConverter = new SnakeCaseToCamelCaseNameConverter(null, true); + $denormalizedValue = $nameConverter->denormalize('lastName', null, null, [SnakeCaseToCamelCaseNameConverter::REQUIRE_CAMEL_CASE_PROPERTIES => true]); + + $this->assertSame('last_name', $denormalizedValue); + } + + public function testErrorDenormalizeWithContext() + { + $nameConverter = new SnakeCaseToCamelCaseNameConverter(null, true); + + $this->expectException(UnexpectedPropertyException::class); + $nameConverter->denormalize('last_name', null, null, [SnakeCaseToCamelCaseNameConverter::REQUIRE_CAMEL_CASE_PROPERTIES => true]); + } +} diff --git a/Tests/Normalizer/AbstractNormalizerTest.php b/Tests/Normalizer/AbstractNormalizerTest.php index ae627d96a..74ca3d6d6 100644 --- a/Tests/Normalizer/AbstractNormalizerTest.php +++ b/Tests/Normalizer/AbstractNormalizerTest.php @@ -15,6 +15,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadata; use Symfony\Component\Serializer\Mapping\ClassMetadata; @@ -27,7 +28,7 @@ use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; 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\Attributes\IgnoreDummy; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithWithVariadicParameterConstructor; use Symfony\Component\Serializer\Tests\Fixtures\NullableConstructorArgumentDummy; @@ -44,15 +45,8 @@ */ class AbstractNormalizerTest extends TestCase { - /** - * @var AbstractNormalizerDummy - */ - private $normalizer; - - /** - * @var MockObject&ClassMetadataFactoryInterface - */ - private $classMetadata; + private AbstractNormalizerDummy $normalizer; + private MockObject&ClassMetadataFactoryInterface $classMetadata; protected function setUp(): void { @@ -170,6 +164,15 @@ public function testObjectWithNullableNonOptionalConstructorArgumentWithoutInput $this->assertNull($dummy->getFoo()); } + public function testObjectWithNullableNonOptionalConstructorArgumentWithoutInputAndRequireAllProperties() + { + $normalizer = new ObjectNormalizer(); + + $this->expectException(MissingConstructorArgumentsException::class); + + $normalizer->denormalize([], NullableConstructorArgumentDummy::class, null, [AbstractNormalizer::REQUIRE_ALL_PROPERTIES => true]); + } + /** * @dataProvider getNormalizer * @dataProvider getNormalizerWithCustomNameConverter @@ -207,8 +210,6 @@ public function testObjectWithVariadicConstructorTypedArguments(AbstractNormaliz } /** - * @requires PHP 8 - * * @dataProvider getNormalizer */ public function testVariadicSerializationWithPreservingKeys(AbstractNormalizer $normalizer) @@ -270,13 +271,13 @@ public function testVariadicConstructorDenormalization() public static function getNormalizerWithCustomNameConverter() { $extractor = new PhpDocExtractor(); - $nameConverter = new class() implements NameConverterInterface { - public function normalize(string $propertyName): string + $nameConverter = new class implements NameConverterInterface { + public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { return ucfirst($propertyName); } - public function denormalize(string $propertyName): string + public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { return lcfirst($propertyName); } @@ -302,9 +303,6 @@ public function testIgnore() $this->assertSame([], $normalizer->normalize($dummy)); } - /** - * @requires PHP 8.1.2 - */ public function testDenormalizeWhenObjectNotInstantiable() { $this->expectException(NotNormalizableValueException::class); diff --git a/Tests/Normalizer/AbstractObjectNormalizerTest.php b/Tests/Normalizer/AbstractObjectNormalizerTest.php index e413be0c1..270b65f33 100644 --- a/Tests/Normalizer/AbstractObjectNormalizerTest.php +++ b/Tests/Normalizer/AbstractObjectNormalizerTest.php @@ -11,13 +11,17 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; +use Symfony\Component\PropertyInfo\PropertyDocBlockExtractorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type; -use Symfony\Component\Serializer\Annotation\SerializedName; +use Symfony\Component\PropertyInfo\Type as LegacyType; +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Attribute\SerializedPath; use Symfony\Component\Serializer\Exception\ExtraAttributesException; use Symfony\Component\Serializer\Exception\InvalidArgumentException; use Symfony\Component\Serializer\Exception\LogicException; @@ -30,19 +34,23 @@ use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer; use Symfony\Component\Serializer\Normalizer\CustomNormalizer; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerAwareInterface; use Symfony\Component\Serializer\SerializerInterface; -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\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; use Symfony\Component\Serializer\Tests\Fixtures\DummyFirstChildQuux; use Symfony\Component\Serializer\Tests\Fixtures\DummySecondChildQuux; use Symfony\Component\Serializer\Tests\Fixtures\DummyString; @@ -50,6 +58,8 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyWithObjectOrBool; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithObjectOrNull; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithStringObject; +use Symfony\Component\Serializer\Tests\Normalizer\Features\ObjectDummyWithContextAttribute; +use Symfony\Component\TypeInfo\Type; class AbstractObjectNormalizerTest extends TestCase { @@ -76,10 +86,12 @@ public function testInstantiateObjectDenormalizer() public function testDenormalizeWithExtraAttribute() { + $factory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new AbstractObjectNormalizerDummy($factory); + $this->expectException(ExtraAttributesException::class); $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo" is unknown).'); - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); - $normalizer = new AbstractObjectNormalizerDummy($factory); + $normalizer->denormalize( ['fooFoo' => 'foo'], Dummy::class, @@ -90,10 +102,12 @@ public function testDenormalizeWithExtraAttribute() public function testDenormalizeWithExtraAttributes() { + $factory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new AbstractObjectNormalizerDummy($factory); + $this->expectException(ExtraAttributesException::class); $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo", "fooBar" are unknown).'); - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); - $normalizer = new AbstractObjectNormalizerDummy($factory); + $normalizer->denormalize( ['fooFoo' => 'foo', 'fooBar' => 'bar'], Dummy::class, @@ -104,9 +118,11 @@ public function testDenormalizeWithExtraAttributes() public function testDenormalizeWithExtraAttributesAndNoGroupsWithMetadataFactory() { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $this->expectException(ExtraAttributesException::class); $this->expectExceptionMessage('Extra attributes are not allowed ("fooFoo", "fooBar" are unknown).'); - $normalizer = new AbstractObjectNormalizerWithMetadata(); + $normalizer->denormalize( ['fooFoo' => 'foo', 'fooBar' => 'bar', 'bar' => 'bar'], Dummy::class, @@ -126,6 +142,256 @@ public function testDenormalizePlainObject() $this->assertSame('bar', $dummy->plainObject->foo); } + public function testDenormalizeWithDuplicateNestedAttributes() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Duplicate serialized path: "one,two,three" used for properties "foo" and "bar".'); + + $normalizer->denormalize([], DuplicateValueNestedDummy::class, 'any'); + } + + public function testDenormalizeWithNestedAttributesWithoutMetadata() + { + $normalizer = new AbstractObjectNormalizerDummy(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + $test = $normalizer->denormalize($data, NestedDummy::class, 'any'); + $this->assertSame('notfoo', $test->foo); + $this->assertSame('baz', $test->baz); + $this->assertNull($test->notfoo); + } + + public function testDenormalizeWithSnakeCaseNestedAttributes() + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($factory, new CamelCaseToSnakeCaseNameConverter()); + $data = [ + 'one' => [ + 'two_three' => 'fooBar', + ], + ]; + $test = $normalizer->denormalize($data, SnakeCaseNestedDummy::class, 'any'); + $this->assertSame('fooBar', $test->fooBar); + } + + public function testNormalizeWithSnakeCaseNestedAttributes() + { + $factory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($factory, new CamelCaseToSnakeCaseNameConverter()); + $dummy = new SnakeCaseNestedDummy(); + $dummy->fooBar = 'fooBar'; + $test = $normalizer->normalize($dummy, 'any'); + $this->assertSame(['one' => ['two_three' => 'fooBar']], $test); + } + + public function testDenormalizeWithNestedAttributes() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + $test = $normalizer->denormalize($data, NestedDummy::class, 'any'); + $this->assertSame('baz', $test->baz); + $this->assertSame('foo', $test->foo); + $this->assertSame('quux', $test->quux); + $this->assertSame('notfoo', $test->notfoo); + } + + public function testDenormalizeWithNestedAttributesDuplicateKeys() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'four' => 'quux', + ], + 'quux' => 'notquux', + ]; + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Duplicate values for key "quux" found. One value is set via the SerializedPath attribute: "one->four", the other one is set via the SerializedName attribute: "notquux".'); + + $normalizer->denormalize($data, DuplicateKeyNestedDummy::class, 'any'); + } + + public function testDenormalizeWithNestedAttributesInConstructor() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + $test = $normalizer->denormalize($data, NestedDummyWithConstructor::class, 'any'); + $this->assertSame('foo', $test->foo); + $this->assertSame('quux', $test->quux); + $this->assertSame('notfoo', $test->notfoo); + $this->assertSame('baz', $test->baz); + } + + public function testDenormalizeWithNestedAttributesInConstructorAndDiscriminatorMap() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + + $test1 = $normalizer->denormalize($data + ['type' => 'first'], AbstractNestedDummyWithConstructorAndDiscriminator::class, 'any'); + $this->assertInstanceOf(FirstNestedDummyWithConstructorAndDiscriminator::class, $test1); + $this->assertSame('foo', $test1->foo); + $this->assertSame('notfoo', $test1->notfoo); + $this->assertSame('baz', $test1->baz); + + $test2 = $normalizer->denormalize($data + ['type' => 'second'], AbstractNestedDummyWithConstructorAndDiscriminator::class, 'any'); + $this->assertInstanceOf(SecondNestedDummyWithConstructorAndDiscriminator::class, $test2); + $this->assertSame('quux', $test2->quux); + $this->assertSame('notfoo', $test2->notfoo); + $this->assertSame('baz', $test2->baz); + } + + public function testNormalizeWithNestedAttributesMixingArrayTypes() + { + $foobar = new AlreadyPopulatedNestedDummy(); + $foobar->foo = 'foo'; + $foobar->bar = 'bar'; + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The element you are trying to set is already populated: "[one][two]"'); + + $normalizer->normalize($foobar, 'any'); + } + + public function testNormalizeWithNestedAttributesElementAlreadySet() + { + $foobar = new DuplicateValueNestedDummy(); + $foobar->foo = 'foo'; + $foobar->bar = 'bar'; + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The element you are trying to set is already populated: "[one][two][three]"'); + + $normalizer->normalize($foobar, 'any'); + } + + public function testNormalizeWithNestedAttributes() + { + $foobar = new NestedDummy(); + $foobar->foo = 'foo'; + $foobar->quux = 'quux'; + $foobar->baz = 'baz'; + $foobar->notfoo = 'notfoo'; + $data = [ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ]; + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + $test = $normalizer->normalize($foobar, 'any'); + $this->assertSame($data, $test); + } + + public function testNormalizeWithNestedAttributesWithoutMetadata() + { + $foobar = new NestedDummy(); + $foobar->foo = 'foo'; + $foobar->quux = 'quux'; + $foobar->baz = 'baz'; + $foobar->notfoo = 'notfoo'; + $data = [ + 'foo' => 'foo', + 'quux' => 'quux', + 'notfoo' => 'notfoo', + 'baz' => 'baz', + ]; + $normalizer = new ObjectNormalizer(); + $test = $normalizer->normalize($foobar, 'any'); + $this->assertSame($data, $test); + } + + public function testNormalizeWithNestedAttributesInConstructor() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $test = $normalizer->normalize(new NestedDummyWithConstructor('foo', 'quux', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test); + } + + public function testNormalizeWithNestedAttributesInConstructorAndDiscriminatorMap() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + + $test1 = $normalizer->normalize(new FirstNestedDummyWithConstructorAndDiscriminator('foo', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'type' => 'first', + 'one' => [ + 'two' => [ + 'three' => 'foo', + ], + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test1); + + $test2 = $normalizer->normalize(new SecondNestedDummyWithConstructorAndDiscriminator('quux', 'notfoo', 'baz'), 'any'); + $this->assertSame([ + 'type' => 'second', + 'one' => [ + 'four' => 'quux', + ], + 'foo' => 'notfoo', + 'baz' => 'baz', + ], $test2); + } + public function testDenormalizeCollectionDecodedFromXmlWithOneChild() { $denormalizer = $this->getDenormalizerForDummyCollection(); @@ -171,11 +437,20 @@ public function testDenormalizeCollectionDecodedFromXmlWithTwoChildren() private function getDenormalizerForDummyCollection() { $extractor = $this->createMock(PhpDocExtractor::class); - $extractor->method('getTypes') - ->willReturn( - [new Type('array', false, null, true, new Type('int'), new Type('object', false, DummyChild::class))], - null - ); + + if (method_exists(PhpDocExtractor::class, 'getType')) { + $extractor->method('getType') + ->willReturn( + Type::list(Type::object(DummyChild::class)), + null, + ); + } else { + $extractor->method('getTypes') + ->willReturn( + [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('object', false, DummyChild::class))], + null + ); + } $denormalizer = new AbstractObjectNormalizerCollectionDummy(null, null, $extractor); $arrayDenormalizer = new ArrayDenormalizerDummy(); @@ -226,11 +501,20 @@ public function testDenormalizeNotSerializableObjectToPopulate() private function getDenormalizerForStringCollection() { $extractor = $this->createMock(PhpDocExtractor::class); - $extractor->method('getTypes') - ->willReturn( - [new Type('array', false, null, true, new Type('int'), new Type('string'))], - null - ); + + if (method_exists(PhpDocExtractor::class, 'getType')) { + $extractor->method('getType') + ->willReturn( + Type::list(Type::string()), + null, + ); + } else { + $extractor->method('getTypes') + ->willReturn( + [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], + null + ); + } $denormalizer = new AbstractObjectNormalizerCollectionDummy(null, null, $extractor); $arrayDenormalizer = new ArrayDenormalizerDummy(); @@ -243,9 +527,9 @@ private function getDenormalizerForStringCollection() public function testDenormalizeWithDiscriminatorMapUsesCorrectClassname() { - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $factory = new ClassMetadataFactory(new AttributeLoader()); - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummy::class === $value) { @@ -278,9 +562,9 @@ public function hasMetadataFor($value): bool public function testDenormalizeWithDiscriminatorMapAndObjectToPopulateUsesCorrectClassname() { - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $factory = new ClassMetadataFactory(new AttributeLoader()); - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummy::class === $value) { @@ -333,21 +617,18 @@ public function hasMetadataFor($value): bool public function testDenormalizeWithNestedDiscriminatorMap() { - $classDiscriminatorResolver = new class() implements ClassDiscriminatorResolverInterface { + $classDiscriminatorResolver = new class implements ClassDiscriminatorResolverInterface { public function getMappingForClass(string $class): ?ClassDiscriminatorMapping { - switch ($class) { - case AbstractDummy::class: - return new ClassDiscriminatorMapping('type', [ - 'foo' => AbstractDummyFirstChild::class, - ]); - case AbstractDummyFirstChild::class: - return new ClassDiscriminatorMapping('nested_type', [ - 'bar' => AbstractDummySecondChild::class, - ]); - default: - return null; - } + return match ($class) { + AbstractDummy::class => new ClassDiscriminatorMapping('type', [ + 'foo' => AbstractDummyFirstChild::class, + ]), + AbstractDummyFirstChild::class => new ClassDiscriminatorMapping('nested_type', [ + 'bar' => AbstractDummySecondChild::class, + ]), + default => null, + }; } public function getMappingForMappedObject($object): ?ClassDiscriminatorMapping @@ -416,21 +697,40 @@ public function testDenormalizeBasicTypePropertiesFromXml() private function getDenormalizerForObjectWithBasicProperties() { $extractor = $this->createMock(PhpDocExtractor::class); - $extractor->method('getTypes') - ->willReturn( - [new Type('bool')], - [new Type('bool')], - [new Type('bool')], - [new Type('bool')], - [new Type('int')], - [new Type('int')], - [new Type('float')], - [new Type('float')], - [new Type('float')], - [new Type('float')], - [new Type('float')], - [new Type('float')] - ); + + if (method_exists(PhpDocExtractor::class, 'getType')) { + $extractor->method('getType') + ->willReturn( + Type::bool(), + Type::bool(), + Type::bool(), + Type::bool(), + Type::int(), + Type::int(), + Type::float(), + Type::float(), + Type::float(), + Type::float(), + Type::float(), + Type::float(), + ); + } else { + $extractor->method('getTypes') + ->willReturn( + [new LegacyType('bool')], + [new LegacyType('bool')], + [new LegacyType('bool')], + [new LegacyType('bool')], + [new LegacyType('int')], + [new LegacyType('int')], + [new LegacyType('float')], + [new LegacyType('float')], + [new LegacyType('float')], + [new LegacyType('float')], + [new LegacyType('float')], + [new LegacyType('float')] + ); + } $denormalizer = new AbstractObjectNormalizerCollectionDummy(null, null, $extractor); $arrayDenormalizer = new ArrayDenormalizerDummy(); @@ -446,9 +746,10 @@ private function getDenormalizerForObjectWithBasicProperties() */ public function testExtraAttributesException() { + $normalizer = new ObjectNormalizer(); + $this->expectException(LogicException::class); $this->expectExceptionMessage('A class metadata factory must be provided in the constructor when setting "allow_extra_attributes" to false.'); - $normalizer = new ObjectNormalizer(); $normalizer->denormalize([], \stdClass::class, 'xml', [ 'allow_extra_attributes' => false, @@ -467,10 +768,150 @@ public function testNormalizeEmptyObject() $this->assertEquals(new \ArrayObject(), $normalizedData); } + public function testDenormalizeRecursiveWithObjectAttributeWithStringValue() + { + $extractor = new ReflectionExtractor(); + $normalizer = new ObjectNormalizer(null, null, null, $extractor); + $serializer = new Serializer([$normalizer]); + + $obj = $serializer->denormalize(['inner' => 'foo'], ObjectOuter::class); + + $this->assertInstanceOf(ObjectInner::class, $obj->getInner()); + } + + public function testDenormalizeUsesContextAttributeForPropertiesInConstructorWithSeralizedName() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), null, $extractor); + $serializer = new Serializer([new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'd-m-Y']), $normalizer]); + + /** @var ObjectDummyWithContextAttribute $obj */ + $obj = $serializer->denormalize(['property_with_serialized_name' => '01-02-2022', 'propertyWithoutSerializedName' => '01-02-2022'], ObjectDummyWithContextAttribute::class); + + $this->assertSame($obj->propertyWithSerializedName->format('Y-m-d'), $obj->propertyWithoutSerializedName->format('Y-m-d')); + } + + public function testNormalizeUsesContextAttributeForPropertiesInConstructorWithSerializedPath() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), null, $extractor); + $serializer = new Serializer([new DateTimeNormalizer(), $normalizer]); + + $obj = new ObjectDummyWithContextAttributeAndSerializedPath(new \DateTimeImmutable('22-02-2023')); + + $data = $serializer->normalize($obj); + + $this->assertSame(['property' => ['with_path' => '02-22-2023']], $data); + } + + public function testNormalizeUsesContextAttributeForProperties() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + + $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); + $normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), null, $extractor); + $serializer = new Serializer([$normalizer]); + + $obj = new ObjectDummyWithContextAttributeSkipNullValues(); + + $data = $serializer->normalize($obj); + + $this->assertSame(['propertyWithoutNullSkipNullValues' => 'foo'], $data); + } + + public function testDefaultExcludeFromCacheKey() + { + $object = new DummyChild(); + $object->bar = 'not called'; + + $normalizer = new class(null, null, null, null, null, [AbstractObjectNormalizer::EXCLUDE_FROM_CACHE_KEY => ['foo']]) extends AbstractObjectNormalizerDummy { + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + AbstractObjectNormalizerTest::assertContains('foo', $this->defaultContext[ObjectNormalizer::EXCLUDE_FROM_CACHE_KEY]); + $data->bar = 'called'; + + return true; + } + }; + + $serializer = new Serializer([$normalizer]); + $serializer->normalize($object); + + $this->assertSame('called', $object->bar); + } + + public function testDenormalizeUnionOfEnums() + { + $serializer = new Serializer([ + new BackedEnumNormalizer(), + new ObjectNormalizer( + classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), + propertyTypeExtractor: new PropertyInfoExtractor([], [new ReflectionExtractor()]), + ), + ]); + + $normalized = $serializer->normalize(new DummyWithEnumUnion(EnumA::A)); + $this->assertEquals(new DummyWithEnumUnion(EnumA::A), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); + + $normalized = $serializer->normalize(new DummyWithEnumUnion(EnumB::B)); + $this->assertEquals(new DummyWithEnumUnion(EnumB::B), $serializer->denormalize($normalized, DummyWithEnumUnion::class)); + } + + public function testDenormalizeWithNumberAsSerializedNameAndNoArrayReindex() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + + $data = [ + '1' => 'foo', + '99' => 'baz', + ]; + + $obj = new class { + #[SerializedName('1')] + public $foo; + + #[SerializedName('99')] + public $baz; + }; + + $test = $normalizer->denormalize($data, $obj::class); + $this->assertSame('foo', $test->foo); + $this->assertSame('baz', $test->baz); + } + + public function testDenormalizeWithCorrectOrderOfAttributeAndProperty() + { + $normalizer = new AbstractObjectNormalizerWithMetadata(); + + $data = [ + 'id' => 'root-level-id', + 'data' => [ + 'id' => 'nested-id', + ], + ]; + + $obj = new class { + #[SerializedPath('[data][id]')] + public $id; + }; + + $test = $normalizer->denormalize($data, $obj::class); + $this->assertSame('nested-id', $test->id); + } + public function testNormalizeBasedOnAllowedAttributes() { - $normalizer = new class() extends AbstractObjectNormalizer { - protected function getAllowedAttributes($classOrObject, array $context, bool $attributesAsString = false) + $normalizer = new class extends AbstractObjectNormalizer { + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + protected function getAllowedAttributes($classOrObject, array $context, bool $attributesAsString = false): array { return ['foo']; } @@ -480,12 +921,12 @@ protected function extractAttributes(object $object, ?string $format = null, arr return []; } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return $object->$attribute; } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void { } }; @@ -497,9 +938,6 @@ protected function setAttributeValue(object $object, string $attribute, $value, $this->assertSame(['foo' => 'foo'], $normalizer->normalize($object)); } - /** - * @requires PHP 8 - */ public function testDenormalizeUntypedFormat() { $serializer = new Serializer([new ObjectNormalizer(null, null, null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]))]); @@ -508,9 +946,6 @@ public function testDenormalizeUntypedFormat() $this->assertEquals(new DummyWithObjectOrNull(null), $actual); } - /** - * @requires PHP 8 - */ public function testDenormalizeUntypedFormatNotNormalizable() { $this->expectException(NotNormalizableValueException::class); @@ -519,9 +954,6 @@ public function testDenormalizeUntypedFormatNotNormalizable() $serializer->denormalize(['value' => 'test'], DummyWithNotNormalizable::class, 'xml'); } - /** - * @requires PHP 8 - */ public function testDenormalizeUntypedFormatMissingArg() { $this->expectException(MissingConstructorArgumentsException::class); @@ -529,9 +961,6 @@ public function testDenormalizeUntypedFormatMissingArg() $serializer->denormalize(['value' => 'invalid'], DummyWithObjectOrNull::class, 'xml'); } - /** - * @requires PHP 8 - */ public function testDenormalizeUntypedFormatScalar() { $serializer = new Serializer([new ObjectNormalizer(null, null, null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]))]); @@ -540,9 +969,6 @@ public function testDenormalizeUntypedFormatScalar() $this->assertEquals(new DummyWithObjectOrBool(false), $actual); } - /** - * @requires PHP 8 - */ public function testDenormalizeUntypedStringObject() { $serializer = new Serializer([new CustomNormalizer(), new ObjectNormalizer(null, null, null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]))]); @@ -559,7 +985,7 @@ public function testProvidingContextCacheKeyGeneratesSameChildContextCacheKey() $foobar->bar = 'bar'; $foobar->baz = 'baz'; - $normalizer = new class() extends AbstractObjectNormalizerDummy { + $normalizer = new class extends AbstractObjectNormalizerDummy { public $childContextCacheKey; protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -567,7 +993,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_keys((array) $object); } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return $object->{$attribute}; } @@ -599,7 +1025,7 @@ public function testChildContextKeepsOriginalContextCacheKey() $foobar->bar = 'bar'; $foobar->baz = 'baz'; - $normalizer = new class() extends AbstractObjectNormalizerDummy { + $normalizer = new class extends AbstractObjectNormalizerDummy { public $childContextCacheKey; protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -607,7 +1033,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_keys((array) $object); } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return $object->{$attribute}; } @@ -634,7 +1060,7 @@ public function testChildContextCacheKeyStaysFalseWhenOriginalCacheKeyIsFalse() $foobar->bar = 'bar'; $foobar->baz = 'baz'; - $normalizer = new class() extends AbstractObjectNormalizerDummy { + $normalizer = new class extends AbstractObjectNormalizerDummy { public $childContextCacheKey; protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -642,7 +1068,7 @@ protected function extractAttributes(object $object, ?string $format = null, arr return array_keys((array) $object); } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return $object->{$attribute}; } @@ -664,10 +1090,10 @@ protected function createChildContext(array $parentContext, string $attribute, ? public function testDenormalizeXmlScalar() { - $normalizer = new class() extends AbstractObjectNormalizer { + $normalizer = new class extends AbstractObjectNormalizer { public function __construct() { - parent::__construct(null, new MetadataAwareNameConverter(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())))); + parent::__construct(null, new MetadataAwareNameConverter(new ClassMetadataFactory(new AttributeLoader()))); } protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -675,20 +1101,85 @@ protected function extractAttributes(object $object, ?string $format = null, arr return []; } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return null; } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void { $object->$attribute = $value; } + + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } }; $this->assertSame('scalar', $normalizer->denormalize('scalar', XmlScalarDummy::class, 'xml')->value); } + public function testNormalizationWithMaxDepthOnStdclassObjectDoesNotThrowWarning() + { + $object = new \stdClass(); + $object->string = 'yes'; + + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory); + $normalized = $normalizer->normalize($object, context: [ + AbstractObjectNormalizer::ENABLE_MAX_DEPTH => true, + ]); + + $this->assertSame(['string' => 'yes'], $normalized); + } + + public function testDenormalizeCollectionOfScalarTypesPropertyWithPhpDocExtractor() + { + $normalizer = new AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors(); + $data = [ + 'type' => 'foo', + 'values' => [ + ['1'], + ['2'], + ['3'], + ['4'], + ['5'], + ], + ]; + $expected = new ScalarCollectionDocBlockDummy([[1], [2], [3], [4], [5]]); + + $this->assertEquals($expected, $normalizer->denormalize($data, ScalarCollectionDocBlockDummy::class)); + } + + public function testDenormalizeCollectionOfUnionTypesPropertyWithPhpDocExtractor() + { + $normalizer = new AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors(); + $data = [ + 'values1' => [ + 'foo' => 'foo', + 'bar' => 222, + ], + 'values2' => [ + 'baz' => 'baz', + 'qux' => 333, + ], + ]; + $expected = new UnionCollectionDocBlockDummy($data['values1']); + $expected->values2 = $data['values2']; + + $this->assertEquals($expected, $normalizer->denormalize($data, UnionCollectionDocBlockDummy::class)); + } + + public function testDenormalizeMixedProperty() + { + $normalizer = new AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors(); + $expected = new MixedPropertyDummy(); + $expected->foo = 'bar'; + + $this->assertEquals($expected, $normalizer->denormalize(['foo' => 'bar'], MixedPropertyDummy::class)); + } + /** * @dataProvider provideBooleanTypesData */ @@ -708,20 +1199,139 @@ public static function provideBooleanTypesData() [['foo' => false], TruePropertyDummy::class], ]; } + + /** + * @dataProvider provideDenormalizeWithFilterBoolData + */ + public function testDenormalizeBooleanTypeWithFilterBool(array $data, ?bool $expectedFoo) + { + $normalizer = new AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors(); + + $dummy = $normalizer->denormalize($data, BoolPropertyDummy::class, null, [AbstractNormalizer::FILTER_BOOL => true]); + + $this->assertSame($expectedFoo, $dummy->foo); + } + + public static function provideDenormalizeWithFilterBoolData(): array + { + return [ + [['foo' => 'true'], true], + [['foo' => 'True'], true], + [['foo' => 'TRUE'], true], + [['foo' => '1'], true], + [['foo' => 1], true], + [['foo' => 'yes'], true], + [['foo' => 'Yes'], true], + [['foo' => 'YES'], true], + [['foo' => 'on'], true], + [['foo' => 'On'], true], + [['foo' => 'ON'], true], + [['foo' => 'false'], false], + [['foo' => 'False'], false], + [['foo' => 'FALSE'], false], + [['foo' => '0'], false], + [['foo' => 0], false], + [['foo' => 'no'], false], + [['foo' => 'No'], false], + [['foo' => 'NO'], false], + [['foo' => 'off'], false], + [['foo' => 'Off'], false], + [['foo' => 'OFF'], false], + [['foo' => ''], false], + [['foo' => null], null], + [['foo' => 'null'], null], + [['foo' => 'something'], null], + [['foo' => 'foo'], null], + [['foo' => 1234567890], null], + [['foo' => -1234567890], null], + ]; + } + + public function testDenormalizeArrayObject() + { + $normalizer = new class extends AbstractObjectNormalizerDummy { + public function __construct() + { + parent::__construct(null, null, new PhpDocExtractor()); + } + + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return true; + } + }; + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $actual = $normalizer->denormalize(['foo' => ['array' => ['key' => 'value']]], DummyWithArrayObject::class); + + $this->assertInstanceOf(DummyWithArrayObject::class, $actual); + $this->assertInstanceOf(\ArrayObject::class, $actual->foo); + $this->assertSame(1, $actual->foo->count()); + } + + public function testTemplateTypeWhenAnObjectIsPassedToDenormalize() + { + $normalizer = new class(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return true; + } + }; + $serializer = new Serializer([$normalizer]); + $normalizer->setSerializer($serializer); + + $denormalizedData = $normalizer->denormalize(['value' => new DummyGenericsValue()], DummyGenericsValueWrapper::class); + + $this->assertInstanceOf(DummyGenericsValueWrapper::class, $denormalizedData); + $this->assertInstanceOf(DummyGenericsValue::class, $denormalizedData->value); + + $this->assertSame('dummy', $denormalizedData->value->type); + } + + public function testDenormalizeTemplateType() + { + if (!interface_exists(PropertyDocBlockExtractorInterface::class)) { + $this->markTestSkipped('The PropertyInfo component before Symfony 7.1 does not support template types.'); + } + + $normalizer = new class(classMetadataFactory: new ClassMetadataFactory(new AttributeLoader()), propertyTypeExtractor: new PropertyInfoExtractor(typeExtractors: [new PhpStanExtractor(), new ReflectionExtractor()])) extends AbstractObjectNormalizerDummy { + protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return true; + } + }; + $serializer = new Serializer([new ArrayDenormalizer(), $normalizer]); + $normalizer->setSerializer($serializer); + + $denormalizedData = $normalizer->denormalize(['value' => ['type' => 'dummy'], 'values' => [['type' => 'dummy']]], DummyGenericsValueWrapper::class); + + $this->assertInstanceOf(DummyGenericsValueWrapper::class, $denormalizedData); + $this->assertInstanceOf(DummyGenericsValue::class, $denormalizedData->value); + $this->assertContainsOnlyInstancesOf(DummyGenericsValue::class, $denormalizedData->values); + $this->assertCount(1, $denormalizedData->values); + $this->assertSame('dummy', $denormalizedData->value->type); + $this->assertSame('dummy', $denormalizedData->values[0]->type); + } } class AbstractObjectNormalizerDummy extends AbstractObjectNormalizer { + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { return []; } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void { $object->$attribute = $value; } @@ -748,24 +1358,163 @@ class EmptyDummy { } +class AlreadyPopulatedNestedDummy +{ + #[SerializedPath('[one][two][three]')] + public $foo; + + #[SerializedPath('[one][two]')] + public $bar; +} + +class DuplicateValueNestedDummy +{ + #[SerializedPath('[one][two][three]')] + public $foo; + + #[SerializedPath('[one][two][three]')] + public $bar; + + public $baz; +} + +class NestedDummy +{ + #[SerializedPath('[one][two][three]')] + public $foo; + + #[SerializedPath('[one][four]')] + public $quux; + + #[SerializedPath('[foo]')] + public $notfoo; + + public $baz; +} + +class NestedDummyWithConstructor +{ + public function __construct( + #[SerializedPath('[one][two][three]')] + public $foo, + + #[SerializedPath('[one][four]')] + public $quux, + + #[SerializedPath('[foo]')] + public $notfoo, + + public $baz, + ) { + } +} + +class SnakeCaseNestedDummy +{ + #[SerializedPath('[one][two_three]')] + public $fooBar; +} + +class MixedPropertyDummy +{ + public mixed $foo; +} + +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'first' => FirstNestedDummyWithConstructorAndDiscriminator::class, + 'second' => SecondNestedDummyWithConstructorAndDiscriminator::class, +])] +abstract class AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + #[SerializedPath('[foo]')] + public $notfoo, + + public $baz, + ) { + } +} + +class FirstNestedDummyWithConstructorAndDiscriminator extends AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + #[SerializedPath('[one][two][three]')] + public $foo, + + $notfoo, + $baz, + ) { + parent::__construct($notfoo, $baz); + } +} + +class SecondNestedDummyWithConstructorAndDiscriminator extends AbstractNestedDummyWithConstructorAndDiscriminator +{ + public function __construct( + #[SerializedPath('[one][four]')] + public $quux, + + $notfoo, + $baz, + ) { + parent::__construct($notfoo, $baz); + } +} + +class DuplicateKeyNestedDummy +{ + #[SerializedPath('[one][four]')] + public $quux; + + #[SerializedName('quux')] + public $notquux; +} + +class ObjectDummyWithContextAttributeAndSerializedPath +{ + public function __construct( + #[Context([DateTimeNormalizer::FORMAT_KEY => 'm-d-Y'])] + #[SerializedPath('[property][with_path]')] + public \DateTimeImmutable $propertyWithPath, + ) { + } +} + +class ObjectDummyWithContextAttributeSkipNullValues +{ + #[Context([AbstractObjectNormalizer::SKIP_NULL_VALUES => true])] + public ?string $propertyWithoutNullSkipNullValues = 'foo'; + + #[Context([AbstractObjectNormalizer::SKIP_NULL_VALUES => true])] + public ?string $propertyWithNullSkipNullValues = null; +} + class AbstractObjectNormalizerWithMetadata extends AbstractObjectNormalizer { public function __construct() { - parent::__construct(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + parent::__construct($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); + } + + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; } protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void { - $object->$attribute = $value; + if (property_exists($object, $attribute)) { + $object->$attribute = $value; + } } } @@ -833,7 +1582,7 @@ class DummyChild class XmlScalarDummy { - /** @SerializedName("#") */ + #[SerializedName('#')] public $value; } @@ -849,9 +1598,21 @@ class TruePropertyDummy public $foo; } +class BoolPropertyDummy +{ + /** @var bool|null */ + public $foo; +} + +class DummyWithArrayObject +{ + /** @var \ArrayObject */ + public $foo; +} + class SerializerCollectionDummy implements SerializerInterface, DenormalizerInterface { - private $normalizers; + private array $normalizers; /** * @param DenormalizerInterface[] $normalizers @@ -865,11 +1626,11 @@ public function serialize($data, string $format, array $context = []): string { } - public function deserialize($data, string $type, string $format, array $context = []) + public function deserialize($data, string $type, string $format, array $context = []): mixed { } - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize($data, string $type, ?string $format = null, array $context = []): mixed { foreach ($this->normalizers as $normalizer) { if ($normalizer instanceof DenormalizerInterface && $normalizer->supportsDenormalization($data, $type, $format, $context)) { @@ -880,7 +1641,12 @@ public function denormalize($data, string $type, ?string $format = null, array $ return null; } - public function supportsDenormalization($data, string $type, ?string $format = null): bool + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool { return true; } @@ -888,15 +1654,20 @@ public function supportsDenormalization($data, string $type, ?string $format = n class AbstractObjectNormalizerCollectionDummy extends AbstractObjectNormalizer { + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + protected function extractAttributes(object $object, ?string $format = null, array $context = []): array { } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []) + protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void { $object->$attribute = $value; } @@ -922,17 +1693,12 @@ public function deserialize($data, string $type, string $format, array $context class ArrayDenormalizerDummy implements DenormalizerInterface, SerializerAwareInterface { - /** - * @var SerializerInterface|DenormalizerInterface - */ - private $serializer; + private SerializerInterface&DenormalizerInterface $serializer; /** - * {@inheritdoc} - * * @throws NotNormalizableValueException */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize($data, string $type, ?string $format = null, array $context = []): mixed { $serializer = $this->serializer; $type = substr($type, 0, -2); @@ -944,20 +1710,20 @@ public function denormalize($data, string $type, ?string $format = null, array $ return $data; } - /** - * {@inheritdoc} - */ + public function getSupportedTypes(?string $format): array + { + return $this->serializer->getSupportedTypes($format); + } + public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool { return str_ends_with($type, '[]') && $this->serializer->supportsDenormalization($data, substr($type, 0, -2), $format, $context); } - /** - * {@inheritdoc} - */ - public function setSerializer(SerializerInterface $serializer) + public function setSerializer(SerializerInterface $serializer): void { + \assert($serializer instanceof DenormalizerInterface); $this->serializer = $serializer; } } @@ -970,11 +1736,63 @@ public function __sleep(): array } } +enum EnumA: string +{ + case A = 'a'; +} + +enum EnumB: string +{ + case B = 'b'; +} + +class DummyWithEnumUnion +{ + public function __construct( + public readonly EnumA|EnumB $enum, + ) { + } +} + +#[DiscriminatorMap('type', ['foo' => ScalarCollectionDocBlockDummy::class])] +class ScalarCollectionDocBlockDummy +{ + /** + * @param array>|null $values + */ + public function __construct( + private readonly ?array $values = null, + ) { + } + + /** @return array>|null */ + public function getValues(): ?array + { + return $this->values; + } +} + +class UnionCollectionDocBlockDummy +{ + /** + * @param array $values1 + */ + public function __construct( + public array $values1, + ) { + } + + /** + * @var array + */ + public array $values2; +} + class AbstractObjectNormalizerWithMetadataAndPropertyTypeExtractors extends AbstractObjectNormalizer { public function __construct() { - parent::__construct(new ClassMetadataFactory(new AnnotationLoader()), null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()])); + parent::__construct(new ClassMetadataFactory(new AttributeLoader()), null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()])); } protected function extractAttributes(object $object, ?string $format = null, array $context = []): array @@ -982,15 +1800,50 @@ protected function extractAttributes(object $object, ?string $format = null, arr return []; } - protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []) + protected function getAttributeValue(object $object, string $attribute, ?string $format = null, array $context = []): mixed { return null; } - protected function setAttributeValue(object $object, string $attribute, $value, ?string $format = null, array $context = []): void + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void { if (property_exists($object, $attribute)) { $object->$attribute = $value; } } + + public function getSupportedTypes(?string $format): array + { + return [ + '*' => false, + ]; + } +} + +#[DiscriminatorMap('type', ['dummy' => DummyGenericsValue::class])] +abstract class AbstractDummyGenericsValue +{ + public function __construct( + public string $type, + ) { + } +} + +class DummyGenericsValue extends AbstractDummyGenericsValue +{ + public function __construct() + { + parent::__construct('dummy'); + } +} + +/** + * @template T of AbstractDummyGenericsValue + */ +class DummyGenericsValueWrapper +{ + /** @var T */ + public mixed $value; + /** @var T[] */ + public array $values; } diff --git a/Tests/Normalizer/ArrayDenormalizerTest.php b/Tests/Normalizer/ArrayDenormalizerTest.php index 3bccfbbff..b60f57bac 100644 --- a/Tests/Normalizer/ArrayDenormalizerTest.php +++ b/Tests/Normalizer/ArrayDenormalizerTest.php @@ -13,28 +13,17 @@ use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; -use Symfony\Component\Serializer\Normalizer\ContextAwareDenormalizerInterface; -use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; class ArrayDenormalizerTest extends TestCase { - use ExpectDeprecationTrait; - - /** - * @var ArrayDenormalizer - */ - private $denormalizer; - - /** - * @var MockObject&ContextAwareDenormalizerInterface - */ - private $serializer; + private ArrayDenormalizer $denormalizer; + private MockObject&DenormalizerInterface $serializer; protected function setUp(): void { - $this->serializer = $this->createMock(ContextAwareDenormalizerInterface::class); + $this->serializer = $this->createMock(DenormalizerInterface::class); $this->denormalizer = new ArrayDenormalizer(); $this->denormalizer->setDenormalizer($this->serializer); } @@ -73,52 +62,6 @@ public function testDenormalize() ); } - /** - * @group legacy - */ - public function testDenormalizeLegacy() - { - $firstArray = new ArrayDummy('one', 'two'); - $secondArray = new ArrayDummy('three', 'four'); - - $series = [ - [[['foo' => 'one', 'bar' => 'two']], $firstArray], - [[['foo' => 'three', 'bar' => 'four']], $secondArray], - ]; - - $serializer = $this->createMock(Serializer::class); - $serializer->expects($this->exactly(2)) - ->method('denormalize') - ->willReturnCallback(function ($data) use (&$series) { - [$expectedArgs, $return] = array_shift($series); - $this->assertSame($expectedArgs, [$data]); - - return $return; - }) - ; - - $denormalizer = new ArrayDenormalizer(); - - $this->expectDeprecation('Since symfony/serializer 5.3: Calling "Symfony\Component\Serializer\Normalizer\ArrayDenormalizer::setSerializer()" is deprecated. Please call setDenormalizer() instead.'); - $denormalizer->setSerializer($serializer); - - $result = $denormalizer->denormalize( - [ - ['foo' => 'one', 'bar' => 'two'], - ['foo' => 'three', 'bar' => 'four'], - ], - __NAMESPACE__.'\ArrayDummy[]' - ); - - $this->assertEquals( - [ - new ArrayDummy('one', 'two'), - new ArrayDummy('three', 'four'), - ], - $result - ); - } - public function testSupportsValidArray() { $this->serializer->expects($this->once()) @@ -165,6 +108,22 @@ public function testSupportsNoArray() ) ); } + + public function testDenormalizeWithoutDenormalizer() + { + $arrayDenormalizer = new ArrayDenormalizer(); + + $this->expectException(\BadMethodCallException::class); + $arrayDenormalizer->denormalize([], 'string[]'); + } + + public function testSupportsDenormalizationWithoutDenormalizer() + { + $arrayDenormalizer = new ArrayDenormalizer(); + + $this->expectException(\BadMethodCallException::class); + $arrayDenormalizer->supportsDenormalization([], 'string[]'); + } } class ArrayDummy diff --git a/Tests/Normalizer/BackedEnumNormalizerTest.php b/Tests/Normalizer/BackedEnumNormalizerTest.php index dcdd17098..46963fe70 100644 --- a/Tests/Normalizer/BackedEnumNormalizerTest.php +++ b/Tests/Normalizer/BackedEnumNormalizerTest.php @@ -24,19 +24,13 @@ */ class BackedEnumNormalizerTest extends TestCase { - /** - * @var BackedEnumNormalizer - */ - private $normalizer; + private BackedEnumNormalizer $normalizer; protected function setUp(): void { $this->normalizer = new BackedEnumNormalizer(); } - /** - * @requires PHP 8.1 - */ public function testSupportsNormalization() { $this->assertTrue($this->normalizer->supportsNormalization(StringBackedEnumDummy::GET)); @@ -45,27 +39,18 @@ public function testSupportsNormalization() $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); } - /** - * @requires PHP 8.1 - */ public function testNormalize() { $this->assertSame('GET', $this->normalizer->normalize(StringBackedEnumDummy::GET)); $this->assertSame(200, $this->normalizer->normalize(IntegerBackedEnumDummy::SUCCESS)); } - /** - * @requires PHP 8.1 - */ public function testNormalizeBadObjectTypeThrowsException() { $this->expectException(InvalidArgumentException::class); $this->normalizer->normalize(new \stdClass()); } - /** - * @requires PHP 8.1 - */ public function testSupportsDenormalization() { $this->assertTrue($this->normalizer->supportsDenormalization(null, StringBackedEnumDummy::class)); @@ -74,45 +59,30 @@ public function testSupportsDenormalization() $this->assertFalse($this->normalizer->supportsDenormalization(null, \stdClass::class)); } - /** - * @requires PHP 8.1 - */ public function testDenormalize() { $this->assertSame(StringBackedEnumDummy::GET, $this->normalizer->denormalize('GET', StringBackedEnumDummy::class)); $this->assertSame(IntegerBackedEnumDummy::SUCCESS, $this->normalizer->denormalize(200, IntegerBackedEnumDummy::class)); } - /** - * @requires PHP 8.1 - */ public function testDenormalizeNullValueThrowsException() { $this->expectException(NotNormalizableValueException::class); $this->normalizer->denormalize(null, StringBackedEnumDummy::class); } - /** - * @requires PHP 8.1 - */ public function testDenormalizeBooleanValueThrowsException() { $this->expectException(NotNormalizableValueException::class); $this->normalizer->denormalize(true, StringBackedEnumDummy::class); } - /** - * @requires PHP 8.1 - */ public function testDenormalizeObjectThrowsException() { $this->expectException(NotNormalizableValueException::class); $this->normalizer->denormalize(new \stdClass(), StringBackedEnumDummy::class); } - /** - * @requires PHP 8.1 - */ public function testDenormalizeBadBackingValueThrowsException() { $this->expectException(NotNormalizableValueException::class); @@ -141,4 +111,19 @@ public function testSupportsNormalizationShouldFailOnAnyPHPVersionForNonEnumObje { $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); } + + public function testItUsesTryFromIfContextIsPassed() + { + $this->assertNull($this->normalizer->denormalize(1, IntegerBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + $this->assertNull($this->normalizer->denormalize('', IntegerBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + $this->assertNull($this->normalizer->denormalize(null, IntegerBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + + $this->assertSame(IntegerBackedEnumDummy::SUCCESS, $this->normalizer->denormalize(200, IntegerBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + + $this->assertNull($this->normalizer->denormalize(1, StringBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + $this->assertNull($this->normalizer->denormalize('foo', StringBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + $this->assertNull($this->normalizer->denormalize(null, StringBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + + $this->assertSame(StringBackedEnumDummy::GET, $this->normalizer->denormalize('GET', StringBackedEnumDummy::class, null, [BackedEnumNormalizer::ALLOW_INVALID_VALUES => true])); + } } diff --git a/Tests/Normalizer/ConstraintViolationListNormalizerTest.php b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php index 510451cc5..1d0afb3cb 100644 --- a/Tests/Normalizer/ConstraintViolationListNormalizerTest.php +++ b/Tests/Normalizer/ConstraintViolationListNormalizerTest.php @@ -24,7 +24,7 @@ */ class ConstraintViolationListNormalizerTest extends TestCase { - private $normalizer; + private ConstraintViolationListNormalizer $normalizer; protected function setUp(): void { @@ -50,21 +50,23 @@ public function testNormalize() 'detail' => 'd: a 4: 1', 'violations' => [ - [ - 'propertyPath' => 'd', - 'title' => 'a', - 'type' => 'urn:uuid:f', - 'parameters' => [ - 'value' => 'foo', - ], - ], - [ - 'propertyPath' => '4', - 'title' => '1', - 'type' => 'urn:uuid:6', - 'parameters' => [], + [ + 'propertyPath' => 'd', + 'title' => 'a', + 'template' => 'b', + 'type' => 'urn:uuid:f', + 'parameters' => [ + 'value' => 'foo', ], ], + [ + 'propertyPath' => '4', + 'title' => '1', + 'template' => '2', + 'type' => 'urn:uuid:6', + 'parameters' => [], + ], + ], ]; $this->assertEquals($expected, $this->normalizer->normalize($list)); @@ -75,9 +77,9 @@ public function testNormalizeWithNameConverter() $normalizer = new ConstraintViolationListNormalizer([], new CamelCaseToSnakeCaseNameConverter()); $list = new ConstraintViolationList([ - new ConstraintViolation('too short', 'a', [], 'c', 'shortDescription', ''), + new ConstraintViolation('too short', 'a', [], '3', 'shortDescription', ''), new ConstraintViolation('too long', 'b', [], '3', 'product.shortDescription', 'Lorem ipsum dolor sit amet'), - new ConstraintViolation('error', 'b', [], '3', '', ''), + new ConstraintViolation('error', 'c', [], '3', '', ''), ]); $expected = [ @@ -90,16 +92,19 @@ public function testNormalizeWithNameConverter() [ 'propertyPath' => 'short_description', 'title' => 'too short', + 'template' => 'a', 'parameters' => [], ], [ 'propertyPath' => 'product.short_description', 'title' => 'too long', + 'template' => 'b', 'parameters' => [], ], [ 'propertyPath' => '', 'title' => 'error', + 'template' => 'c', 'parameters' => [], ], ], diff --git a/Tests/Normalizer/CustomNormalizerTest.php b/Tests/Normalizer/CustomNormalizerTest.php index e3f13fc1f..2b4af4054 100644 --- a/Tests/Normalizer/CustomNormalizerTest.php +++ b/Tests/Normalizer/CustomNormalizerTest.php @@ -21,10 +21,7 @@ class CustomNormalizerTest extends TestCase { - /** - * @var CustomNormalizer - */ - private $normalizer; + private CustomNormalizer $normalizer; protected function setUp(): void { @@ -50,11 +47,11 @@ public function testSerialize() public function testDeserialize() { - $obj = $this->normalizer->denormalize('foo', \get_class(new ScalarDummy()), 'xml'); + $obj = $this->normalizer->denormalize('foo', (new ScalarDummy())::class, 'xml'); $this->assertEquals('foo', $obj->xmlFoo); $this->assertNull($obj->foo); - $obj = $this->normalizer->denormalize('foo', \get_class(new ScalarDummy()), 'json'); + $obj = $this->normalizer->denormalize('foo', (new ScalarDummy())::class, 'json'); $this->assertEquals('foo', $obj->foo); $this->assertNull($obj->xmlFoo); } diff --git a/Tests/Normalizer/DataUriNormalizerTest.php b/Tests/Normalizer/DataUriNormalizerTest.php index 8c55ac1ab..7e9af4360 100644 --- a/Tests/Normalizer/DataUriNormalizerTest.php +++ b/Tests/Normalizer/DataUriNormalizerTest.php @@ -27,10 +27,7 @@ class DataUriNormalizerTest extends TestCase private const TEST_TXT_DATA = 'data:text/plain,K%C3%A9vin%20Dunglas%0A'; private const TEST_TXT_CONTENT = "Kévin Dunglas\n"; - /** - * @var DataUriNormalizer - */ - private $normalizer; + private DataUriNormalizer $normalizer; protected function setUp(): void { @@ -124,7 +121,7 @@ public function testGiveNotAccessToLocalFiles() /** * @dataProvider invalidUriProvider */ - public function testInvalidData($uri) + public function testInvalidData(?string $uri) { $this->expectException(UnexpectedValueException::class); $this->normalizer->denormalize($uri, 'SplFileObject'); @@ -151,7 +148,7 @@ public static function invalidUriProvider() /** * @dataProvider validUriProvider */ - public function testValidData($uri) + public function testValidData(string $uri) { $this->assertInstanceOf(\SplFileObject::class, $this->normalizer->denormalize($uri, 'SplFileObject')); } diff --git a/Tests/Normalizer/DateIntervalNormalizerTest.php b/Tests/Normalizer/DateIntervalNormalizerTest.php index 375702bca..5a7f50dc9 100644 --- a/Tests/Normalizer/DateIntervalNormalizerTest.php +++ b/Tests/Normalizer/DateIntervalNormalizerTest.php @@ -21,10 +21,7 @@ */ class DateIntervalNormalizerTest extends TestCase { - /** - * @var DateIntervalNormalizer - */ - private $normalizer; + private DateIntervalNormalizer $normalizer; protected function setUp(): void { @@ -119,11 +116,6 @@ public function testDenormalizeIntervalsWithOmittedPartsBeingZero() $this->assertDateIntervalEquals($this->getInterval('P0Y0M0DT12H34M0S'), $normalizer->denormalize('PT12H34M', \DateInterval::class)); } - /** - * Since PHP 8.0 DateInterval::construct supports periods containing both D and W period designators. - * - * @requires PHP 8 - */ public function testDenormalizeIntervalWithBothWeeksAndDays() { $input = 'P1W1D'; diff --git a/Tests/Normalizer/DateTimeNormalizerTest.php b/Tests/Normalizer/DateTimeNormalizerTest.php index ee82f319d..81219652b 100644 --- a/Tests/Normalizer/DateTimeNormalizerTest.php +++ b/Tests/Normalizer/DateTimeNormalizerTest.php @@ -21,10 +21,7 @@ */ class DateTimeNormalizerTest extends TestCase { - /** - * @var DateTimeNormalizer - */ - private $normalizer; + private DateTimeNormalizer $normalizer; protected function setUp(): void { @@ -46,21 +43,21 @@ public function testNormalize() public function testNormalizeUsingFormatPassedInContext() { - $this->assertEquals('2016', $this->normalizer->normalize(new \DateTime('2016/01/01'), null, [DateTimeNormalizer::FORMAT_KEY => 'Y'])); + $this->assertEquals('2016', $this->normalizer->normalize(new \DateTimeImmutable('2016/01/01'), null, [DateTimeNormalizer::FORMAT_KEY => 'Y'])); } public function testNormalizeUsingFormatPassedInConstructor() { $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => 'y']); - $this->assertEquals('16', $normalizer->normalize(new \DateTime('2016/01/01', new \DateTimeZone('UTC')))); + $this->assertEquals('16', $normalizer->normalize(new \DateTimeImmutable('2016/01/01', new \DateTimeZone('UTC')))); } public function testNormalizeUsingTimeZonePassedInConstructor() { $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => new \DateTimeZone('Japan')]); - $this->assertSame('2016-12-01T00:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('Japan')))); - $this->assertSame('2016-12-01T09:00:00+09:00', $normalizer->normalize(new \DateTime('2016/12/01', new \DateTimeZone('UTC')))); + $this->assertSame('2016-12-01T00:00:00+09:00', $normalizer->normalize(new \DateTimeImmutable('2016/12/01', new \DateTimeZone('Japan')))); + $this->assertSame('2016-12-01T09:00:00+09:00', $normalizer->normalize(new \DateTimeImmutable('2016/12/01', new \DateTimeZone('UTC')))); } /** @@ -75,10 +72,10 @@ public function testNormalizeUsingTimeZonePassedInContext($expected, $input, $ti public static function normalizeUsingTimeZonePassedInContextProvider() { - yield ['2016-12-01T00:00:00+00:00', new \DateTime('2016/12/01', new \DateTimeZone('UTC')), null]; - yield ['2016-12-01T00:00:00+09:00', new \DateTime('2016/12/01', new \DateTimeZone('Japan')), new \DateTimeZone('Japan')]; - yield ['2016-12-01T09:00:00+09:00', new \DateTime('2016/12/01', new \DateTimeZone('UTC')), new \DateTimeZone('Japan')]; + yield ['2016-12-01T00:00:00+00:00', new \DateTimeImmutable('2016/12/01', new \DateTimeZone('UTC')), null]; + yield ['2016-12-01T00:00:00+09:00', new \DateTimeImmutable('2016/12/01', new \DateTimeZone('Japan')), new \DateTimeZone('Japan')]; yield ['2016-12-01T09:00:00+09:00', new \DateTimeImmutable('2016/12/01', new \DateTimeZone('UTC')), new \DateTimeZone('Japan')]; + yield ['2016-12-01T09:00:00+09:00', new \DateTime('2016/12/01', new \DateTimeZone('UTC')), new \DateTimeZone('Japan')]; } /** @@ -157,6 +154,72 @@ public static function normalizeUsingTimeZonePassedInContextAndExpectedFormatWit ]; } + /** + * @dataProvider provideNormalizeUsingCastCases + */ + public function testNormalizeUsingCastPassedInConstructor(\DateTimeInterface $value, string $format, ?string $cast, string|int|float $expectedResult) + { + $normalizer = new DateTimeNormalizer([DateTimeNormalizer::CAST_KEY => $cast]); + + $this->assertSame($normalizer->normalize($value, null, [DateTimeNormalizer::FORMAT_KEY => $format]), $expectedResult); + } + + /** + * @dataProvider provideNormalizeUsingCastCases + */ + public function testNormalizeUsingCastPassedInContext(\DateTimeInterface $value, string $format, ?string $cast, string|int|float $expectedResult) + { + $this->assertSame($this->normalizer->normalize($value, null, [DateTimeNormalizer::FORMAT_KEY => $format, DateTimeNormalizer::CAST_KEY => $cast]), $expectedResult); + } + + /** + * @return iterable + */ + public static function provideNormalizeUsingCastCases(): iterable + { + yield [ + \DateTimeImmutable::createFromFormat('U', '1703071202'), + 'Y', + null, + '2023', + ]; + + yield [ + \DateTimeImmutable::createFromFormat('U', '1703071202'), + 'Y', + 'int', + 2023, + ]; + + yield [ + \DateTimeImmutable::createFromFormat('U', '1703071202'), + 'Ymd', + 'int', + 20231220, + ]; + + yield [ + \DateTimeImmutable::createFromFormat('U', '1703071202'), + 'Y', + 'int', + 2023, + ]; + + yield [ + \DateTimeImmutable::createFromFormat('U.v', '1703071202.388'), + 'U.v', + 'float', + 1703071202.388, + ]; + + yield [ + \DateTimeImmutable::createFromFormat('U.u', '1703071202.388811'), + 'U.u', + 'float', + 1703071202.388811, + ]; + } + public function testNormalizeInvalidObjectThrowsException() { $this->expectException(InvalidArgumentException::class); @@ -169,6 +232,8 @@ public function testSupportsDenormalization() $this->assertTrue($this->normalizer->supportsDenormalization('2016-01-01T00:00:00+00:00', \DateTimeInterface::class)); $this->assertTrue($this->normalizer->supportsDenormalization('2016-01-01T00:00:00+00:00', \DateTime::class)); $this->assertTrue($this->normalizer->supportsDenormalization('2016-01-01T00:00:00+00:00', \DateTimeImmutable::class)); + $this->assertTrue($this->normalizer->supportsDenormalization('2016-01-01T00:00:00+00:00', DateTimeImmutableChild::class)); + $this->assertTrue($this->normalizer->supportsDenormalization('2016-01-01T00:00:00+00:00', DateTimeChild::class)); $this->assertFalse($this->normalizer->supportsDenormalization('foo', 'Bar')); } @@ -178,6 +243,10 @@ public function testDenormalize() $this->assertEquals(new \DateTimeImmutable('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTimeImmutable::class)); $this->assertEquals(new \DateTime('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTime::class)); $this->assertEquals(new \DateTime('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize(' 2016-01-01T00:00:00+00:00 ', \DateTime::class)); + $this->assertEquals(new DateTimeImmutableChild('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', DateTimeImmutableChild::class)); + $this->assertEquals(new DateTimeImmutableChild('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', DateTimeImmutableChild::class)); + $this->assertEquals(new DateTimeChild('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', DateTimeChild::class)); + $this->assertEquals(new DateTimeChild('2016/01/01', new \DateTimeZone('UTC')), $this->normalizer->denormalize(' 2016-01-01T00:00:00+00:00 ', DateTimeChild::class)); $this->assertEquals(new \DateTimeImmutable('2023-05-06T17:35:34.000000+0000', new \DateTimeZone('UTC')), $this->normalizer->denormalize(1683394534, \DateTimeImmutable::class, null, [DateTimeNormalizer::FORMAT_KEY => 'U'])); $this->assertEquals(new \DateTimeImmutable('2023-05-06T17:35:34.123400+0000', new \DateTimeZone('UTC')), $this->normalizer->denormalize(1683394534.1234, \DateTimeImmutable::class, null, [DateTimeNormalizer::FORMAT_KEY => 'U.u'])); } @@ -185,10 +254,10 @@ public function testDenormalize() public function testDenormalizeUsingTimezonePassedInConstructor() { $timezone = new \DateTimeZone('Japan'); - $expected = new \DateTime('2016/12/01 17:35:00', $timezone); + $expected = new \DateTimeImmutable('2016/12/01 17:35:00', $timezone); $normalizer = new DateTimeNormalizer([DateTimeNormalizer::TIMEZONE_KEY => $timezone]); - $this->assertEquals($expected, $normalizer->denormalize('2016.12.01 17:35:00', \DateTime::class, null, [ + $this->assertEquals($expected, $normalizer->denormalize('2016.12.01 17:35:00', \DateTimeImmutable::class, null, [ DateTimeNormalizer::FORMAT_KEY => 'Y.m.d H:i:s', ])); } @@ -235,7 +304,7 @@ public static function denormalizeUsingTimezonePassedInContextProvider() '2016-12-01T17:35:00Z', new \DateTimeImmutable('2016/12/01 17:35:00', new \DateTimeZone('UTC')), 'Europe/Paris', - \DateTime::RFC3339, + \DateTimeInterface::RFC3339, ]; } @@ -310,7 +379,7 @@ public function testDenormalizeDateTimeStringWithDefaultContextFormat() public function testDenormalizeDateTimeStringWithDefaultContextAllowsErrorFormat() { $format = 'd/m/Y'; // the default format - $string = '2020-01-01'; // the value which is in the wrong format, but is accepted because of `new \DateTime` in DateTimeNormalizer::denormalize + $string = '2020-01-01'; // the value which is in the wrong format, but is accepted because of `new \DateTimeImmutable` in DateTimeNormalizer::denormalize $normalizer = new DateTimeNormalizer([DateTimeNormalizer::FORMAT_KEY => $format]); $denormalizedDate = $normalizer->denormalize($string, \DateTimeInterface::class); @@ -324,3 +393,11 @@ public function testDenormalizeFormatMismatchThrowsException() $this->normalizer->denormalize('2016-01-01T00:00:00+00:00', \DateTimeInterface::class, null, [DateTimeNormalizer::FORMAT_KEY => 'Y-m-d|']); } } + +class DateTimeChild extends \DateTime +{ +} + +class DateTimeImmutableChild extends \DateTimeImmutable +{ +} diff --git a/Tests/Normalizer/DateTimeZoneNormalizerTest.php b/Tests/Normalizer/DateTimeZoneNormalizerTest.php index b32e39d03..6ee179dbe 100644 --- a/Tests/Normalizer/DateTimeZoneNormalizerTest.php +++ b/Tests/Normalizer/DateTimeZoneNormalizerTest.php @@ -21,10 +21,7 @@ */ class DateTimeZoneNormalizerTest extends TestCase { - /** - * @var DateTimeZoneNormalizer - */ - private $normalizer; + private DateTimeZoneNormalizer $normalizer; protected function setUp(): void { diff --git a/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php b/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php index 73b61b45a..296a9dee8 100644 --- a/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php +++ b/Tests/Normalizer/Features/CacheableObjectAttributesTestTrait.php @@ -34,8 +34,6 @@ abstract protected function getNormalizerForCacheableObjectAttributesTest(): Abs * The same normalizer instance normalizes two objects of the same class in a row: * 1. an object having some uninitialized properties * 2. an object with all properties being initialized. - * - * @requires PHP 7.4 */ public function testObjectCollectionNormalization() { @@ -47,8 +45,6 @@ public function testObjectCollectionNormalization() * The same normalizer instance normalizes two objects of the same class in a row: * 1. an object with all properties being initialized * 2. an object having some uninitialized properties. - * - * @requires PHP 7.4 */ public function testReversedObjectCollectionNormalization() { diff --git a/Tests/Normalizer/Features/CallbacksTestTrait.php b/Tests/Normalizer/Features/CallbacksTestTrait.php index e573c8c22..3a9191ae8 100644 --- a/Tests/Normalizer/Features/CallbacksTestTrait.php +++ b/Tests/Normalizer/Features/CallbacksTestTrait.php @@ -59,7 +59,7 @@ public function testNormalizeCallbacksWithNoConstructorArgument($callbacks, $val { $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); - $obj = new class() extends CallbacksObject { + $obj = new class extends CallbacksObject { public function __construct() { } @@ -101,14 +101,14 @@ public function testDenormalizeCallbacksWithNoConstructorArgument($callbacks, $v { $normalizer = $this->getNormalizerForCallbacksWithPropertyTypeExtractor(); - $objWithNoConstructorArgument = new class() extends CallbacksObject { + $objWithNoConstructorArgument = new class extends CallbacksObject { public function __construct() { } }; - $obj = $normalizer->denormalize(['foo' => $valueBar], \get_class($objWithNoConstructorArgument), 'any', ['callbacks' => $callbacks]); - $this->assertInstanceof(\get_class($objWithNoConstructorArgument), $obj); + $obj = $normalizer->denormalize(['foo' => $valueBar], $objWithNoConstructorArgument::class, 'any', ['callbacks' => $callbacks]); + $this->assertInstanceof($objWithNoConstructorArgument::class, $obj); $this->assertEquals($result->getBar(), $obj->getBar()); } @@ -156,12 +156,12 @@ public static function provideNormalizeCallbacks() 'Format a date' => [ [ 'bar' => function ($bar) { - static::assertInstanceOf(\DateTime::class, $bar); + static::assertInstanceOf(\DateTimeImmutable::class, $bar); return $bar->format('d-m-Y H:i:s'); }, ], - new \DateTime('2011-09-10 06:30:00'), + new \DateTimeImmutable('2011-09-10 06:30:00'), ['bar' => '10-09-2011 06:30:00', 'foo' => null], ], 'Collect a property' => [ @@ -180,9 +180,7 @@ public static function provideNormalizeCallbacks() ], 'Count a property' => [ [ - 'bar' => function (array $bars) { - return \count($bars); - }, + 'bar' => fn (array $bars) => \count($bars), ], [new CallbacksObject(), new CallbacksObject()], ['bar' => 2, 'foo' => null], @@ -222,11 +220,11 @@ public static function provideDenormalizeCallbacks(): array 'bar' => function ($bar) { static::assertIsString($bar); - return \DateTime::createFromFormat('d-m-Y H:i:s', $bar); + return \DateTimeImmutable::createFromFormat('d-m-Y H:i:s', $bar); }, ], '10-09-2011 06:30:00', - new CallbacksObject(new \DateTime('2011-09-10 06:30:00')), + new CallbacksObject(new \DateTimeImmutable('2011-09-10 06:30:00')), ], 'Collect a property' => [ [ @@ -244,9 +242,7 @@ public static function provideDenormalizeCallbacks(): array ], 'Count a property' => [ [ - 'bar' => function (array $bars) { - return \count($bars); - }, + 'bar' => fn (array $bars) => \count($bars), ], [new CallbacksObject(), new CallbacksObject()], new CallbacksObject(2), diff --git a/Tests/Normalizer/Features/CircularReferenceTestTrait.php b/Tests/Normalizer/Features/CircularReferenceTestTrait.php index ffbddf2ab..85720bcfe 100644 --- a/Tests/Normalizer/Features/CircularReferenceTestTrait.php +++ b/Tests/Normalizer/Features/CircularReferenceTestTrait.php @@ -42,7 +42,7 @@ public function testUnableToNormalizeCircularReference(array $defaultContext, ar $obj = $this->getSelfReferencingModel(); $this->expectException(CircularReferenceException::class); - $this->expectExceptionMessage(sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', \get_class($obj), $expectedLimit)); + $this->expectExceptionMessage(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', $obj::class, $expectedLimit)); $normalizer->normalize($obj, null, $context); } @@ -51,15 +51,15 @@ public function testCircularReferenceHandler() $normalizer = $this->getNormalizerForCircularReference([]); $obj = $this->getSelfReferencingModel(); - $expected = ['me' => \get_class($obj)]; + $expected = ['me' => $obj::class]; $context = [ 'circular_reference_handler' => function ($actualObj, string $format, array $context) use ($obj) { - $this->assertInstanceOf(\get_class($obj), $actualObj); + $this->assertInstanceOf($obj::class, $actualObj); $this->assertSame('test', $format); $this->assertArrayHasKey('foo', $context); - return \get_class($actualObj); + return $actualObj::class; }, 'foo' => 'bar', ]; diff --git a/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php b/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php index 821c53732..0a5f6f249 100644 --- a/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php +++ b/Tests/Normalizer/Features/ConstructorArgumentsTestTrait.php @@ -64,9 +64,10 @@ public function testConstructorWithMissingData() $normalizer = $this->getDenormalizerForConstructArguments(); try { $normalizer->denormalize($data, ConstructorArgumentsObject::class); - self::fail(sprintf('Failed asserting that exception of type "%s" is thrown.', MissingConstructorArgumentsException::class)); + self::fail(\sprintf('Failed asserting that exception of type "%s" is thrown.', MissingConstructorArgumentsException::class)); } catch (MissingConstructorArgumentsException $e) { - self::assertSame(sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$foo", "$baz".', ConstructorArgumentsObject::class), $e->getMessage()); + self::assertSame(ConstructorArgumentsObject::class, $e->getClass()); + self::assertSame(\sprintf('Cannot create an instance of "%s" from serialized data because its constructor requires the following parameters to be present : "$foo", "$baz".', ConstructorArgumentsObject::class), $e->getMessage()); self::assertSame(['foo', 'baz'], $e->getMissingConstructorArguments()); } } diff --git a/Tests/Normalizer/Features/ContextMetadataTestTrait.php b/Tests/Normalizer/Features/ContextMetadataTestTrait.php index 4dbba913b..787157628 100644 --- a/Tests/Normalizer/Features/ContextMetadataTestTrait.php +++ b/Tests/Normalizer/Features/ContextMetadataTestTrait.php @@ -11,12 +11,11 @@ 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\Attribute\Context; +use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; @@ -29,14 +28,17 @@ */ trait ContextMetadataTestTrait { - public function testContextMetadataNormalize() + /** + * @dataProvider contextMetadataDummyProvider + */ + public function testContextMetadataNormalize(string $contextMetadataDummyClass) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $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'); + $dummy = new $contextMetadataDummyClass(); + $dummy->date = new \DateTimeImmutable('2011-07-28T08:44:00.123+00:00'); self::assertEquals(['date' => '2011-07-28T08:44:00+00:00'], $normalizer->normalize($dummy)); @@ -49,32 +51,44 @@ public function testContextMetadataNormalize() ]), 'base denormalization context is unchanged for this group'); } - public function testContextMetadataContextDenormalize() + /** + * @dataProvider contextMetadataDummyProvider + */ + public function testContextMetadataContextDenormalize(string $contextMetadataDummyClass) { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $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|ContextChildMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '2011-07-28T08:44:00+00:00'], $contextMetadataDummyClass); + self::assertEquals(new \DateTimeImmutable('2011-07-28T08:44:00+00:00'), $dummy->date); - /** @var ContextMetadataDummy $dummy */ + /** @var ContextMetadataDummy|ContextChildMetadataDummy $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'); + self::assertEquals(new \DateTimeImmutable('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, [ + /** @var ContextMetadataDummy|ContextChildMetadataDummy $dummy */ + $dummy = $normalizer->denormalize(['date' => '28/07/2011'], $contextMetadataDummyClass, null, [ ObjectNormalizer::GROUPS => 'simple', ]); self::assertEquals('2011-07-28', $dummy->date->format('Y-m-d'), 'a specific denormalization context is used for this group'); } + public static function contextMetadataDummyProvider(): array + { + return [ + [ContextMetadataDummy::class], + [ContextChildMetadataDummy::class], + [ClassAndPropertyContextMetadataDummy::class], + ]; + } + public function testContextDenormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter(), null, new PhpDocExtractor()); new Serializer([new DateTimeNormalizer(), $normalizer]); @@ -87,28 +101,62 @@ public function testContextDenormalizeWithNameConverter() 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"} - * ) + * @var \DateTimeImmutable + */ + #[Groups(['extended', 'simple'])] + #[Context([DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] + #[Context( + normalizationContext: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339_EXTENDED], + groups: ['extended'], + )] + #[Context( + denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'], + groups: ['simple'], + )] + public $date; +} + +class ContextChildMetadataDummy +{ + /** + * @var \DateTimeImmutable + */ + #[Groups(['extended', 'simple'])] + #[DummyContextChild([DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] + #[DummyContextChild( + normalizationContext: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339_EXTENDED], + groups: ['extended'], + )] + #[DummyContextChild( + denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'], + groups: ['simple'], + )] + public $date; +} + +#[Context(context: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339])] +#[Context( + context: [DateTimeNormalizer::FORMAT_KEY => \DateTimeInterface::RFC3339_EXTENDED], + groups: ['extended'], +)] +class ClassAndPropertyContextMetadataDummy +{ + /** + * @var \DateTimeImmutable */ + #[Groups(['extended', 'simple'])] + #[Context( + denormalizationContext: [DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'], + groups: ['simple'], + )] public $date; } class ContextMetadataNamingDummy { /** - * @var \DateTime - * - * @Context({ DateTimeNormalizer::FORMAT_KEY = "d/m/Y" }) + * @var \DateTimeImmutable */ + #[Context([DateTimeNormalizer::FORMAT_KEY => 'd/m/Y'])] public $createdAt; } diff --git a/Tests/Normalizer/Features/DummyContextChild.php b/Tests/Normalizer/Features/DummyContextChild.php new file mode 100644 index 000000000..8b5fe9ec3 --- /dev/null +++ b/Tests/Normalizer/Features/DummyContextChild.php @@ -0,0 +1,19 @@ + + * + * 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 Symfony\Component\Serializer\Attribute\Context; + +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] +class DummyContextChild extends Context +{ +} diff --git a/Tests/Normalizer/Features/FilterBoolObject.php b/Tests/Normalizer/Features/FilterBoolObject.php new file mode 100644 index 000000000..2d9828b91 --- /dev/null +++ b/Tests/Normalizer/Features/FilterBoolObject.php @@ -0,0 +1,19 @@ + + * + * 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; + +class FilterBoolObject +{ + public function __construct(public ?bool $value) + { + } +} diff --git a/Tests/Normalizer/Features/FilterBoolTestTrait.php b/Tests/Normalizer/Features/FilterBoolTestTrait.php new file mode 100644 index 000000000..ceb80dc3b --- /dev/null +++ b/Tests/Normalizer/Features/FilterBoolTestTrait.php @@ -0,0 +1,47 @@ + + * + * 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 Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Test AbstractNormalizer::FILTER_BOOL. + */ +trait FilterBoolTestTrait +{ + abstract protected function getNormalizerForFilterBool(): DenormalizerInterface; + + /** + * @dataProvider provideObjectWithBoolArguments + */ + public function testObjectWithBoolArguments(?bool $expectedValue, ?string $parameterValue) + { + $normalizer = $this->getNormalizerForFilterBool(); + + $dummy = $normalizer->denormalize(['value' => $parameterValue], FilterBoolObject::class, context: ['filter_bool' => true]); + + $this->assertSame($expectedValue, $dummy->value); + } + + public static function provideObjectWithBoolArguments() + { + yield 'default value' => [null, null]; + yield '0' => [false, '0']; + yield 'false' => [false, 'false']; + yield 'no' => [false, 'no']; + yield 'off' => [false, 'off']; + yield '1' => [true, '1']; + yield 'true' => [true, 'true']; + yield 'yes' => [true, 'yes']; + yield 'on' => [true, 'on']; + } +} diff --git a/Tests/Normalizer/Features/GroupsTestTrait.php b/Tests/Normalizer/Features/GroupsTestTrait.php index 294109736..621ceec41 100644 --- a/Tests/Normalizer/Features/GroupsTestTrait.php +++ b/Tests/Normalizer/Features/GroupsTestTrait.php @@ -13,7 +13,7 @@ use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; /** * Test AbstractNormalizer::GROUPS. @@ -31,6 +31,7 @@ public function testGroupsNormalize() $obj = new GroupDummy(); $obj->setFoo('foo'); $obj->setBar('bar'); + $obj->setQuux('quux'); $obj->setFooBar('fooBar'); $obj->setSymfony('symfony'); $obj->setKevin('kevin'); @@ -59,23 +60,23 @@ public function testGroupsDenormalize() $data = ['foo' => 'foo', 'bar' => 'bar']; - $normalized = $normalizer->denormalize( + $denormalized = $normalizer->denormalize( $data, GroupDummy::class, null, ['groups' => ['a']] ); - $this->assertEquals($obj, $normalized); + $this->assertEquals($obj, $denormalized); $obj->setBar('bar'); - $normalized = $normalizer->denormalize( + $denormalized = $normalizer->denormalize( $data, GroupDummy::class, null, ['groups' => ['a', 'b']] ); - $this->assertEquals($obj, $normalized); + $this->assertEquals($obj, $denormalized); } public function testNormalizeNoPropertyInGroup() diff --git a/Tests/Normalizer/Features/MaxDepthTestTrait.php b/Tests/Normalizer/Features/MaxDepthTestTrait.php index ef3123d58..46c7dfceb 100644 --- a/Tests/Normalizer/Features/MaxDepthTestTrait.php +++ b/Tests/Normalizer/Features/MaxDepthTestTrait.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer\Features; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\MaxDepthDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\MaxDepthDummy; /** * Covers AbstractObjectNormalizer::ENABLE_MAX_DEPTH and AbstractObjectNormalizer::MAX_DEPTH_HANDLER. diff --git a/Tests/Normalizer/Features/ObjectDummy.php b/Tests/Normalizer/Features/ObjectDummy.php index 69661fd16..39f3c354a 100644 --- a/Tests/Normalizer/Features/ObjectDummy.php +++ b/Tests/Normalizer/Features/ObjectDummy.php @@ -22,6 +22,7 @@ class ObjectDummy private $baz; protected $camelCase; protected $object; + private $go; public function getFoo() { @@ -72,4 +73,14 @@ public function getObject() { return $this->object; } + + public function setGo($go) + { + $this->go = $go; + } + + public function canGo() + { + return $this->go; + } } diff --git a/Tests/Normalizer/Features/ObjectDummyWithContextAttribute.php b/Tests/Normalizer/Features/ObjectDummyWithContextAttribute.php new file mode 100644 index 000000000..0421a5c7b --- /dev/null +++ b/Tests/Normalizer/Features/ObjectDummyWithContextAttribute.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\Normalizer\Features; + +use Symfony\Component\Serializer\Attribute\Context; +use Symfony\Component\Serializer\Attribute\SerializedName; +use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; + +final class ObjectDummyWithContextAttribute +{ + public function __construct( + #[Context([DateTimeNormalizer::FORMAT_KEY => 'm-d-Y'])] + #[SerializedName('property_with_serialized_name')] + public \DateTimeImmutable $propertyWithSerializedName, + + #[Context([DateTimeNormalizer::FORMAT_KEY => 'm-d-Y'])] + public \DateTimeImmutable $propertyWithoutSerializedName, + ) { + } +} diff --git a/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php b/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php index 02707768f..3a424faf8 100644 --- a/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php +++ b/Tests/Normalizer/Features/SkipUninitializedValuesTestTrait.php @@ -22,8 +22,6 @@ trait SkipUninitializedValuesTestTrait abstract protected function getNormalizerForSkipUninitializedValues(): AbstractObjectNormalizer; /** - * @requires PHP 7.4 - * * @dataProvider skipUninitializedValuesFlagProvider */ public function testSkipUninitializedValues(array $context) @@ -50,9 +48,6 @@ public static function skipUninitializedValuesFlagProvider(): iterable yield 'using default context value' => [['groups' => ['foo']]]; } - /** - * @requires PHP 7.4 - */ public function testWithoutSkipUninitializedValues() { $object = new TypedPropertiesObjectWithGetters(); diff --git a/Tests/Normalizer/Features/TypedPropertiesObject.php b/Tests/Normalizer/Features/TypedPropertiesObject.php index 49edde54c..e830271cc 100644 --- a/Tests/Normalizer/Features/TypedPropertiesObject.php +++ b/Tests/Normalizer/Features/TypedPropertiesObject.php @@ -11,22 +11,16 @@ namespace Symfony\Component\Serializer\Tests\Normalizer\Features; -use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Attribute\Groups; class TypedPropertiesObject { - /** - * @Groups({"foo"}) - */ + #[Groups(['foo'])] public string $unInitialized; - /** - * @Groups({"foo"}) - */ + #[Groups(['foo'])] public string $initialized = 'value'; - /** - * @Groups({"bar"}) - */ + #[Groups(['bar'])] public string $initialized2 = 'value'; } diff --git a/Tests/Normalizer/FormErrorNormalizerTest.php b/Tests/Normalizer/FormErrorNormalizerTest.php index 4f3e6a1e6..bc18125cf 100644 --- a/Tests/Normalizer/FormErrorNormalizerTest.php +++ b/Tests/Normalizer/FormErrorNormalizerTest.php @@ -19,15 +19,8 @@ class FormErrorNormalizerTest extends TestCase { - /** - * @var FormErrorNormalizer - */ - private $normalizer; - - /** - * @var FormInterface - */ - private $form; + private FormErrorNormalizer $normalizer; + private FormInterface $form; protected function setUp(): void { diff --git a/Tests/Normalizer/GetSetMethodNormalizerTest.php b/Tests/Normalizer/GetSetMethodNormalizerTest.php index 424dd215e..80788a0d2 100644 --- a/Tests/Normalizer/GetSetMethodNormalizerTest.php +++ b/Tests/Normalizer/GetSetMethodNormalizerTest.php @@ -11,16 +11,16 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use Doctrine\Common\Annotations\AnnotationReader; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\Serializer\Annotation\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; @@ -30,14 +30,16 @@ use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ClassWithIgnoreAnnotation; use Symfony\Component\Serializer\Tests\Fixtures\Attributes\ClassWithIgnoreAttribute; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\SiblingHolder; use Symfony\Component\Serializer\Tests\Normalizer\Features\CacheableObjectAttributesTestTrait; 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\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -52,6 +54,7 @@ class GetSetMethodNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -59,21 +62,15 @@ class GetSetMethodNormalizerTest extends TestCase use SkipUninitializedValuesTestTrait; use TypeEnforcementTestTrait; - /** - * @var GetSetMethodNormalizer - */ - private $normalizer; - /** - * @var SerializerInterface - */ - private $serializer; + private GetSetMethodNormalizer $normalizer; + private SerializerInterface&NormalizerInterface&MockObject $serializer; protected function setUp(): void { $this->createNormalizer(); } - private function createNormalizer(array $defaultContext = []) + private function createNormalizer(array $defaultContext = []): void { $this->serializer = $this->createMock(SerializerNormalizer::class); $this->normalizer = new GetSetMethodNormalizer(null, null, null, null, null, $defaultContext); @@ -235,21 +232,21 @@ public function testConstructorWArgWithPrivateMutator() protected function getNormalizerForCallbacksWithPropertyTypeExtractor(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new GetSetMethodNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), $this->getCallbackPropertyTypeExtractor()); } protected function getNormalizerForCallbacks(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new GetSetMethodNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); } protected function getNormalizerForCircularReference(array $defaultContext): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory), null, null, null, $defaultContext); new Serializer([$normalizer]); @@ -263,7 +260,7 @@ protected function getSelfReferencingModel() protected function getDenormalizerForConstructArguments(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $denormalizer = new GetSetMethodNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); new Serializer([$denormalizer]); @@ -272,21 +269,26 @@ protected function getDenormalizerForConstructArguments(): GetSetMethodNormalize protected function getNormalizerForGroups(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new GetSetMethodNormalizer($classMetadataFactory); } protected function getDenormalizerForGroups(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new GetSetMethodNormalizer($classMetadataFactory); } + protected function getNormalizerForFilterBool(): GetSetMethodNormalizer + { + return new GetSetMethodNormalizer(); + } + public function testGroupsNormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new GetSetMethodNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -307,7 +309,7 @@ public function testGroupsNormalizeWithNameConverter() public function testGroupsDenormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new GetSetMethodNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -322,13 +324,13 @@ public function testGroupsDenormalizeWithNameConverter() 'foo_bar' => '@dunglas', 'symfony' => '@coopTilleuls', 'coop_tilleuls' => 'les-tilleuls.coop', - ], 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy', null, [GetSetMethodNormalizer::GROUPS => ['name_converter']]) + ], GroupDummy::class, null, [GetSetMethodNormalizer::GROUPS => ['name_converter']]) ); } protected function getNormalizerForMaxDepth(): NormalizerInterface { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new GetSetMethodNormalizer($classMetadataFactory); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); @@ -338,7 +340,7 @@ protected function getNormalizerForMaxDepth(): NormalizerInterface protected function getDenormalizerForObjectToPopulate(): DenormalizerInterface { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, new PhpDocExtractor()); new Serializer([$normalizer]); @@ -362,7 +364,7 @@ public function testRejectInvalidKey() protected function getNormalizerForIgnoredAttributes(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, new PhpDocExtractor()); new Serializer([$normalizer]); @@ -371,7 +373,7 @@ protected function getNormalizerForIgnoredAttributes(): GetSetMethodNormalizer protected function getDenormalizerForIgnoredAttributes(): GetSetMethodNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, new PhpDocExtractor()); new Serializer([$normalizer]); @@ -380,8 +382,6 @@ protected function getDenormalizerForIgnoredAttributes(): GetSetMethodNormalizer public function testUnableToNormalizeObjectAttribute() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); $serializer = $this->createMock(SerializerInterface::class); $this->normalizer->setSerializer($serializer); @@ -389,6 +389,9 @@ public function testUnableToNormalizeObjectAttribute() $object = new \stdClass(); $obj->setObject($object); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); + $this->normalizer->normalize($obj, 'any'); } @@ -397,14 +400,12 @@ public function testSiblingReference() $serializer = new Serializer([$this->normalizer]); $this->normalizer->setSerializer($serializer); - $siblingHolder = new SiblingHolder(); - $expected = [ 'sibling0' => ['coopTilleuls' => 'Les-Tilleuls.coop'], 'sibling1' => ['coopTilleuls' => 'Les-Tilleuls.coop'], 'sibling2' => ['coopTilleuls' => 'Les-Tilleuls.coop'], ]; - $this->assertEquals($expected, $this->normalizer->normalize($siblingHolder)); + $this->assertEquals($expected, $this->normalizer->normalize(new SiblingHolder())); } public function testDenormalizeNonExistingAttribute() @@ -434,11 +435,21 @@ public function testNoStaticGetSetSupport() } /** - * @requires PHP 8 + * @param class-string $class + * + * @dataProvider provideNotIgnoredMethodSupport */ - public function testNotIgnoredMethodSupport() + public function testNotIgnoredMethodSupport(string $class) { - $this->assertFalse($this->normalizer->supportsNormalization(new ClassWithIgnoreAttribute())); + $this->assertFalse($this->normalizer->supportsNormalization(new $class())); + } + + public static function provideNotIgnoredMethodSupport(): iterable + { + return [ + [ClassWithIgnoreAttribute::class], + [ClassWithIgnoreAnnotation::class], + ]; } public function testPrivateSetter() @@ -498,21 +509,29 @@ protected function getNormalizerForCacheableObjectAttributesTest(): GetSetMethod protected function getNormalizerForSkipUninitializedValues(): GetSetMethodNormalizer { - return new GetSetMethodNormalizer(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))); + return new GetSetMethodNormalizer(new ClassMetadataFactory(new AttributeLoader())); } public function testNormalizeWithDiscriminator() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, null, $discriminator); $this->assertSame(['type' => 'one', 'url' => 'URL_ONE'], $normalizer->normalize(new GetSetMethodDiscriminatedDummyOne())); } + public function testNormalizeWithMethodNamesSimilarToAccessors() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new GetSetMethodNormalizer($classMetadataFactory); + + $this->assertSame(['class' => 'class', 123 => 123], $normalizer->normalize(new GetSetWithAccessorishMethod())); + } + public function testDenormalizeWithDiscriminator() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); $normalizer = new GetSetMethodNormalizer($classMetadataFactory, null, null, $discriminator); @@ -815,12 +834,10 @@ public function __call($key, $value) } } -/** - * @DiscriminatorMap(typeProperty="type", mapping={ - * "one" = GetSetMethodDiscriminatedDummyOne::class, - * "two" = GetSetMethodDiscriminatedDummyTwo::class, - * }) - */ +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'one' => GetSetMethodDiscriminatedDummyOne::class, + 'two' => GetSetMethodDiscriminatedDummyTwo::class, +])] interface GetSetMethodDummyInterface { } @@ -899,3 +916,46 @@ public function setBar($bar = null, $other = true) $this->bar = $bar; } } + +class GetSetWithAccessorishMethod +{ + public function cancel() + { + return 'cancel'; + } + + public function hash() + { + return 'hash'; + } + + public function getClass() + { + return 'class'; + } + + public function setClass() + { + } + + public function get123() + { + return 123; + } + + public function set123() + { + } + + public function gettings() + { + } + + public function settings() + { + } + + public function isolate() + { + } +} diff --git a/Tests/Normalizer/JsonSerializableNormalizerTest.php b/Tests/Normalizer/JsonSerializableNormalizerTest.php index 177b8c6e7..f8f8546d7 100644 --- a/Tests/Normalizer/JsonSerializableNormalizerTest.php +++ b/Tests/Normalizer/JsonSerializableNormalizerTest.php @@ -30,15 +30,8 @@ class JsonSerializableNormalizerTest extends TestCase { use CircularReferenceTestTrait; - /** - * @var JsonSerializableNormalizer - */ - private $normalizer; - - /** - * @var MockObject&JsonSerializerNormalizer - */ - private $serializer; + private JsonSerializableNormalizer $normalizer; + private MockObject&JsonSerializerNormalizer $serializer; protected function setUp(): void { @@ -75,9 +68,10 @@ public function testNormalize() public function testCircularNormalize() { - $this->expectException(CircularReferenceException::class); $this->createNormalizer([JsonSerializableNormalizer::CIRCULAR_REFERENCE_LIMIT => 1]); + $this->expectException(CircularReferenceException::class); + $this->serializer ->expects($this->once()) ->method('normalize') diff --git a/Tests/Normalizer/MapDenormalizationTest.php b/Tests/Normalizer/MapDenormalizationTest.php index 6c32fb925..75ecbccb9 100644 --- a/Tests/Normalizer/MapDenormalizationTest.php +++ b/Tests/Normalizer/MapDenormalizationTest.php @@ -11,7 +11,6 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\Serializer\Exception\InvalidArgumentException; @@ -22,7 +21,7 @@ use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; @@ -188,7 +187,7 @@ public function testNullableAbstractObject() private function getSerializer() { - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummyValue::class === $value) { @@ -210,7 +209,7 @@ public function hasMetadataFor($value): bool } }; - $factory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $factory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($factory, null, null, new PhpDocExtractor(), new ClassDiscriminatorFromClassMetadata($loaderMock)); $serializer = new Serializer([$normalizer, new ArrayDenormalizer()]); $normalizer->setSerializer($serializer); diff --git a/Tests/Normalizer/ObjectNormalizerTest.php b/Tests/Normalizer/ObjectNormalizerTest.php index 4ff8c114d..d45586b44 100644 --- a/Tests/Normalizer/ObjectNormalizerTest.php +++ b/Tests/Normalizer/ObjectNormalizerTest.php @@ -11,31 +11,34 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use Doctrine\Common\Annotations\AnnotationReader; use PHPStan\PhpDocParser\Parser\PhpDocParser; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\Serializer\Annotation\Ignore; +use Symfony\Component\Serializer\Attribute\Ignore; use Symfony\Component\Serializer\Exception\LogicException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Mapping\Loader\YamlFileLoader; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; use Symfony\Component\Serializer\Tests\Fixtures\CircularReferenceDummy; use Symfony\Component\Serializer\Tests\Fixtures\DummyPrivatePropertyWithoutGetter; use Symfony\Component\Serializer\Tests\Fixtures\OtherSerializedNameDummy; @@ -49,6 +52,7 @@ 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\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -71,6 +75,7 @@ class ObjectNormalizerTest extends TestCase use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; use ContextMetadataTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -79,21 +84,15 @@ class ObjectNormalizerTest extends TestCase use SkipUninitializedValuesTestTrait; use TypeEnforcementTestTrait; - /** - * @var ObjectNormalizer - */ - private $normalizer; - /** - * @var SerializerInterface - */ - private $serializer; + private ObjectNormalizer $normalizer; + private SerializerInterface&NormalizerInterface&MockObject $serializer; protected function setUp(): void { $this->createNormalizer(); } - private function createNormalizer(array $defaultContext = [], ?ClassMetadataFactoryInterface $classMetadataFactory = null) + private function createNormalizer(array $defaultContext = [], ?ClassMetadataFactoryInterface $classMetadataFactory = null): void { $this->serializer = $this->createMock(ObjectSerializerNormalizer::class); $this->normalizer = new ObjectNormalizer($classMetadataFactory, null, null, null, null, null, $defaultContext); @@ -109,6 +108,7 @@ public function testNormalize() $obj->setBaz(true); $obj->setCamelCase('camelcase'); $obj->setObject($object); + $obj->setGo(true); $this->serializer ->expects($this->once()) @@ -125,14 +125,12 @@ public function testNormalize() 'fooBar' => 'foobar', 'camelCase' => 'camelcase', 'object' => 'string_object', + 'go' => true, ], $this->normalizer->normalize($obj, 'any') ); } - /** - * @requires PHP 7.4 - */ public function testNormalizeObjectWithUninitializedProperties() { $obj = new Php74Dummy(); @@ -162,9 +160,6 @@ public function testNormalizeObjectWithLazyProperties() ); } - /** - * @requires PHP 7.4 - */ public function testNormalizeObjectWithUninitializedPrivateProperties() { $obj = new Php74DummyPrivate(); @@ -332,8 +327,6 @@ public function testConstructorWithUnconstructableNullableObjectTypeHintDenormal public function testConstructorWithUnknownObjectTypeHintDenormalize() { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Could not determine the class of the parameter "unknown".'); $data = [ 'id' => 10, 'unknown' => [ @@ -346,6 +339,9 @@ public function testConstructorWithUnknownObjectTypeHintDenormalize() $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Could not determine the class of the parameter "unknown".'); + $normalizer->denormalize($data, DummyWithConstructorInexistingObject::class); } @@ -362,13 +358,18 @@ protected function getNormalizerForAttributes(): ObjectNormalizer protected function getDenormalizerForAttributes(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new ReflectionExtractor()); new Serializer([$normalizer]); return $normalizer; } + protected function getNormalizerForFilterBool(): ObjectNormalizer + { + return new ObjectNormalizer(); + } + public function testAttributesContextDenormalizeConstructor() { $normalizer = new ObjectNormalizer(null, null, null, new ReflectionExtractor()); @@ -388,7 +389,7 @@ public function testAttributesContextDenormalizeConstructor() public function testNormalizeSameObjectWithDifferentAttributes() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new ObjectNormalizer($classMetadataFactory); $serializer = new Serializer([$this->normalizer]); $this->normalizer->setSerializer($serializer); @@ -463,7 +464,7 @@ public function testSiblingReference() protected function getDenormalizerForConstructArguments(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $denormalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); $serializer = new Serializer([$denormalizer]); $denormalizer->setSerializer($serializer); @@ -475,7 +476,7 @@ protected function getDenormalizerForConstructArguments(): ObjectNormalizer protected function getNormalizerForGroups(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory); // instantiate a serializer with the normalizer to handle normalizing recursive structures new Serializer([$normalizer]); @@ -485,14 +486,14 @@ protected function getNormalizerForGroups(): ObjectNormalizer protected function getDenormalizerForGroups(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new ObjectNormalizer($classMetadataFactory); } public function testGroupsNormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new ObjectNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -513,7 +514,7 @@ public function testGroupsNormalizeWithNameConverter() public function testGroupsDenormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new ObjectNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -528,13 +529,13 @@ public function testGroupsDenormalizeWithNameConverter() 'foo_bar' => '@dunglas', 'symfony' => '@coopTilleuls', 'coop_tilleuls' => 'les-tilleuls.coop', - ], 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy', null, [ObjectNormalizer::GROUPS => ['name_converter']]) + ], GroupDummy::class, null, [ObjectNormalizer::GROUPS => ['name_converter']]) ); } public function testGroupsDenormalizeWithMetaDataNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new ObjectNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); $this->normalizer->setSerializer($this->serializer); @@ -562,7 +563,7 @@ protected function getNormalizerForIgnoredAttributes(): ObjectNormalizer protected function getDenormalizerForIgnoredAttributes(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new ReflectionExtractor()); new Serializer([$normalizer]); @@ -573,7 +574,7 @@ protected function getDenormalizerForIgnoredAttributes(): ObjectNormalizer protected function getNormalizerForMaxDepth(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); @@ -585,7 +586,7 @@ protected function getNormalizerForMaxDepth(): ObjectNormalizer protected function getDenormalizerForObjectToPopulate(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory, null, null, new PhpDocExtractor()); new Serializer([$normalizer]); @@ -603,7 +604,7 @@ protected function getNormalizerForSkipNullValues(): ObjectNormalizer protected function getNormalizerForSkipUninitializedValues(): ObjectNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new ObjectNormalizer($classMetadataFactory); } @@ -648,8 +649,6 @@ protected function getDenormalizerForTypeEnforcement(): ObjectNormalizer public function testUnableToNormalizeObjectAttribute() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); $serializer = $this->createMock(SerializerInterface::class); $this->normalizer->setSerializer($serializer); @@ -657,6 +656,9 @@ public function testUnableToNormalizeObjectAttribute() $object = new \stdClass(); $obj->setObject($object); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot normalize attribute "object" because the injected serializer is not a normalizer'); + $this->normalizer->normalize($obj, 'any'); } @@ -693,25 +695,13 @@ public function testNormalizeNotSerializableContext() 'camelCase' => null, 'object' => null, 'bar' => null, + 'go' => null, ]; $this->assertEquals($expected, $this->normalizer->normalize($objectDummy, null, ['not_serializable' => function () { }])); } - public function testDefaultExcludeFromCacheKey() - { - $normalizer = new class(null, null, null, null, null, null, [ObjectNormalizer::EXCLUDE_FROM_CACHE_KEY => ['foo']]) extends ObjectNormalizer { - protected function isCircularReference($object, &$context): bool - { - ObjectNormalizerTest::assertContains('foo', $this->defaultContext[ObjectNormalizer::EXCLUDE_FROM_CACHE_KEY]); - - return false; - } - }; - $normalizer->normalize(new ObjectDummy()); - } - public function testThrowUnexpectedValueException() { $this->expectException(UnexpectedValueException::class); @@ -757,37 +747,23 @@ public function testDoesntHaveIssuesWithUnionConstTypes() $normalizer = new ObjectNormalizer(null, null, null, $extractor); $serializer = new Serializer([new ArrayDenormalizer(), new DateTimeNormalizer(), $normalizer]); - $this->assertSame('bar', $serializer->denormalize(['foo' => 'bar'], \get_class(new class() { + $this->assertSame('bar', $serializer->denormalize(['foo' => 'bar'], (new class { /** @var self::*|null */ public $foo; - }))->foo); - } - - public function testExtractAttributesRespectsFormat() - { - $normalizer = new FormatAndContextAwareNormalizer(); - - $data = new ObjectDummy(); - $data->setFoo('bar'); - $data->bar = 'foo'; - - $this->assertSame(['foo' => 'bar', 'bar' => 'foo'], $normalizer->normalize($data, 'foo_and_bar_included')); + })::class)->foo); } public function testExtractAttributesRespectsContext() { - $normalizer = new FormatAndContextAwareNormalizer(); + $normalizer = new ObjectNormalizer(); $data = new ObjectDummy(); $data->setFoo('bar'); $data->bar = 'foo'; - $this->assertSame(['foo' => 'bar', 'bar' => 'foo'], $normalizer->normalize($data, null, ['include_foo_and_bar' => true])); + $this->assertSame(['foo' => 'bar', 'bar' => 'foo'], $normalizer->normalize($data, null, [AbstractNormalizer::ATTRIBUTES => ['foo', 'bar']])); } - /** - * @requires PHP 8 - */ public function testDenormalizeFalsePseudoType() { // given a serializer that extracts the attribute types of an object via ReflectionExtractor @@ -806,15 +782,15 @@ public function testDenormalizeFalsePseudoType() public function testAdvancedNameConverter() { - $nameConverter = new class() implements AdvancedNameConverterInterface { + $nameConverter = new class implements AdvancedNameConverterInterface { public function normalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { - return sprintf('%s-%s-%s-%s', $propertyName, $class, $format, $context['foo']); + return \sprintf('%s-%s-%s-%s', $propertyName, $class, $format, $context['foo']); } public function denormalize(string $propertyName, ?string $class = null, ?string $format = null, array $context = []): string { - return sprintf('%s-%s-%s-%s', $propertyName, $class, $format, $context['foo']); + return \sprintf('%s-%s-%s-%s', $propertyName, $class, $format, $context['foo']); } }; @@ -832,6 +808,7 @@ public function testDefaultObjectClassResolver() $obj->setBaz(true); $obj->setCamelCase('camelcase'); $obj->unwantedProperty = 'notwanted'; + $obj->setGo(false); $this->assertEquals( [ @@ -841,6 +818,7 @@ public function testDefaultObjectClassResolver() 'fooBar' => 'foobar', 'camelCase' => 'camelcase', 'object' => null, + 'go' => false, ], $normalizer->normalize($obj, 'any') ); @@ -848,9 +826,7 @@ public function testDefaultObjectClassResolver() public function testObjectClassResolver() { - $classResolver = function ($object) { - return ObjectDummy::class; - }; + $classResolver = fn ($object) => ObjectDummy::class; $normalizer = new ObjectNormalizer(null, null, null, null, null, $classResolver); @@ -869,6 +845,7 @@ public function testObjectClassResolver() 'fooBar' => 'foobar', 'camelCase' => 'camelcase', 'object' => null, + 'go' => null, ], $normalizer->normalize($obj, 'any') ); @@ -888,26 +865,54 @@ public function testNormalizeStdClass() $this->assertSame(['baz' => 'baz'], $this->normalizer->normalize($o2)); } - public function testNormalizeWithIgnoreAnnotationAndPrivateProperties() + public function testNotNormalizableValueInvalidType() + { + if (!class_exists(InvalidTypeException::class)) { + $this->markTestSkipped('Skipping test as the improvements on PropertyAccess are required.'); + } + + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Expected argument of type "string", "array" given at property path "initialized"'); + + try { + $this->normalizer->denormalize(['initialized' => ['not a string']], TypedPropertiesObject::class, 'array'); + } catch (NotNormalizableValueException $e) { + $this->assertSame(['string'], $e->getExpectedTypes()); + + throw $e; + } + } + + public function testNormalizeWithoutSerializerSet() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot normalize attribute "foo" because the injected serializer is not a normalizer.'); + + $normalizer->normalize(new ObjectConstructorDummy([], [], [])); + } + + public function testNormalizeWithIgnoreAttributeAndPrivateProperties() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory); - $this->assertSame(['foo' => 'foo'], $normalizer->normalize(new ObjectDummyWithIgnoreAnnotationAndPrivateProperty())); + $this->assertSame(['foo' => 'foo'], $normalizer->normalize(new ObjectDummyWithIgnoreAttributeAndPrivateProperty())); } - public function testDenormalizeWithIgnoreAnnotationAndPrivateProperties() + public function testDenormalizeWithIgnoreAttributeAndPrivateProperties() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new ObjectNormalizer($classMetadataFactory); $obj = $normalizer->denormalize([ 'foo' => 'set', 'ignore' => 'set', 'private' => 'set', - ], ObjectDummyWithIgnoreAnnotationAndPrivateProperty::class); + ], ObjectDummyWithIgnoreAttributeAndPrivateProperty::class); - $expected = new ObjectDummyWithIgnoreAnnotationAndPrivateProperty(); + $expected = new ObjectDummyWithIgnoreAttributeAndPrivateProperty(); $expected->foo = 'set'; $this->assertEquals($expected, $obj); @@ -946,6 +951,34 @@ public function testDenormalizeWithPropertyPath() $this->assertEquals($expected, $obj); } + + public function testObjectNormalizerWithAttributeLoaderAndObjectHasStaticProperty() + { + $class = new class { + public static string $foo; + }; + + $normalizer = new ObjectNormalizer(new ClassMetadataFactory(new AttributeLoader())); + $this->assertSame([], $normalizer->normalize($class)); + } + + public function testNormalizeWithMethodNamesSimilarToAccessors() + { + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); + $normalizer = new ObjectNormalizer($classMetadataFactory); + + $object = new ObjectWithAccessorishMethods(); + $normalized = $normalizer->normalize($object); + + $this->assertFalse($object->isAccessorishCalled()); + $this->assertSame([ + 'accessorishCalled' => false, + 'tell' => true, + 'class' => true, + 'responsibility' => true, + 123 => 321, + ], $normalized); + } } class ProxyObjectDummy extends ObjectDummy @@ -1127,22 +1160,6 @@ public function __isset($name): bool } } -class FormatAndContextAwareNormalizer extends ObjectNormalizer -{ - protected function isAllowedAttribute($classOrObject, string $attribute, ?string $format = null, array $context = []): bool - { - if (\in_array($attribute, ['foo', 'bar']) && 'foo_and_bar_included' === $format) { - return true; - } - - if (\in_array($attribute, ['foo', 'bar']) && isset($context['include_foo_and_bar']) && true === $context['include_foo_and_bar']) { - return true; - } - - return false; - } -} - class DummyWithConstructorObject { private $id; @@ -1235,12 +1252,72 @@ public function getInner() } } -class ObjectDummyWithIgnoreAnnotationAndPrivateProperty +class ObjectDummyWithIgnoreAttributeAndPrivateProperty { public $foo = 'foo'; - /** @Ignore */ + #[Ignore] public $ignored = 'ignored'; private $private = 'private'; } + +class ObjectWithAccessorishMethods +{ + private $accessorishCalled = false; + + public function isAccessorishCalled() + { + return $this->accessorishCalled; + } + + public function cancel() + { + $this->accessorishCalled = true; + } + + public function hash() + { + $this->accessorishCalled = true; + } + + public function canTell() + { + return true; + } + + public function getClass() + { + return true; + } + + public function hasResponsibility() + { + return true; + } + + public function get_foo() + { + return 'bar'; + } + + public function get123() + { + return 321; + } + + public function gettings() + { + $this->accessorishCalled = true; + } + + public function settings() + { + $this->accessorishCalled = true; + } + + public function isolate() + { + $this->accessorishCalled = true; + } +} diff --git a/Tests/Normalizer/ProblemNormalizerTest.php b/Tests/Normalizer/ProblemNormalizerTest.php index 4a754ac5b..e0a90229a 100644 --- a/Tests/Normalizer/ProblemNormalizerTest.php +++ b/Tests/Normalizer/ProblemNormalizerTest.php @@ -13,14 +13,20 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\ErrorHandler\Exception\FlattenException; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\Messenger\Exception\ValidationFailedException as MessageValidationFailedException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Serializer\Normalizer\ConstraintViolationListNormalizer; use Symfony\Component\Serializer\Normalizer\ProblemNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Exception\ValidationFailedException; class ProblemNormalizerTest extends TestCase { - /** - * @var ProblemNormalizer - */ - private $normalizer; + private ProblemNormalizer $normalizer; protected function setUp(): void { @@ -45,4 +51,80 @@ public function testNormalize() $this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable(new \RuntimeException('Error')))); } + + public function testNormalizePartialDenormalizationException() + { + $this->normalizer->setSerializer(new Serializer()); + + $expected = [ + 'type' => 'https://symfony.com/errors/validation', + 'title' => 'Validation Failed', + 'status' => 422, + 'detail' => 'foo: This value should be of type int.', + 'violations' => [ + [ + 'propertyPath' => 'foo', + 'title' => 'This value should be of type int.', + 'template' => 'This value should be of type {{ type }}.', + 'parameters' => [ + '{{ type }}' => 'int', + ], + 'hint' => 'Invalid value', + ], + ], + ]; + + $exception = NotNormalizableValueException::createForUnexpectedDataType('Invalid value', null, ['int'], 'foo', true); + $exception = new PartialDenormalizationException('Validation Failed', [$exception]); + $exception = new HttpException(422, 'Validation Failed', $exception); + $this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception])); + } + + public function testNormalizeValidationFailedException() + { + $this->normalizer->setSerializer(new Serializer([new ConstraintViolationListNormalizer()])); + + $expected = [ + 'type' => 'https://symfony.com/errors/validation', + 'title' => 'Validation Failed', + 'status' => 422, + 'detail' => 'Invalid value', + 'violations' => [ + [ + 'propertyPath' => '', + 'title' => 'Invalid value', + 'template' => '', + 'parameters' => [], + ], + ], + ]; + + $exception = new ValidationFailedException('Validation Failed', new ConstraintViolationList([new ConstraintViolation('Invalid value', '', [], '', null, null)])); + $exception = new HttpException(422, 'Validation Failed', $exception); + $this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception])); + } + + public function testNormalizeMessageValidationFailedException() + { + $this->normalizer->setSerializer(new Serializer([new ConstraintViolationListNormalizer()])); + + $expected = [ + 'type' => 'https://symfony.com/errors/validation', + 'title' => 'Validation Failed', + 'status' => 422, + 'detail' => 'Invalid value', + 'violations' => [ + [ + 'propertyPath' => '', + 'title' => 'Invalid value', + 'template' => '', + 'parameters' => [], + ], + ], + ]; + + $exception = new MessageValidationFailedException(new \stdClass(), new ConstraintViolationList([new ConstraintViolation('Invalid value', '', [], '', null, null)])); + $exception = new HttpException(422, 'Validation Failed', $exception); + $this->assertSame($expected, $this->normalizer->normalize(FlattenException::createFromThrowable($exception), null, ['exception' => $exception])); + } } diff --git a/Tests/Normalizer/PropertyNormalizerTest.php b/Tests/Normalizer/PropertyNormalizerTest.php index 3257c30fd..ecdae436e 100644 --- a/Tests/Normalizer/PropertyNormalizerTest.php +++ b/Tests/Normalizer/PropertyNormalizerTest.php @@ -11,16 +11,15 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\Serializer\Annotation\DiscriminatorMap; +use Symfony\Component\Serializer\Attribute\DiscriminatorMap; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Mapping\ClassDiscriminatorFromClassMetadata; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; use Symfony\Component\Serializer\NameConverter\MetadataAwareNameConverter; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; @@ -29,8 +28,8 @@ use Symfony\Component\Serializer\Normalizer\PropertyNormalizer; use Symfony\Component\Serializer\Serializer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy; -use Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummyChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\GroupDummyChild; use Symfony\Component\Serializer\Tests\Fixtures\Dummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74Dummy; use Symfony\Component\Serializer\Tests\Fixtures\PropertyCircularReferenceDummy; @@ -39,6 +38,7 @@ 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\FilterBoolTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\GroupsTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\IgnoredAttributesTestTrait; use Symfony\Component\Serializer\Tests\Normalizer\Features\MaxDepthTestTrait; @@ -53,6 +53,7 @@ class PropertyNormalizerTest extends TestCase use CallbacksTestTrait; use CircularReferenceTestTrait; use ConstructorArgumentsTestTrait; + use FilterBoolTestTrait; use GroupsTestTrait; use IgnoredAttributesTestTrait; use MaxDepthTestTrait; @@ -60,22 +61,15 @@ class PropertyNormalizerTest extends TestCase use SkipUninitializedValuesTestTrait; use TypeEnforcementTestTrait; - /** - * @var PropertyNormalizer - */ - private $normalizer; - - /** - * @var SerializerInterface - */ - private $serializer; + private PropertyNormalizer $normalizer; + private SerializerInterface $serializer; protected function setUp(): void { $this->createNormalizer(); } - private function createNormalizer(array $defaultContext = []) + private function createNormalizer(array $defaultContext = []): void { $this->serializer = $this->createMock(SerializerInterface::class); $this->normalizer = new PropertyNormalizer(null, null, null, null, null, $defaultContext); @@ -94,9 +88,6 @@ public function testNormalize() ); } - /** - * @requires PHP 7.4 - */ public function testNormalizeObjectWithUninitializedProperties() { $obj = new Php74Dummy(); @@ -126,6 +117,54 @@ public function testNormalizeObjectWithLazyProperties() ); } + public function testNormalizeOnlyPublic() + { + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + $obj->setCamelCase('camelcase'); + $this->assertEquals( + ['foo' => 'foo'], + $this->normalizer->normalize($obj, 'any', ['normalize_visibility' => PropertyNormalizer::NORMALIZE_PUBLIC]) + ); + } + + public function testNormalizeOnlyProtected() + { + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + $obj->setCamelCase('camelcase'); + $this->assertEquals( + ['camelCase' => 'camelcase'], + $this->normalizer->normalize($obj, 'any', ['normalize_visibility' => PropertyNormalizer::NORMALIZE_PROTECTED]) + ); + } + + public function testNormalizeOnlyPrivate() + { + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + $obj->setCamelCase('camelcase'); + $this->assertEquals( + ['bar' => 'bar'], + $this->normalizer->normalize($obj, 'any', ['normalize_visibility' => PropertyNormalizer::NORMALIZE_PRIVATE]) + ); + } + + public function testNormalizePublicAndProtected() + { + $obj = new PropertyDummy(); + $obj->foo = 'foo'; + $obj->setBar('bar'); + $obj->setCamelCase('camelcase'); + $this->assertEquals( + ['foo' => 'foo', 'camelCase' => 'camelcase'], + $this->normalizer->normalize($obj, 'any', ['normalize_visibility' => PropertyNormalizer::NORMALIZE_PUBLIC | PropertyNormalizer::NORMALIZE_PROTECTED]) + ); + } + public function testDenormalize() { $obj = $this->normalizer->denormalize( @@ -147,7 +186,16 @@ public function testNormalizeWithParentClass() $group->setKevin('Kevin'); $group->setCoopTilleuls('coop'); $this->assertEquals( - ['foo' => 'foo', 'bar' => 'bar', 'quux' => 'quux', 'kevin' => 'Kevin', 'coopTilleuls' => 'coop', 'fooBar' => null, 'symfony' => null, 'baz' => 'baz'], + [ + 'foo' => 'foo', + 'bar' => 'bar', + 'quux' => 'quux', + 'kevin' => 'Kevin', + 'coopTilleuls' => 'coop', + 'fooBar' => null, + 'symfony' => null, + 'baz' => 'baz', + ], $this->normalizer->normalize($group, 'any') ); } @@ -211,6 +259,11 @@ protected function getSelfReferencingModel() return new PropertyCircularReferenceDummy(); } + protected function getNormalizerForFilterBool(): PropertyNormalizer + { + return new PropertyNormalizer(); + } + public function testSiblingReference() { $serializer = new Serializer([$this->normalizer]); @@ -228,7 +281,7 @@ public function testSiblingReference() protected function getDenormalizerForConstructArguments(): PropertyNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $denormalizer = new PropertyNormalizer($classMetadataFactory, new MetadataAwareNameConverter($classMetadataFactory)); $serializer = new Serializer([$denormalizer]); $denormalizer->setSerializer($serializer); @@ -238,21 +291,21 @@ protected function getDenormalizerForConstructArguments(): PropertyNormalizer protected function getNormalizerForGroups(): PropertyNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new PropertyNormalizer($classMetadataFactory); } protected function getDenormalizerForGroups(): PropertyNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new PropertyNormalizer($classMetadataFactory); } public function testGroupsNormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new PropertyNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -273,7 +326,7 @@ public function testGroupsNormalizeWithNameConverter() public function testGroupsDenormalizeWithNameConverter() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $this->normalizer = new PropertyNormalizer($classMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); $this->normalizer->setSerializer($this->serializer); @@ -283,12 +336,16 @@ public function testGroupsDenormalizeWithNameConverter() $this->assertEquals( $obj, - $this->normalizer->denormalize([ - 'bar' => null, - 'foo_bar' => '@dunglas', - 'symfony' => '@coopTilleuls', - 'coop_tilleuls' => 'les-tilleuls.coop', - ], 'Symfony\Component\Serializer\Tests\Fixtures\Annotations\GroupDummy', null, [PropertyNormalizer::GROUPS => ['name_converter']]) + $this->normalizer->denormalize( + [ + 'bar' => null, + 'foo_bar' => '@dunglas', + 'symfony' => '@coopTilleuls', + 'coop_tilleuls' => 'les-tilleuls.coop', + ], + GroupDummy::class, null, + [PropertyNormalizer::GROUPS => ['name_converter']] + ) ); } @@ -317,7 +374,7 @@ public function testIgnoredAttributesContextDenormalizeInherit() protected function getNormalizerForMaxDepth(): PropertyNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new PropertyNormalizer($classMetadataFactory); $serializer = new Serializer([$normalizer]); $normalizer->setSerializer($serializer); @@ -327,7 +384,7 @@ protected function getNormalizerForMaxDepth(): PropertyNormalizer protected function getDenormalizerForObjectToPopulate(): PropertyNormalizer { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $normalizer = new PropertyNormalizer($classMetadataFactory, null, new PhpDocExtractor()); new Serializer([$normalizer]); @@ -348,13 +405,19 @@ public function testDenormalizeNonExistingAttribute() { $this->assertEquals( new PropertyDummy(), - $this->normalizer->denormalize(['non_existing' => true], PropertyDummy::class) + $this->normalizer->denormalize( + ['non_existing' => true], + PropertyDummy::class + ) ); } public function testDenormalizeShouldIgnoreStaticProperty() { - $obj = $this->normalizer->denormalize(['outOfScope' => true], PropertyDummy::class); + $obj = $this->normalizer->denormalize( + ['outOfScope' => true], + PropertyDummy::class + ); $this->assertEquals(new PropertyDummy(), $obj); $this->assertEquals('out_of_scope', PropertyDummy::$outOfScope); @@ -362,8 +425,6 @@ public function testDenormalizeShouldIgnoreStaticProperty() public function testUnableToNormalizeObjectAttribute() { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('Cannot normalize attribute "bar" because the injected serializer is not a normalizer'); $serializer = $this->createMock(SerializerInterface::class); $this->normalizer->setSerializer($serializer); @@ -371,6 +432,9 @@ public function testUnableToNormalizeObjectAttribute() $object = new \stdClass(); $obj->setBar($object); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Cannot normalize attribute "bar" because the injected serializer is not a normalizer'); + $this->normalizer->normalize($obj, 'any'); } @@ -392,7 +456,8 @@ public function testInheritedPropertiesSupport() public function testMultiDimensionObject() { $normalizer = $this->getDenormalizerForTypeEnforcement(); - $root = $normalizer->denormalize([ + $root = $normalizer->denormalize( + [ 'children' => [[ ['foo' => 'one', 'bar' => 'two'], ['foo' => 'three', 'bar' => 'four'], @@ -409,7 +474,7 @@ public function testMultiDimensionObject() RootDummy::class, 'any' ); - $this->assertEquals(\get_class($root), RootDummy::class); + $this->assertSame(RootDummy::class, $root::class); // children (two dimension array) $this->assertCount(1, $root->children); @@ -456,12 +521,12 @@ protected function getNormalizerForCacheableObjectAttributesTest(): AbstractObje protected function getNormalizerForSkipUninitializedValues(): PropertyNormalizer { - return new PropertyNormalizer(new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))); + return new PropertyNormalizer(new ClassMetadataFactory(new AttributeLoader())); } public function testNormalizeWithDiscriminator() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); $normalizer = new PropertyNormalizer($classMetadataFactory, null, null, $discriminator); @@ -470,14 +535,20 @@ public function testNormalizeWithDiscriminator() public function testDenormalizeWithDiscriminator() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $discriminator = new ClassDiscriminatorFromClassMetadata($classMetadataFactory); $normalizer = new PropertyNormalizer($classMetadataFactory, null, null, $discriminator); $denormalized = new PropertyDiscriminatedDummyTwo(); $denormalized->url = 'url'; - $this->assertEquals($denormalized, $normalizer->denormalize(['type' => 'two', 'url' => 'url'], PropertyDummyInterface::class)); + $this->assertEquals( + $denormalized, + $normalizer->denormalize( + ['type' => 'two', 'url' => 'url'], + PropertyDummyInterface::class + ) + ); } } @@ -583,12 +654,10 @@ public function getIntMatrix(): array } } -/** - * @DiscriminatorMap(typeProperty="type", mapping={ - * "one" = PropertyDiscriminatedDummyOne::class, - * "two" = PropertyDiscriminatedDummyTwo::class, - * }) - */ +#[DiscriminatorMap(typeProperty: 'type', mapping: [ + 'one' => PropertyDiscriminatedDummyOne::class, + 'two' => PropertyDiscriminatedDummyTwo::class, +])] interface PropertyDummyInterface { } diff --git a/Tests/Normalizer/TestDenormalizer.php b/Tests/Normalizer/TestDenormalizer.php index 6639c76d6..cd51b3c19 100644 --- a/Tests/Normalizer/TestDenormalizer.php +++ b/Tests/Normalizer/TestDenormalizer.php @@ -20,17 +20,16 @@ */ class TestDenormalizer implements DenormalizerInterface { - /** - * {@inheritdoc} - */ - public function denormalize($data, string $type, ?string $format = null, array $context = []) + public function denormalize($data, string $type, ?string $format = null, array $context = []): mixed { } - /** - * {@inheritdoc} - */ - public function supportsDenormalization($data, string $type, ?string $format = null): bool + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function supportsDenormalization($data, string $type, ?string $format = null, array $context = []): bool { return true; } diff --git a/Tests/Normalizer/TestNormalizer.php b/Tests/Normalizer/TestNormalizer.php index 84b806941..941fef42b 100644 --- a/Tests/Normalizer/TestNormalizer.php +++ b/Tests/Normalizer/TestNormalizer.php @@ -20,18 +20,17 @@ */ class TestNormalizer implements NormalizerInterface { - /** - * {@inheritdoc} - */ - public function normalize($object, ?string $format = null, array $context = []) + public function normalize($object, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { return null; } - /** - * {@inheritdoc} - */ - public function supportsNormalization($data, ?string $format = null): bool + public function getSupportedTypes(?string $format): array + { + return ['*' => false]; + } + + public function supportsNormalization($data, ?string $format = null, array $context = []): bool { return true; } diff --git a/Tests/Normalizer/TranslatableNormalizerTest.php b/Tests/Normalizer/TranslatableNormalizerTest.php new file mode 100644 index 000000000..3499521b6 --- /dev/null +++ b/Tests/Normalizer/TranslatableNormalizerTest.php @@ -0,0 +1,63 @@ + + * + * 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; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Normalizer\TranslatableNormalizer; +use Symfony\Contracts\Translation\TranslatableInterface; +use Symfony\Contracts\Translation\TranslatorInterface; + +class TranslatableNormalizerTest extends TestCase +{ + private readonly TranslatableNormalizer $normalizer; + + protected function setUp(): void + { + $this->normalizer = new TranslatableNormalizer($this->createMock(TranslatorInterface::class)); + } + + public function testSupportsNormalization() + { + $this->assertTrue($this->normalizer->supportsNormalization(new TestMessage())); + $this->assertFalse($this->normalizer->supportsNormalization(new \stdClass())); + } + + public function testNormalize() + { + $message = new TestMessage(); + + $this->assertSame('key_null', $this->normalizer->normalize($message)); + $this->assertSame('key_fr', $this->normalizer->normalize($message, context: ['translatable_normalization_locale' => 'fr'])); + $this->assertSame('key_en', $this->normalizer->normalize($message, context: ['translatable_normalization_locale' => 'en'])); + } + + public function testNormalizeWithNormalizationLocalePassedInConstructor() + { + $normalizer = new TranslatableNormalizer( + $this->createMock(TranslatorInterface::class), + ['translatable_normalization_locale' => 'es'], + ); + $message = new TestMessage(); + + $this->assertSame('key_es', $normalizer->normalize($message)); + $this->assertSame('key_fr', $normalizer->normalize($message, context: ['translatable_normalization_locale' => 'fr'])); + $this->assertSame('key_en', $normalizer->normalize($message, context: ['translatable_normalization_locale' => 'en'])); + } +} + +class TestMessage implements TranslatableInterface +{ + public function trans(TranslatorInterface $translator, ?string $locale = null): string + { + return 'key_'.($locale ?? 'null'); + } +} diff --git a/Tests/Normalizer/UidNormalizerTest.php b/Tests/Normalizer/UidNormalizerTest.php index 72e6b64c3..734b15b48 100644 --- a/Tests/Normalizer/UidNormalizerTest.php +++ b/Tests/Normalizer/UidNormalizerTest.php @@ -25,10 +25,7 @@ class UidNormalizerTest extends TestCase { - /** - * @var UidNormalizer - */ - private $normalizer; + private UidNormalizer $normalizer; protected function setUp(): void { @@ -50,8 +47,8 @@ public static function normalizeProvider() { $uidFormats = [null, 'canonical', 'base58', 'base32', 'rfc4122']; $data = [ - [ - UuidV1::fromString('9b7541de-6f87-11ea-ab3c-9da9a81562fc'), + [ + UuidV1::fromString('9b7541de-6f87-11ea-ab3c-9da9a81562fc'), '9b7541de-6f87-11ea-ab3c-9da9a81562fc', '9b7541de-6f87-11ea-ab3c-9da9a81562fc', 'LCQS8f2p5SDSiAt9V7ZYnF', @@ -145,7 +142,7 @@ public function testSupportsDenormalizationForNonUid() public function testSupportOurAbstractUid() { - $this->assertTrue($this->normalizer->supportsDenormalization('1ea6ecef-eb9a-66fe-b62b-957b45f17e43', AbstractUid::class)); + $this->assertFalse($this->normalizer->supportsDenormalization('1ea6ecef-eb9a-66fe-b62b-957b45f17e43', AbstractUid::class)); } public function testSupportCustomAbstractUid() @@ -163,11 +160,17 @@ public function testDenormalize($uuidString, $class) public function testDenormalizeOurAbstractUid() { + $this->expectException(\Error::class); + $this->expectExceptionMessage('Cannot call abstract method Symfony\Component\Uid\AbstractUid::fromString()'); + $this->assertEquals(Uuid::fromString($uuidString = '1ea6ecef-eb9a-66fe-b62b-957b45f17e43'), $this->normalizer->denormalize($uuidString, AbstractUid::class)); } public function testDenormalizeCustomAbstractUid() { + $this->expectException(\Error::class); + $this->expectExceptionMessage('Cannot instantiate abstract class Symfony\Component\Serializer\Tests\Normalizer\TestAbstractCustomUid'); + $this->assertEquals(Uuid::fromString($uuidString = '1ea6ecef-eb9a-66fe-b62b-957b45f17e43'), $this->normalizer->denormalize($uuidString, TestAbstractCustomUid::class)); } diff --git a/Tests/Normalizer/UnwrappinDenormalizerTest.php b/Tests/Normalizer/UnwrappinDenormalizerTest.php index 25063b862..59ddd6da6 100644 --- a/Tests/Normalizer/UnwrappinDenormalizerTest.php +++ b/Tests/Normalizer/UnwrappinDenormalizerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Serializer\Tests\Normalizer; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; @@ -21,9 +22,8 @@ */ class UnwrappinDenormalizerTest extends TestCase { - private $denormalizer; - - private $serializer; + private UnwrappingDenormalizer $denormalizer; + private MockObject&Serializer $serializer; protected function setUp(): void { diff --git a/Tests/SerializerTest.php b/Tests/SerializerTest.php index 639d14e0d..8fd0ff885 100644 --- a/Tests/SerializerTest.php +++ b/Tests/SerializerTest.php @@ -11,15 +11,13 @@ namespace Symfony\Component\Serializer\Tests; -use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyAccess\Exception\InvalidTypeException; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Encoder\CsvEncoder; -use Symfony\Component\Serializer\Encoder\DecoderInterface; -use Symfony\Component\Serializer\Encoder\EncoderInterface; use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\ExtraAttributesException; @@ -34,7 +32,7 @@ use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; -use Symfony\Component\Serializer\Mapping\Loader\AnnotationLoader; +use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; @@ -53,10 +51,10 @@ use Symfony\Component\Serializer\Normalizer\UidNormalizer; use Symfony\Component\Serializer\Normalizer\UnwrappingDenormalizer; use Symfony\Component\Serializer\Serializer; -use Symfony\Component\Serializer\SerializerInterface; -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\Attributes\AbstractDummy; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummyFirstChild; +use Symfony\Component\Serializer\Tests\Fixtures\Attributes\AbstractDummySecondChild; +use Symfony\Component\Serializer\Tests\Fixtures\DenormalizableDummy; use Symfony\Component\Serializer\Tests\Fixtures\DummyFirstChildQuux; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageInterface; use Symfony\Component\Serializer\Tests\Fixtures\DummyMessageNumberOne; @@ -66,29 +64,23 @@ use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumConstructor; use Symfony\Component\Serializer\Tests\Fixtures\DummyObjectWithEnumProperty; use Symfony\Component\Serializer\Tests\Fixtures\DummyWithObjectOrNull; +use Symfony\Component\Serializer\Tests\Fixtures\DummyWithVariadicParameter; use Symfony\Component\Serializer\Tests\Fixtures\FalseBuiltInDummy; +use Symfony\Component\Serializer\Tests\Fixtures\FooImplementationDummy; +use Symfony\Component\Serializer\Tests\Fixtures\FooInterfaceDummyDenormalizer; use Symfony\Component\Serializer\Tests\Fixtures\NormalizableTraversableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\ObjectCollectionPropertyDummy; use Symfony\Component\Serializer\Tests\Fixtures\Php74Full; use Symfony\Component\Serializer\Tests\Fixtures\Php80WithOptionalConstructorParameter; use Symfony\Component\Serializer\Tests\Fixtures\Php80WithPromotedTypedConstructor; use Symfony\Component\Serializer\Tests\Fixtures\TraversableDummy; +use Symfony\Component\Serializer\Tests\Fixtures\TrueBuiltInDummy; use Symfony\Component\Serializer\Tests\Fixtures\WithTypedConstructor; use Symfony\Component\Serializer\Tests\Normalizer\TestDenormalizer; use Symfony\Component\Serializer\Tests\Normalizer\TestNormalizer; class SerializerTest extends TestCase { - public function testInterface() - { - $serializer = new Serializer(); - - $this->assertInstanceOf(SerializerInterface::class, $serializer); - $this->assertInstanceOf(NormalizerInterface::class, $serializer); - $this->assertInstanceOf(DenormalizerInterface::class, $serializer); - $this->assertInstanceOf(EncoderInterface::class, $serializer); - $this->assertInstanceOf(DecoderInterface::class, $serializer); - } - public function testItThrowsExceptionOnInvalidNormalizer() { $this->expectException(InvalidArgumentException::class); @@ -107,8 +99,10 @@ public function testItThrowsExceptionOnInvalidEncoder() public function testNormalizeNoMatch() { + $serializer = new Serializer([$this->createMock(NormalizerInterface::class)]); + $this->expectException(UnexpectedValueException::class); - $serializer = new Serializer([$this->createMock(CustomNormalizer::class)]); + $serializer->normalize(new \stdClass(), 'xml'); } @@ -128,23 +122,37 @@ public function testNormalizeGivesPriorityToInterfaceOverTraversable() public function testNormalizeOnDenormalizer() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([new TestDenormalizer()], []); + + $this->expectException(UnexpectedValueException::class); + $this->assertTrue($serializer->normalize(new \stdClass(), 'json')); } public function testDenormalizeNoMatch() { + $serializer = new Serializer([$this->createMock(NormalizerInterface::class)]); + $this->expectException(UnexpectedValueException::class); - $serializer = new Serializer([$this->createMock(CustomNormalizer::class)]); + $serializer->denormalize('foo', 'stdClass'); } + public function testDenormalizeOnObjectThatOnlySupportsDenormalization() + { + $serializer = new Serializer([new CustomNormalizer()]); + + $obj = $serializer->denormalize('foo', (new DenormalizableDummy())::class, 'xml'); + $this->assertInstanceOf(DenormalizableDummy::class, $obj); + } + public function testDenormalizeOnNormalizer() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([new TestNormalizer()], []); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(UnexpectedValueException::class); + $this->assertTrue($serializer->denormalize(json_encode($data), 'stdClass', 'json')); } @@ -160,13 +168,13 @@ public function testCustomNormalizerCanNormalizeCollectionsAndScalar() public function testNormalizeWithSupportOnData() { $normalizer1 = $this->createMock(NormalizerInterface::class); + $normalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer1->method('supportsNormalization') - ->willReturnCallback(function ($data, $format) { - return isset($data->test); - }); + ->willReturnCallback(fn ($data, $format) => isset($data->test)); $normalizer1->method('normalize')->willReturn('test1'); $normalizer2 = $this->createMock(NormalizerInterface::class); + $normalizer2->method('getSupportedTypes')->willReturn(['*' => false]); $normalizer2->method('supportsNormalization') ->willReturn(true); $normalizer2->method('normalize')->willReturn('test2'); @@ -183,13 +191,13 @@ public function testNormalizeWithSupportOnData() public function testDenormalizeWithSupportOnData() { $denormalizer1 = $this->createMock(DenormalizerInterface::class); + $denormalizer1->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer1->method('supportsDenormalization') - ->willReturnCallback(function ($data, $type, $format) { - return isset($data['test1']); - }); + ->willReturnCallback(fn ($data, $type, $format) => isset($data['test1'])); $denormalizer1->method('denormalize')->willReturn('test1'); $denormalizer2 = $this->createMock(DenormalizerInterface::class); + $denormalizer2->method('getSupportedTypes')->willReturn(['*' => false]); $denormalizer2->method('supportsDenormalization') ->willReturn(true); $denormalizer2->method('denormalize')->willReturn('test2'); @@ -239,17 +247,21 @@ public function testSerializeEmpty() public function testSerializeNoEncoder() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([], []); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(UnexpectedValueException::class); + $serializer->serialize($data, 'json'); } public function testSerializeNoNormalizer() { - $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(LogicException::class); + $serializer->serialize(Model::fromArray($data), 'json'); } @@ -273,25 +285,31 @@ public function testDeserializeUseCache() public function testDeserializeNoNormalizer() { - $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(LogicException::class); + $serializer->deserialize(json_encode($data), Model::class, 'json'); } public function testDeserializeWrongNormalizer() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([new CustomNormalizer()], ['json' => new JsonEncoder()]); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(UnexpectedValueException::class); + $serializer->deserialize(json_encode($data), Model::class, 'json'); } public function testDeserializeNoEncoder() { - $this->expectException(UnexpectedValueException::class); $serializer = new Serializer([], []); $data = ['title' => 'foo', 'numbers' => [5, 3]]; + + $this->expectException(UnexpectedValueException::class); + $serializer->deserialize(json_encode($data), Model::class, 'json'); } @@ -381,8 +399,7 @@ public function testNormalizerAware() { $normalizerAware = $this->createMock(NormalizerAwareNormalizer::class); $normalizerAware->expects($this->once()) - ->method('setNormalizer') - ->with($this->isInstanceOf(NormalizerInterface::class)); + ->method('setNormalizer'); new Serializer([$normalizerAware]); } @@ -391,8 +408,7 @@ public function testDenormalizerAware() { $denormalizerAware = $this->createMock(DenormalizerAwareDenormalizer::class); $denormalizerAware->expects($this->once()) - ->method('setDenormalizer') - ->with($this->isInstanceOf(DenormalizerInterface::class)); + ->method('setDenormalizer'); new Serializer([$denormalizerAware]); } @@ -411,7 +427,7 @@ public function testDeserializeAndSerializeAbstractObjectsWithTheClassMetadataDi $example = new AbstractDummyFirstChild('foo-value', 'bar-value'); $example->setQuux(new DummyFirstChildQuux('quux')); - $loaderMock = new class() implements ClassMetadataFactoryInterface { + $loaderMock = new class implements ClassMetadataFactoryInterface { public function getMetadataFor($value): ClassMetadataInterface { if (AbstractDummy::class === $value) { @@ -592,10 +608,10 @@ public static function provideObjectOrCollectionTests() $data['c2'] = new \ArrayObject(['nested' => new \ArrayObject(['k' => 'v'])]); $data['d1'] = new \ArrayObject(['nested' => []]); $data['d2'] = new \ArrayObject(['nested' => ['k' => 'v']]); - $data['e1'] = new class() { + $data['e1'] = new class { public $map = []; }; - $data['e2'] = new class() { + $data['e2'] = new class { public $map = ['k' => 'v']; }; $data['f1'] = new class(new \ArrayObject()) { @@ -693,29 +709,37 @@ public function testDeserializeScalar() public function testDeserializeLegacyScalarType() { - $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); + + $this->expectException(LogicException::class); + $serializer->deserialize('42', 'integer', 'json'); } public function testDeserializeScalarTypeToCustomType() { - $this->expectException(LogicException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); + + $this->expectException(LogicException::class); + $serializer->deserialize('"something"', Foo::class, 'json'); } public function testDeserializeNonscalarTypeToScalar() { - $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); + + $this->expectException(NotNormalizableValueException::class); + $serializer->deserialize('{"foo":true}', 'string', 'json'); } public function testDeserializeInconsistentScalarType() { - $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([], ['json' => new JsonEncoder()]); + + $this->expectException(NotNormalizableValueException::class); + $serializer->deserialize('"42"', 'int', 'json'); } @@ -731,11 +755,28 @@ public function testDeserializeScalarArray() public function testDeserializeInconsistentScalarArray() { - $this->expectException(NotNormalizableValueException::class); $serializer = new Serializer([new ArrayDenormalizer()], ['json' => new JsonEncoder()]); + + $this->expectException(NotNormalizableValueException::class); + $serializer->deserialize('["42"]', 'int[]', 'json'); } + public function testDeserializeOnObjectWithObjectCollectionProperty() + { + $serializer = new Serializer([new FooInterfaceDummyDenormalizer(), new ObjectNormalizer(null, null, null, new PhpDocExtractor())], [new JsonEncoder()]); + + $obj = $serializer->deserialize('{"foo":[{"name":"bar"}]}', ObjectCollectionPropertyDummy::class, 'json'); + $this->assertInstanceOf(ObjectCollectionPropertyDummy::class, $obj); + + $fooDummyObjects = $obj->getFoo(); + $this->assertCount(1, $fooDummyObjects); + + $fooDummyObject = $fooDummyObjects[0]; + $this->assertInstanceOf(FooImplementationDummy::class, $fooDummyObject); + $this->assertSame('bar', $fooDummyObject->name); + } + public function testDeserializeWrappedScalar() { $serializer = new Serializer([new UnwrappingDenormalizer()], ['json' => new JsonEncoder()]); @@ -743,9 +784,6 @@ public function testDeserializeWrappedScalar() $this->assertSame(42, $serializer->deserialize('{"wrapper": 42}', 'int', 'json', [UnwrappingDenormalizer::UNWRAP_PATH => '[wrapper]'])); } - /** - * @requires PHP 8 - */ public function testDeserializeNullableIntInXml() { $extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]); @@ -758,7 +796,7 @@ public function testDeserializeNullableIntInXml() public function testUnionTypeDeserializable() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); $serializer = new Serializer( [ @@ -769,20 +807,20 @@ public function testUnionTypeDeserializable() ); $actual = $serializer->deserialize('{ "changed": null }', DummyUnionType::class, 'json', [ - DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + DateTimeNormalizer::FORMAT_KEY => \DateTimeinterface::ATOM, ]); $this->assertEquals((new DummyUnionType())->setChanged(null), $actual, 'Union type denormalization first case failed.'); $actual = $serializer->deserialize('{ "changed": "2022-03-22T16:15:05+0000" }', DummyUnionType::class, 'json', [ - DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + DateTimeNormalizer::FORMAT_KEY => \DateTimeinterface::ATOM, ]); - $expectedDateTime = \DateTime::createFromFormat(\DateTime::ISO8601, '2022-03-22T16:15:05+0000'); + $expectedDateTime = \DateTimeImmutable::createFromFormat(\DateTimeinterface::ATOM, '2022-03-22T16:15:05+0000'); $this->assertEquals((new DummyUnionType())->setChanged($expectedDateTime), $actual, 'Union type denormalization second case failed.'); $actual = $serializer->deserialize('{ "changed": false }', DummyUnionType::class, 'json', [ - DateTimeNormalizer::FORMAT_KEY => \DateTime::ISO8601, + DateTimeNormalizer::FORMAT_KEY => \DateTimeinterface::ATOM, ]); $this->assertEquals(new DummyUnionType(), $actual, 'Union type denormalization third case failed.'); @@ -790,7 +828,7 @@ public function testUnionTypeDeserializable() public function testUnionTypeDeserializableWithoutAllowedExtraAttributes() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $extractor = new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]); $serializer = new Serializer( [ @@ -823,9 +861,6 @@ public function testUnionTypeDeserializableWithoutAllowedExtraAttributes() ]); } - /** - * @requires PHP 8.2 - */ public function testFalseBuiltInTypes() { $extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]); @@ -836,9 +871,16 @@ public function testFalseBuiltInTypes() $this->assertEquals(new FalseBuiltInDummy(), $actual); } - /** - * @requires PHP 8 - */ + public function testTrueBuiltInTypes() + { + $extractor = new PropertyInfoExtractor([], [new ReflectionExtractor()]); + $serializer = new Serializer([new ObjectNormalizer(null, null, null, $extractor)], ['json' => new JsonEncoder()]); + + $actual = $serializer->deserialize('{"true":true}', TrueBuiltInDummy::class, 'json'); + + $this->assertEquals(new TrueBuiltInDummy(), $actual); + } + public function testDeserializeUntypedFormat() { $serializer = new Serializer([new ObjectNormalizer(null, null, null, new PropertyInfoExtractor([], [new PhpDocExtractor(), new ReflectionExtractor()]))], ['csv' => new CsvEncoder()]); @@ -849,7 +891,7 @@ public function testDeserializeUntypedFormat() private function serializerWithClassDiscriminator() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader())); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); return new Serializer([new ObjectNormalizer($classMetadataFactory, null, null, new ReflectionExtractor(), new ClassDiscriminatorFromClassMetadata($classMetadataFactory))], ['json' => new JsonEncoder()]); } @@ -870,8 +912,6 @@ public function testDeserializeAndUnwrap() /** * @dataProvider provideCollectDenormalizationErrors - * - * @requires PHP 7.4 */ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMetadataFactory) { @@ -934,15 +974,13 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet $this->assertInstanceOf(Php74Full::class, $th->getData()); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1113,8 +1151,6 @@ public function testCollectDenormalizationErrors(?ClassMetadataFactory $classMet /** * @dataProvider provideCollectDenormalizationErrors - * - * @requires PHP 7.4 */ public function testCollectDenormalizationErrors2(?ClassMetadataFactory $classMetadataFactory) { @@ -1152,15 +1188,13 @@ public function testCollectDenormalizationErrors2(?ClassMetadataFactory $classMe $this->assertInstanceOf(Php74Full::class, $th->getData()[0]); $this->assertInstanceOf(Php74Full::class, $th->getData()[1]); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1181,14 +1215,11 @@ public function testCollectDenormalizationErrors2(?ClassMetadataFactory $classMe 'useMessageForUser' => false, 'message' => 'The type of the "string" attribute for class "Symfony\\Component\\Serializer\\Tests\\Fixtures\\Php74Full" must be one of "string" ("null" given).', ], - ]; + ]; $this->assertSame($expected, $exceptionsAsArray); } - /** - * @requires PHP 7.4 - */ public function testCollectDenormalizationErrorsWithoutTypeExtractor() { $json = ' @@ -1212,21 +1243,19 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() $this->assertInstanceOf(Php74Full::class, $th->getData()); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'string' : 'unknown', ], 'path' => 'string', 'useMessageForUser' => false, @@ -1235,7 +1264,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'int' : 'unknown', ], 'path' => 'int', 'useMessageForUser' => false, @@ -1244,7 +1273,7 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() [ 'currentType' => 'array', 'expectedTypes' => [ - 'unknown', + class_exists(InvalidTypeException::class) ? 'float' : 'unknown', ], 'path' => 'float', 'useMessageForUser' => false, @@ -1257,8 +1286,6 @@ public function testCollectDenormalizationErrorsWithoutTypeExtractor() /** * @dataProvider provideCollectDenormalizationErrors - * - * @requires PHP 8.0 */ public function testCollectDenormalizationErrorsWithConstructor(?ClassMetadataFactory $classMetadataFactory) { @@ -1285,15 +1312,13 @@ public function testCollectDenormalizationErrorsWithConstructor(?ClassMetadataFa $this->assertInstanceOf(Php80WithPromotedTypedConstructor::class, $th->getData()); - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'expectedTypes' => $e->getExpectedTypes(), - 'path' => $e->getPath(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'expectedTypes' => $e->getExpectedTypes(), + 'path' => $e->getPath(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1389,9 +1414,6 @@ public function testCollectDenormalizationErrorsWithInvalidConstructorTypes() $this->assertSame($expected, $exceptionsAsArray); } - /** - * @requires PHP 8.1 - */ public function testCollectDenormalizationErrorsWithEnumConstructor() { $serializer = new Serializer( @@ -1410,13 +1432,11 @@ public function testCollectDenormalizationErrorsWithEnumConstructor() $this->assertInstanceOf(PartialDenormalizationException::class, $th); } - $exceptionsAsArray = array_map(function (NotNormalizableValueException $e): array { - return [ - 'currentType' => $e->getCurrentType(), - 'useMessageForUser' => $e->canUseMessageForUser(), - 'message' => $e->getMessage(), - ]; - }, $th->getErrors()); + $exceptionsAsArray = array_map(fn (NotNormalizableValueException $e): array => [ + 'currentType' => $e->getCurrentType(), + 'useMessageForUser' => $e->canUseMessageForUser(), + 'message' => $e->getMessage(), + ], $th->getErrors()); $expected = [ [ @@ -1429,12 +1449,9 @@ public function testCollectDenormalizationErrorsWithEnumConstructor() $this->assertSame($expected, $exceptionsAsArray); } - /** - * @requires PHP 8.1 - */ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruct() { - $classMetadataFactory = new ClassMetadataFactory(new AnnotationLoader()); + $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader()); $reflectionExtractor = new ReflectionExtractor(); $propertyInfoExtractor = new PropertyInfoExtractor([], [$reflectionExtractor], [], [], []); @@ -1448,8 +1465,8 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc try { $serializer->deserialize('{"get": "POST"}', DummyObjectWithEnumProperty::class, 'json', [ - DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, - ]); + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); } catch (\Throwable $e) { $this->assertInstanceOf(PartialDenormalizationException::class, $e); } @@ -1473,9 +1490,6 @@ public function testCollectDenormalizationErrorsWithWrongPropertyWithoutConstruc $this->assertSame($expected, $exceptionsAsArray); } - /** - * @requires PHP 8.1 - */ public function testNoCollectDenormalizationErrorsWithWrongEnumOnConstructor() { $serializer = new Serializer( @@ -1496,17 +1510,106 @@ public function testNoCollectDenormalizationErrorsWithWrongEnumOnConstructor() } } - public static function provideCollectDenormalizationErrors() + public function testGroupsOnClassSerialization() + { + $obj = new Fixtures\Attributes\GroupClassDummy(); + $obj->setFoo('foo'); + $obj->setBar('bar'); + $obj->setBaz('baz'); + + $serializer = new Serializer( + [ + new ObjectNormalizer(), + ], + [ + 'json' => new JsonEncoder(), + ] + ); + + $this->assertSame( + '{"foo":"foo","bar":"bar","baz":"baz"}', + $serializer->serialize($obj, 'json', ['groups' => ['a']]) + ); + } + + public static function provideCollectDenormalizationErrors(): array { return [ [null], - [new ClassMetadataFactory(new AnnotationLoader(new AnnotationReader()))], + [new ClassMetadataFactory(new AttributeLoader())], ]; } - /** - * @requires PHP 8 - */ + public function testSerializerUsesSupportedTypesMethod() + { + $neverCalledNormalizer = $this->createMock(DummyNormalizer::class); + $neverCalledNormalizer + // once for normalization, once for denormalization + ->expects($this->exactly(2)) + ->method('getSupportedTypes') + ->willReturn([ + Foo::class => true, + Bar::class => false, + ]); + + $supportedAndCachedNormalizer = $this->createMock(DummyNormalizer::class); + $supportedAndCachedNormalizer + // once for normalization, once for denormalization + ->expects($this->exactly(2)) + ->method('getSupportedTypes') + ->willReturn([ + Model::class => true, + ]); + + $serializer = new Serializer( + [ + $neverCalledNormalizer, + $supportedAndCachedNormalizer, + new ObjectNormalizer(), + ], + ['json' => new JsonEncoder()] + ); + + // Normalization process + $neverCalledNormalizer + ->expects($this->never()) + ->method('supportsNormalization'); + $neverCalledNormalizer + ->expects($this->never()) + ->method('normalize'); + + $supportedAndCachedNormalizer + ->expects($this->once()) + ->method('supportsNormalization') + ->willReturn(true); + $supportedAndCachedNormalizer + ->expects($this->exactly(2)) + ->method('normalize') + ->willReturn(['foo' => 'bar']); + + $serializer->normalize(new Model(), 'json'); + $serializer->normalize(new Model(), 'json'); + + // Denormalization pass + $neverCalledNormalizer + ->expects($this->never()) + ->method('supportsDenormalization'); + $neverCalledNormalizer + ->expects($this->never()) + ->method('denormalize'); + $supportedAndCachedNormalizer + ->expects($this->once()) + ->method('supportsDenormalization') + ->willReturn(true); + $supportedAndCachedNormalizer + ->expects($this->exactly(2)) + ->method('denormalize') + ->willReturn(new Model()); + + $serializer->denormalize('foo', Model::class, 'json'); + $serializer->denormalize('foo', Model::class, 'json'); + } + public function testPartialDenormalizationWithMissingConstructorTypes() { $json = '{"one": "one string", "three": "three string"}'; @@ -1558,6 +1661,45 @@ public function testPartialDenormalizationWithMissingConstructorTypes() $this->assertSame($expected, $exceptionsAsArray); } + + public function testPartialDenormalizationWithInvalidVariadicParameter() + { + $json = '{"variadic": ["a random string"]}'; + + $serializer = new Serializer([new UidNormalizer(), new ObjectNormalizer()], ['json' => new JsonEncoder()]); + + $this->expectException(PartialDenormalizationException::class); + + $serializer->deserialize($json, DummyWithVariadicParameter::class, 'json', [ + DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true, + ]); + } + + public function testEmptyArrayAsObjectDefaultContext() + { + $serializer = new Serializer( + defaultContext: [Serializer::EMPTY_ARRAY_AS_OBJECT => true], + ); + $this->assertEquals(new \ArrayObject(), $serializer->normalize([])); + } + + public function testPreserveEmptyObjectsAsDefaultContext() + { + $serializer = new Serializer( + defaultContext: [AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS => true], + ); + $this->assertEquals(new \ArrayObject(), $serializer->normalize(new \ArrayIterator())); + } + + public function testCollectDenormalizationErrorsDefaultContext() + { + $data = ['variadic' => ['a random string']]; + $serializer = new Serializer([new UidNormalizer(), new ObjectNormalizer()], [], [DenormalizerInterface::COLLECT_DENORMALIZATION_ERRORS => true]); + + $this->expectException(PartialDenormalizationException::class); + + $serializer->denormalize($data, DummyWithVariadicParameter::class); + } } class Model @@ -1627,16 +1769,16 @@ public function __construct($value) class DummyUnionType { /** - * @var \DateTime|bool|null + * @var \DateTimeImmutable|bool|null */ public $changed = false; /** - * @param \DateTime|bool|null + * @param \DateTimeImmutable|bool|null * * @return $this */ - public function setChanged($changed): self + public function setChanged($changed): static { $this->changed = $changed; @@ -1712,6 +1854,11 @@ public function getIterator(): \ArrayIterator } } +abstract class DummyNormalizer implements NormalizerInterface, DenormalizerInterface +{ + abstract public function getSupportedTypes(?string $format): array; +} + interface NormalizerAwareNormalizer extends NormalizerInterface, NormalizerAwareInterface { } diff --git a/composer.json b/composer.json index ec5e37a3f..d8809fa07 100644 --- a/composer.json +++ b/composer.json @@ -16,49 +16,44 @@ } ], "require": { - "php": ">=7.2.5", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-php80": "^1.16" + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8" }, "require-dev": { - "doctrine/annotations": "^1.12|^2", "phpdocumentor/reflection-docblock": "^3.2|^4.0|^5.0", - "symfony/cache": "^4.4|^5.0|^6.0", - "symfony/config": "^4.4|^5.0|^6.0", - "symfony/dependency-injection": "^4.4|^5.0|^6.0", - "symfony/error-handler": "^4.4|^5.0|^6.0", - "symfony/filesystem": "^4.4|^5.0|^6.0", - "symfony/form": "^4.4|^5.0|^6.0", - "symfony/http-foundation": "^4.4|^5.0|^6.0", - "symfony/http-kernel": "^4.4|^5.0|^6.0", - "symfony/mime": "^4.4|^5.0|^6.0", - "symfony/property-access": "^5.4.26|^6.3", - "symfony/property-info": "^5.4.24|^6.2.11", - "symfony/uid": "^5.3|^6.0", - "symfony/validator": "^4.4|^5.0|^6.0", - "symfony/var-dumper": "^4.4|^5.0|^6.0", - "symfony/var-exporter": "^4.4|^5.0|^6.0", - "symfony/yaml": "^4.4|^5.0|^6.0" + "phpstan/phpdoc-parser": "^1.0|^2.0", + "seld/jsonlint": "^1.10", + "symfony/cache": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0", + "symfony/console": "^6.4|^7.0", + "symfony/dependency-injection": "^7.2", + "symfony/error-handler": "^6.4|^7.0", + "symfony/filesystem": "^6.4|^7.0", + "symfony/form": "^6.4|^7.0", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0", + "symfony/messenger": "^6.4|^7.0", + "symfony/mime": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0", + "symfony/property-info": "^6.4|^7.0", + "symfony/translation-contracts": "^2.5|^3", + "symfony/type-info": "^7.1", + "symfony/uid": "^6.4|^7.0", + "symfony/validator": "^6.4|^7.0", + "symfony/var-dumper": "^6.4|^7.0", + "symfony/var-exporter": "^6.4|^7.0", + "symfony/yaml": "^6.4|^7.0" }, "conflict": { - "doctrine/annotations": "<1.12", "phpdocumentor/reflection-docblock": "<3.2.2", "phpdocumentor/type-resolver": "<1.4.0", - "symfony/dependency-injection": "<4.4", - "symfony/property-access": "<5.4", - "symfony/property-info": "<5.4.24|>=6,<6.2.11", - "symfony/uid": "<5.3", - "symfony/yaml": "<4.4" - }, - "suggest": { - "psr/cache-implementation": "For using the metadata cache.", - "symfony/property-info": "To deserialize relations.", - "symfony/yaml": "For using the default YAML mapping loader.", - "symfony/config": "For using the XML mapping loader.", - "symfony/property-access": "For using the ObjectNormalizer.", - "symfony/mime": "For using a MIME type guesser within the DataUriNormalizer.", - "symfony/var-exporter": "For using the metadata compiler." + "symfony/dependency-injection": "<6.4", + "symfony/property-access": "<6.4", + "symfony/property-info": "<6.4", + "symfony/uid": "<6.4", + "symfony/validator": "<6.4", + "symfony/yaml": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Component\\Serializer\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 2d99ce1d4..fd66cdfcc 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - - + + ./ - - ./Tests - ./vendor - - - + + + ./Tests + ./vendor + +